Files
psf-memo/psf-memo-db/test/property/backfill-topic-indexes.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

176 lines
6.4 KiB
JavaScript

/*
Property tests for the topic index backfill.
The unit tests probe backfillTopicIndexes at a few fixed fixtures. These
properties pin down the invariants that must hold over arbitrary rooms-store
contents:
- Conservation: every room in the rooms store gets a summary whose
postCount equals its number of post entries, whose lastHeight is the
newest post height and lastSeen the newest post time (0 for follow-only
rooms), and whose followerCount equals its number of active follows.
- Recency shape: topicRecency holds exactly one record per summarized room,
keyed at that room's lastHeight, with the matching value.
- Idempotence: running the backfill twice produces identical indexes, and
stale records from earlier runs are removed.
- 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 { backfillTopicIndexes, topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js'
import { FakeDb } from '../support/level-double.js'
const rng = seededRandom(20260917)
const ROOMS = ['room-0', 'room-1', 'room-2', 'room-3']
const FOLLOW_ADDRS = ['addr-0', 'addr-1', 'addr-2']
// Random rooms-store contents: each room gets a random number of posts and
// follow records, including unfollows and follows with no posts.
function roomsGen () {
return () => {
const entries = []
let seq = 0
for (const room of ROOMS) {
const postCount = intGen(rng, 0, 6)()
for (let i = 0; i < postCount; i++) {
entries.push([
`${room}:post-${seq}`,
{ room, txid: `post-${seq}`, type: 'post', blockHeight: intGen(rng, 0, 9000000)(), seen: intGen(rng, 0, 5000000)() }
])
seq++
}
const followCount = intGen(rng, 0, 3)()
for (let i = 0; i < followCount; i++) {
const addr = FOLLOW_ADDRS[Math.floor(rng() * FOLLOW_ADDRS.length)]
entries.push([
`${room}:${addr}-${seq}`,
{ room, addr, type: 'follow', unfollow: rng() < 0.4 }
])
seq++
}
}
return entries
}
}
function expectedSummaries (entries) {
const summaries = new Map()
for (const [, value] of entries) {
const room = value.room
if (!summaries.has(room)) {
summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 })
}
const summary = summaries.get(room)
if (value.type === 'post') {
summary.postCount++
const height = value.blockHeight ?? 0
if (height > summary.lastHeight) summary.lastHeight = height
const seen = value.seen ?? 0
if (seen > summary.lastSeen) summary.lastSeen = seen
} else if (value.type === 'follow' && value.unfollow !== true) {
summary.followerCount++
}
}
return summaries
}
function snapshot (db) {
return JSON.stringify(Array.from(db.store.entries()).sort())
}
test('backfill conserves every room summary and builds the recency index', async () => {
await forAll(
roomsGen(),
async (entries) => {
const roomsDb = new FakeDb(entries)
const topicSummariesDb = new FakeDb()
const topicRecencyDb = new FakeDb()
const result = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
const expected = expectedSummaries(entries)
if (result.rooms !== expected.size) return false
if (topicSummariesDb.store.size !== expected.size) return false
for (const [room, summary] of expected) {
const stored = topicSummariesDb.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
const key = topicRecencyKey(summary.lastHeight, room)
const recency = topicRecencyDb.store.get(key)
if (!recency) return false
if (recency.room !== room) return false
if (recency.blockHeight !== summary.lastHeight) return false
}
// Exactly one recency record per room.
if (topicRecencyDb.store.size !== expected.size) return false
// Idempotence: a second run leaves the indexes byte-for-byte identical.
const beforeSummaries = snapshot(topicSummariesDb)
const beforeRecency = snapshot(topicRecencyDb)
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
if (snapshot(topicSummariesDb) !== beforeSummaries) return false
if (snapshot(topicRecencyDb) !== beforeRecency) return false
return true
},
{ label: 'backfill conservation, recency shape, and idempotence' }
)
})
test('backfill drops stale recency and summary records from earlier runs', async () => {
await forAll(
roomsGen(),
async (entries) => {
const roomsDb = new FakeDb(entries)
const topicSummariesDb = new FakeDb([
['stale-room', { room: 'stale-room', postCount: 9, lastHeight: 999999 }]
])
const topicRecencyDb = new FakeDb([
[topicRecencyKey(999999, 'stale-room'), { room: 'stale-room', blockHeight: 999999 }],
[topicRecencyKey(123456, 'another-stale'), { room: 'another-stale', blockHeight: 123456 }]
])
await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb })
// No record may survive for a room absent from the rooms store.
for (const [room] of topicSummariesDb.store) {
if (room === 'stale-room') return false
}
for (const [, value] of topicRecencyDb.store) {
if (value.room === 'stale-room' || value.room === 'another-stale') return false
}
return true
},
{ label: 'backfill removes stale records' }
)
})
test('topicRecencyKey sorts newest height first and ties by room ascending', async () => {
await forAll(
() => ({
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' }
)
})