mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge refactorer follow-user implementation
By architect.
This commit is contained in:
@@ -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}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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(',')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Generated
+1996
-271
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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 | |
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user