From d8ab5bc79c7fd7ba7cf0b9020ecc534bfce8bdfb Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 28 Aug 2026 19:58:39 -0700 Subject: [PATCH] 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. --- .../src/components/app-body/profile/index.js | 42 +----- psf-memo-client/src/services/memo-db.js | 73 +++------- psf-memo-client/src/services/profile-page.js | 42 +++--- .../property/mute-services.property.test.js | 132 +++++++++++++++++ .../test/property/mute-query.property.test.js | 137 ++++++++++++++++++ .../property/mute-action.property.test.js | 130 +++++++++++++++++ 6 files changed, 443 insertions(+), 113 deletions(-) create mode 100644 psf-memo-client/test/property/mute-services.property.test.js create mode 100644 psf-memo-db/test/property/mute-query.property.test.js create mode 100644 psf-memo-indexer/test/property/mute-action.property.test.js diff --git a/psf-memo-client/src/components/app-body/profile/index.js b/psf-memo-client/src/components/app-body/profile/index.js index f883fc0..1899759 100644 --- a/psf-memo-client/src/components/app-body/profile/index.js +++ b/psf-memo-client/src/components/app-body/profile/index.js @@ -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 () => { diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 21c9979..0962c80 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -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 } } diff --git a/psf-memo-client/src/services/profile-page.js b/psf-memo-client/src/services/profile-page.js index fd9f91f..986c12e 100644 --- a/psf-memo-client/src/services/profile-page.js +++ b/psf-memo-client/src/services/profile-page.js @@ -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 } } diff --git a/psf-memo-client/test/property/mute-services.property.test.js b/psf-memo-client/test/property/mute-services.property.test.js new file mode 100644 index 0000000..65f6f3e --- /dev/null +++ b/psf-memo-client/test/property/mute-services.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-db/test/property/mute-query.property.test.js b/psf-memo-db/test/property/mute-query.property.test.js new file mode 100644 index 0000000..70e3ac4 --- /dev/null +++ b/psf-memo-db/test/property/mute-query.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-indexer/test/property/mute-action.property.test.js b/psf-memo-indexer/test/property/mute-action.property.test.js new file mode 100644 index 0000000..bafcb99 --- /dev/null +++ b/psf-memo-indexer/test/property/mute-action.property.test.js @@ -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' } + ) +})