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

312 lines
10 KiB
JavaScript

/*
Property tests for the TopicQuery adapter.
The unit tests probe listTopics and getTopicPostTxids at a few fixed
fixtures. These properties pin down invariants that hold over broad random
inputs:
- listTopics conservation: the sum of postCounts equals the number of
post entries in the rooms store, each room's count matches its own post
entries, each room's lastSeen and followerCount match its post times and
active follows, and the topics are returned sorted by most recent post.
- getTopicPostTxids ordering + pagination: posts are returned newest-first
by block height, the total matches the room's post entries, and the
offset/limit slice is exact.
- roomFromKey / txidFromKey round-trip: the room and txid are recovered
from a `${room}:${txid}` key.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
import TopicQuery from '../../src/adapters/topic-query.js'
import { topicRecencyKey } from '../../src/lib/backfill-topic-indexes.js'
const rng = seededRandom(20260828)
// Pool of cash-like address labels used to assign post authorship in fixtures.
const ADDRESSES = ['alice', 'bob', 'carol', 'dave', 'erin']
// In-memory rooms store mirroring the LevelDB iterator contract (gte/lte
// prefix bounds over the key string).
function makeRoomsDb (entries) {
const store = new Map(entries.map((e) => [e.key, e.value]))
return {
async * iterator (opts = {}) {
const { gte, lte } = opts
let keys = Array.from(store.keys()).sort()
if (gte !== undefined) keys = keys.filter((k) => k >= gte)
if (lte !== undefined) keys = keys.filter((k) => k <= lte)
for (const key of keys) {
yield [key, store.get(key)]
}
}
}
}
function makeQuery (entries) {
return new TopicQuery({
roomsDb: makeRoomsDb(entries),
postsDb: {},
topicSummariesDb: makeIndexDb([]),
topicRecencyDb: makeIndexDb([])
})
}
// In-memory index store honoring the LevelDB `limit` option used by listTopics.
function makeIndexDb (records) {
const store = new Map(records)
return {
async * iterator (opts = {}) {
const keys = Array.from(store.keys()).sort()
const limit = opts.limit === undefined ? keys.length : opts.limit
for (const key of keys.slice(0, limit)) {
yield [key, store.get(key)]
}
}
}
}
// Derive topicSummaries and topicRecency records from rooms-store entries the
// way the indexer and backfill do.
function indexDbsFromEntries (entries) {
const summaries = new Map()
for (const e of entries) {
const room = e.value.room
if (!summaries.has(room)) {
summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 })
}
const summary = summaries.get(room)
if (e.value.type === 'post') {
summary.postCount++
const height = e.value.blockHeight ?? 0
if (height > summary.lastHeight) summary.lastHeight = height
const seen = e.value.seen ?? 0
if (seen > summary.lastSeen) summary.lastSeen = seen
} else if (e.value.type === 'follow' && e.value.unfollow !== true) {
summary.followerCount++
}
}
const recency = []
for (const summary of summaries.values()) {
recency.push([
topicRecencyKey(summary.lastHeight, summary.room),
{ room: summary.room, blockHeight: summary.lastHeight }
])
}
return {
topicSummariesDb: makeIndexDb(Array.from(summaries.entries())),
topicRecencyDb: makeIndexDb(recency)
}
}
// In-memory posts store exposing the LevelDB `get` contract used by the mute
// post lookup. Returns the post record or throws a LEVEL_NOT_FOUND-style error.
function makePostsDb (entries) {
const posts = new Map(
entries.filter((e) => e.value.type === 'post').map((e) => [e.value.txid, e.value])
)
return {
async get (txid) {
const post = posts.get(txid)
if (!post) {
const err = new Error('not found')
err.notFound = true
throw err
}
return post
}
}
}
function fixtureGen () {
return () => {
const roomCount = intGen(rng, 0, 5)()
const rooms = []
const entries = []
for (let i = 0; i < roomCount; i++) {
const room = `room-${i}`
rooms.push(room)
const postCount = intGen(rng, 0, 8)()
for (let j = 0; j < postCount; j++) {
entries.push({
key: `${room}:${txidGen(rng)}`,
value: {
type: 'post',
txid: txidGen(rng),
addr: ADDRESSES[intGen(rng, 0, ADDRESSES.length - 1)()],
blockHeight: intGen(rng, 0, 9000000)(),
seen: intGen(rng, 0, 5000000)(),
room
}
})
}
const followCount = intGen(rng, 0, 3)()
for (let j = 0; j < followCount; j++) {
entries.push({
key: `${room}:${txidGen(rng)}`,
value: { type: 'follow', room, unfollow: rng() < 0.4 }
})
}
}
return {
entries,
rooms,
limit: intGen(rng, 1, 10)(),
offset: intGen(rng, 0, 8)()
}
}
}
test('listTopics conserves post counts and returns rooms sorted by most recent post', async () => {
await forAll(
fixtureGen(),
async ({ entries, limit, offset }) => {
const query = new TopicQuery({
roomsDb: makeRoomsDb(entries),
postsDb: {},
...indexDbsFromEntries(entries)
})
const { topics, pagination } = await query.listTopics({ limit, offset })
const postEntries = entries.filter((e) => e.value.type === 'post')
const expectedTopics = [...new Set(entries.map((e) => e.value.room))]
.map((room) => {
const roomPosts = entries.filter((e) => e.value.room === room && e.value.type === 'post')
const heights = roomPosts.map((e) => e.value.blockHeight ?? 0)
const seens = roomPosts.map((e) => e.value.seen ?? 0)
const followerCount = entries.filter(
(e) => e.value.room === room && e.value.type === 'follow' && e.value.unfollow !== true
).length
return {
room,
lastHeight: heights.length ? Math.max(...heights) : 0,
lastSeen: seens.length ? Math.max(...seens) : 0,
followerCount
}
})
.sort((a, b) => {
if (b.lastHeight !== a.lastHeight) return b.lastHeight - a.lastHeight
return a.room.localeCompare(b.room)
})
if (pagination.total !== expectedTopics.length) return false
const expectedPage = expectedTopics.slice(offset, offset + limit)
if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedPage.map((t) => t.room))) return false
if (pagination.hasMore !== (offset + topics.length < pagination.total)) return false
for (const topic of topics) {
const expectedTopic = expectedTopics.find((t) => t.room === topic.room)
if (!expectedTopic) return false
const roomPosts = postEntries.filter((e) => e.value.room === topic.room).length
if (topic.postCount !== roomPosts) return false
if (topic.lastSeen !== expectedTopic.lastSeen) return false
if (topic.followerCount !== expectedTopic.followerCount) return false
}
return true
},
{ label: 'listTopics conservation, ordering, and pagination' }
)
})
test('getTopicPostTxids returns posts newest-first with an exact total and slice', async () => {
await forAll(
fixtureGen(),
async ({ entries, rooms, limit, offset }) => {
if (rooms.length === 0) return true
const room = rooms[0]
const query = makeQuery(entries)
const { txids, total } = await query.getTopicPostTxids(room, { limit, offset })
const roomPosts = entries
.filter((e) => e.value.type === 'post' && e.value.room === room)
.sort((a, b) => b.value.blockHeight - a.value.blockHeight)
if (total !== roomPosts.length) return false
const expectedTxids = roomPosts.slice(offset, offset + limit).map((e) => e.value.txid)
if (JSON.stringify(txids) !== JSON.stringify(expectedTxids)) return false
// Newest-first ordering invariant.
for (let i = 1; i < roomPosts.length; i++) {
if (roomPosts[i - 1].value.blockHeight < roomPosts[i].value.blockHeight) return false
}
return true
},
{ label: 'getTopicPostTxids ordering and pagination' }
)
})
test('roomFromKey and txidFromKey round-trip a room:txid key', async () => {
const query = makeQuery([])
await forAll(
fixtureGen(),
({ entries }) => {
for (const e of entries) {
const room = query.roomFromKey(e.key, undefined)
const txid = query.txidFromKey(e.key)
const [expectedRoom, expectedTxid] = e.key.split(':')
if (room !== expectedRoom) return false
if (txid !== expectedTxid) return false
}
return true
},
{ label: 'roomFromKey/txidFromKey round-trip' }
)
})
test('getTopicPostTxids excludes muted addresses and conserves total and pagination', async () => {
await forAll(
fixtureGen(),
async ({ entries, rooms, limit, offset }) => {
if (rooms.length === 0) return true
const room = rooms[0]
// Pick a deterministic muted subset of the address pool per sample.
const mutedAddrs = ADDRESSES.filter(() => rng() < 0.4)
const muteQuery = { listMuted: async () => mutedAddrs }
const query = new TopicQuery({
roomsDb: makeRoomsDb(entries),
postsDb: makePostsDb(entries),
topicSummariesDb: makeIndexDb([]),
topicRecencyDb: makeIndexDb([]),
muteQuery
})
const roomPosts = entries
.filter((e) => e.value.type === 'post' && e.value.room === room)
.filter((e) => !mutedAddrs.includes(e.value.addr))
.sort((a, b) => b.value.blockHeight - a.value.blockHeight)
const { txids, total } = await query.getTopicPostTxids(room, {
limit,
offset,
viewerAddr: 'viewer-addr'
})
// Conservation: total counts only non-muted posts in the room.
if (total !== roomPosts.length) return false
// Pagination slice matches the filtered, ordering-preserved list.
const expectedTxids = roomPosts.slice(offset, offset + limit).map((e) => e.value.txid)
if (JSON.stringify(txids) !== JSON.stringify(expectedTxids)) return false
// Exhaustiveness: no returned txid may be authored by a muted address.
for (const txid of txids) {
const post = roomPosts.find((e) => e.value.txid === txid)
if (!post || mutedAddrs.includes(post.value.addr)) return false
}
return true
},
{ label: 'getTopicPostTxids mute filtering excludes and conserves' }
)
})