Implement follow-user and follow-read behavior

- Add MemoFollow service for follow/unfollow broadcast with hash160 payloads
- Update Profiles store with follow state
- Extend ProfilePage and Profile UI with Follow/Unfollow buttons
- Add psf-memo-db /follow/state, /follow/following, /follow/followers endpoints
- Add FollowQuery adapter, use cases, REST controller, and unit tests
- Update acceptance handlers for both components

By coder.
This commit is contained in:
Chris Troutner
2026-08-27 10:35:33 -07:00
parent d901cfc6cb
commit 247d4a9fcf
26 changed files with 3523 additions and 290 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}.`)
}
}
}
]
@@ -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
+52 -7
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,8 +17,11 @@ 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 } = {}) {
@@ -32,7 +36,48 @@ class ProfilePage {
this.posts = data.posts || []
this.pagination = data.pagination || null
return { posts: this.posts, pagination: this.pagination }
if (this.myAddr && !this.isOwnProfile()) {
this.followState = await this.memoDb.getFollowState(this.myAddr, this.addr)
} else {
this.followState = false
}
return {
posts: this.posts,
pagination: this.pagination,
followState: this.followState,
isOwnProfile: this.isOwnProfile()
}
}
isOwnProfile () {
return Boolean(this.myAddr) && this.myAddr === this.addr
}
canFollow () {
return Boolean(this.myAddr) && !this.isOwnProfile()
}
isFollowing () {
return this.followState === true
}
async follow () {
if (!this.memoFollow) {
throw new Error('Profile page requires a memo follow handler.')
}
await this.memoFollow.follow(this.addr)
this.followState = true
return { ok: true }
}
async unfollow () {
if (!this.memoFollow) {
throw new Error('Profile page requires a memo follow handler.')
}
await this.memoFollow.unfollow(this.addr)
this.followState = false
return { ok: true }
}
getPost (txid) {
+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,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",
+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)
}
}
+34
View File
@@ -0,0 +1,34 @@
/*
Use case: report whether one address follows another.
Returns { followerAddr, followeeAddr, following: boolean }.
*/
class FollowState {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating FollowState use case.')
}
if (!this.adapters.followQuery) {
throw new Error('followQuery adapter required for FollowState use case.')
}
this.execute = this.execute.bind(this)
}
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,31 @@
/*
Use case: list the addresses that currently follow a followee.
Returns { followeeAddr, followers: string[] }.
*/
class ListFollowers {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating ListFollowers use case.')
}
if (!this.adapters.followQuery) {
throw new Error('followQuery adapter required for ListFollowers use case.')
}
this.execute = this.execute.bind(this)
}
async execute (inObj = {}) {
const { followeeAddr } = inObj
if (!followeeAddr || typeof followeeAddr !== 'string') {
throw new Error('followeeAddr is required')
}
const followers = await this.adapters.followQuery.listFollowers(followeeAddr)
return { followeeAddr, followers }
}
}
export default ListFollowers
@@ -0,0 +1,31 @@
/*
Use case: list the addresses a follower currently follows.
Returns { followerAddr, following: string[] }.
*/
class ListFollowing {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating ListFollowing use case.')
}
if (!this.adapters.followQuery) {
throw new Error('followQuery adapter required for ListFollowing use case.')
}
this.execute = this.execute.bind(this)
}
async execute (inObj = {}) {
const { followerAddr } = inObj
if (!followerAddr || typeof followerAddr !== 'string') {
throw new Error('followerAddr is required')
}
const following = await this.adapters.followQuery.listFollowing(followerAddr)
return { followerAddr, following }
}
}
export default ListFollowing
@@ -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/)
}
})
})