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.
312 lines
10 KiB
JavaScript
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' }
|
|
)
|
|
})
|