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.
This commit is contained in:
Chris Troutner
2026-09-18 06:02:13 -07:00
parent 8da0f09f46
commit dd11964174
13 changed files with 497 additions and 485 deletions
@@ -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' }
)
})
@@ -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)
})
@@ -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 (value.type === 'post') {
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
@@ -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 }]
])
@@ -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 (e.value.type === 'post') {
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++
}
}
@@ -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
},
+20 -10
View File
@@ -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)]
}
}
@@ -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', () => {
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.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin')
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,7 +168,11 @@ describe('#TopicQuery', () => {
])
})
it('should paginate using recency order and report total and hasMore', async () => {
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,
@@ -230,27 +180,13 @@ describe('#TopicQuery', () => {
topicRecencyDb: makeIteratorDb(recency)
})
const result = await uut.listTopics({ limit: 2, offset: 2 })
const result = await uut.listTopics({ limit, offset })
assert.deepEqual(result.topics.map((t) => t.room), ['dance', 'anime'])
assert.deepEqual(result.topics.map((t) => t.room), expectedRooms)
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)
assert.equal(result.pagination.hasMore, hasMore)
})
}
it('should read the recency index without iterating the rooms store', async () => {
uut = new TopicQuery({
@@ -283,7 +219,11 @@ describe('#TopicQuery', () => {
assert.equal(result.total, 2)
})
it('should paginate topic posts', async () => {
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 }]
@@ -292,26 +232,12 @@ describe('#TopicQuery', () => {
.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.deepEqual(result.txids, txids)
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)
})
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' })
@@ -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 })
@@ -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 = {
@@ -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.
@@ -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 }
}
}
}
@@ -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)
})
it('should let a re-follow increase the count after an unfollow', async () => {
for (const { name, steps, expected } of followerCases) {
it(name, async () => {
const adapters = makeAdapters()
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
})
await processFollow(adapters, { txid: 'follow-11', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' })
for (const step of steps) {
await processFollow(adapters, step)
}
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1)
assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, expected)
})
}
it('should log a process error and write nothing when the push data count is invalid', async () => {
const adapters = makeAdapters()
@@ -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)
})
})
@@ -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 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' }
]
}
]
for (const { name, messages } of accumulationCases) {
it(name, async () => {
const adapters = makeAdapters()
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' })
await processMessages(adapters, messages)
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 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)
assertAccumulatedSummary(adapters, { postCount: 2, lastHeight: 600200, lastSeen: 1, followerCount: 0 })
})
}
it('should not double-count a reprocessed topic message', async () => {
const adapters = makeAdapters()