Files
psf-memo/psf-memo-indexer/test/property/topic-indexing.property.test.js
T
Chris Troutner dd11964174 Refactor topic metadata: cut CRAP, share test doubles, harden property tests
- 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.
2026-09-18 06:02:13 -07:00

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' }
)
})