mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement follow-user and follow-read behavior
- Add MemoFollow service for follow/unfollow broadcast with hash160 payloads - Update Profiles store with follow state - Extend ProfilePage and Profile UI with Follow/Unfollow buttons - Add psf-memo-db /follow/state, /follow/following, /follow/followers endpoints - Add FollowQuery adapter, use cases, REST controller, and unit tests - Update acceptance handlers for both components By coder.
This commit is contained in:
@@ -32,6 +32,7 @@ const SetAvatarUrlPage = require('../../src/services/set-avatar-url-page')
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
const MemoFollow = require('../../src/services/memo-follow')
|
||||
const RecentFeedPage = require('../../src/services/recent-feed-page')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
@@ -42,6 +43,8 @@ const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
|
||||
const MEMO_SET_BIO_PREFIX = MemoSetBio.MEMO_SET_BIO_PREFIX
|
||||
const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX
|
||||
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
|
||||
const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX
|
||||
const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
|
||||
|
||||
// Default author address used by Gherkin steps that refer to "the author address".
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
@@ -55,6 +58,14 @@ const THIRD_ADDRESS = 'bitcoincash:third-address'
|
||||
function makeWallet (address) {
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress: address },
|
||||
bchjs: {
|
||||
Address: {
|
||||
toHash160 (addr) {
|
||||
// Stable fake hash160: first 20 bytes of sha256 of the address.
|
||||
return require('crypto').createHash('sha256').update(addr).digest('hex').slice(0, 40)
|
||||
}
|
||||
}
|
||||
},
|
||||
utxos: [],
|
||||
broadcasts: [],
|
||||
getUtxos: async function () {
|
||||
@@ -79,21 +90,28 @@ function makeFeed () {
|
||||
}
|
||||
}
|
||||
|
||||
// A fake profile store recording display names, bios, and avatar URLs set for addresses.
|
||||
// A fake profile store recording display names, bios, avatar URLs, and follow state.
|
||||
function makeProfiles () {
|
||||
const names = {}
|
||||
const bios = {}
|
||||
const avatarUrls = {}
|
||||
const following = {}
|
||||
return {
|
||||
names,
|
||||
bios,
|
||||
avatarUrls,
|
||||
following,
|
||||
setName: (addr, name) => { names[addr] = name },
|
||||
getName: (addr) => names[addr] || null,
|
||||
setBio: (addr, bio) => { bios[addr] = bio },
|
||||
getBio: (addr) => bios[addr] || null,
|
||||
setAvatarUrl: (addr, url) => { avatarUrls[addr] = url },
|
||||
getAvatarUrl: (addr) => avatarUrls[addr] || null
|
||||
getAvatarUrl: (addr) => avatarUrls[addr] || null,
|
||||
setFollowState: (selfAddr, targetAddr, isFollowing) => {
|
||||
if (!following[selfAddr]) following[selfAddr] = {}
|
||||
following[selfAddr][targetAddr] = isFollowing
|
||||
},
|
||||
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +130,21 @@ function makeThread () {
|
||||
function makeMemoDb () {
|
||||
const posts = []
|
||||
const threads = {}
|
||||
const followState = {}
|
||||
|
||||
return {
|
||||
posts,
|
||||
threads,
|
||||
followState,
|
||||
addPost (post) {
|
||||
posts.push(post)
|
||||
},
|
||||
addThread (txid, thread) {
|
||||
threads[txid] = thread
|
||||
},
|
||||
setFollowState (followerAddr, followeeAddr, following) {
|
||||
followState[`${followerAddr}:${followeeAddr}`] = following
|
||||
},
|
||||
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
||||
const page = posts.slice(offset, offset + limit)
|
||||
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
|
||||
@@ -133,6 +156,9 @@ function makeMemoDb () {
|
||||
},
|
||||
async getPostThread (txid) {
|
||||
return threads[txid] || { post: null }
|
||||
},
|
||||
async getFollowState (followerAddr, followeeAddr) {
|
||||
return followState[`${followerAddr}:${followeeAddr}`] || false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,10 +167,12 @@ function makeMemoDb () {
|
||||
function createWorld () {
|
||||
const wallet = makeWallet('')
|
||||
const feed = makeFeed()
|
||||
const profiles = makeProfiles()
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
const thread = makeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
const memoFollow = new MemoFollow({ wallet, profiles })
|
||||
const memoDb = makeMemoDb()
|
||||
|
||||
const world = {
|
||||
@@ -154,6 +182,7 @@ function createWorld () {
|
||||
memoPost,
|
||||
memoReply,
|
||||
memoLike,
|
||||
memoFollow,
|
||||
memoDb,
|
||||
currentPath: null,
|
||||
menuOpen: false,
|
||||
@@ -185,7 +214,6 @@ function createWorld () {
|
||||
|
||||
// The Set Name Page and Account Page controllers share a profile store so
|
||||
// a name set on one page is visible on the other.
|
||||
const profiles = makeProfiles()
|
||||
const memoSetName = new MemoSetName({ wallet, profiles })
|
||||
world.setNamePage = new SetNamePage({
|
||||
memoSetName,
|
||||
@@ -1182,6 +1210,140 @@ const handlers = [
|
||||
throw new Error(`Expected like count ${expected} for reply ${txid}, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API reports I follow address',
|
||||
pattern: /^the psf-memo-db API reports that I follow the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.memoDb.setFollowState(myAddr, addr, true)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open profile page for address',
|
||||
pattern: /^I open the profile page for the address (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.profilePage = new ProfilePage({
|
||||
memoDb: world.memoDb,
|
||||
addr,
|
||||
myAddr,
|
||||
memoFollow: world.memoFollow
|
||||
})
|
||||
await world.profilePage.load()
|
||||
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open profile page for own address',
|
||||
pattern: /^I open the profile page for my own address$/,
|
||||
async run (m, example, world) {
|
||||
const myAddr = world.wallet.walletInfo.cashAddress
|
||||
world.profilePage = new ProfilePage({
|
||||
memoDb: world.memoDb,
|
||||
addr: myAddr,
|
||||
myAddr,
|
||||
memoFollow: world.memoFollow
|
||||
})
|
||||
await world.profilePage.load()
|
||||
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(myAddr)}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'profile page shows Follow button',
|
||||
pattern: /^the profile page shows a Follow button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.profilePage) {
|
||||
throw new Error('No profile page is loaded.')
|
||||
}
|
||||
if (!world.profilePage.canFollow()) {
|
||||
throw new Error('Profile page cannot show a Follow button for this address.')
|
||||
}
|
||||
if (world.profilePage.isFollowing()) {
|
||||
throw new Error('Profile page shows Unfollow, but Follow was expected.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'profile page shows Unfollow button',
|
||||
pattern: /^the profile page shows an Unfollow button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.profilePage) {
|
||||
throw new Error('No profile page is loaded.')
|
||||
}
|
||||
if (!world.profilePage.canFollow()) {
|
||||
throw new Error('Profile page cannot show an Unfollow button for this address.')
|
||||
}
|
||||
if (!world.profilePage.isFollowing()) {
|
||||
throw new Error('Profile page shows Follow, but Unfollow was expected.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'profile page does not show Follow button',
|
||||
pattern: /^the profile page does not show a Follow button$/,
|
||||
run (m, example, world) {
|
||||
if (!world.profilePage) {
|
||||
throw new Error('No profile page is loaded.')
|
||||
}
|
||||
if (world.profilePage.canFollow()) {
|
||||
throw new Error('Profile page should not show a Follow button.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Follow button',
|
||||
pattern: /^I click the Follow button$/,
|
||||
async run (m, example, world) {
|
||||
await world.profilePage.follow()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Unfollow button',
|
||||
pattern: /^I click the Unfollow button$/,
|
||||
async run (m, example, world) {
|
||||
await world.profilePage.unfollow()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo follow prefix for address',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo follow prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_FOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo follow prefix ${MEMO_FOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast follow hash160 did not match ${addr}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo unfollow prefix for address',
|
||||
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo unfollow prefix for the address (.+)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const hash160 = world.wallet.bchjs.Address.toHash160(addr)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_UNFOLLOW_PREFIX) {
|
||||
throw new Error(`Expected Memo unfollow prefix ${MEMO_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (last.msg.toString('hex') !== hash160) {
|
||||
throw new Error(`Broadcast unfollow hash160 did not match ${addr}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Container, Row, Col, Spinner, Card } from 'react-bootstrap'
|
||||
import { Container, Row, Col, Spinner, Card, Button } from 'react-bootstrap'
|
||||
import Jdenticon from '@chris.troutner/react-jdenticon'
|
||||
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import MemoFollow from '../../../services/memo-follow'
|
||||
import ProfilePage from '../../../services/profile-page'
|
||||
import PostReplyCount from '../../post-reply-count'
|
||||
import LikeButton from '../../post-feed/like-button'
|
||||
import PostThreadModal from '../../post-thread-modal'
|
||||
@@ -59,6 +61,8 @@ function Profile (props) {
|
||||
const [threadTxid, setThreadTxid] = useState(null)
|
||||
const [showThreadModal, setShowThreadModal] = useState(false)
|
||||
const [profiles, setProfiles] = useState({})
|
||||
const [profilePage, setProfilePage] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const openThread = (txid) => {
|
||||
setThreadTxid(txid)
|
||||
@@ -70,6 +74,28 @@ function Profile (props) {
|
||||
setThreadTxid(null)
|
||||
}
|
||||
|
||||
const handleFollow = async () => {
|
||||
if (!profilePage || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await profilePage.follow()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to follow')
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
const handleUnfollow = async () => {
|
||||
if (!profilePage || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await profilePage.unfollow()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to unfollow')
|
||||
}
|
||||
setBusy(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const loadProfile = async () => {
|
||||
setLoading(true)
|
||||
@@ -77,16 +103,23 @@ function Profile (props) {
|
||||
|
||||
try {
|
||||
const memoDb = new MemoDb()
|
||||
const [profile, profilePic, postsData] = await Promise.all([
|
||||
const myAddr = appData?.wallet?.walletInfo?.cashAddress || null
|
||||
const memoFollow = myAddr
|
||||
? new MemoFollow({ wallet: appData.wallet, profiles: appData.profiles })
|
||||
: null
|
||||
const page = new ProfilePage({ memoDb, addr, myAddr, memoFollow })
|
||||
|
||||
const [profile, profilePic, pageData] = await Promise.all([
|
||||
memoDb.getProfile(addr),
|
||||
memoDb.getProfilePic(addr),
|
||||
memoDb.getPostsByAddr(addr, { limit: 100, offset: 0 })
|
||||
page.load()
|
||||
])
|
||||
|
||||
setProfileText(profile?.text || '')
|
||||
setProfilePicUrl(profilePic?.url || null)
|
||||
setPosts(postsData.posts || [])
|
||||
setPagination(postsData.pagination || null)
|
||||
setPosts(pageData.posts || [])
|
||||
setPagination(pageData.pagination || null)
|
||||
setProfilePage(page)
|
||||
setProfiles({}) // Future: load profile names for the post list.
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load profile')
|
||||
@@ -101,7 +134,10 @@ function Profile (props) {
|
||||
setError('Missing profile address')
|
||||
setLoading(false)
|
||||
}
|
||||
}, [addr])
|
||||
}, [addr, appData?.wallet, appData?.profiles])
|
||||
|
||||
const showFollowButton = profilePage && profilePage.canFollow() && !profilePage.isFollowing()
|
||||
const showUnfollowButton = profilePage && profilePage.canFollow() && profilePage.isFollowing()
|
||||
|
||||
return (
|
||||
<Container fluid className='profile-page mt-4'>
|
||||
@@ -131,6 +167,28 @@ function Profile (props) {
|
||||
<span className='profile-address-label'>BCH</span>
|
||||
<span className='profile-address-value' title={addr}>{addr}</span>
|
||||
</div>
|
||||
{showFollowButton && (
|
||||
<Button
|
||||
className='mt-3'
|
||||
variant='primary'
|
||||
onClick={handleFollow}
|
||||
disabled={busy}
|
||||
data-testid='follow-button'
|
||||
>
|
||||
Follow
|
||||
</Button>
|
||||
)}
|
||||
{showUnfollowButton && (
|
||||
<Button
|
||||
className='mt-3'
|
||||
variant='outline-primary'
|
||||
onClick={handleUnfollow}
|
||||
disabled={busy}
|
||||
data-testid='unfollow-button'
|
||||
>
|
||||
Unfollow
|
||||
</Button>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col lg={9} md={8} className='profile-posts'>
|
||||
|
||||
@@ -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,8 +17,11 @@ class ProfilePage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.addr = deps.addr || null
|
||||
this.myAddr = deps.myAddr || null
|
||||
this.memoFollow = deps.memoFollow || null
|
||||
this.posts = []
|
||||
this.pagination = null
|
||||
this.followState = null
|
||||
}
|
||||
|
||||
async load ({ limit = 100, offset = 0 } = {}) {
|
||||
@@ -32,7 +36,48 @@ class ProfilePage {
|
||||
this.posts = data.posts || []
|
||||
this.pagination = data.pagination || null
|
||||
|
||||
return { posts: this.posts, pagination: this.pagination }
|
||||
if (this.myAddr && !this.isOwnProfile()) {
|
||||
this.followState = await this.memoDb.getFollowState(this.myAddr, this.addr)
|
||||
} else {
|
||||
this.followState = false
|
||||
}
|
||||
|
||||
return {
|
||||
posts: this.posts,
|
||||
pagination: this.pagination,
|
||||
followState: this.followState,
|
||||
isOwnProfile: this.isOwnProfile()
|
||||
}
|
||||
}
|
||||
|
||||
isOwnProfile () {
|
||||
return Boolean(this.myAddr) && this.myAddr === this.addr
|
||||
}
|
||||
|
||||
canFollow () {
|
||||
return Boolean(this.myAddr) && !this.isOwnProfile()
|
||||
}
|
||||
|
||||
isFollowing () {
|
||||
return this.followState === true
|
||||
}
|
||||
|
||||
async follow () {
|
||||
if (!this.memoFollow) {
|
||||
throw new Error('Profile page requires a memo follow handler.')
|
||||
}
|
||||
await this.memoFollow.follow(this.addr)
|
||||
this.followState = true
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async unfollow () {
|
||||
if (!this.memoFollow) {
|
||||
throw new Error('Profile page requires a memo follow handler.')
|
||||
}
|
||||
await this.memoFollow.unfollow(this.addr)
|
||||
this.followState = false
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
getPost (txid) {
|
||||
|
||||
@@ -14,6 +14,7 @@ class Profiles {
|
||||
this.names = new Map()
|
||||
this.bios = new Map()
|
||||
this.avatarUrls = new Map()
|
||||
this.following = new Map()
|
||||
}
|
||||
|
||||
setName (addr, name) {
|
||||
@@ -45,6 +46,20 @@ class Profiles {
|
||||
if (!addr) return null
|
||||
return this.avatarUrls.get(addr) || null
|
||||
}
|
||||
|
||||
// Track whether the current wallet follows a given address.
|
||||
setFollowState (selfAddr, targetAddr, isFollowing) {
|
||||
if (!selfAddr || !targetAddr) return
|
||||
if (!this.following.has(selfAddr)) {
|
||||
this.following.set(selfAddr, new Map())
|
||||
}
|
||||
this.following.get(selfAddr).set(targetAddr, isFollowing)
|
||||
}
|
||||
|
||||
getFollowState (selfAddr, targetAddr) {
|
||||
if (!selfAddr || !targetAddr) return false
|
||||
return this.following.get(selfAddr)?.get(targetAddr) || false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Profiles
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
Unit tests for the Memo follow/unfollow behavior.
|
||||
|
||||
The follow action validates the followee cash address, converts it to a
|
||||
20-byte hash160, and broadcasts it with the Memo follow (0x6d06) or unfollow
|
||||
(0x6d07) prefix. A successful broadcast is reflected on the injected profile
|
||||
store.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const MemoFollow = require('../../src/services/memo-follow')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const FOLLOWEE_ADDRESS = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const FOLLOWEE_HASH160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
|
||||
function makeBchjs () {
|
||||
return {
|
||||
Address: {
|
||||
toHash160 (addr) {
|
||||
if (addr === FOLLOWEE_ADDRESS) return FOLLOWEE_HASH160
|
||||
throw new Error('unsupported address in test')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeWallet (address = MY_ADDRESS) {
|
||||
return {
|
||||
walletInfo: { cashAddress: address },
|
||||
bchjs: makeBchjs(),
|
||||
broadcasts: [],
|
||||
async getUtxos () {
|
||||
return []
|
||||
},
|
||||
async sendOpReturn (msg, prefix) {
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeProfiles () {
|
||||
const state = {}
|
||||
return {
|
||||
state,
|
||||
setFollowState: (selfAddr, targetAddr, isFollowing) => {
|
||||
if (!state[selfAddr]) state[selfAddr] = {}
|
||||
state[selfAddr][targetAddr] = isFollowing
|
||||
},
|
||||
getFollowState: (selfAddr, targetAddr) => state[selfAddr]?.[targetAddr] || false
|
||||
}
|
||||
}
|
||||
|
||||
test('follow broadcasts with the Memo follow prefix and hash160 payload', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoFollow = new MemoFollow({ wallet })
|
||||
|
||||
await memoFollow.follow(FOLLOWEE_ADDRESS)
|
||||
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_FOLLOW_PREFIX)
|
||||
assert.ok(Buffer.isBuffer(wallet.broadcasts[0].msg))
|
||||
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
|
||||
})
|
||||
|
||||
test('unfollow broadcasts with the Memo unfollow prefix and hash160 payload', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoFollow = new MemoFollow({ wallet })
|
||||
|
||||
await memoFollow.unfollow(FOLLOWEE_ADDRESS)
|
||||
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, MemoFollow.MEMO_UNFOLLOW_PREFIX)
|
||||
assert.equal(wallet.broadcasts[0].msg.toString('hex'), FOLLOWEE_HASH160)
|
||||
})
|
||||
|
||||
test('follow reflects the new follow state on the profile store', async () => {
|
||||
const wallet = makeWallet()
|
||||
const profiles = makeProfiles()
|
||||
const memoFollow = new MemoFollow({ wallet, profiles })
|
||||
|
||||
await memoFollow.follow(FOLLOWEE_ADDRESS)
|
||||
|
||||
assert.equal(profiles.getFollowState(MY_ADDRESS, FOLLOWEE_ADDRESS), true)
|
||||
})
|
||||
|
||||
test('unfollow reflects the new unfollow state on the profile store', async () => {
|
||||
const wallet = makeWallet()
|
||||
const profiles = makeProfiles()
|
||||
const memoFollow = new MemoFollow({ wallet, profiles })
|
||||
|
||||
await memoFollow.unfollow(FOLLOWEE_ADDRESS)
|
||||
|
||||
assert.equal(profiles.getFollowState(MY_ADDRESS, FOLLOWEE_ADDRESS), false)
|
||||
})
|
||||
|
||||
test('follow rejects an empty address', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoFollow = new MemoFollow({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoFollow.follow(''),
|
||||
{ code: 'follow_validation', message: /Follow address is required/ }
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('follow requires a wallet', async () => {
|
||||
const memoFollow = new MemoFollow({})
|
||||
|
||||
await assert.rejects(
|
||||
() => memoFollow.follow(FOLLOWEE_ADDRESS),
|
||||
/Memo follow requires a wallet/
|
||||
)
|
||||
})
|
||||
|
||||
test('follow requires bch-js on the wallet', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.bchjs = null
|
||||
const memoFollow = new MemoFollow({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoFollow.follow(FOLLOWEE_ADDRESS),
|
||||
/Wallet does not expose bch-js Address.toHash160/
|
||||
)
|
||||
})
|
||||
|
||||
test('follow surfaces a broadcast failure', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
|
||||
const memoFollow = new MemoFollow({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoFollow.follow(FOLLOWEE_ADDRESS),
|
||||
/broadcast failed/
|
||||
)
|
||||
})
|
||||
@@ -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,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,34 @@
|
||||
/*
|
||||
Use case: report whether one address follows another.
|
||||
|
||||
Returns { followerAddr, followeeAddr, following: boolean }.
|
||||
*/
|
||||
|
||||
class FollowState {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating FollowState use case.')
|
||||
}
|
||||
if (!this.adapters.followQuery) {
|
||||
throw new Error('followQuery adapter required for FollowState use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const { followerAddr, followeeAddr } = inObj
|
||||
if (!followerAddr || typeof followerAddr !== 'string') {
|
||||
throw new Error('followerAddr is required')
|
||||
}
|
||||
if (!followeeAddr || typeof followeeAddr !== 'string') {
|
||||
throw new Error('followeeAddr is required')
|
||||
}
|
||||
|
||||
const following = await this.adapters.followQuery.isFollowing(followerAddr, followeeAddr)
|
||||
|
||||
return { followerAddr, followeeAddr, following }
|
||||
}
|
||||
}
|
||||
|
||||
export default FollowState
|
||||
@@ -6,6 +6,9 @@ import ListRecentProfiles from './list-recent-profiles.js'
|
||||
import ListRecentPosts from './list-recent-posts.js'
|
||||
import ListPostsByAddr from './list-posts-by-addr.js'
|
||||
import GetPostThread from './get-post-thread.js'
|
||||
import FollowState from './follow-state.js'
|
||||
import ListFollowing from './list-following.js'
|
||||
import ListFollowers from './list-followers.js'
|
||||
|
||||
class UseCases {
|
||||
constructor (localConfig = {}) {
|
||||
@@ -21,6 +24,9 @@ class UseCases {
|
||||
this.listRecentPosts = null
|
||||
this.listPostsByAddr = null
|
||||
this.getPostThread = null
|
||||
this.followState = null
|
||||
this.listFollowing = null
|
||||
this.listFollowers = null
|
||||
}
|
||||
|
||||
async start () {
|
||||
@@ -40,6 +46,18 @@ class UseCases {
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.followState = new FollowState({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.listFollowing = new ListFollowing({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
this.listFollowers = new ListFollowers({
|
||||
adapters: this.adapters
|
||||
})
|
||||
|
||||
console.log('Use cases initialized.')
|
||||
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Use case: list the addresses that currently follow a followee.
|
||||
|
||||
Returns { followeeAddr, followers: string[] }.
|
||||
*/
|
||||
|
||||
class ListFollowers {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListFollowers use case.')
|
||||
}
|
||||
if (!this.adapters.followQuery) {
|
||||
throw new Error('followQuery adapter required for ListFollowers use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const { followeeAddr } = inObj
|
||||
if (!followeeAddr || typeof followeeAddr !== 'string') {
|
||||
throw new Error('followeeAddr is required')
|
||||
}
|
||||
|
||||
const followers = await this.adapters.followQuery.listFollowers(followeeAddr)
|
||||
|
||||
return { followeeAddr, followers }
|
||||
}
|
||||
}
|
||||
|
||||
export default ListFollowers
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Use case: list the addresses a follower currently follows.
|
||||
|
||||
Returns { followerAddr, following: string[] }.
|
||||
*/
|
||||
|
||||
class ListFollowing {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListFollowing use case.')
|
||||
}
|
||||
if (!this.adapters.followQuery) {
|
||||
throw new Error('followQuery adapter required for ListFollowing use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const { followerAddr } = inObj
|
||||
if (!followerAddr || typeof followerAddr !== 'string') {
|
||||
throw new Error('followerAddr is required')
|
||||
}
|
||||
|
||||
const following = await this.adapters.followQuery.listFollowing(followerAddr)
|
||||
|
||||
return { followerAddr, following }
|
||||
}
|
||||
}
|
||||
|
||||
export default ListFollowing
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
Unit tests for the FollowQuery adapter.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import FollowQuery from '../../../src/adapters/follow-query.js'
|
||||
|
||||
function makeFollowsDb (records = {}) {
|
||||
const store = new Map(Object.entries(records))
|
||||
return {
|
||||
async get (key) {
|
||||
if (!store.has(key)) {
|
||||
const err = new Error('not found')
|
||||
err.notFound = true
|
||||
throw err
|
||||
}
|
||||
return store.get(key)
|
||||
},
|
||||
iterator (opts = {}) {
|
||||
const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const { gte, lt } = opts
|
||||
const filtered = entries.filter(([key]) => {
|
||||
if (gte && key < gte) return false
|
||||
if (lt && key >= lt) return false
|
||||
return true
|
||||
})
|
||||
let i = 0
|
||||
return {
|
||||
[Symbol.asyncIterator] () {
|
||||
return this
|
||||
},
|
||||
async next () {
|
||||
if (i >= filtered.length) return { value: undefined, done: true }
|
||||
const entry = filtered[i++]
|
||||
return { value: entry, done: false }
|
||||
},
|
||||
async close () {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const FOLLOWER = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const FOLLOWEE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const OTHER_FOLLOWER = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
|
||||
|
||||
describe('#FollowQuery', () => {
|
||||
it('should throw when followsDb is missing', () => {
|
||||
assert.throws(() => new FollowQuery({}), /followsDb required/)
|
||||
})
|
||||
|
||||
it('isFollowing returns false when no follow record exists', async () => {
|
||||
const query = new FollowQuery({ followsDb: makeFollowsDb({}) })
|
||||
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
|
||||
assert.equal(result, false)
|
||||
})
|
||||
|
||||
it('isFollowing returns true for an active follow record', async () => {
|
||||
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
const followsDb = makeFollowsDb({
|
||||
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false }
|
||||
})
|
||||
const query = new FollowQuery({ followsDb })
|
||||
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
|
||||
assert.equal(result, true)
|
||||
})
|
||||
|
||||
it('isFollowing returns false when the latest record is an unfollow', async () => {
|
||||
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
const followsDb = makeFollowsDb({
|
||||
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: true }
|
||||
})
|
||||
const query = new FollowQuery({ followsDb })
|
||||
const result = await query.isFollowing(FOLLOWER, FOLLOWEE)
|
||||
assert.equal(result, false)
|
||||
})
|
||||
|
||||
it('listFollowing returns active followees for a follower', async () => {
|
||||
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
const hash160Two = '44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3'
|
||||
const followsDb = makeFollowsDb({
|
||||
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false },
|
||||
[`${FOLLOWER}:${hash160Two}`]: { followerAddr: FOLLOWER, followeePkHash: hash160Two, unfollow: true },
|
||||
[`${OTHER_FOLLOWER}:${hash160}`]: { followerAddr: OTHER_FOLLOWER, followeePkHash: hash160, unfollow: false }
|
||||
})
|
||||
const query = new FollowQuery({ followsDb })
|
||||
const result = await query.listFollowing(FOLLOWER)
|
||||
assert.deepEqual(result, [FOLLOWEE])
|
||||
})
|
||||
|
||||
it('listFollowers returns active followers for a followee', async () => {
|
||||
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
const followsDb = makeFollowsDb({
|
||||
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: false },
|
||||
[`${OTHER_FOLLOWER}:${hash160}`]: { followerAddr: OTHER_FOLLOWER, followeePkHash: hash160, unfollow: false },
|
||||
[`${FOLLOWER}:44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3`]: { followerAddr: FOLLOWER, followeePkHash: '44c44cfcb6e4e00386c7b0d14eaac6b7f47695e3', unfollow: false }
|
||||
})
|
||||
const query = new FollowQuery({ followsDb })
|
||||
const result = await query.listFollowers(FOLLOWEE)
|
||||
assert.sameMembers(result, [FOLLOWER, OTHER_FOLLOWER])
|
||||
})
|
||||
|
||||
it('listFollowers ignores unfollow records', async () => {
|
||||
const hash160 = 'cb481232299cd5743151ac4b2d63ae198e7bb0a9'
|
||||
const followsDb = makeFollowsDb({
|
||||
[`${FOLLOWER}:${hash160}`]: { followerAddr: FOLLOWER, followeePkHash: hash160, unfollow: true }
|
||||
})
|
||||
const query = new FollowQuery({ followsDb })
|
||||
const result = await query.listFollowers(FOLLOWEE)
|
||||
assert.deepEqual(result, [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Unit tests for the Follow REST controller.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import FollowRESTControllerLib from '../../../src/controllers/rest-api/follow/controller.js'
|
||||
|
||||
describe('#FollowRESTController', () => {
|
||||
let sandbox
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
function makeUut (useCases) {
|
||||
return new FollowRESTControllerLib({
|
||||
adapters: {},
|
||||
useCases
|
||||
})
|
||||
}
|
||||
|
||||
function makeCtx (query = {}, params = {}) {
|
||||
return { query, params, body: null, throw: sandbox.stub() }
|
||||
}
|
||||
|
||||
it('should return follow state from use case', async () => {
|
||||
const followState = { execute: sandbox.stub().resolves({ followerAddr: 'a', followeeAddr: 'b', following: true }) }
|
||||
const uut = makeUut({ followState })
|
||||
const ctx = makeCtx({ follower: 'a', followee: 'b' })
|
||||
|
||||
await uut.getFollowState(ctx)
|
||||
|
||||
assert.equal(followState.execute.callCount, 1)
|
||||
assert.deepEqual(followState.execute.firstCall.args[0], { followerAddr: 'a', followeeAddr: 'b' })
|
||||
assert.equal(ctx.body.following, true)
|
||||
})
|
||||
|
||||
it('should return following list from use case', async () => {
|
||||
const listFollowing = { execute: sandbox.stub().resolves({ followerAddr: 'a', following: ['b', 'c'] }) }
|
||||
const uut = makeUut({ listFollowing })
|
||||
const ctx = makeCtx({}, { follower: 'a' })
|
||||
|
||||
await uut.getFollowing(ctx)
|
||||
|
||||
assert.equal(listFollowing.execute.callCount, 1)
|
||||
assert.deepEqual(listFollowing.execute.firstCall.args[0], { followerAddr: 'a' })
|
||||
assert.deepEqual(ctx.body.following, ['b', 'c'])
|
||||
})
|
||||
|
||||
it('should return followers list from use case', async () => {
|
||||
const listFollowers = { execute: sandbox.stub().resolves({ followeeAddr: 'b', followers: ['a', 'c'] }) }
|
||||
const uut = makeUut({ listFollowers })
|
||||
const ctx = makeCtx({}, { followee: 'b' })
|
||||
|
||||
await uut.getFollowers(ctx)
|
||||
|
||||
assert.equal(listFollowers.execute.callCount, 1)
|
||||
assert.deepEqual(listFollowers.execute.firstCall.args[0], { followeeAddr: 'b' })
|
||||
assert.deepEqual(ctx.body.followers, ['a', 'c'])
|
||||
})
|
||||
|
||||
it('should handle use case errors', async () => {
|
||||
const followState = { execute: sandbox.stub().rejects(new Error('boom')) }
|
||||
const uut = makeUut({ followState })
|
||||
const ctx = makeCtx({ follower: 'a', followee: 'b' })
|
||||
|
||||
await uut.getFollowState(ctx)
|
||||
|
||||
assert.equal(ctx.throw.callCount, 1)
|
||||
assert.equal(ctx.throw.firstCall.args[0], 500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
Unit tests for the FollowState use case.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import FollowState from '../../../src/use-cases/follow-state.js'
|
||||
|
||||
function makeAdapters (isFollowingResult) {
|
||||
return {
|
||||
followQuery: {
|
||||
async isFollowing (followerAddr, followeeAddr) {
|
||||
return isFollowingResult
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#FollowState', () => {
|
||||
it('should throw when adapters is missing', () => {
|
||||
assert.throws(() => new FollowState({}), /Adapters required/)
|
||||
})
|
||||
|
||||
it('should throw when followQuery adapter is missing', () => {
|
||||
assert.throws(() => new FollowState({ adapters: {} }), /followQuery adapter required/)
|
||||
})
|
||||
|
||||
it('should return following=true', async () => {
|
||||
const useCase = new FollowState({ adapters: makeAdapters(true) })
|
||||
const result = await useCase.execute({
|
||||
followerAddr: 'bitcoincash:follower',
|
||||
followeeAddr: 'bitcoincash:followee'
|
||||
})
|
||||
assert.deepEqual(result, {
|
||||
followerAddr: 'bitcoincash:follower',
|
||||
followeeAddr: 'bitcoincash:followee',
|
||||
following: true
|
||||
})
|
||||
})
|
||||
|
||||
it('should return following=false', async () => {
|
||||
const useCase = new FollowState({ adapters: makeAdapters(false) })
|
||||
const result = await useCase.execute({
|
||||
followerAddr: 'bitcoincash:follower',
|
||||
followeeAddr: 'bitcoincash:followee'
|
||||
})
|
||||
assert.deepEqual(result, {
|
||||
followerAddr: 'bitcoincash:follower',
|
||||
followeeAddr: 'bitcoincash:followee',
|
||||
following: false
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject a missing followerAddr', async () => {
|
||||
const useCase = new FollowState({ adapters: makeAdapters(false) })
|
||||
try {
|
||||
await useCase.execute({ followeeAddr: 'bitcoincash:followee' })
|
||||
assert.fail('expected error')
|
||||
} catch (err) {
|
||||
assert.match(err.message, /followerAddr is required/)
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a missing followeeAddr', async () => {
|
||||
const useCase = new FollowState({ adapters: makeAdapters(false) })
|
||||
try {
|
||||
await useCase.execute({ followerAddr: 'bitcoincash:follower' })
|
||||
assert.fail('expected error')
|
||||
} catch (err) {
|
||||
assert.match(err.message, /followeeAddr is required/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Unit tests for the ListFollowers use case.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import ListFollowers from '../../../src/use-cases/list-followers.js'
|
||||
|
||||
function makeAdapters (followers) {
|
||||
return {
|
||||
followQuery: {
|
||||
async listFollowers (followeeAddr) {
|
||||
return followers
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#ListFollowers', () => {
|
||||
it('should throw when adapters is missing', () => {
|
||||
assert.throws(() => new ListFollowers({}), /Adapters required/)
|
||||
})
|
||||
|
||||
it('should throw when followQuery adapter is missing', () => {
|
||||
assert.throws(() => new ListFollowers({ adapters: {} }), /followQuery adapter required/)
|
||||
})
|
||||
|
||||
it('should return the followers list', async () => {
|
||||
const useCase = new ListFollowers({ adapters: makeAdapters(['bitcoincash:a', 'bitcoincash:b']) })
|
||||
const result = await useCase.execute({ followeeAddr: 'bitcoincash:followee' })
|
||||
assert.deepEqual(result, {
|
||||
followeeAddr: 'bitcoincash:followee',
|
||||
followers: ['bitcoincash:a', 'bitcoincash:b']
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject a missing followeeAddr', async () => {
|
||||
const useCase = new ListFollowers({ adapters: makeAdapters([]) })
|
||||
try {
|
||||
await useCase.execute({})
|
||||
assert.fail('expected error')
|
||||
} catch (err) {
|
||||
assert.match(err.message, /followeeAddr is required/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Unit tests for the ListFollowing use case.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import ListFollowing from '../../../src/use-cases/list-following.js'
|
||||
|
||||
function makeAdapters (following) {
|
||||
return {
|
||||
followQuery: {
|
||||
async listFollowing (followerAddr) {
|
||||
return following
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('#ListFollowing', () => {
|
||||
it('should throw when adapters is missing', () => {
|
||||
assert.throws(() => new ListFollowing({}), /Adapters required/)
|
||||
})
|
||||
|
||||
it('should throw when followQuery adapter is missing', () => {
|
||||
assert.throws(() => new ListFollowing({ adapters: {} }), /followQuery adapter required/)
|
||||
})
|
||||
|
||||
it('should return the following list', async () => {
|
||||
const useCase = new ListFollowing({ adapters: makeAdapters(['bitcoincash:a', 'bitcoincash:b']) })
|
||||
const result = await useCase.execute({ followerAddr: 'bitcoincash:follower' })
|
||||
assert.deepEqual(result, {
|
||||
followerAddr: 'bitcoincash:follower',
|
||||
following: ['bitcoincash:a', 'bitcoincash:b']
|
||||
})
|
||||
})
|
||||
|
||||
it('should reject a missing followerAddr', async () => {
|
||||
const useCase = new ListFollowing({ adapters: makeAdapters([]) })
|
||||
try {
|
||||
await useCase.execute({})
|
||||
assert.fail('expected error')
|
||||
} catch (err) {
|
||||
assert.match(err.message, /followerAddr is required/)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user