Refactor mute-user: reduce DRY, add property coverage

Extract shared _getState/_getList helpers in the client MemoDb, a shared
_loadState/_setState base in ProfilePage, and a runAction helper for the
profile follow/mute handlers. Add property tests for the MemoMute service,
the DB MuteQuery adapter, and the indexer handleMute action. Preserve
behavior; all unit, acceptance, and property suites pass across client, DB,
and indexer.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-28 19:58:39 -07:00
parent 4b03210e85
commit d8ab5bc79c
6 changed files with 443 additions and 113 deletions
@@ -75,49 +75,21 @@ function Profile (props) {
setThreadTxid(null)
}
const handleFollow = async () => {
const runAction = async (method, failMsg) => {
if (!profilePage || busy) return
setBusy(true)
try {
await profilePage.follow()
await profilePage[method]()
} catch (err) {
setError(err.message || 'Failed to follow')
setError(err.message || failMsg)
}
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)
}
const handleMute = async () => {
if (!profilePage || busy) return
setBusy(true)
try {
await profilePage.mute()
} catch (err) {
setError(err.message || 'Failed to mute')
}
setBusy(false)
}
const handleUnmute = async () => {
if (!profilePage || busy) return
setBusy(true)
try {
await profilePage.unmute()
} catch (err) {
setError(err.message || 'Failed to unmute')
}
setBusy(false)
}
const handleFollow = () => runAction('follow', 'Failed to follow')
const handleUnfollow = () => runAction('unfollow', 'Failed to unfollow')
const handleMute = () => runAction('mute', 'Failed to mute')
const handleUnmute = () => runAction('unmute', 'Failed to unmute')
useEffect(() => {
const loadProfile = async () => {
+20 -53
View File
@@ -31,51 +31,15 @@ class MemoDb {
}
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
}
return this._getState('/follow/state', 'getFollowState', { follower: followerAddr, followee: followeeAddr }, 'following')
}
async getMuteState (muterAddr, muteeAddr) {
try {
const result = await this.axios.get(
`${config.backend}/mute/state`,
{
params: {
muter: muterAddr,
mutee: muteeAddr
}
}
)
return result.data.muted === true
} catch (err) {
console.error('Error in getMuteState()')
throw err
}
return this._getState('/mute/state', 'getMuteState', { muter: muterAddr, mutee: muteeAddr }, 'muted')
}
async getMuted (muterAddr) {
try {
const result = await this.axios.get(
`${config.backend}/mute/muted/${encodeURIComponent(muterAddr)}`
)
return result.data.muted || []
} catch (err) {
console.error('Error in getMuted()')
throw err
}
return this._getList(`/mute/muted/${encodeURIComponent(muterAddr)}`, 'getMuted', 'muted')
}
async getTopics () {
@@ -87,28 +51,31 @@ class MemoDb {
}
async getTopicFollowState (room, addr) {
return this._getState(`/topics/${encodeURIComponent(room)}/follow/state`, 'getTopicFollowState', { addr }, 'following')
}
async getTopicFollowers (room) {
return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers')
}
// GET a boolean state endpoint and coerce the named field to a boolean.
async _getState (path, name, params, field) {
try {
const result = await this.axios.get(
`${config.backend}/topics/${encodeURIComponent(room)}/follow/state`,
{
params: { addr }
}
)
return result.data.following === true
const result = await this.axios.get(`${config.backend}${path}`, { params })
return result.data[field] === true
} catch (err) {
console.error('Error in getTopicFollowState()')
console.error(`Error in ${name}()`)
throw err
}
}
async getTopicFollowers (room) {
// GET a list endpoint and return the named array field, defaulting to [].
async _getList (path, name, field) {
try {
const result = await this.axios.get(
`${config.backend}/topics/${encodeURIComponent(room)}/followers`
)
return result.data.followers || []
const result = await this.axios.get(`${config.backend}${path}`)
return result.data[field] || []
} catch (err) {
console.error('Error in getTopicFollowers()')
console.error(`Error in ${name}()`)
throw err
}
}
+17 -25
View File
@@ -32,8 +32,8 @@ class ProfilePage {
const data = await this.memoDb.getPostsByAddr(this.addr, { limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.followState = await this._loadFollowState()
this.muteState = await this._loadMuteState()
this.followState = await this._loadState('getFollowState')
this.muteState = await this._loadState('getMuteState')
return {
posts: this.posts,
@@ -54,20 +54,11 @@ class ProfilePage {
}
}
// 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 () {
// Fetch a viewer state for the target address, or false when there is no
// viewer or the profile is the viewer's own.
async _loadState (method) {
if (this.myAddr && !this.isOwnProfile()) {
return this.memoDb.getFollowState(this.myAddr, this.addr)
}
return false
}
// Fetch the viewer's mute state for the target address, or false when
// there is no viewer or the profile is the viewer's own.
async _loadMuteState () {
if (this.myAddr && !this.isOwnProfile()) {
return this.memoDb.getMuteState(this.myAddr, this.addr)
return this.memoDb[method](this.myAddr, this.addr)
}
return false
}
@@ -94,12 +85,7 @@ class ProfilePage {
// 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 }
return this._setState(this.memoFollow, 'follow', 'followState', method, nextState)
}
canMute () {
@@ -120,11 +106,17 @@ class ProfilePage {
// Delegate mute/unmute to the injected handler and reflect the new state.
async _setMuteState (method, nextState) {
if (!this.memoMute) {
throw new Error('Profile page requires a memo mute handler.')
return this._setState(this.memoMute, 'mute', 'muteState', method, nextState)
}
// Delegate a follow/mute action to the injected handler and reflect the new
// state on the matching field.
async _setState (handler, label, stateField, method, nextState) {
if (!handler) {
throw new Error(`Profile page requires a memo ${label} handler.`)
}
await this.memoMute[method](this.addr)
this.muteState = nextState
await handler[method](this.addr)
this[stateField] = nextState
return { ok: true }
}
@@ -0,0 +1,132 @@
/*
Property tests for the Memo mute/unmute 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: mute() and unmute() each broadcast exactly one Memo
action carrying the mutee's 20-byte hash160 payload.
- hash length: the broadcast payload is always exactly PK_HASH_LENGTH
bytes (a hash160).
- round trip: mute then unmute toggles the reflected mute 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 MemoMute = require('../../src/services/memo-mute')
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 {
setMuteState: (selfAddr, targetAddr, isMuting) => {
if (!state[selfAddr]) state[selfAddr] = {}
state[selfAddr][targetAddr] = isMuting
},
getMuteState: (selfAddr, targetAddr) => state[selfAddr]?.[targetAddr] || false
}
}
test('mute broadcasts exactly one hash160 payload with the mute prefix', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const memoMute = new MemoMute({ wallet })
await memoMute.mute(addr)
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_MUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
},
{ label: 'mute broadcast conservation and hash160 length' }
)
})
test('unmute broadcasts exactly one hash160 payload with the unmute prefix', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const memoMute = new MemoMute({ wallet })
await memoMute.unmute(addr)
return wallet.broadcasts.length === 1 &&
wallet.broadcasts[0].prefix === MemoMute.MEMO_UNMUTE_PREFIX &&
Buffer.isBuffer(wallet.broadcasts[0].msg) &&
wallet.broadcasts[0].msg.length === MemoMute.PK_HASH_LENGTH &&
wallet.broadcasts[0].msg.toString('hex') === hash20(addr)
},
{ label: 'unmute broadcast conservation and hash160 length' }
)
})
test('mute then unmute round-trips the reflected mute state', async () => {
await forAll(
(i) => addressGen(),
async (addr) => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoMute = new MemoMute({ wallet, profiles })
await memoMute.mute(addr)
const afterMute = profiles.getMuteState(MY_ADDRESS, addr)
await memoMute.unmute(addr)
const afterUnmute = profiles.getMuteState(MY_ADDRESS, addr)
return afterMute === true && afterUnmute === false
},
{ label: 'mute/unmute reflected state round trip' }
)
})
@@ -0,0 +1,137 @@
/*
Property tests for the MuteQuery adapter.
The unit tests probe isMuted/listMuted 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.
- isMuted conservation: an active mute record reports true and an unmute
record reports false, regardless of surrounding records.
- list consistency: listMuted returns exactly the active mutees for a
muter.
*/
import test from 'node:test'
import BCHJS from '@psf/bch-js'
import { seededRandom, forAll, intGen } from './harness.js'
import MuteQuery from '../../src/adapters/mute-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 mutes Db mirroring the LevelDB contract MuteQuery relies on.
function makeMutesDb (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 mute records: a mix of muters, hash160s, and
// mute/unmute flags. Not every pair is recorded.
function recordSetGen () {
const muters = []
for (let i = 0, n = intGen(rng, 1, 6)(); i < n; i++) muters.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 muter of muters) {
for (const hash160 of hash160s) {
if (rng() < 0.4) continue
records.push({ key: `${muter}:${hash160}`, unmute: rng() < 0.5 })
}
}
return { muters, 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('isMuted is true exactly for active mute records', async () => {
await forAll(
(i) => recordSetGen(),
async ({ records }) => {
const query = new MuteQuery({ mutesDb: makeMutesDb(records), bchjs })
for (const record of records) {
// Muter 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 muter = record.key.slice(0, sep)
const hash160 = record.key.slice(sep + 1)
const mutee = bchjs.Address.hash160ToCash(hash160)
const muted = await query.isMuted(muter, mutee)
if (muted !== (record.unmute !== true)) return false
}
return true
},
{ label: 'isMuted conservation' }
)
})
test('listMuted returns exactly the active mutees for a muter', async () => {
await forAll(
(i) => recordSetGen(),
async ({ muters, records }) => {
const query = new MuteQuery({ mutesDb: makeMutesDb(records), bchjs })
for (const muter of muters) {
const prefix = `${muter}:`
const expected = records
.filter((r) => r.key.startsWith(prefix) && r.unmute !== true)
.map((r) => bchjs.Address.hash160ToCash(r.key.slice(prefix.length)))
const got = (await query.listMuted(muter)).sort()
const want = Array.from(new Set(expected)).sort()
if (JSON.stringify(got) !== JSON.stringify(want)) return false
}
return true
},
{ label: 'listMuted consistency' }
)
})
@@ -0,0 +1,130 @@
/*
Property tests for the indexer's mute action handler.
These pin down invariants the unit tests only probe at fixed fixtures:
- handleMute stores a record keyed `${signerAddr}:${muteePkHash}` with the
exact mutee hash160, the correct unmute flag for the prefix, and the
surrounding tx context, for any valid mute/unmute payload.
- handleMute rejects a wrong-size mutee hash without storing a record and
logs a process error instead.
*/
import test from 'node:test'
import { seededRandom, forAll } from './harness.js'
import { handleMute } from '../../src/use-cases/action-types/mute.js'
import { PREFIX_MUTE, PREFIX_UNMUTE, PK_HASH_LENGTH } from '../../src/lib/memo-codes.js'
const rng = seededRandom(20260829)
function randomHex (len) {
const hex = '0123456789abcdef'
let out = ''
for (let i = 0; i < len; i++) {
out += hex[Math.floor(rng() * 16)]
}
return out
}
function makeDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
test('handleMute stores the exact mutee hash, unmute flag, and tx context', async () => {
await forAll(
() => ({
unmute: rng() < 0.5,
hashHex: randomHex(PK_HASH_LENGTH * 2),
signer: `bitcoincash:q${randomHex(40)}`,
txid: randomHex(64),
seen: Math.floor(rng() * 1e9),
blockHeight: Math.floor(rng() * 1e6)
}),
async ({ unmute, hashHex, signer, txid, seen, blockHeight }) => {
const adapters = {
muteDb: makeDb(),
processErrorDb: makeDb()
}
const prefix = unmute ? PREFIX_UNMUTE : PREFIX_MUTE
await handleMute({
adapters,
txid,
signerAddr: signer,
seen,
blockHeight,
decoded: {
action: unmute ? 'unmute' : 'mute',
prefix,
pushDatas: [prefix, Buffer.from(hashHex, 'hex')]
}
})
const record = await adapters.muteDb.get(`${signer}:${hashHex}`)
return record.muterAddr === signer &&
record.muteePkHash === hashHex &&
record.unmute === unmute &&
record.txid === txid &&
record.seen === seen &&
record.blockHeight === blockHeight &&
adapters.processErrorDb.entries().length === 0
},
{ label: 'handleMute record invariants' }
)
})
test('handleMute rejects a wrong-size mutee hash without storing', async () => {
await forAll(
() => ({
badLen: 1 + Math.floor(rng() * 40)
}),
async ({ badLen }) => {
if (badLen === PK_HASH_LENGTH) return true
const adapters = {
muteDb: makeDb(),
processErrorDb: makeDb()
}
await handleMute({
adapters,
txid: 'txid-1',
signerAddr: 'bitcoincash:qaddr-a',
seen: 1,
blockHeight: 100,
decoded: {
action: 'mute',
prefix: PREFIX_MUTE,
pushDatas: [PREFIX_MUTE, Buffer.alloc(badLen, 1)]
}
})
let stored = false
try {
await adapters.muteDb.get('bitcoincash:qaddr-a:')
stored = true
} catch (err) {
// expected: not stored
}
return !stored && adapters.processErrorDb.entries().length > 0
},
{ label: 'wrong-size mutee hash rejection' }
)
})