mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
- Replace the ternary follower delta in recordTopicFollow with Number(isActive) - Number(wasActive), dropping its cyclomatic complexity from 8 to 6 (CRAP 8.0 -> 6.0). No behavior change. - Extend the indexer topic property test to conserve lastSeen (newest post time) and followerCount (each address's final active follow state) alongside postCount/lastHeight, with varying post seen times. - Extend the DB backfill and topic-query property tests to conserve lastSeen and followerCount; generated follows now include unfollows. - Add a client topic-metadata property test covering relativeTime bucket classification, pluralization, monotonicity, and getLastSeenLabel consistency, and cover ensureTopicRoom's existing-summary path plus recordTopicFollow's legacy-follower-count fallback. - Share one in-memory DB double per component (indexer test/support/ memory-db.js, DB test/support/level-double.js) and table-drive the parallel topic-follow/topic-message/topic-query cases, clearing every dry4javascript candidate in the changed files. Verification: indexer 113 unit / 12 property / 7 acceptance; db 387 unit / 57 property / 16 acceptance; client 441 unit / 90 property / 33 acceptance + build; lint clean. CRAP max 6.0; mutation scans under 100 sites/file. By refactorer.
217 lines
7.4 KiB
JavaScript
217 lines
7.4 KiB
JavaScript
/*
|
|
Property tests for the topic recency indexes maintained by the indexer.
|
|
|
|
The unit tests probe handleTopicMessage, handleTopicFollow, and the
|
|
topic-indexing helpers at a few fixed fixtures. These properties pin down
|
|
the invariants that must hold over arbitrary interleavings of topic posts,
|
|
follows, and unfollows:
|
|
|
|
- Conservation: each room's summary postCount equals the number of distinct
|
|
topic-message txids seen for that room, lastHeight equals the newest
|
|
post height and lastSeen the newest post time (both 0 when the room has
|
|
no posts), and followerCount equals the number of addresses whose final
|
|
follow state for the room is active.
|
|
- Recency shape: topicRecency holds exactly one record per summarized room,
|
|
keyed at that room's lastHeight, with the matching value.
|
|
- Idempotence: replaying the whole action sequence leaves every index
|
|
unchanged, so reprocessing a block never double-counts.
|
|
- Key ordering: the inverted topicRecency key sorts newest-height-first and
|
|
breaks ties by room name ascending, which is the read side's contract.
|
|
*/
|
|
|
|
import test from 'node:test'
|
|
|
|
import { seededRandom, forAll, intGen } from './harness.js'
|
|
import { handleTopicMessage } from '../../src/use-cases/action-types/topic-message.js'
|
|
import { handleTopicFollow } from '../../src/use-cases/action-types/topic-follow.js'
|
|
import { topicRecencyKey } from '../../src/use-cases/action-types/helpers.js'
|
|
import { PREFIX_TOPIC_FOLLOW, PREFIX_TOPIC_UNFOLLOW } from '../../src/lib/memo-codes.js'
|
|
import { makeMemoryDb } from '../support/memory-db.js'
|
|
|
|
const rng = seededRandom(20260917)
|
|
|
|
const ROOMS = ['room-0', 'room-1', 'room-2', 'room-3']
|
|
const FOLLOW_ADDRS = ['bitcoincash:qaddr-0', 'bitcoincash:qaddr-1', 'bitcoincash:qaddr-2']
|
|
|
|
function makeAdapters () {
|
|
return {
|
|
postDb: makeMemoryDb(),
|
|
postHeightDb: makeMemoryDb(),
|
|
addrPostHeightDb: makeMemoryDb(),
|
|
roomDb: makeMemoryDb(),
|
|
topicSummaryDb: makeMemoryDb(),
|
|
topicRecencyDb: makeMemoryDb(),
|
|
processErrorDb: makeMemoryDb()
|
|
}
|
|
}
|
|
|
|
async function processEvent (adapters, event) {
|
|
if (event.kind === 'post') {
|
|
const prefix = Buffer.from('6d0c', 'hex')
|
|
await handleTopicMessage({
|
|
adapters,
|
|
txid: event.txid,
|
|
signerAddr: 'bitcoincash:qauthor',
|
|
seen: event.seen,
|
|
blockHeight: event.height,
|
|
decoded: {
|
|
action: 'topic-message',
|
|
prefix,
|
|
pushDatas: [prefix, Buffer.from(event.room, 'utf8'), Buffer.from(event.text, 'utf8')]
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
const prefix = event.unfollow ? PREFIX_TOPIC_UNFOLLOW : PREFIX_TOPIC_FOLLOW
|
|
await handleTopicFollow({
|
|
adapters,
|
|
txid: event.txid,
|
|
signerAddr: event.addr,
|
|
seen: 1,
|
|
blockHeight: event.height,
|
|
decoded: {
|
|
action: 'topic-follow',
|
|
prefix,
|
|
pushDatas: [prefix, Buffer.from(event.room, 'utf8')]
|
|
}
|
|
})
|
|
}
|
|
|
|
// Random interleaving of topic posts, follows, and unfollows across a small
|
|
// room set. Txids are unique per event so the expected conservation values are
|
|
// well defined even when the sequence is replayed.
|
|
function eventSequenceGen () {
|
|
return () => {
|
|
const count = intGen(rng, 0, 40)()
|
|
const events = []
|
|
for (let i = 0; i < count; i++) {
|
|
const room = ROOMS[Math.floor(rng() * ROOMS.length)]
|
|
if (rng() < 0.7) {
|
|
events.push({
|
|
kind: 'post',
|
|
txid: `txid-${i}`,
|
|
room,
|
|
height: intGen(rng, 0, 9000000)(),
|
|
seen: intGen(rng, 0, 5000000)(),
|
|
text: `text-${i}`
|
|
})
|
|
} else {
|
|
events.push({
|
|
kind: 'follow',
|
|
txid: `txid-${i}`,
|
|
room,
|
|
addr: FOLLOW_ADDRS[Math.floor(rng() * FOLLOW_ADDRS.length)],
|
|
height: intGen(rng, 0, 9000000)(),
|
|
unfollow: rng() < 0.4
|
|
})
|
|
}
|
|
}
|
|
return events
|
|
}
|
|
}
|
|
|
|
// Expected summaries derived straight from the event list, independent of the
|
|
// implementation under test. Follower counts use each address's final
|
|
// follow/unfollow state per room.
|
|
function expectedSummaries (events) {
|
|
const summaries = new Map()
|
|
const followState = new Map()
|
|
const ensure = (room) => {
|
|
if (!summaries.has(room)) {
|
|
summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 })
|
|
}
|
|
return summaries.get(room)
|
|
}
|
|
|
|
for (const event of events) {
|
|
if (event.kind === 'post') {
|
|
const summary = ensure(event.room)
|
|
summary.postCount++
|
|
if (event.height > summary.lastHeight) summary.lastHeight = event.height
|
|
if (event.seen > summary.lastSeen) summary.lastSeen = event.seen
|
|
} else {
|
|
followState.set(`${event.room}:${event.addr}`, { room: event.room, unfollow: event.unfollow })
|
|
// Only an active follow creates a zero-post room; unfollows do not.
|
|
if (!event.unfollow) ensure(event.room)
|
|
}
|
|
}
|
|
|
|
for (const { room, unfollow } of followState.values()) {
|
|
if (!unfollow && summaries.has(room)) summaries.get(room).followerCount++
|
|
}
|
|
|
|
return summaries
|
|
}
|
|
|
|
function snapshot (adapters) {
|
|
return JSON.stringify({
|
|
summaries: Array.from(adapters.topicSummaryDb.store.entries()).sort(),
|
|
recency: Array.from(adapters.topicRecencyDb.store.entries()).sort()
|
|
})
|
|
}
|
|
|
|
test('topic indexes conserve counts and heights over arbitrary action sequences', async () => {
|
|
await forAll(
|
|
eventSequenceGen(),
|
|
async (events) => {
|
|
const adapters = makeAdapters()
|
|
for (const event of events) {
|
|
await processEvent(adapters, event)
|
|
}
|
|
|
|
const expected = expectedSummaries(events)
|
|
|
|
// Conservation: one summary per room with the exact counts and times.
|
|
if (adapters.topicSummaryDb.store.size !== expected.size) return false
|
|
for (const [room, summary] of expected) {
|
|
const stored = adapters.topicSummaryDb.store.get(room)
|
|
if (!stored) return false
|
|
if (stored.postCount !== summary.postCount) return false
|
|
if (stored.lastHeight !== summary.lastHeight) return false
|
|
if (stored.lastSeen !== summary.lastSeen) return false
|
|
if (stored.followerCount !== summary.followerCount) return false
|
|
}
|
|
|
|
// Recency shape: exactly one record per room, at its lastHeight.
|
|
if (adapters.topicRecencyDb.store.size !== expected.size) return false
|
|
for (const [room, summary] of expected) {
|
|
const key = topicRecencyKey(summary.lastHeight, room)
|
|
const record = adapters.topicRecencyDb.store.get(key)
|
|
if (!record) return false
|
|
if (record.room !== room) return false
|
|
if (record.blockHeight !== summary.lastHeight) return false
|
|
}
|
|
|
|
// Idempotence: replaying the sequence changes nothing.
|
|
const before = snapshot(adapters)
|
|
for (const event of events) {
|
|
await processEvent(adapters, event)
|
|
}
|
|
if (snapshot(adapters) !== before) return false
|
|
|
|
return true
|
|
},
|
|
{ label: 'topic index conservation, shape, and idempotence' }
|
|
)
|
|
})
|
|
|
|
test('topicRecencyKey sorts newest height first and ties by room ascending', async () => {
|
|
await forAll(
|
|
() => {
|
|
return {
|
|
h1: intGen(rng, 0, 5000000)(),
|
|
h2: intGen(rng, 0, 5000000)(),
|
|
r1: ROOMS[Math.floor(rng() * ROOMS.length)],
|
|
r2: ROOMS[Math.floor(rng() * ROOMS.length)]
|
|
}
|
|
},
|
|
({ h1, h2, r1, r2 }) => {
|
|
const lower = topicRecencyKey(h1, r1) < topicRecencyKey(h2, r2)
|
|
const expected = h1 > h2 || (h1 === h2 && r1 < r2)
|
|
return lower === expected
|
|
},
|
|
{ label: 'topicRecencyKey ordering' }
|
|
)
|
|
})
|