From dd11964174e7965e35a6fb29d3441d4c2aa14fb9 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 18 Sep 2026 06:02:13 -0700 Subject: [PATCH] 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. --- .../property/topic-metadata.property.test.js | 121 +++++++++++ .../test/unit/topic-discovery-page.test.js | 31 ++- .../backfill-topic-indexes.property.test.js | 60 ++---- .../property/topic-query.property.test.js | 36 +++- psf-memo-db/test/support/level-double.js | 30 ++- .../test/unit/adapters/topic-query.unit.js | 196 ++++++------------ .../unit/lib/backfill-topic-indexes.unit.js | 63 ++---- .../use-cases/action-types/topic-indexing.js | 2 +- .../property/topic-indexing.property.test.js | 73 +++---- psf-memo-indexer/test/support/memory-db.js | 32 +++ .../action-types/topic-follow.unit.js | 145 +++++-------- .../action-types/topic-indexing.unit.js | 72 ++++--- .../action-types/topic-message.unit.js | 121 +++++------ 13 files changed, 497 insertions(+), 485 deletions(-) create mode 100644 psf-memo-client/test/property/topic-metadata.property.test.js create mode 100644 psf-memo-indexer/test/support/memory-db.js diff --git a/psf-memo-client/test/property/topic-metadata.property.test.js b/psf-memo-client/test/property/topic-metadata.property.test.js new file mode 100644 index 0000000..e338e6f --- /dev/null +++ b/psf-memo-client/test/property/topic-metadata.property.test.js @@ -0,0 +1,121 @@ +/* + Property tests for the topic metadata display helpers. + + The unit tests probe relativeTime and getLastSeenLabel at fixed fixtures. + These properties pin down the formatting contract over broad random times: + + - Classification: the label matches the feature spec's buckets computed + independently from elapsed time. + - Pluralization: a label is singular exactly when its magnitude is one. + - Monotonicity: a longer elapsed time never yields a smaller hour/day + magnitude, so the label orders time correctly. + - Consistency: getLastSeenLabel returns the same label relativeTime + produces for a loaded topic, and null for an unknown topic. + + `now` is injected so every property is deterministic. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { seededRandom, forAll, intGen } = require('./harness') +const { relativeTime } = require('../../src/services/relative-time') +const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') + +const rng = seededRandom(20260918) + +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR +const NOW = 1800000000000 + +// Reference classifier written from the feature spec, independent of the +// implementation under test. +function expectedLabel (elapsed) { + if (elapsed < HOUR) return 'Less than an hour ago' + if (elapsed < DAY) { + const hours = Math.floor(elapsed / HOUR) + return hours === 1 ? '1 hour ago' : `${hours} hours ago` + } + const days = Math.floor(elapsed / DAY) + return days === 1 ? '1 day ago' : `${days} days ago` +} + +// Convert an hour/day label back to a magnitude in hours, for ordering checks. +function elapsedHours (label) { + const match = /^(\d+) (hour|day)s? ago$/.exec(label) + if (!match) return 0 + const value = Number(match[1]) + return match[2] === 'day' ? value * 24 : value +} + +test('relativeTime classifies every elapsed time per the spec buckets', async () => { + await forAll( + () => intGen(rng, 0, 400 * DAY)(), + (elapsed) => relativeTime(NOW - elapsed, NOW) === expectedLabel(elapsed), + { label: 'relativeTime bucket classification' } + ) +}) + +test('relativeTime labels are singular exactly at magnitude one', async () => { + await forAll( + () => intGen(rng, HOUR, 365 * DAY)(), + (elapsed) => { + const label = relativeTime(NOW - elapsed, NOW) + const match = /^(\d+) (hour|day)s? ago$/.exec(label) + if (!match) return false + return label.includes('s ago') === (Number(match[1]) !== 1) + }, + { label: 'relativeTime pluralization' } + ) +}) + +test('relativeTime is monotonic as elapsed time grows', async () => { + await forAll( + () => { + const first = HOUR + intGen(rng, 0, 60 * DAY)() + return { first, second: first + intGen(rng, 0, 60 * DAY)() } + }, + ({ first, second }) => { + return elapsedHours(relativeTime(NOW - first, NOW)) <= + elapsedHours(relativeTime(NOW - second, NOW)) + }, + { label: 'relativeTime monotonicity' } + ) +}) + +test('relativeTime reports "No posts" for a missing last-seen time', () => { + for (const lastSeen of [0, null, undefined]) { + assert.equal(relativeTime(lastSeen, NOW), 'No posts') + } +}) + +test('getLastSeenLabel mirrors relativeTime for loaded topics and null otherwise', async () => { + await forAll( + () => { + const topics = [] + const count = intGen(rng, 0, 10)() + for (let i = 0; i < count; i++) { + topics.push({ + room: `room-${i}`, + postCount: 1, + lastSeen: NOW - intGen(rng, 0, 400 * DAY)() + }) + } + return topics + }, + async (topics) => { + const page = new TopicDiscoveryPage({ + memoDb: { async getTopics () { return { topics } } } + }) + await page.load() + + for (const topic of topics) { + if (page.getLastSeenLabel(topic.room, NOW) !== relativeTime(topic.lastSeen, NOW)) return false + } + if (page.getLastSeenLabel('missing-room', NOW) !== null) return false + return true + }, + { label: 'getLastSeenLabel consistency' } + ) +}) diff --git a/psf-memo-client/test/unit/topic-discovery-page.test.js b/psf-memo-client/test/unit/topic-discovery-page.test.js index b0100be..30763e1 100644 --- a/psf-memo-client/test/unit/topic-discovery-page.test.js +++ b/psf-memo-client/test/unit/topic-discovery-page.test.js @@ -19,6 +19,13 @@ function makeMemoDb ({ topics = [], pagination = null } = {}) { } } +// Build a controller over a fixed topic list and run its initial load. +async function loadPage (topics) { + const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) }) + await page.load() + return page +} + test('load returns topics with post counts', async () => { const topics = [ { room: 'bitcoin', postCount: 2 }, @@ -95,45 +102,31 @@ test('getTopic returns the matching topic', async () => { { room: 'bitcoin', postCount: 2 }, { room: 'cash', postCount: 1 } ] - const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) }) - - await page.load() + const page = await loadPage(topics) assert.deepEqual(page.getTopic('bitcoin'), { room: 'bitcoin', postCount: 2 }) }) test('getTopic returns null when the topic is not loaded', async () => { - const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) }) - - await page.load() + const page = await loadPage([]) assert.equal(page.getTopic('bitcoin'), null) }) test('getLastSeenLabel formats the topic last-seen time', async () => { - const page = new TopicDiscoveryPage({ - memoDb: makeMemoDb({ topics: [{ room: 'bitcoin', postCount: 1, lastSeen: 1799998200000 }] }) - }) - - await page.load() + const page = await loadPage([{ room: 'bitcoin', postCount: 1, lastSeen: 1799998200000 }]) assert.equal(page.getLastSeenLabel('bitcoin', 1800000000000), 'Less than an hour ago') }) test('getLastSeenLabel returns "No posts" for a topic with no posts', async () => { - const page = new TopicDiscoveryPage({ - memoDb: makeMemoDb({ topics: [{ room: 'lone', postCount: 0, lastSeen: 0 }] }) - }) - - await page.load() + const page = await loadPage([{ room: 'lone', postCount: 0, lastSeen: 0 }]) assert.equal(page.getLastSeenLabel('lone', 1800000000000), 'No posts') }) test('getLastSeenLabel returns null for an unknown topic', async () => { - const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) }) - - await page.load() + const page = await loadPage([]) assert.equal(page.getLastSeenLabel('missing', 1800000000000), null) }) diff --git a/psf-memo-db/test/property/backfill-topic-indexes.property.test.js b/psf-memo-db/test/property/backfill-topic-indexes.property.test.js index 555c16a..17247bf 100644 --- a/psf-memo-db/test/property/backfill-topic-indexes.property.test.js +++ b/psf-memo-db/test/property/backfill-topic-indexes.property.test.js @@ -6,8 +6,9 @@ contents: - Conservation: every room in the rooms store gets a summary whose - postCount equals its number of post entries and whose lastHeight is the - newest post height (0 for follow-only rooms). + 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 @@ -20,40 +21,13 @@ 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'] -// In-memory LevelDB-shaped store supporting the iterator/get/put/del surface -// the backfill uses. -function makeDb (records = []) { - const store = new Map(records) - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async put (key, value) { - store.set(key, value) - }, - async del (key) { - store.delete(key) - }, - async * iterator () { - for (const key of Array.from(store.keys()).sort()) { - yield [key, store.get(key)] - } - } - } -} - // Random rooms-store contents: each room gets a random number of posts and // follow records, including unfollows and follows with no posts. function roomsGen () { @@ -65,7 +39,7 @@ function roomsGen () { for (let i = 0; i < postCount; i++) { entries.push([ `${room}:post-${seq}`, - { room, txid: `post-${seq}`, type: 'post', blockHeight: intGen(rng, 0, 9000000)() } + { room, txid: `post-${seq}`, type: 'post', blockHeight: intGen(rng, 0, 9000000)(), seen: intGen(rng, 0, 5000000)() } ]) seq++ } @@ -88,12 +62,18 @@ 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 }) + 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') { - const summary = summaries.get(room) 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 @@ -107,9 +87,9 @@ test('backfill conserves every room summary and builds the recency index', async await forAll( roomsGen(), async (entries) => { - const roomsDb = makeDb(entries) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const roomsDb = new FakeDb(entries) + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() const result = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) @@ -122,6 +102,8 @@ test('backfill conserves every room summary and builds the recency index', async 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) @@ -150,11 +132,11 @@ test('backfill drops stale recency and summary records from earlier runs', async await forAll( roomsGen(), async (entries) => { - const roomsDb = makeDb(entries) - const topicSummariesDb = makeDb([ + const roomsDb = new FakeDb(entries) + const topicSummariesDb = new FakeDb([ ['stale-room', { room: 'stale-room', postCount: 9, lastHeight: 999999 }] ]) - const topicRecencyDb = makeDb([ + const topicRecencyDb = new FakeDb([ [topicRecencyKey(999999, 'stale-room'), { room: 'stale-room', blockHeight: 999999 }], [topicRecencyKey(123456, 'another-stale'), { room: 'another-stale', blockHeight: 123456 }] ]) diff --git a/psf-memo-db/test/property/topic-query.property.test.js b/psf-memo-db/test/property/topic-query.property.test.js index 6264574..7b857be 100644 --- a/psf-memo-db/test/property/topic-query.property.test.js +++ b/psf-memo-db/test/property/topic-query.property.test.js @@ -7,7 +7,8 @@ - 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, and the topics are returned sorted by most recent 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. @@ -72,12 +73,18 @@ 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 }) + 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') { - const summary = summaries.get(room) 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++ } } @@ -132,6 +139,7 @@ function fixtureGen () { txid: txidGen(rng), addr: ADDRESSES[intGen(rng, 0, ADDRESSES.length - 1)()], blockHeight: intGen(rng, 0, 9000000)(), + seen: intGen(rng, 0, 5000000)(), room } }) @@ -141,7 +149,7 @@ function fixtureGen () { for (let j = 0; j < followCount; j++) { entries.push({ key: `${room}:${txidGen(rng)}`, - value: { type: 'follow', room } + value: { type: 'follow', room, unfollow: rng() < 0.4 } }) } } @@ -169,10 +177,18 @@ test('listTopics conserves post counts and returns rooms sorted by most recent p const postEntries = entries.filter((e) => e.value.type === 'post') const expectedTopics = [...new Set(entries.map((e) => e.value.room))] .map((room) => { - const heights = entries - .filter((e) => e.value.room === room && e.value.type === 'post') - .map((e) => e.value.blockHeight ?? 0) - return { room, lastHeight: heights.length ? Math.max(...heights) : 0 } + 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 @@ -186,8 +202,12 @@ test('listTopics conserves post counts and returns rooms sorted by most recent p 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 }, diff --git a/psf-memo-db/test/support/level-double.js b/psf-memo-db/test/support/level-double.js index 2660dfa..6bb39aa 100644 --- a/psf-memo-db/test/support/level-double.js +++ b/psf-memo-db/test/support/level-double.js @@ -1,15 +1,21 @@ /* - In-memory LevelDB double shared by the txid repair unit and property tests. + In-memory LevelDB double shared by the txid repair and topic index unit and + property tests. - FakeDb implements just the get/put/del/iterator surface the repair library - uses, plus keys() for assertions. makeLevel() wires the same store names the - real psf-memo-db exposes so the repair library can be driven without opening - any real LevelDB files. + FakeDb implements the get/put/del/iterator surface the libraries use, honors + the `gte`/`lte`/`limit` iterator options the topic read path relies on, and + exposes the backing Map as `store` for assertions. makeLevel() wires the same + store names the real psf-memo-db exposes so the repair library can be driven + without opening any real LevelDB files. */ export class FakeDb { - constructor () { - this.map = new Map() + constructor (records = []) { + this.map = new Map(records) + } + + get store () { + return this.map } async get (key) { @@ -29,9 +35,13 @@ export class FakeDb { this.map.delete(key) } - async * iterator () { - for (const [key, value] of this.map) { - yield [key, value] + async * iterator (opts = {}) { + let keys = [...this.map.keys()].sort() + if (opts.gte !== undefined) keys = keys.filter((key) => key >= opts.gte) + if (opts.lte !== undefined) keys = keys.filter((key) => key <= opts.lte) + const limit = opts.limit === undefined ? keys.length : opts.limit + for (const key of keys.slice(0, limit)) { + yield [key, this.map.get(key)] } } diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index 96513e4..205d17c 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -2,65 +2,16 @@ import { assert } from 'chai' import sinon from 'sinon' import TopicQuery from '../../../src/adapters/topic-query.js' import { topicRecencyKey } from '../../../src/lib/backfill-topic-indexes.js' +import { FakeDb } from '../../support/level-double.js' function makeRoomsDb (records = {}) { - const store = new Map(Object.entries(records)) - return { - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - iterator (opts = {}) { - const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0])) - const { gte, lte } = opts - const filtered = entries.filter(([key]) => { - if (gte && key < gte) return false - if (lte && key > lte) return false - return true - }) - let i = 0 - return { - [Symbol.asyncIterator] () { - return this - }, - async next () { - if (i >= filtered.length) return { value: undefined, done: true } - const entry = filtered[i++] - return { value: entry, done: false } - }, - async close () {} - } - } - } + return new FakeDb(Object.entries(records)) } // In-memory index store whose iterator honors the LevelDB `limit` option, so // the read path's bounded recency reads can be asserted. function makeIteratorDb (records = []) { - const store = new Map(records) - return { - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async * iterator (opts = {}) { - let keys = Array.from(store.keys()).sort() - if (opts.gte !== undefined) keys = keys.filter((k) => k >= opts.gte) - if (opts.lte !== undefined) keys = keys.filter((k) => k <= opts.lte) - const limit = opts.limit === undefined ? keys.length : opts.limit - for (const key of keys.slice(0, limit)) { - yield [key, store.get(key)] - } - } - } + return new FakeDb(records) } describe('#TopicQuery', () => { @@ -128,15 +79,20 @@ describe('#TopicQuery', () => { } }) - describe('#roomFromKey', () => { - it('should return the room from the value when present', () => { - assert.equal(uut.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin') - }) + for (const { method, presentKey, fallbackKey } of [ + { method: 'roomFromKey', presentKey: 'ignored:post-1', fallbackKey: 'cash:post-1' }, + { method: 'summaryRoom', presentKey: 'ignored', fallbackKey: 'cash' } + ]) { + describe(`#${method}`, () => { + it('should return the room from the value when present', () => { + assert.equal(uut[method](presentKey, { room: 'bitcoin' }), 'bitcoin') + }) - it('should fall back to the first segment of the key', () => { - assert.equal(uut.roomFromKey('cash:post-1', {}), 'cash') + it('should fall back to the key when the value omits the room', () => { + assert.equal(uut[method](fallbackKey, {}), 'cash') + }) }) - }) + } describe('#txidFromKey', () => { it('should return the last segment of the key', () => { @@ -144,16 +100,6 @@ describe('#TopicQuery', () => { }) }) - describe('#summaryRoom', () => { - it('should return the room from the value when present', () => { - assert.equal(uut.summaryRoom('ignored', { room: 'bitcoin' }), 'bitcoin') - }) - - it('should fall back to the key when the value omits the room', () => { - assert.equal(uut.summaryRoom('cash', {}), 'cash') - }) - }) - describe('#recencyRoom', () => { it('should return the room from the value when present', () => { assert.equal(uut.recencyRoom('ignored', { room: 'bitcoin' }), 'bitcoin') @@ -222,35 +168,25 @@ describe('#TopicQuery', () => { ]) }) - it('should paginate using recency order and report total and hasMore', async () => { - uut = new TopicQuery({ - roomsDb, - postsDb, - topicSummariesDb: makeIteratorDb(summaries), - topicRecencyDb: makeIteratorDb(recency) + for (const { name, limit, offset, expectedRooms, hasMore } of [ + { name: 'should paginate using recency order and report total and hasMore', limit: 2, offset: 2, expectedRooms: ['dance', 'anime'], hasMore: true }, + { name: 'should report hasMore false on the last page', limit: 2, offset: 4, expectedRooms: ['lone', 'quiet'], hasMore: false } + ]) { + it(name, async () => { + uut = new TopicQuery({ + roomsDb, + postsDb, + topicSummariesDb: makeIteratorDb(summaries), + topicRecencyDb: makeIteratorDb(recency) + }) + + const result = await uut.listTopics({ limit, offset }) + + assert.deepEqual(result.topics.map((t) => t.room), expectedRooms) + assert.equal(result.pagination.total, 6) + assert.equal(result.pagination.hasMore, hasMore) }) - - const result = await uut.listTopics({ limit: 2, offset: 2 }) - - assert.deepEqual(result.topics.map((t) => t.room), ['dance', 'anime']) - assert.equal(result.pagination.total, 6) - assert.equal(result.pagination.hasMore, true) - }) - - it('should report hasMore false on the last page', async () => { - uut = new TopicQuery({ - roomsDb, - postsDb, - topicSummariesDb: makeIteratorDb(summaries), - topicRecencyDb: makeIteratorDb(recency) - }) - - const result = await uut.listTopics({ limit: 2, offset: 4 }) - - assert.deepEqual(result.topics.map((t) => t.room), ['lone', 'quiet']) - assert.equal(result.pagination.total, 6) - assert.equal(result.pagination.hasMore, false) - }) + } it('should read the recency index without iterating the rooms store', async () => { uut = new TopicQuery({ @@ -283,35 +219,25 @@ describe('#TopicQuery', () => { assert.equal(result.total, 2) }) - it('should paginate topic posts', async () => { - async function * mockRooms () { - yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] - yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] - } - roomsDb.iterator - .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) - .returns(mockRooms()) + for (const { name, limit, offset, txids } of [ + { name: 'should paginate topic posts', limit: 1, offset: 0, txids: ['post-300'] }, + { name: 'should apply offset', limit: 100, offset: 1, txids: ['post-200'] } + ]) { + it(name, async () => { + async function * mockRooms () { + yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] + yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] + } + roomsDb.iterator + .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) + .returns(mockRooms()) - const result = await uut.getTopicPostTxids('bitcoin', { limit: 1, offset: 0 }) + const result = await uut.getTopicPostTxids('bitcoin', { limit, offset }) - assert.deepEqual(result.txids, ['post-300']) - assert.equal(result.total, 2) - }) - - it('should apply offset', async () => { - async function * mockRooms () { - yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] - yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] - } - roomsDb.iterator - .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) - .returns(mockRooms()) - - const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 1 }) - - assert.deepEqual(result.txids, ['post-200']) - assert.equal(result.total, 2) - }) + assert.deepEqual(result.txids, txids) + assert.equal(result.total, 2) + }) + } it('should return empty result for a topic with no posts', async () => { async function * empty () {} @@ -392,19 +318,17 @@ describe('#TopicQuery', () => { assert.equal(result, false) }) - it('should return true for an active follow record', async () => { - roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false }) + for (const { addr, unfollow, expected } of [ + { addr: 'addr-a', unfollow: false, expected: true }, + { addr: 'addr-c', unfollow: true, expected: false } + ]) { + it(`should return ${expected} for a follow record with unfollow ${unfollow}`, async () => { + roomsDb.get.withArgs(`bitcoin:${addr}`).resolves({ room: 'bitcoin', addr, type: 'follow', unfollow }) - const result = await uut.isFollowingRoom('addr-a', 'bitcoin') - assert.equal(result, true) - }) - - it('should return false for an unfollow record', async () => { - roomsDb.get.withArgs('bitcoin:addr-c').resolves({ room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true }) - - const result = await uut.isFollowingRoom('addr-c', 'bitcoin') - assert.equal(result, false) - }) + const result = await uut.isFollowingRoom(addr, 'bitcoin') + assert.equal(result, expected) + }) + } it('should return false for a non-follow record', async () => { roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', txid: 'post-1', type: 'post' }) diff --git a/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js b/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js index d962b49..be83c00 100644 --- a/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js +++ b/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js @@ -1,43 +1,18 @@ import { assert } from 'chai' import { backfillTopicIndexes, topicRecencyKey } from '../../../src/lib/backfill-topic-indexes.js' - -function makeDb (records = []) { - const store = new Map(records) - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async put (key, value) { - store.set(key, value) - }, - async del (key) { - store.delete(key) - }, - async * iterator () { - for (const key of Array.from(store.keys()).sort()) { - yield [key, store.get(key)] - } - } - } -} +import { FakeDb } from '../../support/level-double.js' describe('#backfillTopicIndexes', () => { it('should summarize rooms with posts and follow-only rooms', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700000000000 }], ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000999000 }], ['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }], ['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250, seen: 1700002000000 }], ['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() const result = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) @@ -71,13 +46,13 @@ describe('#backfillTopicIndexes', () => { }) it('should count only active follows and ignore unfollowed addresses', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['bitcoin:addr-a', { room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false }], ['bitcoin:addr-b', { room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: true }], ['bitcoin:addr-c', { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: false }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) @@ -85,12 +60,12 @@ describe('#backfillTopicIndexes', () => { }) it('should keep the newest lastSeen when post heights and seen times disagree', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700009999000 }], ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000000000 }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) @@ -100,12 +75,12 @@ describe('#backfillTopicIndexes', () => { }) it('should be idempotent across repeated runs', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700003000000 }], ['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) const firstSummaries = new Map(topicSummariesDb.store) @@ -118,11 +93,11 @@ describe('#backfillTopicIndexes', () => { }) it('should remove stale recency records from a previous run', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000000000 }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb([ + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb([ [topicRecencyKey(600100, 'bitcoin'), { room: 'bitcoin', blockHeight: 600100 }], [topicRecencyKey(0, 'stale'), { room: 'stale', blockHeight: 0 }] ]) @@ -135,11 +110,11 @@ describe('#backfillTopicIndexes', () => { }) it('should fall back to the key room segment when a record omits the room', async () => { - const roomsDb = makeDb([ + const roomsDb = new FakeDb([ ['cash:post-1', { type: 'post', blockHeight: 500 }] ]) - const topicSummariesDb = makeDb() - const topicRecencyDb = makeDb() + const topicSummariesDb = new FakeDb() + const topicRecencyDb = new FakeDb() await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) diff --git a/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js b/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js index a2e8732..925fcc2 100644 --- a/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js +++ b/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js @@ -77,7 +77,7 @@ export async function recordTopicFollow (adapters, record) { if (!existing && !isActive) return null const summary = existing || await ensureTopicRoom(adapters, room) - const delta = (isActive ? 1 : 0) - (wasActive ? 1 : 0) + const delta = Number(isActive) - Number(wasActive) if (delta === 0) return summary const updated = { diff --git a/psf-memo-indexer/test/property/topic-indexing.property.test.js b/psf-memo-indexer/test/property/topic-indexing.property.test.js index 9a1ac76..5e76ea2 100644 --- a/psf-memo-indexer/test/property/topic-indexing.property.test.js +++ b/psf-memo-indexer/test/property/topic-indexing.property.test.js @@ -7,8 +7,10 @@ follows, and unfollows: - Conservation: each room's summary postCount equals the number of distinct - topic-message txids seen for that room, and lastHeight equals the newest - post height (0 when the room has no posts). + 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 @@ -24,48 +26,22 @@ import { handleTopicMessage } from '../../src/use-cases/action-types/topic-messa 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 makeDb () { - const store = new Map() - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async create (key, value) { - if (!store.has(key)) store.set(key, value) - return { success: true } - }, - async update (key, value) { - store.set(key, value) - return { success: true } - }, - async delete (key) { - store.delete(key) - return { success: true } - } - } -} - function makeAdapters () { return { - postDb: makeDb(), - postHeightDb: makeDb(), - addrPostHeightDb: makeDb(), - roomDb: makeDb(), - topicSummaryDb: makeDb(), - topicRecencyDb: makeDb(), - processErrorDb: makeDb() + postDb: makeMemoryDb(), + postHeightDb: makeMemoryDb(), + addrPostHeightDb: makeMemoryDb(), + roomDb: makeMemoryDb(), + topicSummaryDb: makeMemoryDb(), + topicRecencyDb: makeMemoryDb(), + processErrorDb: makeMemoryDb() } } @@ -76,7 +52,7 @@ async function processEvent (adapters, event) { adapters, txid: event.txid, signerAddr: 'bitcoincash:qauthor', - seen: 1, + seen: event.seen, blockHeight: event.height, decoded: { action: 'topic-message', @@ -117,6 +93,7 @@ function eventSequenceGen () { txid: `txid-${i}`, room, height: intGen(rng, 0, 9000000)(), + seen: intGen(rng, 0, 5000000)(), text: `text-${i}` }) } else { @@ -135,11 +112,15 @@ function eventSequenceGen () { } // Expected summaries derived straight from the event list, independent of the -// implementation under test. +// 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 }) + if (!summaries.has(room)) { + summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 }) + } return summaries.get(room) } @@ -148,12 +129,18 @@ function expectedSummaries (events) { const summary = ensure(event.room) summary.postCount++ if (event.height > summary.lastHeight) summary.lastHeight = event.height - } else if (!event.unfollow) { + 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. - ensure(event.room) + if (!event.unfollow) ensure(event.room) } } + for (const { room, unfollow } of followState.values()) { + if (!unfollow && summaries.has(room)) summaries.get(room).followerCount++ + } + return summaries } @@ -175,13 +162,15 @@ test('topic indexes conserve counts and heights over arbitrary action sequences' const expected = expectedSummaries(events) - // Conservation: one summary per room with the exact count and height. + // 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. diff --git a/psf-memo-indexer/test/support/memory-db.js b/psf-memo-indexer/test/support/memory-db.js new file mode 100644 index 0000000..546492a --- /dev/null +++ b/psf-memo-indexer/test/support/memory-db.js @@ -0,0 +1,32 @@ +/* + In-memory LevelDB double shared by the Memo action-type unit and property + tests. It supports the get/create/update/delete surface the action handlers + use and exposes the backing Map as `store` for assertions. +*/ + +export function makeMemoryDb () { + const store = new Map() + return { + store, + async get (key) { + if (!store.has(key)) { + const err = new Error('not found') + err.notFound = true + throw err + } + return store.get(key) + }, + async create (key, value) { + if (!store.has(key)) store.set(key, value) + return { success: true } + }, + async update (key, value) { + store.set(key, value) + return { success: true } + }, + async delete (key) { + store.delete(key) + return { success: true } + } + } +} diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js index 6fbbfdc..d8fe50b 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js @@ -2,40 +2,14 @@ import { assert } from 'chai' import { handleTopicFollow } from '../../../../src/use-cases/action-types/topic-follow.js' import { PREFIX_TOPIC_FOLLOW, PREFIX_TOPIC_UNFOLLOW } from '../../../../src/lib/memo-codes.js' import { topicRecencyKey } from '../../../../src/use-cases/action-types/helpers.js' - -function makeDb () { - const store = new Map() - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async create (key, value) { - store.set(key, value) - return { success: true } - }, - async update (key, value) { - store.set(key, value) - return { success: true } - }, - async delete (key) { - store.delete(key) - return { success: true } - } - } -} +import { makeMemoryDb } from '../../../support/memory-db.js' function makeAdapters () { return { - roomDb: makeDb(), - topicSummaryDb: makeDb(), - topicRecencyDb: makeDb(), - processErrorDb: makeDb() + roomDb: makeMemoryDb(), + topicSummaryDb: makeMemoryDb(), + topicRecencyDb: makeMemoryDb(), + processErrorDb: makeMemoryDb() } } @@ -114,70 +88,63 @@ describe('#handleTopicFollow topic indexes', () => { assert.equal(adapters.topicRecencyDb.store.size, 0) }) - it('should count two distinct followers', async () => { - const adapters = makeAdapters() + const followerAddrA = 'bitcoincash:qaddr-a' + const followerAddrB = 'bitcoincash:qaddr-b' + const followStep = (txid, addr) => ({ txid, room: 'bitcoin', addr, prefix: PREFIX_TOPIC_FOLLOW }) + const unfollowStep = (txid, addr) => ({ txid, room: 'bitcoin', addr, prefix: PREFIX_TOPIC_UNFOLLOW }) - await processFollow(adapters, { txid: 'follow-3', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - await processFollow(adapters, { txid: 'follow-4', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' }) - - assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 2) - }) - - it('should decrease the follower count on an unfollow', async () => { - const adapters = makeAdapters() - - await processFollow(adapters, { txid: 'follow-5', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - await processFollow(adapters, { txid: 'follow-6', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' }) - await processFollow(adapters, { - txid: 'unfollow-2', - room: 'bitcoin', - addr: 'bitcoincash:qaddr-a', - prefix: PREFIX_TOPIC_UNFOLLOW - }) - - assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) - }) - - it('should not change the follower count when a follow is reprocessed', async () => { - const adapters = makeAdapters() - - await processFollow(adapters, { txid: 'follow-7', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - await processFollow(adapters, { txid: 'follow-7', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - - assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) - }) - - it('should not double-decrement when an unfollow is reprocessed', async () => { - const adapters = makeAdapters() - - await processFollow(adapters, { txid: 'follow-8', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - await processFollow(adapters, { txid: 'follow-9', room: 'bitcoin', addr: 'bitcoincash:qaddr-b' }) - const unfollow = { - txid: 'unfollow-3', - room: 'bitcoin', - addr: 'bitcoincash:qaddr-a', - prefix: PREFIX_TOPIC_UNFOLLOW + const followerCases = [ + { + name: 'should count two distinct followers', + steps: [followStep('follow-3', followerAddrA), followStep('follow-4', followerAddrB)], + expected: 2 + }, + { + name: 'should decrease the follower count on an unfollow', + steps: [ + followStep('follow-5', followerAddrA), + followStep('follow-6', followerAddrB), + unfollowStep('unfollow-2', followerAddrA) + ], + expected: 1 + }, + { + name: 'should not change the follower count when a follow is reprocessed', + steps: [followStep('follow-7', followerAddrA), followStep('follow-7', followerAddrA)], + expected: 1 + }, + { + name: 'should not double-decrement when an unfollow is reprocessed', + steps: [ + followStep('follow-8', followerAddrA), + followStep('follow-9', followerAddrB), + unfollowStep('unfollow-3', followerAddrA), + unfollowStep('unfollow-3', followerAddrA) + ], + expected: 1 + }, + { + name: 'should let a re-follow increase the count after an unfollow', + steps: [ + followStep('follow-10', followerAddrA), + unfollowStep('unfollow-4', followerAddrA), + followStep('follow-11', followerAddrA) + ], + expected: 1 } - await processFollow(adapters, unfollow) - await processFollow(adapters, unfollow) + ] - assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) - }) + for (const { name, steps, expected } of followerCases) { + it(name, async () => { + const adapters = makeAdapters() - it('should let a re-follow increase the count after an unfollow', async () => { - const adapters = makeAdapters() + for (const step of steps) { + await processFollow(adapters, step) + } - await processFollow(adapters, { txid: 'follow-10', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - await processFollow(adapters, { - txid: 'unfollow-4', - room: 'bitcoin', - addr: 'bitcoincash:qaddr-a', - prefix: PREFIX_TOPIC_UNFOLLOW + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, expected) }) - await processFollow(adapters, { txid: 'follow-11', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) - - assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) - }) + } it('should log a process error and write nothing when the push data count is invalid', async () => { const adapters = makeAdapters() diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js index a2410a9..b7260f2 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js @@ -1,32 +1,13 @@ import { assert } from 'chai' -import { recordTopicPost } from '../../../../src/use-cases/action-types/topic-indexing.js' +import { recordTopicPost, ensureTopicRoom, recordTopicFollow } from '../../../../src/use-cases/action-types/topic-indexing.js' import { topicRecencyKey } from '../../../../src/use-cases/action-types/helpers.js' - -function makeDb () { - const store = new Map() - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async update (key, value) { - store.set(key, value) - }, - async delete (key) { - store.delete(key) - } - } -} +import { makeMemoryDb } from '../../../support/memory-db.js' function makeAdapters () { return { - topicSummaryDb: makeDb(), - topicRecencyDb: makeDb() + roomDb: makeMemoryDb(), + topicSummaryDb: makeMemoryDb(), + topicRecencyDb: makeMemoryDb() } } @@ -132,3 +113,46 @@ describe('#recordTopicPost lastSeen', () => { assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 4) }) }) + +describe('#ensureTopicRoom', () => { + it('should return an existing summary unchanged', async () => { + const adapters = makeAdapters() + const existing = { + room: 'bitcoin', + postCount: 2, + lastHeight: 600100, + lastSeen: 1700000000000, + followerCount: 3 + } + adapters.topicSummaryDb.store.set('bitcoin', existing) + + const result = await ensureTopicRoom(adapters, 'bitcoin') + + assert.deepEqual(result, existing) + assert.equal(adapters.topicRecencyDb.store.size, 0) + }) +}) + +describe('#recordTopicFollow legacy summaries', () => { + it('should default a missing follower count to zero', async () => { + const adapters = makeAdapters() + adapters.topicSummaryDb.store.set('bitcoin', { + room: 'bitcoin', + postCount: 2, + lastHeight: 600100, + lastSeen: 1700000000000 + }) + + await recordTopicFollow(adapters, { + room: 'bitcoin', + addr: 'bitcoincash:qaddr-a', + unfollow: false, + txid: 'follow-1', + seen: 1, + type: 'follow', + blockHeight: 600100 + }) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js index 09ef92f..ee777b0 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js @@ -2,46 +2,17 @@ import { assert } from 'chai' import { handleTopicMessage } from '../../../../src/use-cases/action-types/topic-message.js' import { topicRecencyKey } from '../../../../src/use-cases/action-types/helpers.js' import { MAX_POST_SIZE } from '../../../../src/lib/memo-codes.js' - -function makeDb () { - const store = new Map() - return { - store, - async get (key) { - if (!store.has(key)) { - const err = new Error('not found') - err.notFound = true - throw err - } - return store.get(key) - }, - async create (key, value) { - store.set(key, value) - return { success: true } - }, - async update (key, value) { - store.set(key, value) - return { success: true } - }, - async delete (key) { - store.delete(key) - return { success: true } - }, - entries () { - return Array.from(store.entries()) - } - } -} +import { makeMemoryDb } from '../../../support/memory-db.js' function makeAdapters () { return { - postDb: makeDb(), - postHeightDb: makeDb(), - addrPostHeightDb: makeDb(), - roomDb: makeDb(), - topicSummaryDb: makeDb(), - topicRecencyDb: makeDb(), - processErrorDb: makeDb() + postDb: makeMemoryDb(), + postHeightDb: makeMemoryDb(), + addrPostHeightDb: makeMemoryDb(), + roomDb: makeMemoryDb(), + topicSummaryDb: makeMemoryDb(), + topicRecencyDb: makeMemoryDb(), + processErrorDb: makeMemoryDb() } } @@ -61,6 +32,23 @@ async function processTopicMessage (adapters, { txid, room, addr, height, text, }) } +// Process a batch of messages for the bitcoin room. +async function processMessages (adapters, messages) { + for (const message of messages) { + await processTopicMessage(adapters, { room: 'bitcoin', addr: 'bitcoincash:qaddr-a', ...message }) + } +} + +// Assert the room summary and its matching single recency record. +function assertAccumulatedSummary (adapters, expected) { + assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', ...expected }) + assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(expected.lastHeight, 'bitcoin')), { + room: 'bitcoin', + blockHeight: expected.lastHeight + }) + assert.equal(adapters.topicRecencyDb.store.size, 1) +} + describe('#handleTopicMessage topic indexes', () => { it('should record a room summary and a recency record', async () => { const adapters = makeAdapters() @@ -101,45 +89,32 @@ describe('#handleTopicMessage topic indexes', () => { assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 1700000000000) }) - it('should accumulate postCount and keep the newest height', async () => { - const adapters = makeAdapters() + const accumulationCases = [ + { + name: 'should accumulate postCount and keep the newest height', + messages: [ + { txid: 'topic-a1', height: 600100, text: 'hello' }, + { txid: 'topic-a2', height: 600200, text: 'again' } + ] + }, + { + name: 'should keep the newest height when a later message has an earlier height', + messages: [ + { txid: 'topic-a3', height: 600200, text: 'later' }, + { txid: 'topic-a4', height: 600100, text: 'earlier' } + ] + } + ] - await processTopicMessage(adapters, { txid: 'topic-a1', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600100, text: 'hello' }) - await processTopicMessage(adapters, { txid: 'topic-a2', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600200, text: 'again' }) + for (const { name, messages } of accumulationCases) { + it(name, async () => { + const adapters = makeAdapters() - assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { - room: 'bitcoin', - postCount: 2, - lastHeight: 600200, - lastSeen: 1, - followerCount: 0 + await processMessages(adapters, messages) + + assertAccumulatedSummary(adapters, { postCount: 2, lastHeight: 600200, lastSeen: 1, followerCount: 0 }) }) - assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { - room: 'bitcoin', - blockHeight: 600200 - }) - assert.equal(adapters.topicRecencyDb.store.size, 1) - }) - - it('should keep the newest height when a later message has an earlier height', async () => { - const adapters = makeAdapters() - - await processTopicMessage(adapters, { txid: 'topic-a3', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600200, text: 'later' }) - await processTopicMessage(adapters, { txid: 'topic-a4', room: 'bitcoin', addr: 'bitcoincash:qaddr-a', height: 600100, text: 'earlier' }) - - assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { - room: 'bitcoin', - postCount: 2, - lastHeight: 600200, - lastSeen: 1, - followerCount: 0 - }) - assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { - room: 'bitcoin', - blockHeight: 600200 - }) - assert.equal(adapters.topicRecencyDb.store.size, 1) - }) + } it('should not double-count a reprocessed topic message', async () => { const adapters = makeAdapters()