Merge refactorer follow-user implementation

By architect.
This commit is contained in:
Chris Troutner
2026-08-27 10:47:19 -07:00
33 changed files with 3968 additions and 314 deletions
+165 -3
View File
@@ -32,6 +32,7 @@ const SetAvatarUrlPage = require('../../src/services/set-avatar-url-page')
const AccountPage = require('../../src/services/account-page')
const MemoLike = require('../../src/services/memo-like')
const LikeTipPage = require('../../src/services/like-tip-page')
const MemoFollow = require('../../src/services/memo-follow')
const RecentFeedPage = require('../../src/services/recent-feed-page')
const ProfilePage = require('../../src/services/profile-page')
const ThreadPage = require('../../src/services/thread-page')
@@ -42,6 +43,8 @@ const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX
const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX
const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
@@ -55,6 +58,14 @@ const THIRD_ADDRESS = 'bitcoincash:third-address'
function makeWallet (address) {
const wallet = {
walletInfo: { cashAddress: address },
bchjs: {
Address: {
toHash160 (addr) {
// Stable fake hash160: first 20 bytes of sha256 of the address.
return require('crypto').createHash('sha256').update(addr).digest('hex').slice(0, 40)
}
}
},
utxos: [],
broadcasts: [],
getUtxos: async function () {
@@ -79,21 +90,28 @@ function makeFeed () {
}
}
// A fake profile store recording display names, bios, and avatar URLs set for addresses.
// A fake profile store recording display names, bios, avatar URLs, and follow state.
function makeProfiles () {
const names = {}
const bios = {}
const avatarUrls = {}
const following = {}
return {
names,
bios,
avatarUrls,
following,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null,
setBio: (addr, bio) => { bios[addr] = bio },
getBio: (addr) => bios[addr] || null,
setAvatarUrl: (addr, url) => { avatarUrls[addr] = url },
getAvatarUrl: (addr) => avatarUrls[addr] || null
getAvatarUrl: (addr) => avatarUrls[addr] || null,
setFollowState: (selfAddr, targetAddr, isFollowing) => {
if (!following[selfAddr]) following[selfAddr] = {}
following[selfAddr][targetAddr] = isFollowing
},
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false
}
}
@@ -112,16 +130,21 @@ function makeThread () {
function makeMemoDb () {
const posts = []
const threads = {}
const followState = {}
return {
posts,
threads,
followState,
addPost (post) {
posts.push(post)
},
addThread (txid, thread) {
threads[txid] = thread
},
setFollowState (followerAddr, followeeAddr, following) {
followState[`${followerAddr}:${followeeAddr}`] = following
},
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
const page = posts.slice(offset, offset + limit)
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
@@ -133,6 +156,9 @@ function makeMemoDb () {
},
async getPostThread (txid) {
return threads[txid] || { post: null }
},
async getFollowState (followerAddr, followeeAddr) {
return followState[`${followerAddr}:${followeeAddr}`] || false
}
}
}
@@ -141,10 +167,12 @@ function makeMemoDb () {
function createWorld () {
const wallet = makeWallet('')
const feed = makeFeed()
const profiles = makeProfiles()
const memoPost = new MemoPost({ wallet, feed })
const thread = makeThread()
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
const memoFollow = new MemoFollow({ wallet, profiles })
const memoDb = makeMemoDb()
const world = {
@@ -154,6 +182,7 @@ function createWorld () {
memoPost,
memoReply,
memoLike,
memoFollow,
memoDb,
currentPath: null,
menuOpen: false,
@@ -185,7 +214,6 @@ function createWorld () {
// The Set Name Page and Account Page controllers share a profile store so
// a name set on one page is visible on the other.
const profiles = makeProfiles()
const memoSetName = new MemoSetName({ wallet, profiles })
world.setNamePage = new SetNamePage({
memoSetName,
@@ -1182,6 +1210,140 @@ const handlers = [
throw new Error(`Expected like count ${expected} for reply ${txid}, got ${actual}.`)
}
}
},
{
name: 'API reports I follow address',
pattern: /^the psf-memo-db API reports that I follow the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setFollowState(myAddr, addr, true)
}
},
{
name: 'open profile page for address',
pattern: /^I open the profile page for the address (.+)$/,
async run (m, example, world) {
const addr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr,
myAddr,
memoFollow: world.memoFollow
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
},
{
name: 'open profile page for own address',
pattern: /^I open the profile page for my own address$/,
async run (m, example, world) {
const myAddr = world.wallet.walletInfo.cashAddress
world.profilePage = new ProfilePage({
memoDb: world.memoDb,
addr: myAddr,
myAddr,
memoFollow: world.memoFollow
})
await world.profilePage.load()
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(myAddr)}`
}
},
{
name: 'profile page shows Follow button',
pattern: /^the profile page shows a Follow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canFollow()) {
throw new Error('Profile page cannot show a Follow button for this address.')
}
if (world.profilePage.isFollowing()) {
throw new Error('Profile page shows Unfollow, but Follow was expected.')
}
}
},
{
name: 'profile page shows Unfollow button',
pattern: /^the profile page shows an Unfollow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (!world.profilePage.canFollow()) {
throw new Error('Profile page cannot show an Unfollow button for this address.')
}
if (!world.profilePage.isFollowing()) {
throw new Error('Profile page shows Follow, but Unfollow was expected.')
}
}
},
{
name: 'profile page does not show Follow button',
pattern: /^the profile page does not show a Follow button$/,
run (m, example, world) {
if (!world.profilePage) {
throw new Error('No profile page is loaded.')
}
if (world.profilePage.canFollow()) {
throw new Error('Profile page should not show a Follow button.')
}
}
},
{
name: 'click Follow button',
pattern: /^I click the Follow button$/,
async run (m, example, world) {
await world.profilePage.follow()
}
},
{
name: 'click Unfollow button',
pattern: /^I click the Unfollow button$/,
async run (m, example, world) {
await world.profilePage.unfollow()
}
},
{
name: 'broadcasts OP_RETURN with Memo follow prefix for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_FOLLOW_PREFIX) {
throw new Error(`Expected Memo follow prefix ${MEMO_FOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast follow hash160 did not match ${addr}.`)
}
}
},
{
name: 'broadcasts OP_RETURN with Memo unfollow prefix for address',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) {
throw new Error('No OP_RETURN transaction was broadcast.')
}
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_UNFOLLOW_PREFIX) {
throw new Error(`Expected Memo unfollow prefix ${MEMO_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg.toString('hex') !== hash160) {
throw new Error(`Broadcast unfollow hash160 did not match ${addr}.`)
}
}
}
]
+55
View File
@@ -0,0 +1,55 @@
# Scenarios: Follow User - 1, Follow User - 2, Follow User - 3, Follow User - 4, Follow User - 5
#
# The follow/unfollow OP_RETURN payload is the followee's 20-byte hash160.
# Convert the followee's cash address with bch-js Address.toHash160() (available
# through the minimal-slp-wallet embedded bch-js) before broadcasting 0x6d06 /
# 0x6d07. Do not add a separate cashaddr dependency.
Feature: Follow User
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Follow User - 1 viewing another user's profile shows a Follow button
Given I open the profile page for the address <addr>
Then the profile page shows a Follow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
| bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r |
Scenario Outline: Follow User - 2 clicking Follow broadcasts the follow action and shows Unfollow
Given I open the profile page for the address <addr>
When I click the Follow button
Then the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address <addr>
Then the profile page shows an Unfollow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario Outline: Follow User - 3 clicking Unfollow broadcasts the unfollow action and shows Follow
Given the psf-memo-db API reports that I follow the address <addr>
Given I open the profile page for the address <addr>
When I click the Unfollow button
Then the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address <addr>
Then the profile page shows a Follow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario: Follow User - 4 viewing my own profile does not show a Follow button
Given I open the profile page for my own address
Then the profile page does not show a Follow button
Scenario Outline: Follow User - 5 the profile page shows Unfollow when I already follow the user
Given the psf-memo-db API reports that I follow the address <addr>
Given I open the profile page for the address <addr>
Then the profile page shows an Unfollow button
Examples:
| addr |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
| bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r |
@@ -4,10 +4,12 @@
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Container, Row, Col, Spinner, Card } from 'react-bootstrap'
import { Container, Row, Col, Spinner, Card, Button } from 'react-bootstrap'
import Jdenticon from '@chris.troutner/react-jdenticon'
import MemoDb from '../../../services/memo-db'
import MemoFollow from '../../../services/memo-follow'
import ProfilePage from '../../../services/profile-page'
import PostReplyCount from '../../post-reply-count'
import LikeButton from '../../post-feed/like-button'
import PostThreadModal from '../../post-thread-modal'
@@ -59,6 +61,8 @@ function Profile (props) {
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const [profiles, setProfiles] = useState({})
const [profilePage, setProfilePage] = useState(null)
const [busy, setBusy] = useState(false)
const openThread = (txid) => {
setThreadTxid(txid)
@@ -70,6 +74,28 @@ function Profile (props) {
setThreadTxid(null)
}
const handleFollow = async () => {
if (!profilePage || busy) return
setBusy(true)
try {
await profilePage.follow()
} catch (err) {
setError(err.message || 'Failed to follow')
}
setBusy(false)
}
const handleUnfollow = async () => {
if (!profilePage || busy) return
setBusy(true)
try {
await profilePage.unfollow()
} catch (err) {
setError(err.message || 'Failed to unfollow')
}
setBusy(false)
}
useEffect(() => {
const loadProfile = async () => {
setLoading(true)
@@ -77,16 +103,23 @@ function Profile (props) {
try {
const memoDb = new MemoDb()
const [profile, profilePic, postsData] = await Promise.all([
const myAddr = appData?.wallet?.walletInfo?.cashAddress || null
const memoFollow = myAddr
? new MemoFollow({ wallet: appData.wallet, profiles: appData.profiles })
: null
const page = new ProfilePage({ memoDb, addr, myAddr, memoFollow })
const [profile, profilePic, pageData] = await Promise.all([
memoDb.getProfile(addr),
memoDb.getProfilePic(addr),
memoDb.getPostsByAddr(addr, { limit: 100, offset: 0 })
page.load()
])
setProfileText(profile?.text || '')
setProfilePicUrl(profilePic?.url || null)
setPosts(postsData.posts || [])
setPagination(postsData.pagination || null)
setPosts(pageData.posts || [])
setPagination(pageData.pagination || null)
setProfilePage(page)
setProfiles({}) // Future: load profile names for the post list.
} catch (err) {
setError(err.message || 'Failed to load profile')
@@ -101,7 +134,10 @@ function Profile (props) {
setError('Missing profile address')
setLoading(false)
}
}, [addr])
}, [addr, appData?.wallet, appData?.profiles])
const showFollowButton = profilePage && profilePage.canFollow() && !profilePage.isFollowing()
const showUnfollowButton = profilePage && profilePage.canFollow() && profilePage.isFollowing()
return (
<Container fluid className='profile-page mt-4'>
@@ -131,6 +167,28 @@ function Profile (props) {
<span className='profile-address-label'>BCH</span>
<span className='profile-address-value' title={addr}>{addr}</span>
</div>
{showFollowButton && (
<Button
className='mt-3'
variant='primary'
onClick={handleFollow}
disabled={busy}
data-testid='follow-button'
>
Follow
</Button>
)}
{showUnfollowButton && (
<Button
className='mt-3'
variant='outline-primary'
onClick={handleUnfollow}
disabled={busy}
data-testid='unfollow-button'
>
Unfollow
</Button>
)}
</Col>
<Col lg={9} md={8} className='profile-posts'>
+18
View File
@@ -30,6 +30,24 @@ class MemoDb {
return this.getLevelResource('name', addr, 'getName')
}
async getFollowState (followerAddr, followeeAddr) {
try {
const result = await this.axios.get(
`${config.backend}/follow/state`,
{
params: {
follower: followerAddr,
followee: followeeAddr
}
}
)
return result.data.following === true
} catch (err) {
console.error('Error in getFollowState()')
throw err
}
}
// GET a paginated 'recent' listing endpoint.
async getRecent (path, name, params) {
try {
+116
View File
@@ -0,0 +1,116 @@
/*
Memo follow/unfollow behavior: compose, validate, and broadcast a Memo
follow (0x6d06) or unfollow (0x6d07) action.
A follow transaction carries the followee's 20-byte hash160. The cash
address is converted with the wallet's embedded bch-js Address.toHash160()
so no separate cashaddr dependency is needed.
The wallet and an injected profile store are used so this module stays
testable and free of UI/network concerns; environmentally unsuitable I/O
lives behind those small adapter boundaries.
Constants
MEMO_FOLLOW_PREFIX : hex prefix for the Memo "follow" action (0x6d06)
MEMO_UNFOLLOW_PREFIX : hex prefix for the Memo "unfollow" action (0x6d07)
PK_HASH_LENGTH : size of the followee hash160 in bytes (20)
*/
const MemoAction = require('./memo-action')
const MEMO_FOLLOW_PREFIX = '6d06'
const MEMO_UNFOLLOW_PREFIX = '6d07'
const PK_HASH_LENGTH = 20
class MemoFollow extends MemoAction {
static config = {
prefix: MEMO_FOLLOW_PREFIX,
walletRequiredMsg: 'Memo follow requires a wallet.',
lengthMessage: 'Follow address is invalid.',
emptyMessage: 'Follow address is required.',
lengthCode: 'follow_validation',
validationCode: 'follow_validation'
}
constructor (deps = {}) {
super(deps)
this.profiles = deps.profiles
}
// Validate a candidate cash address. Returns { ok: true } or throws a typed
// validation error.
validate (addr) {
if (typeof addr !== 'string' || addr.trim().length === 0) {
const err = new Error(this.emptyMessage)
err.code = this.validationCode
throw err
}
try {
this._toHash160(addr)
return { ok: true }
} catch (err) {
const validationErr = new Error(`Invalid cash address: ${addr}`)
validationErr.code = this.validationCode
throw validationErr
}
}
// Broadcast a Memo follow for the given followee address.
async follow (followeeAddr) {
return this._broadcastAction(followeeAddr, MEMO_FOLLOW_PREFIX, true)
}
// Broadcast a Memo unfollow for the given followee address.
async unfollow (followeeAddr) {
return this._broadcastAction(followeeAddr, MEMO_UNFOLLOW_PREFIX, false)
}
// Internal: validate, broadcast, and reflect a follow/unfollow action.
async _broadcastAction (followeeAddr, prefix, isFollow) {
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
this._ensureBchjs()
this.validate(followeeAddr)
await this.wallet.getUtxos()
const hash160 = this._toHash160(followeeAddr)
const raw = Buffer.from(hash160, 'hex')
const txid = await this.wallet.sendOpReturn(raw, prefix)
this.reflect(txid, followeeAddr, isFollow)
return txid
}
// Convert a cash address to its 20-byte hash160 hex string using the wallet's
// embedded bch-js.
_toHash160 (addr) {
return this.wallet.bchjs.Address.toHash160(addr)
}
_ensureBchjs () {
if (!this.wallet.bchjs || typeof this.wallet.bchjs.Address.toHash160 !== 'function') {
throw new Error('Wallet does not expose bch-js Address.toHash160.')
}
}
// Record the new follow state on the injected profile store when it exposes
// the follow methods.
reflect (txid, followeeAddr, isFollow) {
if (this.profiles && typeof this.profiles.setFollowState === 'function') {
const myAddr = this.wallet?.walletInfo?.cashAddress
this.profiles.setFollowState(myAddr, followeeAddr, isFollow)
}
}
}
MemoFollow.MEMO_FOLLOW_PREFIX = MEMO_FOLLOW_PREFIX
MemoFollow.MEMO_UNFOLLOW_PREFIX = MEMO_UNFOLLOW_PREFIX
MemoFollow.PK_HASH_LENGTH = PK_HASH_LENGTH
module.exports = MemoFollow
+65 -11
View File
@@ -1,13 +1,14 @@
/*
Profile Page behavior: load and display a single address's Memo posts.
Profile Page behavior: load and display a single address's Memo posts,
follow state, and follow/unfollow controls.
This is the testable controller behind the React "Profile" page. It wraps
the MemoDb client, targets a specific address, and exposes the loaded posts so
the view can render per-post data such as the like count.
the MemoDb client, targets a specific address, exposes the loaded posts,
and coordinates follow state with an injected MemoFollow action.
The memoDb and address concerns are injected so this module stays free of
UI/network concerns; environmentally unsuitable I/O lives behind those small
adapter boundaries.
The memoDb, address, viewer address, and memoFollow concerns are injected so
this module stays free of UI/network concerns; environmentally unsuitable
I/O lives behind those small adapter boundaries.
*/
const PROFILE_PATH_PREFIX = '/profile'
@@ -16,23 +17,76 @@ class ProfilePage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.addr = deps.addr || null
this.myAddr = deps.myAddr || null
this.memoFollow = deps.memoFollow || null
this.posts = []
this.pagination = null
this.followState = null
}
async load ({ limit = 100, offset = 0 } = {}) {
this._assertReady()
const data = await this.memoDb.getPostsByAddr(this.addr, { limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.followState = await this._loadFollowState()
return {
posts: this.posts,
pagination: this.pagination,
followState: this.followState,
isOwnProfile: this.isOwnProfile()
}
}
// Throw unless the injected dependencies and target address are present.
_assertReady () {
if (!this.memoDb) {
throw new Error('Profile page requires a memo db client.')
}
if (!this.addr) {
throw new Error('Profile page requires an address.')
}
}
const data = await this.memoDb.getPostsByAddr(this.addr, { limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
// Fetch the viewer's follow state for the target address, or false when
// there is no viewer or the profile is the viewer's own.
async _loadFollowState () {
if (this.myAddr && !this.isOwnProfile()) {
return this.memoDb.getFollowState(this.myAddr, this.addr)
}
return false
}
return { posts: this.posts, pagination: this.pagination }
isOwnProfile () {
return Boolean(this.myAddr) && this.myAddr === this.addr
}
canFollow () {
return Boolean(this.myAddr) && !this.isOwnProfile()
}
isFollowing () {
return this.followState === true
}
async follow () {
return this._setFollowState('follow', true)
}
async unfollow () {
return this._setFollowState('unfollow', false)
}
// Delegate follow/unfollow to the injected handler and reflect the new state.
async _setFollowState (method, nextState) {
if (!this.memoFollow) {
throw new Error('Profile page requires a memo follow handler.')
}
await this.memoFollow[method](this.addr)
this.followState = nextState
return { ok: true }
}
getPost (txid) {
@@ -45,5 +99,5 @@ ProfilePage.PROFILE_PATH_PREFIX = PROFILE_PATH_PREFIX
module.exports = ProfilePage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T03:38:35.169Z","module_hash":"3d7b866f2997caa538addbc83086aec9b697f318a83cdc976d7f976cefa38e99","functions":[{"id":"func/ProfilePage.constructor","name":"ProfilePage.constructor","line":16,"end_line":21,"hash":"7c71c8c700d9f388b7a6904b8b3fae0f4c50e23288e96ac90e26f89797c16136"},{"id":"func/ProfilePage.load","name":"ProfilePage.load","line":23,"end_line":36,"hash":"6ccf9d1559cc12ba56d876cf68c2b0f369f195ec2d1058451c0b3bb9dcd3a515"},{"id":"func/ProfilePage.getPost","name":"ProfilePage.getPost","line":38,"end_line":40,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-08-27T17:41:02.811Z","module_hash":"556ad5297067de3e39ddbb028a87d502cc0a9ab6525b1bba2c3d36c669207e1d","functions":[{"id":"func/ProfilePage.constructor","name":"ProfilePage.constructor","line":17,"end_line":25,"hash":"5e8bd826d8059e2d86aa3064c7b7d3041d5cf2fff7eb8942976bda600d2812a2"},{"id":"func/ProfilePage.load","name":"ProfilePage.load","line":27,"end_line":41,"hash":"e6c81ab6dbfda80903b134bdfea626d7e82e567780fd577d7a78e6dd44a22f37"},{"id":"func/ProfilePage._assertReady","name":"ProfilePage._assertReady","line":44,"end_line":51,"hash":"e575f98fa1b492b4c11ae5275459d9f9996df4c566b84c7cae91413c72a02f38"},{"id":"func/ProfilePage._loadFollowState","name":"ProfilePage._loadFollowState","line":55,"end_line":60,"hash":"65ff871041cab36b5e45acad5c8ca904c411af197f77dbb528e31ac5666668b3"},{"id":"func/ProfilePage.isOwnProfile","name":"ProfilePage.isOwnProfile","line":62,"end_line":64,"hash":"4d149bfe7b183b5d38d496c0a8126d22bd9a919e684f375d66e2c8c1348dd773"},{"id":"func/ProfilePage.canFollow","name":"ProfilePage.canFollow","line":66,"end_line":68,"hash":"0bc2c0c17991d8f1a1d18fba29c901e42633b7cf2c2c8b5747a49e5832d7001d"},{"id":"func/ProfilePage.isFollowing","name":"ProfilePage.isFollowing","line":70,"end_line":72,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/ProfilePage.follow","name":"ProfilePage.follow","line":74,"end_line":76,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/ProfilePage.unfollow","name":"ProfilePage.unfollow","line":78,"end_line":80,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/ProfilePage._setFollowState","name":"ProfilePage._setFollowState","line":83,"end_line":90,"hash":"8eda1a436468b17327e8e20df4f84bf0263591f320c494f2c74c1b5be21b2ad8"},{"id":"func/ProfilePage.getPost","name":"ProfilePage.getPost","line":92,"end_line":94,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
+15
View File
@@ -14,6 +14,7 @@ class Profiles {
this.names = new Map()
this.bios = new Map()
this.avatarUrls = new Map()
this.following = new Map()
}
setName (addr, name) {
@@ -45,6 +46,20 @@ class Profiles {
if (!addr) return null
return this.avatarUrls.get(addr) || null
}
// Track whether the current wallet follows a given address.
setFollowState (selfAddr, targetAddr, isFollowing) {
if (!selfAddr || !targetAddr) return
if (!this.following.has(selfAddr)) {
this.following.set(selfAddr, new Map())
}
this.following.get(selfAddr).set(targetAddr, isFollowing)
}
getFollowState (selfAddr, targetAddr) {
if (!selfAddr || !targetAddr) return false
return this.following.get(selfAddr)?.get(targetAddr) || false
}
}
module.exports = Profiles
@@ -0,0 +1,132 @@
/*
Property tests for the Memo follow/unfollow behavior.
The unit tests probe the broadcast at a couple of fixed addresses. These
properties cover broad input ranges so the invariants hold everywhere:
- conservation: follow() and unfollow() each broadcast exactly one Memo
action carrying the followee's 20-byte hash160 payload.
- hash length: the broadcast payload is always exactly PK_HASH_LENGTH
bytes (a hash160).
- round trip: follow then unfollow toggles the reflected follow state
back to false, and vice versa.
*/
'use strict'
const test = require('node:test')
const crypto = require('node:crypto')
const { seededRandom, forAll, intGen } = require('./harness')
const MemoFollow = require('../../src/services/memo-follow')
const rng = seededRandom(20260830)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const CHARS = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
// Deterministic 20-byte hash160 hex for any input string, mirroring what a
// real wallet's bch-js produces for a valid cash address.
function hash20 (s) {
return crypto.createHash('sha256').update(s).digest('hex').slice(0, 40)
}
// Random cash-address-shaped string (never empty so it passes validation).
function addressGen () {
const len = intGen(rng, 40, 60)()
let out = 'bitcoincash:q'
for (let i = 0; i < len; i++) {
out += CHARS[Math.floor(rng() * CHARS.length)]
}
return out
}
function makeBchjs () {
return {
Address: {
toHash160 (addr) {
return hash20(addr)
}
}
}
}
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
bchjs: makeBchjs(),
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeProfiles () {
const state = {}
return {
setFollowState: (selfAddr, targetAddr, isFollowing) => {
if (!state[selfAddr]) state[selfAddr] = {}
state[selfAddr][targetAddr] = isFollowing
},
getFollowState: (selfAddr, targetAddr) => state[selfAddr]?.[targetAddr] || false
}
}
test('follow broadcasts exactly one hash160 payload with the follow prefix', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const memoFollow = new MemoFollow({ wallet })
await memoFollow.follow(addr)
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_FOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
},
{ label: 'follow broadcast conservation and hash160 length' }
)
})
test('unfollow broadcasts exactly one hash160 payload with the unfollow prefix', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const memoFollow = new MemoFollow({ wallet })
await memoFollow.unfollow(addr)
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoFollow.MEMO_UNFOLLOW_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg.length === MemoFollow.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
},
{ label: 'unfollow broadcast conservation and hash160 length' }
)
})
test('follow then unfollow round-trips the reflected follow state', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoFollow = new MemoFollow({ wallet, profiles })
await memoFollow.follow(addr)
const afterFollow = profiles.getFollowState(MY_ADDRESS, addr)
await memoFollow.unfollow(addr)
const afterUnfollow = profiles.getFollowState(MY_ADDRESS, addr)
return afterFollow === true && afterUnfollow === false
},
{ label: 'follow/unfollow reflected state round trip' }
)
})
@@ -0,0 +1,141 @@
/*
Unit tests for the Memo follow/unfollow behavior.
The follow action validates the followee cash address, converts it to a
20-byte hash160, and broadcasts it with the Memo follow (0x6d06) or unfollow
(0x6d07) prefix. A successful broadcast is reflected on the injected profile
store.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoFollow = require('../../src/services/memo-follow')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const FOLLOWEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const FOLLOWEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
function makeBchjs () {
return {
Address: {
toHash160 (addr) {
if (addr === FOLLOWEE_ADDRESS) return FOLLOWEE_HASH160
throw new Error('unsupported address in test')
}
}
}
}
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
bchjs: makeBchjs(),
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeProfiles () {
const state = {}
return {
state,
setFollowState: (selfAddr, targetAddr, isFollowing) => {
if (!state[selfAddr]) state[selfAddr] = {}
state[selfAddr][targetAddr] = isFollowing
},
getFollowState: (selfAddr, targetAddr) => state[selfAddr]?.[targetAddr] || false
}
}
test('follow broadcasts with the Memo follow prefix and hash160 payload', async () => {
const wallet = makeWallet()
const memoFollow = new MemoFollow({ wallet })
await memoFollow.follow(FOLLOWEE_ADDRESS)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_FOLLOW_PREFIX)
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
})
test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', async () => {
const wallet = makeWallet()
const memoFollow = new MemoFollow({ wallet })
await memoFollow.unfollow(FOLLOWEE_ADDRESS)
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_UNFOLLOW_PREFIX)
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
})
test('follow reflects the new follow state on the profile store', async () => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoFollow = new MemoFollow({ wallet, profiles })
await memoFollow.follow(FOLLOWEE_ADDRESS)
assert.equal(profiles.getFollowState(MY_ADDRESS, FOLLOWEE_ADDRESS), true)
})
test('unfollow reflects the new unfollow state on the profile store', async () => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoFollow = new MemoFollow({ wallet, profiles })
await memoFollow.unfollow(FOLLOWEE_ADDRESS)
assert.equal(profiles.getFollowState(MY_ADDRESS, FOLLOWEE_ADDRESS), false)
})
test('follow rejects an empty address', async () => {
const wallet = makeWallet()
const memoFollow = new MemoFollow({ wallet })
await assert.rejects(
() => memoFollow.follow(''),
{ code: 'follow_validation', message: /Follow address is required/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('follow requires a wallet', async () => {
const memoFollow = new MemoFollow({})
await assert.rejects(
() => memoFollow.follow(FOLLOWEE_ADDRESS),
/Memo follow requires a wallet/
)
})
test('follow requires bch-js on the wallet', async () => {
const wallet = makeWallet()
wallet.bchjs = null
const memoFollow = new MemoFollow({ wallet })
await assert.rejects(
() => memoFollow.follow(FOLLOWEE_ADDRESS),
/Wallet does not expose bch-js Address.toHash160/
)
})
test('follow surfaces a broadcast failure', async () => {
const wallet = makeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
const memoFollow = new MemoFollow({ wallet })
await assert.rejects(
() => memoFollow.follow(FOLLOWEE_ADDRESS),
/broadcast failed/
)
})
+99 -3
View File
@@ -1,8 +1,9 @@
/*
Unit tests for the profile page controller.
The profile page loads a single address's posts from the MemoDb client. The
like count returned by the API must be preserved so the view can display it.
The profile page loads a single address's posts from the MemoDb client and
fetches the follow state for the viewer. The like count returned by the API
must be preserved so the view can display it.
*/
'use strict'
@@ -11,10 +12,24 @@ const test = require('node:test')
const assert = require('node:assert/strict')
const ProfilePage = require('../../src/services/profile-page')
function makeMemoDb (postsByAddr) {
function makeMemoDb (postsByAddr, followState = {}) {
return {
async getPostsByAddr (addr, { limit, offset }) {
return { posts: postsByAddr[addr] || [], pagination: { total: (postsByAddr[addr] || []).length } }
},
async getFollowState (followerAddr, followeeAddr) {
return followState[`${followerAddr}:${followeeAddr}`] || false
}
}
}
function makeMemoFollow (profilePage) {
return {
async follow (addr) {
profilePage.followState = true
},
async unfollow (addr) {
profilePage.followState = false
}
}
}
@@ -48,6 +63,15 @@ test('load throws when no memo db client is provided', async () => {
)
})
test('load throws when no address is provided', async () => {
const page = new ProfilePage({ memoDb: makeMemoDb({}) })
await assert.rejects(
() => page.load(),
/requires an address/
)
})
test('load defaults limit to 100 and offset to 0', async () => {
const calls = []
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
@@ -55,6 +79,9 @@ test('load defaults limit to 100 and offset to 0', async () => {
async getPostsByAddr (a, params) {
calls.push({ a, params })
return { posts: [], pagination: {} }
},
async getFollowState () {
return false
}
}
const page = new ProfilePage({ memoDb, addr })
@@ -69,6 +96,9 @@ test('load sets pagination to null when the API returns none', async () => {
const memoDb = {
async getPostsByAddr () {
return { posts: [], pagination: undefined }
},
async getFollowState () {
return false
}
}
const page = new ProfilePage({ memoDb, addr })
@@ -78,3 +108,69 @@ test('load sets pagination to null when the API returns none', async () => {
assert.equal(result.pagination, null)
assert.equal(page.pagination, null)
})
test('load fetches follow state when a viewer address is provided', async () => {
const myAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const memoDb = makeMemoDb({}, { [`${myAddr}:${addr}`]: true })
const page = new ProfilePage({ memoDb, addr, myAddr })
const result = await page.load()
assert.equal(result.followState, true)
assert.equal(page.isFollowing(), true)
})
test('isOwnProfile returns true when viewer address matches profile address', () => {
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const page = new ProfilePage({ memoDb: {}, addr, myAddr: addr })
assert.equal(page.isOwnProfile(), true)
assert.equal(page.canFollow(), false)
})
test('canFollow returns true when viewing another profile', () => {
const myAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const page = new ProfilePage({ memoDb: {}, addr, myAddr })
assert.equal(page.canFollow(), true)
})
test('follow delegates to the memo follow handler and updates state', async () => {
const myAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const memoDb = makeMemoDb({})
const page = new ProfilePage({ memoDb, addr, myAddr })
page.memoFollow = makeMemoFollow(page)
const result = await page.follow()
assert.equal(result.ok, true)
assert.equal(page.isFollowing(), true)
})
test('unfollow delegates to the memo follow handler and updates state', async () => {
const myAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const memoDb = makeMemoDb({})
const page = new ProfilePage({ memoDb, addr, myAddr })
page.memoFollow = makeMemoFollow(page)
page.followState = true
const result = await page.unfollow()
assert.equal(result.ok, true)
assert.equal(page.isFollowing(), false)
})
test('follow throws when no memo follow handler is injected', async () => {
const myAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const page = new ProfilePage({ memoDb: makeMemoDb({}), addr, myAddr })
await assert.rejects(
() => page.follow(),
/requires a memo follow handler/
)
})
@@ -94,6 +94,46 @@ test('setAvatarUrl with no address does nothing', () => {
assert.equal(profiles.getAvatarUrl(''), null)
})
test('setFollowState stores and getFollowState retrieves follow state', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
profiles.setFollowState(selfAddr, targetAddr, true)
assert.equal(profiles.getFollowState(selfAddr, targetAddr), true)
})
test('getFollowState returns false for an unknown follow relationship', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
assert.equal(profiles.getFollowState(selfAddr, targetAddr), false)
})
test('setFollowState with no self address does nothing', () => {
const profiles = new Profiles()
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
profiles.setFollowState('', targetAddr, true)
assert.equal(profiles.getFollowState('', targetAddr), false)
})
test('follow state storage is independent per self address', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const otherSelfAddr = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
profiles.setFollowState(selfAddr, targetAddr, true)
profiles.setFollowState(otherSelfAddr, targetAddr, false)
assert.equal(profiles.getFollowState(selfAddr, targetAddr), true)
assert.equal(profiles.getFollowState(otherSelfAddr, targetAddr), false)
})
test('avatar URL storage is independent of name and bio storage', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
+114
View File
@@ -15,6 +15,9 @@ import Adapters from '../../src/adapters/index.js'
import ListRecentPosts from '../../src/use-cases/list-recent-posts.js'
import ListPostsByAddr from '../../src/use-cases/list-posts-by-addr.js'
import GetPostThread from '../../src/use-cases/get-post-thread.js'
import FollowState from '../../src/use-cases/follow-state.js'
import ListFollowing from '../../src/use-cases/list-following.js'
import ListFollowers from '../../src/use-cases/list-followers.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
@@ -82,6 +85,9 @@ async function createWorld () {
const listRecentPosts = new ListRecentPosts({ adapters })
const listPostsByAddr = new ListPostsByAddr({ adapters })
const getPostThread = new GetPostThread({ adapters })
const followState = new FollowState({ adapters })
const listFollowing = new ListFollowing({ adapters })
const listFollowers = new ListFollowers({ adapters })
let lastResponse = null
@@ -90,6 +96,9 @@ async function createWorld () {
listRecentPosts,
listPostsByAddr,
getPostThread,
followState,
listFollowing,
listFollowers,
postHeightsIteratorCounter,
postChildrenIteratorCounter,
postsGetCounter,
@@ -112,6 +121,11 @@ async function loadFixture (world, name) {
return
}
if (name === 'follows') {
await loadFollows(world)
return
}
if (name !== 'three-top-level-posts-and-one-reply') {
throw new Error(`Unknown fixture: ${name}`)
}
@@ -197,6 +211,28 @@ async function loadPostsWithLikes (world) {
}
}
async function loadFollows (world) {
const follower1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const follower2 = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
const followeeHash = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const records = [
{ key: `${follower1}:${followeeHash}`, followerAddr: follower1, followeePkHash: followeeHash, unfollow: false },
{ key: `${follower2}:${followeeHash}`, followerAddr: follower2, followeePkHash: followeeHash, unfollow: false }
]
for (const record of records) {
await world.adapters.level.followsDb.put(record.key, {
followerAddr: record.followerAddr,
followeePkHash: record.followeePkHash,
unfollow: record.unfollow,
txid: `follow-${record.followerAddr.slice(-8)}-${record.followeePkHash.slice(-8)}`,
seen: Date.now(),
blockHeight: 600000
})
}
}
const handlers = [
{
name: 'db instance with posts and postHeights stores',
@@ -391,6 +427,84 @@ const handlers = [
throw new Error(`Expected exactly one postChildren scan, got ${calls}`)
}
}
},
{
name: 'db instance with follows store',
pattern: /^a psf-memo-db instance with a follows store$/,
async run () {
// World is already created with the follows store.
}
},
{
name: 'load fixture into follows store',
pattern: /^the fixture "(.+)" is loaded into the follows store$/,
async run (m, example, world) {
await loadFixture(world, m[1])
}
},
{
name: 'request follow state',
pattern: /^the client requests the follow state for follower (<[A-Za-z0-9_]+>) and followee (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const follower = resolveParam(m[1], example)
const followee = resolveParam(m[2], example)
const resp = await world.followState.execute({ followerAddr: follower, followeeAddr: followee })
world.setLastResponse(resp)
}
},
{
name: 'follow state reports following',
pattern: /^the follow state reports following (<following>)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example) === 'true'
const actual = world.getLastResponse().following
if (actual !== expected) {
throw new Error(`Expected following ${expected}, got ${actual}`)
}
}
},
{
name: 'request following list',
pattern: /^the client requests the following list for (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const follower = resolveParam(m[1], example)
const resp = await world.listFollowing.execute({ followerAddr: follower })
world.setLastResponse(resp)
}
},
{
name: 'following list contains addresses',
pattern: /^the following list contains the addresses (<[A-Za-z0-9_]+>)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim()).filter(Boolean)
const actual = world.getLastResponse().following
if (expected.join(',') !== actual.join(',')) {
throw new Error(`Expected following ${expected.join(',')}, got ${actual.join(',')}`)
}
}
},
{
name: 'request followers list',
pattern: /^the client requests the followers list for (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const followee = resolveParam(m[1], example)
const resp = await world.listFollowers.execute({ followeeAddr: followee })
world.setLastResponse(resp)
}
},
{
name: 'followers list contains addresses',
pattern: /^the followers list contains the addresses (<[A-Za-z0-9_]+>)$/,
run (m, example, world) {
const raw = resolveParam(m[1], example).trim()
const expected = raw.length === 0 ? [] : raw.split(',').map((s) => s.trim())
const actual = world.getLastResponse().followers
const expectedSet = new Set(expected)
const actualSet = new Set(actual)
if (expectedSet.size !== actualSet.size || !expectedSet.isSubsetOf(actualSet)) {
throw new Error(`Expected followers ${expected.join(',')}, got ${actual.join(',')}`)
}
}
}
]
+1996 -271
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"url": "localhost:5021"
},
"dependencies": {
"@psf/bch-js": "7.1.11",
"dotenv": "17.2.3",
"kcors": "2.2.2",
"koa": "2.13.1",
+39
View File
@@ -0,0 +1,39 @@
# Scenarios: Follow Read - 1, Follow Read - 2, Follow Read - 3
#
# The follows store keys followees by 20-byte hash160. Use bch-js
# Address.toHash160() / Address.hash160ToCash() to convert between cash
# addresses and hash160 for the follow state and following/followers lists.
# Prefer bch-js over adding a separate cashaddr dependency.
Feature: Follow Read
Background:
Given a psf-memo-db instance with a follows store
Given the fixture "follows" is loaded into the follows store
Scenario Outline: Follow Read - 1 GET /follow/state reports whether a follower follows a followee
When the client requests the follow state for follower <follower> and followee <followee>
Then the follow state reports following <following>
Examples:
| follower | followee | following |
| bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | true |
| bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | false |
| bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | true |
Scenario Outline: Follow Read - 2 GET /follow/following lists the addresses a follower follows
When the client requests the following list for <follower>
Then the following list contains the addresses <expected>
Examples:
| follower | expected |
| bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
| bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy |
Scenario Outline: Follow Read - 3 GET /follow/followers lists the addresses that follow a followee
When the client requests the followers list for <followee>
Then the followers list contains the addresses <expected>
Examples:
| followee | expected |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d,bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a |
| bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | |
+84
View File
@@ -0,0 +1,84 @@
/*
Adapter for querying Memo follow relationships from the follows LevelDB.
The indexer stores a follow record keyed as `${followerAddr}:${followeeHash160}`
with an `unfollow` flag. This adapter exposes:
- isFollowing(followerAddr, followeeAddr)
- listFollowing(followerAddr)
- listFollowers(followeeAddr)
Cash addresses are converted to 20-byte hash160 hex via bch-js Address.toHash160()
and back via Address.hash160ToCash().
*/
import BCHJS from '@psf/bch-js'
class FollowQuery {
constructor (localConfig = {}) {
const { followsDb, bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' }) } = localConfig
if (!followsDb) {
throw new Error('followsDb required when instantiating FollowQuery adapter.')
}
this.followsDb = followsDb
this.bchjs = bchjs
this.isFollowing = this.isFollowing.bind(this)
this.listFollowing = this.listFollowing.bind(this)
this.listFollowers = this.listFollowers.bind(this)
}
// Return true when followerAddr has an active (not unfollowed) follow record
// for followeeAddr.
async isFollowing (followerAddr, followeeAddr) {
const hash160 = this._toHash160(followeeAddr)
const key = `${followerAddr}:${hash160}`
try {
const record = await this.followsDb.get(key)
return record.unfollow !== true
} catch (err) {
if (err.notFound) return false
throw err
}
}
// Return the cash addresses the follower currently follows.
async listFollowing (followerAddr) {
const prefix = `${followerAddr}:`
const following = new Set()
for await (const [key, record] of this.followsDb.iterator({ gte: prefix, lt: this._nextString(prefix) })) {
if (record.unfollow === true) continue
const hash160 = key.slice(prefix.length)
following.add(this._toCashAddress(hash160))
}
return Array.from(following)
}
// Return the cash addresses that currently follow the followee.
async listFollowers (followeeAddr) {
const hash160 = this._toHash160(followeeAddr)
const suffix = `:${hash160}`
const followers = new Set()
for await (const [key, record] of this.followsDb.iterator()) {
if (!key.endsWith(suffix)) continue
if (record.unfollow === true) continue
const followerAddr = key.slice(0, key.length - suffix.length)
followers.add(followerAddr)
}
return Array.from(followers)
}
_toHash160 (addr) {
return this.bchjs.Address.toHash160(addr)
}
_toCashAddress (hash160) {
return this.bchjs.Address.hash160ToCash(hash160)
}
// Lexicographic successor for a string, used as an exclusive upper bound
// for LevelDB prefix scans.
_nextString (s) {
return s.slice(0, -1) + String.fromCharCode(s.charCodeAt(s.length - 1) + 1)
}
}
export default FollowQuery
+4
View File
@@ -6,6 +6,7 @@ import LevelDb from './level-db.js'
import DbBackup from './db-backup.js'
import ProfileQuery from './profile-query.js'
import PostQuery from './post-query.js'
import FollowQuery from './follow-query.js'
class Adapters {
constructor () {
@@ -29,6 +30,9 @@ class Adapters {
postChildrenDb: level.postChildrenDb,
likesDb: level.likesDb
})
this.followQuery = new FollowQuery({
followsDb: level.followsDb
})
return true
}
@@ -0,0 +1,111 @@
/*
REST API controller for /follow routes.
*/
import wlogger from '../../../adapters/wlogger.js'
class FollowRESTControllerLib {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
this.useCases = localConfig.useCases
if (!this.adapters) {
throw new Error('Adapters required for Follow REST Controller.')
}
if (!this.useCases) {
throw new Error('Use Cases required for Follow REST Controller.')
}
this.getFollowState = this.getFollowState.bind(this)
this.getFollowing = this.getFollowing.bind(this)
this.getFollowers = this.getFollowers.bind(this)
this.handleError = this.handleError.bind(this)
}
handleError (ctx, err) {
if (err.status) {
ctx.throw(err.status, err.message || err)
} else {
wlogger.error('Error in follow controller: ', err)
ctx.throw(500, err.message || 'Internal server error')
}
}
/**
* @api {get} /follow/state Check follow state
* @apiPermission public
* @apiName GetFollowState
* @apiGroup REST Follow
*
* @apiDescription Returns whether a follower address follows a followee address.
*
* @apiQuery {String} follower Cash address of the follower
* @apiQuery {String} followee Cash address of the followee
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/follow/state?follower=bitcoincash:q...&followee=bitcoincash:q..."
*
* @apiSuccess {String} followerAddr Follower cash address
* @apiSuccess {String} followeeAddr Followee cash address
* @apiSuccess {Boolean} following True when an active follow record exists
*/
async getFollowState (ctx) {
try {
const { follower, followee } = ctx.query
ctx.body = await this.useCases.followState.execute({ followerAddr: follower, followeeAddr: followee })
} catch (err) {
this.handleError(ctx, err)
}
}
/**
* @api {get} /follow/following List following
* @apiPermission public
* @apiName GetFollowing
* @apiGroup REST Follow
*
* @apiDescription Returns the addresses a follower currently follows.
*
* @apiParam {String} follower Cash address of the follower
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/follow/following/bitcoincash:q..."
*
* @apiSuccess {String} followerAddr Follower cash address
* @apiSuccess {String[]} following Array of followee cash addresses
*/
async getFollowing (ctx) {
try {
const { follower } = ctx.params
ctx.body = await this.useCases.listFollowing.execute({ followerAddr: follower })
} catch (err) {
this.handleError(ctx, err)
}
}
/**
* @api {get} /follow/followers List followers
* @apiPermission public
* @apiName GetFollowers
* @apiGroup REST Follow
*
* @apiDescription Returns the addresses that currently follow a followee.
*
* @apiParam {String} followee Cash address of the followee
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/follow/followers/bitcoincash:q..."
*
* @apiSuccess {String} followeeAddr Followee cash address
* @apiSuccess {String[]} followers Array of follower cash addresses
*/
async getFollowers (ctx) {
try {
const { followee } = ctx.params
ctx.body = await this.useCases.listFollowers.execute({ followeeAddr: followee })
} catch (err) {
this.handleError(ctx, err)
}
}
}
export default FollowRESTControllerLib
@@ -0,0 +1,36 @@
/*
REST API router for /follow routes.
*/
import Router from 'koa-router'
import FollowRESTControllerLib from './controller.js'
class FollowRouter {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
this.useCases = localConfig.useCases
if (!this.adapters) {
throw new Error('Adapters required when instantiating Follow REST Controller.')
}
if (!this.useCases) {
throw new Error('Use Cases required when instantiating Follow REST Controller.')
}
this.followRESTController = new FollowRESTControllerLib({
adapters: this.adapters,
useCases: this.useCases
})
this.router = new Router({ prefix: '/follow' })
}
attach (app) {
this.router.get('/state', this.followRESTController.getFollowState)
this.router.get('/following/:follower', this.followRESTController.getFollowing)
this.router.get('/followers/:followee', this.followRESTController.getFollowers)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
}
export default FollowRouter
@@ -6,6 +6,7 @@ import LevelRESTController from './level/index.js'
import HealthRouter from './health/index.js'
import ProfileRouter from './profile/index.js'
import PostsRouter from './posts/index.js'
import FollowRouter from './follow/index.js'
class RESTControllers {
constructor (localConfig = {}) {
@@ -31,6 +32,9 @@ class RESTControllers {
const postsRouter = new PostsRouter(dependencies)
postsRouter.attach(app)
const followRouter = new FollowRouter(dependencies)
followRouter.attach(app)
}
}
+29
View File
@@ -0,0 +1,29 @@
/*
Use case: report whether one address follows another.
Returns { followerAddr, followeeAddr, following: boolean }.
*/
import { ListUseCase } from './lib/use-case.js'
class FollowState extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'FollowState', adapterName: 'followQuery' })
}
async execute (inObj = {}) {
const { followerAddr, followeeAddr } = inObj
if (!followerAddr || typeof followerAddr !== 'string') {
throw new Error('followerAddr is required')
}
if (!followeeAddr || typeof followeeAddr !== 'string') {
throw new Error('followeeAddr is required')
}
const following = await this.adapters.followQuery.isFollowing(followerAddr, followeeAddr)
return { followerAddr, followeeAddr, following }
}
}
export default FollowState
+18
View File
@@ -6,6 +6,9 @@ import ListRecentProfiles from './list-recent-profiles.js'
import ListRecentPosts from './list-recent-posts.js'
import ListPostsByAddr from './list-posts-by-addr.js'
import GetPostThread from './get-post-thread.js'
import FollowState from './follow-state.js'
import ListFollowing from './list-following.js'
import ListFollowers from './list-followers.js'
class UseCases {
constructor (localConfig = {}) {
@@ -21,6 +24,9 @@ class UseCases {
this.listRecentPosts = null
this.listPostsByAddr = null
this.getPostThread = null
this.followState = null
this.listFollowing = null
this.listFollowers = null
}
async start () {
@@ -40,6 +46,18 @@ class UseCases {
adapters: this.adapters
})
this.followState = new FollowState({
adapters: this.adapters
})
this.listFollowing = new ListFollowing({
adapters: this.adapters
})
this.listFollowers = new ListFollowers({
adapters: this.adapters
})
console.log('Use cases initialized.')
return true
@@ -0,0 +1,30 @@
/*
Shared construction and execution contract for the follow list use cases
(list-following, list-followers).
Both follow list use cases validate a single address field, call the matching
FollowQuery list method, and return that address alongside the result list.
Centralizing this removes the per-class constructor and execute boilerplate
that the two endpoints previously duplicated. Construction validation is
delegated to ListUseCase.
*/
import { ListUseCase } from './use-case.js'
export class FollowListUseCase extends ListUseCase {
constructor (localConfig, { useCaseName, adapterMethod, addrField, resultField }) {
super(localConfig, { useCaseName, adapterName: 'followQuery' })
this.addrField = addrField
this.resultField = resultField
this._list = this.adapters.followQuery[adapterMethod].bind(this.adapters.followQuery)
}
async execute (inObj = {}) {
const addr = inObj[this.addrField]
if (!addr || typeof addr !== 'string') {
throw new Error(`${this.addrField} is required`)
}
const list = await this._list(addr)
return { [this.addrField]: addr, [this.resultField]: list }
}
}
@@ -0,0 +1,20 @@
/*
Use case: list the addresses that currently follow a followee.
Returns { followeeAddr, followers: string[] }.
*/
import { FollowListUseCase } from './lib/follow-list-use-case.js'
class ListFollowers extends FollowListUseCase {
constructor (localConfig = {}) {
super(localConfig, {
useCaseName: 'ListFollowers',
adapterMethod: 'listFollowers',
addrField: 'followeeAddr',
resultField: 'followers'
})
}
}
export default ListFollowers
@@ -0,0 +1,20 @@
/*
Use case: list the addresses a follower currently follows.
Returns { followerAddr, following: string[] }.
*/
import { FollowListUseCase } from './lib/follow-list-use-case.js'
class ListFollowing extends FollowListUseCase {
constructor (localConfig = {}) {
super(localConfig, {
useCaseName: 'ListFollowing',
adapterMethod: 'listFollowing',
addrField: 'followerAddr',
resultField: 'following'
})
}
}
export default ListFollowing
@@ -0,0 +1,158 @@
/*
Property tests for the FollowQuery adapter.
The unit tests probe isFollowing/listFollowing/listFollowers at a few fixed
fixtures. These properties cover broad random record sets so the invariants
hold everywhere:
- round trip: a hash160 converts to a cash address and back to the same
hash160.
- isFollowing conservation: an active follow record reports true and an
unfollow record reports false, regardless of surrounding records.
- list consistency: listFollowing and listFollowers each return exactly
the active relationships in their direction.
*/
import test from 'node:test'
import BCHJS from '@psf/bch-js'
import { seededRandom, forAll, intGen } from './harness.js'
import FollowQuery from '../../src/adapters/follow-query.js'
const rng = seededRandom(20260830)
const bchjs = new BCHJS({ restURL: 'https://api.fullstack.cash/v5/' })
const HEX = '0123456789abcdef'
function hash160Gen () {
let out = ''
for (let i = 0; i < 40; i++) {
out += HEX[Math.floor(rng() * HEX.length)]
}
return out
}
// An in-memory follows Db mirroring the LevelDB contract FollowQuery relies on.
function makeFollowsDb (records) {
const store = new Map(records.map((r) => [r.key, r]))
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
iterator (opts = {}) {
const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0]))
const { gte, lt } = opts
const filtered = entries.filter(([key]) => {
if (gte && key < gte) return false
if (lt && key >= lt) return false
return true
})
let i = 0
return {
[Symbol.asyncIterator] () {
return this
},
async next () {
if (i >= filtered.length) return { value: undefined, done: true }
const entry = filtered[i++]
return { value: entry, done: false }
},
async close () {}
}
}
}
}
// Build a random set of follow records: a mix of followers, hash160s, and
// follow/unfollow flags. Not every pair is recorded.
function recordSetGen () {
const followers = []
for (let i = 0, n = intGen(rng, 1, 6)(); i < n; i++) followers.push(`bitcoincash:q${hash160Gen()}`)
const hash160s = []
for (let i = 0, n = intGen(rng, 1, 6)(); i < n; i++) hash160s.push(hash160Gen())
const records = []
for (const follower of followers) {
for (const hash160 of hash160s) {
if (rng() < 0.4) continue
records.push({ key: `${follower}:${hash160}`, unfollow: rng() < 0.5 })
}
}
return { followers, hash160s, records }
}
test('hash160 to cash address and back round-trips to the same hash160', async () => {
await forAll(
(i) => hash160Gen(),
(hash160) => {
const cash = bchjs.Address.hash160ToCash(hash160)
return bchjs.Address.toHash160(cash) === hash160
},
{ label: 'hash160 <-> cash address round trip' }
)
})
test('isFollowing is true exactly for active follow records', async () => {
await forAll(
(i) => recordSetGen(),
async ({ records }) => {
const query = new FollowQuery({ followsDb: makeFollowsDb(records), bchjs })
for (const record of records) {
// Follower cash addresses contain a colon ('bitcoincash:q...'), so the
// record key has two colons; the hash160 is the trailing segment.
const sep = record.key.lastIndexOf(':')
const follower = record.key.slice(0, sep)
const hash160 = record.key.slice(sep + 1)
const followee = bchjs.Address.hash160ToCash(hash160)
const following = await query.isFollowing(follower, followee)
if (following !== (record.unfollow !== true)) return false
}
return true
},
{ label: 'isFollowing conservation' }
)
})
test('listFollowing returns exactly the active followees for a follower', async () => {
await forAll(
(i) => recordSetGen(),
async ({ followers, records }) => {
const query = new FollowQuery({ followsDb: makeFollowsDb(records), bchjs })
for (const follower of followers) {
const prefix = `${follower}:`
const expected = records
.filter((r) => r.key.startsWith(prefix) && r.unfollow !== true)
.map((r) => bchjs.Address.hash160ToCash(r.key.slice(prefix.length)))
const got = (await query.listFollowing(follower)).sort()
const want = Array.from(new Set(expected)).sort()
if (JSON.stringify(got) !== JSON.stringify(want)) return false
}
return true
},
{ label: 'listFollowing consistency' }
)
})
test('listFollowers returns exactly the active followers for a followee', async () => {
await forAll(
(i) => recordSetGen(),
async ({ hash160s, records }) => {
const query = new FollowQuery({ followsDb: makeFollowsDb(records), bchjs })
for (const hash160 of hash160s) {
const suffix = `:${hash160}`
const expected = records
.filter((r) => r.key.endsWith(suffix) && r.unfollow !== true)
.map((r) => r.key.slice(0, r.key.length - suffix.length))
const got = (await query.listFollowers(bchjs.Address.hash160ToCash(hash160))).sort()
const want = Array.from(new Set(expected)).sort()
if (JSON.stringify(got) !== JSON.stringify(want)) return false
}
return true
},
{ label: 'listFollowers consistency' }
)
})
@@ -0,0 +1,112 @@
/*
Unit tests for the FollowQuery adapter.
*/
import { assert } from 'chai'
import FollowQuery from '../../../src/adapters/follow-query.js'
function makeFollowsDb (records = {}) {
const store = new Map(Object.entries(records))
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
iterator (opts = {}) {
const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0]))
const { gte, lt } = opts
const filtered = entries.filter(([key]) => {
if (gte && key < gte) return false
if (lt && key >= lt) return false
return true
})
let i = 0
return {
[Symbol.asyncIterator] () {
return this
},
async next () {
if (i >= filtered.length) return { value: undefined, done: true }
const entry = filtered[i++]
return { value: entry, done: false }
},
async close () {}
}
}
}
}
const FOLLOWER = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const FOLLOWEE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const OTHER_FOLLOWER = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
describe('#FollowQuery', () => {
it('should throw when followsDb is missing', () => {
assert.throws(() => new FollowQuery({}), /followsDb required/)
})
it('isFollowing returns false when no follow record exists', async () => {
const query = new FollowQuery({ followsDb: makeFollowsDb({}) })
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
assert.equal(result, false)
})
it('isFollowing returns true for an active follow record', async () => {
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const followsDb = makeFollowsDb({
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false }
})
const query = new FollowQuery({ followsDb })
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
assert.equal(result, true)
})
it('isFollowing returns false when the latest record is an unfollow', async () => {
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const followsDb = makeFollowsDb({
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: true }
})
const query = new FollowQuery({ followsDb })
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
assert.equal(result, false)
})
it('listFollowing returns active followees for a follower', async () => {
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const hash160Two = '44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3'
const followsDb = makeFollowsDb({
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false },
[`${FOLLOWER}:${hash160Two}`]: { followerAddr: FOLLOWER, followeePkHash: hash160Two, unfollow: true },
[`${OTHER_FOLLOWER}:${hash160}`]: { followerAddr: OTHER_FOLLOWER, followeePkHash: hash160, unfollow: false }
})
const query = new FollowQuery({ followsDb })
const result = await query.listFollowing(FOLLOWER)
assert.deepEqual(result, [FOLLOWEE])
})
it('listFollowers returns active followers for a followee', async () => {
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const followsDb = makeFollowsDb({
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false },
[`${OTHER_FOLLOWER}:${hash160}`]: { followerAddr: OTHER_FOLLOWER, followeePkHash: hash160, unfollow: false },
[`${FOLLOWER}:44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3`]: { followerAddr: FOLLOWER, followeePkHash: '44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3', unfollow: false }
})
const query = new FollowQuery({ followsDb })
const result = await query.listFollowers(FOLLOWEE)
assert.sameMembers(result, [FOLLOWER, OTHER_FOLLOWER])
})
it('listFollowers ignores unfollow records', async () => {
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
const followsDb = makeFollowsDb({
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: true }
})
const query = new FollowQuery({ followsDb })
const result = await query.listFollowers(FOLLOWEE)
assert.deepEqual(result, [])
})
})
@@ -0,0 +1,75 @@
/*
Unit tests for the Follow REST controller.
*/
import { assert } from 'chai'
import sinon from 'sinon'
import FollowRESTControllerLib from '../../../src/controllers/rest-api/follow/controller.js'
describe('#FollowRESTController', () => {
let sandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
})
afterEach(() => sandbox.restore())
function makeUut (useCases) {
return new FollowRESTControllerLib({
adapters: {},
useCases
})
}
function makeCtx (query = {}, params = {}) {
return { query, params, body: null, throw: sandbox.stub() }
}
it('should return follow state from use case', async () => {
const followState = { execute: sandbox.stub().resolves({ followerAddr: 'a', followeeAddr: 'b', following: true }) }
const uut = makeUut({ followState })
const ctx = makeCtx({ follower: 'a', followee: 'b' })
await uut.getFollowState(ctx)
assert.equal(followState.execute.callCount, 1)
assert.deepEqual(followState.execute.firstCall.args[0], { followerAddr: 'a', followeeAddr: 'b' })
assert.equal(ctx.body.following, true)
})
it('should return following list from use case', async () => {
const listFollowing = { execute: sandbox.stub().resolves({ followerAddr: 'a', following: ['b', 'c'] }) }
const uut = makeUut({ listFollowing })
const ctx = makeCtx({}, { follower: 'a' })
await uut.getFollowing(ctx)
assert.equal(listFollowing.execute.callCount, 1)
assert.deepEqual(listFollowing.execute.firstCall.args[0], { followerAddr: 'a' })
assert.deepEqual(ctx.body.following, ['b', 'c'])
})
it('should return followers list from use case', async () => {
const listFollowers = { execute: sandbox.stub().resolves({ followeeAddr: 'b', followers: ['a', 'c'] }) }
const uut = makeUut({ listFollowers })
const ctx = makeCtx({}, { followee: 'b' })
await uut.getFollowers(ctx)
assert.equal(listFollowers.execute.callCount, 1)
assert.deepEqual(listFollowers.execute.firstCall.args[0], { followeeAddr: 'b' })
assert.deepEqual(ctx.body.followers, ['a', 'c'])
})
it('should handle use case errors', async () => {
const followState = { execute: sandbox.stub().rejects(new Error('boom')) }
const uut = makeUut({ followState })
const ctx = makeCtx({ follower: 'a', followee: 'b' })
await uut.getFollowState(ctx)
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 500)
})
})
@@ -0,0 +1,72 @@
/*
Unit tests for the FollowState use case.
*/
import { assert } from 'chai'
import FollowState from '../../../src/use-cases/follow-state.js'
function makeAdapters (isFollowingResult) {
return {
followQuery: {
async isFollowing (followerAddr, followeeAddr) {
return isFollowingResult
}
}
}
}
describe('#FollowState', () => {
it('should throw when adapters is missing', () => {
assert.throws(() => new FollowState({}), /Adapters required/)
})
it('should throw when followQuery adapter is missing', () => {
assert.throws(() => new FollowState({ adapters: {} }), /followQuery adapter required/)
})
it('should return following=true', async () => {
const useCase = new FollowState({ adapters: makeAdapters(true) })
const result = await useCase.execute({
followerAddr: 'bitcoincash:follower',
followeeAddr: 'bitcoincash:followee'
})
assert.deepEqual(result, {
followerAddr: 'bitcoincash:follower',
followeeAddr: 'bitcoincash:followee',
following: true
})
})
it('should return following=false', async () => {
const useCase = new FollowState({ adapters: makeAdapters(false) })
const result = await useCase.execute({
followerAddr: 'bitcoincash:follower',
followeeAddr: 'bitcoincash:followee'
})
assert.deepEqual(result, {
followerAddr: 'bitcoincash:follower',
followeeAddr: 'bitcoincash:followee',
following: false
})
})
it('should reject a missing followerAddr', async () => {
const useCase = new FollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ followeeAddr: 'bitcoincash:followee' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /followerAddr is required/)
}
})
it('should reject a missing followeeAddr', async () => {
const useCase = new FollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ followerAddr: 'bitcoincash:follower' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /followeeAddr is required/)
}
})
})
@@ -0,0 +1,45 @@
/*
Unit tests for the ListFollowers use case.
*/
import { assert } from 'chai'
import ListFollowers from '../../../src/use-cases/list-followers.js'
function makeAdapters (followers) {
return {
followQuery: {
async listFollowers (followeeAddr) {
return followers
}
}
}
}
describe('#ListFollowers', () => {
it('should throw when adapters is missing', () => {
assert.throws(() => new ListFollowers({}), /Adapters required/)
})
it('should throw when followQuery adapter is missing', () => {
assert.throws(() => new ListFollowers({ adapters: {} }), /followQuery adapter required/)
})
it('should return the followers list', async () => {
const useCase = new ListFollowers({ adapters: makeAdapters(['bitcoincash:a', 'bitcoincash:b']) })
const result = await useCase.execute({ followeeAddr: 'bitcoincash:followee' })
assert.deepEqual(result, {
followeeAddr: 'bitcoincash:followee',
followers: ['bitcoincash:a', 'bitcoincash:b']
})
})
it('should reject a missing followeeAddr', async () => {
const useCase = new ListFollowers({ adapters: makeAdapters([]) })
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /followeeAddr is required/)
}
})
})
@@ -0,0 +1,45 @@
/*
Unit tests for the ListFollowing use case.
*/
import { assert } from 'chai'
import ListFollowing from '../../../src/use-cases/list-following.js'
function makeAdapters (following) {
return {
followQuery: {
async listFollowing (followerAddr) {
return following
}
}
}
}
describe('#ListFollowing', () => {
it('should throw when adapters is missing', () => {
assert.throws(() => new ListFollowing({}), /Adapters required/)
})
it('should throw when followQuery adapter is missing', () => {
assert.throws(() => new ListFollowing({ adapters: {} }), /followQuery adapter required/)
})
it('should return the following list', async () => {
const useCase = new ListFollowing({ adapters: makeAdapters(['bitcoincash:a', 'bitcoincash:b']) })
const result = await useCase.execute({ followerAddr: 'bitcoincash:follower' })
assert.deepEqual(result, {
followerAddr: 'bitcoincash:follower',
following: ['bitcoincash:a', 'bitcoincash:b']
})
})
it('should reject a missing followerAddr', async () => {
const useCase = new ListFollowing({ adapters: makeAdapters([]) })
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /followerAddr is required/)
}
})
})
+20 -11
View File
@@ -167,17 +167,17 @@ Goal: reach feature parity with [memo.cash](https://memo.cash).
| Tier | Features | Status |
|------|----------|--------|
| **P0** | Post, Set name, Reply, Efficient pagination | ✅ shipped |
| **P1** | Like counts (read side) ✅; client display ✅; Set profile text ✅; Set profile picture, Follow/Unfollow | next |
| **P1** | Like counts (read side) ✅; client display ✅; Set profile text ✅; Set profile picture ✅; Follow/Unfollow | next |
| **P2** | Topics (list, feed, post, follow/unfollow) | later |
| **P3** | Polls (create, add option, vote) | later |
| **P4** | Mute / unmute user | later |
| **P5** | Send money memo action, MIP-0009 token exchange | later |
| **P6** | Repost, ranked feed, notifications, search, tags, following feed | later |
**Suggested next spec:** P1.4 — add a "Set Avatar URL" UI on the Account page that
broadcasts the `0x6d0a` Set profile picture action. The indexer and DB already
read/store profile pictures; the missing piece is the client write path and the
Account page editor.
**Suggested next spec:** P1.5/P1.6 — add Follow/Unfollow buttons on the profile
page that broadcast `0x6d06`/`0x6d07`, plus the DB read side (follow state,
following/followers lists). The indexer already stores follows in `followsDb`;
the missing pieces are the client write path and the DB read side.
---
@@ -313,6 +313,17 @@ that a single user-facing feature may require specs in more than one component.
client Set Bio UI enforces 217. The indexer's `handleSetProfile` still
validates against `MAX_POST_SIZE = 65000`; the looser indexer limit is a
separate hardening item (protocol parity would use 217).
14. **set-avatar-url Scenario 1 has a tautological assertion (gotcha #12 again).**
The "account page shows my avatar URL as \"<url>\"" assertion echoes the same
example value that was broadcast, so Gherkin mutation of the URL survives
trivially. Same pattern as set-bio Scenario 1. If tightening, tie the
assertion to independent fixture data rather than the broadcast example.
15. **Use bch-js for cashaddr conversion, not a new dependency.** The follow
(`0x6d06`) / unfollow (`0x6d07`) payload is the followee's 20-byte hash160
(P2PKH). Convert with `bchjs.Address.toHash160()` (client, via the
minimal-slp-wallet embedded bch-js) and `bchjs.Address.hash160ToCash()`
(DB read side). Prefer bch-js over installing a separate cashaddr library.
See `specs/feature-backlog.md` "Suggested next spec" for the follow feature.
---
@@ -350,9 +361,7 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `e321c7f` (set-bio merged to `master`; client unit 48,
property 6, acceptance all-features, lint, and build passing). An unrelated
`swarmforge.conf` specifier-model switch that leaked in with the merge was
reverted to keep the merge focused on set-bio.
Next action: **spec P1.4** — add a "Set Avatar URL" UI on the Account page that
broadcasts the `0x6d0a` Set profile picture action.
Current `master` HEAD: `ba2b3bf` (set-avatar-url merged to `master`; client unit
74, property 11, acceptance all-features, lint, and build passing).
Next action: **spec P1.5/P1.6** — Follow/Unfollow user (client write path +
DB read side).
+25 -9
View File
@@ -122,7 +122,7 @@ adding/editing the client UI.
| 1.1 | Like / tip a Memo — read side | `0x6d04` | D | ✅ | `likeCount` returned on `/posts/*` and `/posts/:txid/thread` |
| 1.2 | Like / tip a Memo — client display | `0x6d04` | C | ✅ | Feed/Profile/Thread read `likeCount` from API instead of defaulting to 0 |
| 1.3 | Set profile text (bio) | `0x6d05` | C | ✅ | C: "Set Bio" UI on Account page broadcasts `0x6d05` (217-byte limit) |
| 1.4 | Set profile picture | `0x6d0a` | C, I, D | 🟡 partial | C: add "Set Avatar URL" UI; I/D already read |
| 1.4 | Set profile picture | `0x6d0a` | C, I, D | | C: "Set Avatar URL" UI broadcasts `0x6d0a` (217-byte limit); I/D already read |
| 1.5 | Follow a user | `0x6d06` | C, I, D | 🔴 missing | C: follow button on profile; D: follow state + following/followers lists |
| 1.6 | Unfollow a user | `0x6d07` | C, I, D | 🔴 missing | C: unfollow button; D: follow state |
@@ -133,8 +133,8 @@ adding/editing the client UI.
thread views now read `likeCount` from the API (profile shows a read-only
like button).
3. **Set profile text** ✅ DONE. Account page "Set Bio" UI broadcasts `0x6d05` with a 217-byte limit and byte counter (task `set-bio`).
4. **Set profile picture** — URL broadcast + Account page UI. NEXT.
5. **Follow / Unfollow user** — social graph; enables the following feed later.
4. **Set profile picture** ✅ DONE. Account page "Set Avatar URL" UI broadcasts `0x6d0a` with a 217-byte limit and byte counter (task `set-avatar-url`).
5. **Follow / Unfollow user** — social graph; enables the following feed later. NEXT.
### Like / tip details
@@ -213,13 +213,29 @@ Polls require a new data model and rendering. The indexer has no handler yet.
## Suggested next spec
**Set profile picture (bio)** (P1.4):
- `psf-memo-client`: add a "Set Avatar URL" UI on the Account page that broadcasts
the `0x6d0a` Set profile picture action via `minimal-slp-wallet.sendOpReturn()`.
- The indexer and DB already read/store profile pictures; the missing piece is
the client write path and the Account page editor.
**Follow / Unfollow user** (P1.5 / P1.6):
- `psf-memo-client`: add a Follow button on the profile page that broadcasts the
`0x6d06` Follow action (and an Unfollow button broadcasting `0x6d07`).
- `psf-memo-db`: expose follow state and following/followers lists.
- The indexer already stores follows in `followsDb`; the missing pieces are the
client write path and the DB read side.
This is the next smallest end-to-end win after the set-bio write path closed.
### cashaddr conversion (use bch-js, not a new dependency)
The follow/unfollow OP_RETURN payload is the followee's **20-byte hash160**
(P2PKH). The `followsDb` store keys followees by that hash160. Convert between
cash addresses and hash160 with **bch-js** `Address` tools, which are already
available and preferred over adding a separate cashaddr library:
- Client: `bchjs.Address.toHash160(followeeCashAddress)` returns the hash160
hex for the `0x6d06` / `0x6d07` payload. bch-js is embedded in
`minimal-slp-wallet`, so no new client dependency is needed.
- DB read side: `bchjs.Address.toHash160(followeeCashAddress)` for the follow
state lookup, and `bchjs.Address.hash160ToCash(followeePkHash)` to return
cash addresses in the following/followers lists. Add `@psf/bch-js` to
`psf-memo-db` rather than a separate cashaddr package.
This is the next smallest end-to-end win after the set-avatar-url write path closed.
---