mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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 (!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 }]
|
||||
])
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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', () => {
|
||||
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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
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()
|
||||
|
||||
@@ -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 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()
|
||||
|
||||
Reference in New Issue
Block a user