diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 2c2e227..8d391b0 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -217,6 +217,7 @@ function makeMemoDb () { const topicPosts = {} const topicCounts = new Map() const topicFollow = new Map() + const topicMetadata = new Map() return { posts, @@ -228,6 +229,7 @@ function makeMemoDb () { topics, topicPosts, topicCounts, + topicMetadata, addPost (post) { posts.push(post) }, @@ -274,6 +276,7 @@ function makeMemoDb () { }, addTopic (room, postCount) { topicCounts.set(room, postCount) + topicMetadata.set(room, { followerCount: 0, lastSeen: 0 }) topicPosts[room] = [] for (let i = 0; i < postCount; i++) { const txid = `${room}-post-${i + 1}`.padEnd(64, '0') @@ -285,6 +288,16 @@ function makeMemoDb () { }) } }, + setTopicFollowerCount (room, followerCount) { + const meta = topicMetadata.get(room) || {} + meta.followerCount = followerCount + topicMetadata.set(room, meta) + }, + setTopicLastSeen (room, lastSeen) { + const meta = topicMetadata.get(room) || {} + meta.lastSeen = lastSeen + topicMetadata.set(room, meta) + }, addTopicPost (room, post) { if (!topicPosts[room]) topicPosts[room] = [] topicPosts[room].push(post) @@ -364,7 +377,13 @@ function makeMemoDb () { async getTopics ({ limit = 50, offset = 0 } = {}) { const list = [] for (const [room, postCount] of topicCounts.entries()) { - list.push({ room, postCount }) + const meta = topicMetadata.get(room) || {} + list.push({ + room, + postCount, + lastSeen: meta.lastSeen ?? 0, + followerCount: meta.followerCount ?? 0 + }) } list.sort((a, b) => a.room.localeCompare(b.room)) const total = list.length @@ -2101,13 +2120,38 @@ const handlers = [ }, { name: 'API serves topic with post count', - pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/, + pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\S+) posts?$/, run (m, example, world) { - const room = m[1] - const count = parseInt(m[2], 10) + const room = resolveParam(m[1], example) + const count = parseInt(resolveParam(m[2], example), 10) world.memoDb.addTopic(room, count) } }, + { + name: 'topic has follower count', + pattern: /^the topic "([^"]+)" has (\S+) followers?$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const followerCount = parseInt(resolveParam(m[2], example), 10) + world.memoDb.setTopicFollowerCount(room, followerCount) + } + }, + { + name: 'topic was last posted at', + pattern: /^the topic "([^"]+)" was last posted at (\S+)$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const lastSeen = parseInt(resolveParam(m[2], example), 10) + world.memoDb.setTopicLastSeen(room, lastSeen) + } + }, + { + name: 'set current time', + pattern: /^the current time is (\S+)$/, + run (m, example, world) { + world.currentTime = parseInt(resolveParam(m[1], example), 10) + } + }, { name: 'API serves post in topic with address and text', pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/, @@ -2196,7 +2240,7 @@ const handlers = [ }, { name: 'topics page shows topic count', - pattern: /^the topics page shows the topic () with () posts$/, + pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) posts$/, run (m, example, world) { const room = resolveParam(m[1], example) const expected = parseInt(resolveParam(m[2], example), 10) @@ -2209,6 +2253,34 @@ const handlers = [ } } }, + { + name: 'topics page shows topic follower count', + pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) followers?$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const expected = parseInt(resolveParam(m[2], example), 10) + const topic = world.topicDiscoveryPage.getTopic(room) + if (!topic) { + throw new Error(`Topic ${room} is not shown on the topics page.`) + } + if (topic.followerCount !== expected) { + throw new Error(`Expected ${room} to have ${expected} followers, got ${topic.followerCount}.`) + } + } + }, + { + name: 'topics page shows most recent post label', + pattern: /^the topics page shows the most recent post for the topic "([^"]+)" as "(.+)"$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const expected = resolveParam(m[2], example) + const now = world.currentTime ?? Date.now() + const actual = world.topicDiscoveryPage.getLastSeenLabel(room, now) + if (actual !== expected) { + throw new Error(`Expected ${room} most recent post "${expected}", got "${actual}".`) + } + } + }, { name: 'click topic', pattern: /^I click the topic ()$/, diff --git a/psf-memo-client/src/components/app-body/topics/index.js b/psf-memo-client/src/components/app-body/topics/index.js index 88c8a8d..c4d6c63 100644 --- a/psf-memo-client/src/components/app-body/topics/index.js +++ b/psf-memo-client/src/components/app-body/topics/index.js @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom' // Local libraries import MemoDb from '../../../services/memo-db' import TopicDiscoveryPage from '../../../services/topic-discovery-page' +import { relativeTime } from '../../../services/relative-time' import '../../../App.css' const PAGE_SIZE = 50 @@ -101,9 +102,12 @@ function Topics (props) { key={topic.room} action onClick={() => handleClick(topic.room)} + className='d-flex justify-content-between align-items-center' > - #{topic.room}{' '} - ({topic.postCount} posts) + #{topic.room} + {relativeTime(topic.lastSeen, Date.now())} + {topic.postCount} posts + {topic.followerCount} followers ))} diff --git a/psf-memo-client/src/services/relative-time.js b/psf-memo-client/src/services/relative-time.js new file mode 100644 index 0000000..e655084 --- /dev/null +++ b/psf-memo-client/src/services/relative-time.js @@ -0,0 +1,31 @@ +/* + Format the time since a topic's most recent post as a short label. + + Inputs are epoch milliseconds: `lastSeen` is the room's most recent post + time (0 when the room has no posts) and `now` is the current time. The + labels match the topics page contract: + - no posts -> "No posts" + - less than an hour -> "Less than an hour ago" + - under 24 hours -> "N hours ago" (singular "1 hour ago") + - 24 hours or more -> "N days ago" (singular "1 day ago") +*/ + +const HOUR_MS = 60 * 60 * 1000 +const DAY_MS = 24 * HOUR_MS + +function relativeTime (lastSeen, now = Date.now()) { + if (!lastSeen) return 'No posts' + + const elapsed = now - lastSeen + if (elapsed < HOUR_MS) return 'Less than an hour ago' + + if (elapsed < DAY_MS) { + const hours = Math.floor(elapsed / HOUR_MS) + return hours === 1 ? '1 hour ago' : `${hours} hours ago` + } + + const days = Math.floor(elapsed / DAY_MS) + return days === 1 ? '1 day ago' : `${days} days ago` +} + +module.exports = { relativeTime } diff --git a/psf-memo-client/src/services/topic-discovery-page.js b/psf-memo-client/src/services/topic-discovery-page.js index bc4083c..3297d50 100644 --- a/psf-memo-client/src/services/topic-discovery-page.js +++ b/psf-memo-client/src/services/topic-discovery-page.js @@ -7,6 +7,7 @@ */ const PaginatedPage = require('./paginated-page') +const { relativeTime } = require('./relative-time') const TOPICS_PATH = '/topics' @@ -24,6 +25,15 @@ class TopicDiscoveryPage extends PaginatedPage { return this.topics.find((topic) => topic.room === room) || null } + // Label describing how long ago the room's most recent post was, computed + // from the API's lastSeen timestamp. Returns null when the topic is not + // loaded. `now` is injectable so the label is deterministic in tests. + getLastSeenLabel (room, now = Date.now()) { + const topic = this.getTopic(room) + if (!topic) return null + return relativeTime(topic.lastSeen, now) + } + openTopic (room) { const path = TopicDiscoveryPage.topicFeedPath(room) this.navigate(path) diff --git a/psf-memo-client/test/unit/relative-time.test.js b/psf-memo-client/test/unit/relative-time.test.js new file mode 100644 index 0000000..cb89bbc --- /dev/null +++ b/psf-memo-client/test/unit/relative-time.test.js @@ -0,0 +1,48 @@ +/* + Unit tests for the relative-time formatter used by the topics page. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { relativeTime } = require('../../src/services/relative-time') + +const NOW = 1800000000000 +const HOUR = 60 * 60 * 1000 +const DAY = 24 * HOUR + +test('returns "No posts" when there is no last-seen time', () => { + assert.equal(relativeTime(0, NOW), 'No posts') + assert.equal(relativeTime(null, NOW), 'No posts') + assert.equal(relativeTime(undefined, NOW), 'No posts') +}) + +test('returns "Less than an hour ago" for under an hour', () => { + assert.equal(relativeTime(NOW - 1, NOW), 'Less than an hour ago') + assert.equal(relativeTime(NOW - (HOUR - 1), NOW), 'Less than an hour ago') +}) + +test('returns "1 hour ago" at exactly one hour', () => { + assert.equal(relativeTime(NOW - HOUR, NOW), '1 hour ago') +}) + +test('returns whole hours under a day', () => { + assert.equal(relativeTime(NOW - 2 * HOUR, NOW), '2 hours ago') + assert.equal(relativeTime(NOW - 5 * HOUR, NOW), '5 hours ago') + assert.equal(relativeTime(NOW - 23 * HOUR, NOW), '23 hours ago') +}) + +test('returns "1 day ago" at exactly one day', () => { + assert.equal(relativeTime(NOW - DAY, NOW), '1 day ago') +}) + +test('returns whole days at or beyond a day', () => { + assert.equal(relativeTime(NOW - 2 * DAY, NOW), '2 days ago') + assert.equal(relativeTime(NOW - 30 * DAY, NOW), '30 days ago') +}) + +test('floors partial hours and days', () => { + assert.equal(relativeTime(NOW - (5 * HOUR + 59 * 60 * 1000), NOW), '5 hours ago') + assert.equal(relativeTime(NOW - (2 * DAY + 23 * HOUR), NOW), '2 days ago') +}) diff --git a/psf-memo-client/test/unit/topic-discovery-page.test.js b/psf-memo-client/test/unit/topic-discovery-page.test.js index bd4682d..b0100be 100644 --- a/psf-memo-client/test/unit/topic-discovery-page.test.js +++ b/psf-memo-client/test/unit/topic-discovery-page.test.js @@ -110,6 +110,34 @@ test('getTopic returns null when the topic is not loaded', async () => { 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() + + 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() + + 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() + + assert.equal(page.getLastSeenLabel('missing', 1800000000000), null) +}) + test('openTopic navigates to the encoded topic feed path', () => { const calls = [] const page = new TopicDiscoveryPage({ diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 5caf9b2..63b2559 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -207,6 +207,16 @@ async function loadFixture (world, name) { return } + if (name === 'topic-metadata-indexes') { + await loadTopicMetadataIndexes(world) + return + } + + if (name === 'rooms-with-topic-metadata') { + await loadRoomsWithTopicMetadata(world) + return + } + if (name === 'rooms-with-topics-and-follows') { await loadRoomsWithTopicsAndFollows(world) return @@ -556,6 +566,42 @@ async function loadTopicIndexes (world) { } } +// Fixture "topic-metadata-indexes" from topic-metadata.feature: topic index +// stores with lastSeen and followerCount already populated. +async function loadTopicMetadataIndexes (world) { + const summaries = [ + { room: 'memo', postCount: 5, lastHeight: 600500, lastSeen: 1700020000000, followerCount: 12 }, + { room: 'cash', postCount: 2, lastHeight: 600400, lastSeen: 1700010000000, followerCount: 4 }, + { room: 'lone', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 7 } + ] + + for (const summary of summaries) { + await world.adapters.level.topicSummariesDb.put(summary.room, summary) + await world.adapters.level.topicRecencyDb.put( + topicRecencyKey(summary.lastHeight, summary.room), + { room: summary.room, blockHeight: summary.lastHeight } + ) + } +} + +// Fixture "rooms-with-topic-metadata" from topic-metadata.feature: raw rooms +// store entries the backfill summarizes into lastSeen and followerCount. +async function loadRoomsWithTopicMetadata (world) { + const entries = [ + { key: 'bitcoin:post-100', room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100, seen: 1700000000000 }, + { key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700009999000 }, + { key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: false }, + { key: 'bitcoin:addr-g', room: 'bitcoin', addr: 'bitcoincash:qaddr-g', type: 'follow', unfollow: false }, + { key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 600250, seen: 1700012345000 }, + { key: 'cash:addr-f', room: 'cash', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: true }, + { key: 'lone:addr-f', room: 'lone', addr: 'bitcoincash:qaddr-f', type: 'follow', unfollow: false } + ] + + for (const entry of entries) { + await world.adapters.level.roomsDb.put(entry.key, entry) + } +} + // Fixture "rooms-with-topics-and-follows" from backfill-topic-indexes.feature. async function loadRoomsWithTopicsAndFollows (world) { const entries = [ @@ -1184,6 +1230,36 @@ const handlers = [ } } }, + { + name: 'response contains topic last seen', + pattern: /^the response contains the topic "()" last seen at ()$/, + run (m, example, world) { + const topic = resolveParam(m[1], example) + const expected = parseInt(resolveParam(m[2], example), 10) + const found = world.getLastResponse().topics.find((t) => t.room === topic) + if (!found) { + throw new Error(`Topic ${topic} not found in response`) + } + if (found.lastSeen !== expected) { + throw new Error(`Expected lastSeen ${expected} for ${topic}, got ${found.lastSeen}`) + } + } + }, + { + name: 'response contains topic follower count', + pattern: /^the response contains the topic "()" with () followers?$/, + run (m, example, world) { + const topic = resolveParam(m[1], example) + const expected = parseInt(resolveParam(m[2], example), 10) + const found = world.getLastResponse().topics.find((t) => t.room === topic) + if (!found) { + throw new Error(`Topic ${topic} not found in response`) + } + if (found.followerCount !== expected) { + throw new Error(`Expected followerCount ${expected} for ${topic}, got ${found.followerCount}`) + } + } + }, { name: 'response lists topics in order', pattern: /^the response lists topics in order (<[A-Za-z0-9_]+>)$/, @@ -1366,6 +1442,36 @@ const handlers = [ } } }, + { + name: 'topicSummaries records room last seen', + pattern: /^the topicSummaries store records the room "()" last seen at ()$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const expected = parseInt(resolveParam(m[2], example), 10) + const record = await world.adapters.level.topicSummariesDb.get(room) + if (!record) { + throw new Error(`No topicSummaries record for ${room}`) + } + if (record.lastSeen !== expected) { + throw new Error(`Expected ${room} lastSeen ${expected}, got ${JSON.stringify(record)}`) + } + } + }, + { + name: 'topicSummaries records room follower count', + pattern: /^the topicSummaries store records the room "()" with () followers?$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const expected = parseInt(resolveParam(m[2], example), 10) + const record = await world.adapters.level.topicSummariesDb.get(room) + if (!record) { + throw new Error(`No topicSummaries record for ${room}`) + } + if (record.followerCount !== expected) { + throw new Error(`Expected ${room} followerCount ${expected}, got ${JSON.stringify(record)}`) + } + } + }, { name: 'topicRecency records room at height', pattern: /^the topicRecency store records the room "()" at block height ()$/, diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js index d48cb49..c6a8476 100644 --- a/psf-memo-db/src/adapters/topic-query.js +++ b/psf-memo-db/src/adapters/topic-query.js @@ -8,7 +8,7 @@ Topic listing is served from two indexes so it can order and paginate without iterating the rooms store: - topicSummaries: one record per room keyed by room, value - { room, postCount, lastHeight }. + { room, postCount, lastHeight, lastSeen, followerCount }. - topicRecency: one record per room keyed `${invertedHeight}:${room}`, value { room, blockHeight }. Follow-only rooms live at height 0. The height is inverted so an ascending scan yields newest-first. @@ -84,15 +84,16 @@ class TopicQuery { } // Return a page of topics ordered by their most recent post, descending, - // with rooms at the same height ordered by name ascending. Counts and the - // total come from topicSummaries; ordering and pagination come from - // topicRecency, which is read only through offset + limit records. + // with rooms at the same height ordered by name ascending. Post count, + // last-seen time, follower count, and the total come from topicSummaries; + // ordering and pagination come from topicRecency, which is read only + // through offset + limit records. async listTopics ({ limit = 100, offset = 0 } = {}) { - const postCounts = new Map() + const summaries = new Map() for await (const [key, value] of this.topicSummariesDb.iterator()) { - postCounts.set(this.summaryRoom(key, value), value?.postCount ?? 0) + summaries.set(this.summaryRoom(key, value), value) } - const total = postCounts.size + const total = summaries.size const recencyRooms = [] for await (const [key, value] of this.topicRecencyDb.iterator({ limit: offset + limit })) { @@ -100,10 +101,15 @@ class TopicQuery { } const pageRooms = recencyRooms.slice(offset, offset + limit) - const topics = pageRooms.map((room) => ({ - room, - postCount: postCounts.get(room) ?? 0 - })) + const topics = pageRooms.map((room) => { + const summary = summaries.get(room) + return { + room, + postCount: summary?.postCount ?? 0, + lastSeen: summary?.lastSeen ?? 0, + followerCount: summary?.followerCount ?? 0 + } + }) return { topics, diff --git a/psf-memo-db/src/lib/backfill-topic-indexes.js b/psf-memo-db/src/lib/backfill-topic-indexes.js index b9ba5e4..8f81d05 100644 --- a/psf-memo-db/src/lib/backfill-topic-indexes.js +++ b/psf-memo-db/src/lib/backfill-topic-indexes.js @@ -4,7 +4,7 @@ The read side serves GET /topics from these two indexes: - topicSummaries: one record per room keyed by room, value - { room, postCount, lastHeight }. + { room, postCount, lastHeight, lastSeen, followerCount }. - topicRecency: one record per room keyed `${invertedHeight}:${room}`, value { room, blockHeight }. Follow-only rooms live at height 0. The height is inverted so an ascending scan yields newest-first. @@ -35,11 +35,21 @@ function roomFromEntry (key, value) { return String(key).split(':')[0] } -// Fold one post entry into its room summary, tracking the newest height. +// Fold one post entry into its room summary, tracking the newest height and +// newest seen time. function applyPost (summary, value) { 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 +} + +// Count one active follow. Each address has at most one follow record per +// room, so the number of active follow records is the follower count. +function applyFollow (summary, value) { + if (value?.unfollow === true) return + summary.followerCount++ } async function collectSummaries (roomsDb) { @@ -49,11 +59,13 @@ async function collectSummaries (roomsDb) { const room = roomFromEntry(key, value) if (!room) continue if (!summaries.has(room)) { - summaries.set(room, { room, postCount: 0, lastHeight: 0 }) + summaries.set(room, { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 }) } if (value?.type === 'post') { applyPost(summaries.get(room), value) + } else if (value?.type === 'follow') { + applyFollow(summaries.get(room), value) } } diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index 22ed58c..96513e4 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -166,12 +166,12 @@ describe('#TopicQuery', () => { describe('#listTopics', () => { const summaries = [ - ['memo', { room: 'memo', postCount: 5, lastHeight: 600500 }], - ['cash', { room: 'cash', postCount: 2, lastHeight: 600400 }], - ['dance', { room: 'dance', postCount: 3, lastHeight: 600400 }], - ['anime', { room: 'anime', postCount: 1, lastHeight: 600300 }], - ['lone', { room: 'lone', postCount: 0, lastHeight: 0 }], - ['quiet', { room: 'quiet', postCount: 0, lastHeight: 0 }] + ['memo', { room: 'memo', postCount: 5, lastHeight: 600500, lastSeen: 1700020000000, followerCount: 12 }], + ['cash', { room: 'cash', postCount: 2, lastHeight: 600400, lastSeen: 1700010000000, followerCount: 4 }], + ['dance', { room: 'dance', postCount: 3, lastHeight: 600400, lastSeen: 1700008000000, followerCount: 1 }], + ['anime', { room: 'anime', postCount: 1, lastHeight: 600300, lastSeen: 1700006000000, followerCount: 0 }], + ['lone', { room: 'lone', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 7 }], + ['quiet', { room: 'quiet', postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 }] ] const recency = [ [topicRecencyKey(600500, 'memo'), { room: 'memo', blockHeight: 600500 }], @@ -193,16 +193,35 @@ describe('#TopicQuery', () => { const result = await uut.listTopics({ limit: 100, offset: 0 }) assert.deepEqual(result.topics, [ - { room: 'memo', postCount: 5 }, - { room: 'cash', postCount: 2 }, - { room: 'dance', postCount: 3 }, - { room: 'anime', postCount: 1 }, - { room: 'lone', postCount: 0 }, - { room: 'quiet', postCount: 0 } + { room: 'memo', postCount: 5, lastSeen: 1700020000000, followerCount: 12 }, + { room: 'cash', postCount: 2, lastSeen: 1700010000000, followerCount: 4 }, + { room: 'dance', postCount: 3, lastSeen: 1700008000000, followerCount: 1 }, + { room: 'anime', postCount: 1, lastSeen: 1700006000000, followerCount: 0 }, + { room: 'lone', postCount: 0, lastSeen: 0, followerCount: 7 }, + { room: 'quiet', postCount: 0, lastSeen: 0, followerCount: 0 } ]) assert.deepEqual(result.pagination, { limit: 100, offset: 0, total: 6, hasMore: false }) }) + it('should default missing metadata fields to zero for legacy summaries', async () => { + uut = new TopicQuery({ + roomsDb, + postsDb, + topicSummariesDb: makeIteratorDb([ + ['memo', { room: 'memo', postCount: 5, lastHeight: 600500 }] + ]), + topicRecencyDb: makeIteratorDb([ + [topicRecencyKey(600500, 'memo'), { room: 'memo', blockHeight: 600500 }] + ]) + }) + + const result = await uut.listTopics({ limit: 100, offset: 0 }) + + assert.deepEqual(result.topics, [ + { room: 'memo', postCount: 5, lastSeen: 0, followerCount: 0 } + ]) + }) + it('should paginate using recency order and report total and hasMore', async () => { uut = new TopicQuery({ roomsDb, diff --git a/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js b/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js index 3a7701f..d962b49 100644 --- a/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js +++ b/psf-memo-db/test/unit/lib/backfill-topic-indexes.unit.js @@ -30,10 +30,10 @@ function makeDb (records = []) { describe('#backfillTopicIndexes', () => { it('should summarize rooms with posts and follow-only rooms', async () => { const roomsDb = makeDb([ - ['bitcoin:post-100', { room: 'bitcoin', txid: 'post-100', type: 'post', blockHeight: 600100 }], - ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }], + ['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 }], + ['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() @@ -42,9 +42,27 @@ describe('#backfillTopicIndexes', () => { const result = await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) assert.equal(result.rooms, 3) - assert.deepEqual(topicSummariesDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 2, lastHeight: 600200 }) - assert.deepEqual(topicSummariesDb.store.get('cash'), { room: 'cash', postCount: 1, lastHeight: 600250 }) - assert.deepEqual(topicSummariesDb.store.get('lone'), { room: 'lone', postCount: 0, lastHeight: 0 }) + assert.deepEqual(topicSummariesDb.store.get('bitcoin'), { + room: 'bitcoin', + postCount: 2, + lastHeight: 600200, + lastSeen: 1700000999000, + followerCount: 1 + }) + assert.deepEqual(topicSummariesDb.store.get('cash'), { + room: 'cash', + postCount: 1, + lastHeight: 600250, + lastSeen: 1700002000000, + followerCount: 0 + }) + assert.deepEqual(topicSummariesDb.store.get('lone'), { + room: 'lone', + postCount: 0, + lastHeight: 0, + lastSeen: 0, + followerCount: 1 + }) assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { room: 'bitcoin', blockHeight: 600200 }) assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(600250, 'cash')), { room: 'cash', blockHeight: 600250 }) @@ -52,9 +70,38 @@ describe('#backfillTopicIndexes', () => { assert.equal(topicRecencyDb.store.size, 3) }) + it('should count only active follows and ignore unfollowed addresses', async () => { + const roomsDb = makeDb([ + ['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() + + await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) + + assert.equal(topicSummariesDb.store.get('bitcoin').followerCount, 2) + }) + + it('should keep the newest lastSeen when post heights and seen times disagree', async () => { + const roomsDb = makeDb([ + ['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() + + await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) + + const summary = topicSummariesDb.store.get('bitcoin') + assert.equal(summary.lastHeight, 600200) + assert.equal(summary.lastSeen, 1700009999000) + }) + it('should be idempotent across repeated runs', async () => { const roomsDb = makeDb([ - ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }], + ['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() @@ -72,7 +119,7 @@ describe('#backfillTopicIndexes', () => { it('should remove stale recency records from a previous run', async () => { const roomsDb = makeDb([ - ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200 }] + ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 600200, seen: 1700000000000 }] ]) const topicSummariesDb = makeDb() const topicRecencyDb = makeDb([ @@ -96,7 +143,13 @@ describe('#backfillTopicIndexes', () => { await backfillTopicIndexes({ roomsDb, topicSummariesDb, topicRecencyDb }) - assert.deepEqual(topicSummariesDb.store.get('cash'), { room: 'cash', postCount: 1, lastHeight: 500 }) + assert.deepEqual(topicSummariesDb.store.get('cash'), { + room: 'cash', + postCount: 1, + lastHeight: 500, + lastSeen: 0, + followerCount: 0 + }) assert.deepEqual(topicRecencyDb.store.get(topicRecencyKey(500, 'cash')), { room: 'cash', blockHeight: 500 }) }) }) diff --git a/psf-memo-indexer/acceptance/lib/handlers.js b/psf-memo-indexer/acceptance/lib/handlers.js index 1bc9b37..fa08392 100644 --- a/psf-memo-indexer/acceptance/lib/handlers.js +++ b/psf-memo-indexer/acceptance/lib/handlers.js @@ -719,13 +719,14 @@ const muteHandlers = [ }, { name: 'process a Memo topic message', - pattern: /^the indexer processes a Memo topic message (.+) in room "(.+)" from (.+) at block height (.+) with text "(.+)"$/, + pattern: /^the indexer processes a Memo topic message (.+) in room "(.+)" from (.+) at block height (.+) with text "(.+)"(?: seen at (.+))?$/, async run (m, example, world) { const txid = resolveTxid(m[1], example, world) - const room = m[2] + const room = resolveParam(m[2], example) const addr = resolveParam(m[3], example) const height = parseInt(resolveParam(m[4], example), 10) const text = m[5] + const seen = m[6] ? parseInt(resolveParam(m[6], example), 10) : Date.now() world.lastTxid = txid world.lastHeight = height @@ -736,7 +737,7 @@ const muteHandlers = [ adapters: world.adapters, txid, signerAddr: addr, - seen: Date.now(), + seen, blockHeight: height, decoded: { action: 'topicMessage', @@ -763,7 +764,7 @@ const muteHandlers = [ name: 'process a Memo topic follow', pattern: /^the indexer processes a Memo topic follow for room "(.+)" from (.+)$/, async run (m, example, world) { - const room = m[1] + const room = resolveParam(m[1], example) const addr = resolveParam(m[2], example) const txid = deriveTxid(`topic-follow-${room}-${addr}`) @@ -771,7 +772,7 @@ const muteHandlers = [ world.lastAddr = addr const prefix = Buffer.from('6d0d', 'hex') - await handleTopicFollow({ + const ctx = { adapters: world.adapters, txid, signerAddr: addr, @@ -782,14 +783,56 @@ const muteHandlers = [ prefix, pushDatas: [prefix, Buffer.from(room, 'utf8')] } - }) + } + world.lastTopicFollow = ctx + + await handleTopicFollow(ctx) + } + }, + { + name: 'process a Memo topic unfollow', + pattern: /^the indexer processes a Memo topic unfollow for room "(.+)" from (.+)$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const txid = deriveTxid(`topic-unfollow-${room}-${addr}`) + + world.lastTxid = txid + world.lastAddr = addr + + const prefix = Buffer.from('6d0e', 'hex') + const ctx = { + adapters: world.adapters, + txid, + signerAddr: addr, + seen: Date.now(), + blockHeight: 600000, + decoded: { + action: 'topicUnfollow', + prefix, + pushDatas: [prefix, Buffer.from(room, 'utf8')] + } + } + world.lastTopicFollow = ctx + + await handleTopicFollow(ctx) + } + }, + { + name: 'reprocess the last Memo topic follow', + pattern: /^the indexer processes the same Memo topic follow for room "(.+)" from (.+) again$/, + async run (m, example, world) { + if (!world.lastTopicFollow) { + throw new Error('No previous topic follow to reprocess') + } + await handleTopicFollow(world.lastTopicFollow) } }, { name: 'topicSummaries contains room with postCount and lastHeight', pattern: /^the topicSummaries store contains the room "(.+)" with postCount (.+) and lastHeight (.+)$/, async run (m, example, world) { - const room = m[1] + const room = resolveParam(m[1], example) const postCount = parseInt(resolveParam(m[2], example), 10) const lastHeight = parseInt(resolveParam(m[3], example), 10) const record = await world.topicSummariesDb.get(room) @@ -801,11 +844,41 @@ const muteHandlers = [ } } }, + { + name: 'topicSummaries records room last seen', + pattern: /^the topicSummaries store records the room "(.+)" last seen at (.+)$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const lastSeen = parseInt(resolveParam(m[2], example), 10) + const record = await world.topicSummariesDb.get(room) + if (!record) { + throw new Error(`No topicSummaries record for ${room}`) + } + if (record.lastSeen !== lastSeen) { + throw new Error(`Expected ${room} lastSeen ${lastSeen}, got ${JSON.stringify(record)}`) + } + } + }, + { + name: 'topicSummaries records room follower count', + pattern: /^the topicSummaries store records the room "(.+)" with (.+?) followers?$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const followerCount = parseInt(resolveParam(m[2], example), 10) + const record = await world.topicSummariesDb.get(room) + if (!record) { + throw new Error(`No topicSummaries record for ${room}`) + } + if (record.followerCount !== followerCount) { + throw new Error(`Expected ${room} followerCount ${followerCount}, got ${JSON.stringify(record)}`) + } + } + }, { name: 'topicRecency records room at height', pattern: /^the topicRecency store records the room "(.+)" at block height (.+)$/, async run (m, example, world) { - const room = m[1] + const room = resolveParam(m[1], example) const height = parseInt(resolveParam(m[2], example), 10) const record = await world.topicRecencyDb.get(topicRecencyKey(height, room)) if (!record) { diff --git a/psf-memo-indexer/src/use-cases/action-types/topic-follow.js b/psf-memo-indexer/src/use-cases/action-types/topic-follow.js index a6902d8..258ac1f 100644 --- a/psf-memo-indexer/src/use-cases/action-types/topic-follow.js +++ b/psf-memo-indexer/src/use-cases/action-types/topic-follow.js @@ -1,6 +1,6 @@ -import { utf8FromPush, logProcessError, roomKey } from './helpers.js' +import { utf8FromPush, logProcessError } from './helpers.js' import { PREFIX_TOPIC_UNFOLLOW } from '../../lib/memo-codes.js' -import { ensureTopicRoom } from './topic-indexing.js' +import { recordTopicFollow } from './topic-indexing.js' export async function handleTopicFollow (ctx) { const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx @@ -14,7 +14,7 @@ export async function handleTopicFollow (ctx) { const room = utf8FromPush(pushDatas[1]) const unfollow = prefix[1] === PREFIX_TOPIC_UNFOLLOW[1] - await adapters.roomDb.create(roomKey(room, signerAddr), { + await recordTopicFollow(adapters, { room, addr: signerAddr, unfollow, @@ -23,10 +23,6 @@ export async function handleTopicFollow (ctx) { type: 'follow', blockHeight }) - - if (!unfollow) { - await ensureTopicRoom(adapters, room) - } } // mutate4javascript-manifest-begin diff --git a/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js b/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js index 6742130..a2e8732 100644 --- a/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js +++ b/psf-memo-indexer/src/use-cases/action-types/topic-indexing.js @@ -2,7 +2,7 @@ Maintain the topicSummaries and topicRecency indexes for Memo topic activity. topicSummaries: one record per room keyed by room, value - { room, postCount, lastHeight }. + { room, postCount, lastHeight, lastSeen, followerCount }. topicRecency: one record per room keyed by `${invertedHeight}:${room}`, value { room, blockHeight }. Follow-only rooms live at height 0. The height is inverted so an ascending scan yields newest-first. @@ -11,20 +11,26 @@ re-running an action is safe. */ -import { getIfPresent, topicRecencyKey } from './helpers.js' +import { getIfPresent, roomKey, topicRecencyKey } from './helpers.js' // Add a post to the room's summary and move its recency record to the newest // height. A room that is reprocessed is filtered out before this runs, so the -// post count is not double-counted. -export async function recordTopicPost (adapters, room, blockHeight) { +// post count is not double-counted. lastSeen tracks the newest post `seen` +// time while lastHeight tracks the newest block height, so a replayed block +// with out-of-order timestamps still converges. +export async function recordTopicPost (adapters, room, blockHeight, seen) { const height = blockHeight ?? 0 + const seenAt = seen ?? 0 const summary = await getIfPresent(adapters.topicSummaryDb, room) const lastHeight = Math.max(summary?.lastHeight ?? 0, height) + const lastSeen = Math.max(summary?.lastSeen ?? 0, seenAt) await adapters.topicSummaryDb.update(room, { room, postCount: (summary?.postCount ?? 0) + 1, - lastHeight + lastHeight, + lastSeen, + followerCount: summary?.followerCount ?? 0 }) // When the room moved to a newer height, delete the stale recency record so @@ -40,13 +46,46 @@ export async function recordTopicPost (adapters, room, blockHeight) { } // Give a follow-only room a zero-post summary and recency record. A room that -// already has a summary (because it has posts) is left unchanged. +// already has a summary (because it has posts) is left unchanged. Returns the +// existing or created summary. export async function ensureTopicRoom (adapters, room) { const summary = await getIfPresent(adapters.topicSummaryDb, room) - if (summary) return + if (summary) return summary - await adapters.topicSummaryDb.update(room, { room, postCount: 0, lastHeight: 0 }) + const created = { room, postCount: 0, lastHeight: 0, lastSeen: 0, followerCount: 0 } + await adapters.topicSummaryDb.update(room, created) await adapters.topicRecencyDb.update(topicRecencyKey(0, room), { room, blockHeight: 0 }) + return created +} + +// Apply a topic follow or unfollow to the room's summary. The follower count +// changes only when the address's follow state actually flips, so replaying a +// follow (or unfollow) is idempotent. The room record is upserted so the +// previous state is available to compute the delta. +export async function recordTopicFollow (adapters, record) { + const { room, addr, unfollow } = record + const key = roomKey(room, addr) + const previous = await getIfPresent(adapters.roomDb, key) + const wasActive = previous?.type === 'follow' && previous.unfollow !== true + const isActive = !unfollow + + await adapters.roomDb.update(key, record) + + const existing = await getIfPresent(adapters.topicSummaryDb, room) + // An unfollow for a room that was never summarized is a no-op and must not + // create a zero-post room. + if (!existing && !isActive) return null + + const summary = existing || await ensureTopicRoom(adapters, room) + const delta = (isActive ? 1 : 0) - (wasActive ? 1 : 0) + if (delta === 0) return summary + + const updated = { + ...summary, + followerCount: Math.max(0, (summary.followerCount ?? 0) + delta) + } + await adapters.topicSummaryDb.update(room, updated) + return updated } // mutate4javascript-manifest-begin diff --git a/psf-memo-indexer/src/use-cases/action-types/topic-message.js b/psf-memo-indexer/src/use-cases/action-types/topic-message.js index be5f757..5d9e1c2 100644 --- a/psf-memo-indexer/src/use-cases/action-types/topic-message.js +++ b/psf-memo-indexer/src/use-cases/action-types/topic-message.js @@ -33,7 +33,7 @@ export async function handleTopicMessage (ctx) { await adapters.roomDb.create(key, { room, txid, seen, type: 'post', blockHeight }) if (!alreadyIndexed) { - await recordTopicPost(adapters, room, blockHeight) + await recordTopicPost(adapters, room, blockHeight, seen) } } diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js index 3a254db..6fbbfdc 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-follow.unit.js @@ -55,7 +55,7 @@ async function processFollow (adapters, { txid, room, addr, prefix = PREFIX_TOPI } describe('#handleTopicFollow topic indexes', () => { - it('should record a zero-post room for a follow-only room', async () => { + it('should record a zero-post room with one follower for a follow-only room', async () => { const adapters = makeAdapters() await processFollow(adapters, { txid: 'follow-1', room: 'lone', addr: 'bitcoincash:qaddr-a' }) @@ -63,7 +63,9 @@ describe('#handleTopicFollow topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('lone'), { room: 'lone', postCount: 0, - lastHeight: 0 + lastHeight: 0, + lastSeen: 0, + followerCount: 1 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(0, 'lone')), { room: 'lone', @@ -71,9 +73,15 @@ describe('#handleTopicFollow topic indexes', () => { }) }) - it('should leave an existing room summary unchanged', async () => { + it('should preserve post metadata and increase the follower count for an existing room', async () => { const adapters = makeAdapters() - adapters.topicSummaryDb.store.set('bitcoin', { room: 'bitcoin', postCount: 1, lastHeight: 600400 }) + adapters.topicSummaryDb.store.set('bitcoin', { + room: 'bitcoin', + postCount: 1, + lastHeight: 600400, + lastSeen: 1700012345000, + followerCount: 0 + }) adapters.topicRecencyDb.store.set(topicRecencyKey(600400, 'bitcoin'), { room: 'bitcoin', blockHeight: 600400 }) await processFollow(adapters, { txid: 'follow-2', room: 'bitcoin', addr: 'bitcoincash:qaddr-a' }) @@ -81,7 +89,9 @@ describe('#handleTopicFollow topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 1, - lastHeight: 600400 + lastHeight: 600400, + lastSeen: 1700012345000, + followerCount: 1 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600400, 'bitcoin')), { room: 'bitcoin', @@ -90,7 +100,7 @@ describe('#handleTopicFollow topic indexes', () => { assert.isFalse(adapters.topicRecencyDb.store.has(topicRecencyKey(0, 'bitcoin'))) }) - it('should not create a zero-post room for an unfollow', async () => { + it('should not create a zero-post room for an unfollow with no prior summary', async () => { const adapters = makeAdapters() await processFollow(adapters, { @@ -104,6 +114,71 @@ describe('#handleTopicFollow topic indexes', () => { assert.equal(adapters.topicRecencyDb.store.size, 0) }) + it('should count two distinct followers', async () => { + const adapters = makeAdapters() + + 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 + } + 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 () => { + 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' }) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 1) + }) + it('should log a process error and write nothing when the push data count is invalid', async () => { const adapters = makeAdapters() diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js index ed9dee7..a2410a9 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-indexing.unit.js @@ -42,7 +42,9 @@ describe('#recordTopicPost height fallbacks', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 1, - lastHeight: 0 + lastHeight: 0, + lastSeen: 0, + followerCount: 0 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(0, 'bitcoin')), { room: 'bitcoin', @@ -65,7 +67,9 @@ describe('#recordTopicPost height fallbacks', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 3, - lastHeight: 600100 + lastHeight: 600100, + lastSeen: 0, + followerCount: 0 }) }) @@ -83,3 +87,48 @@ describe('#recordTopicPost height fallbacks', () => { }) }) }) + +describe('#recordTopicPost lastSeen', () => { + it('should record the post seen time as lastSeen', async () => { + const adapters = makeAdapters() + + await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 1700000000000) + }) + + it('should keep the newest lastSeen when a later post has an earlier seen time', async () => { + const adapters = makeAdapters() + + await recordTopicPost(adapters, 'bitcoin', 600200, 1700009999000) + await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000) + + const summary = adapters.topicSummaryDb.store.get('bitcoin') + assert.equal(summary.lastSeen, 1700009999000) + assert.equal(summary.lastHeight, 600200) + assert.equal(summary.postCount, 2) + }) + + it('should default lastSeen to zero when seen is missing', async () => { + const adapters = makeAdapters() + + await recordTopicPost(adapters, 'bitcoin', 600100) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 0) + }) + + it('should preserve an existing followerCount when recording a post', async () => { + const adapters = makeAdapters() + adapters.topicSummaryDb.store.set('bitcoin', { + room: 'bitcoin', + postCount: 0, + lastHeight: 0, + lastSeen: 0, + followerCount: 4 + }) + + await recordTopicPost(adapters, 'bitcoin', 600100, 1700000000000) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').followerCount, 4) + }) +}) diff --git a/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js b/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js index 1271aef..09ef92f 100644 --- a/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js +++ b/psf-memo-indexer/test/unit/use-cases/action-types/topic-message.unit.js @@ -45,13 +45,13 @@ function makeAdapters () { } } -async function processTopicMessage (adapters, { txid, room, addr, height, text }) { +async function processTopicMessage (adapters, { txid, room, addr, height, text, seen = 1 }) { const prefix = Buffer.from('6d0c', 'hex') await handleTopicMessage({ adapters, txid, signerAddr: addr, - seen: 1, + seen, blockHeight: height, decoded: { action: 'topic-message', @@ -76,7 +76,9 @@ describe('#handleTopicMessage topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 1, - lastHeight: 600100 + lastHeight: 600100, + lastSeen: 1, + followerCount: 0 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600100, 'bitcoin')), { room: 'bitcoin', @@ -84,6 +86,21 @@ describe('#handleTopicMessage topic indexes', () => { }) }) + it('should record the post seen time as lastSeen', async () => { + const adapters = makeAdapters() + + await processTopicMessage(adapters, { + txid: 'topic-a1', + room: 'bitcoin', + addr: 'bitcoincash:qaddr-a', + height: 600100, + text: 'hello', + seen: 1700000000000 + }) + + assert.equal(adapters.topicSummaryDb.store.get('bitcoin').lastSeen, 1700000000000) + }) + it('should accumulate postCount and keep the newest height', async () => { const adapters = makeAdapters() @@ -93,7 +110,9 @@ describe('#handleTopicMessage topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 2, - lastHeight: 600200 + lastHeight: 600200, + lastSeen: 1, + followerCount: 0 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { room: 'bitcoin', @@ -111,7 +130,9 @@ describe('#handleTopicMessage topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 2, - lastHeight: 600200 + lastHeight: 600200, + lastSeen: 1, + followerCount: 0 }) assert.deepEqual(adapters.topicRecencyDb.store.get(topicRecencyKey(600200, 'bitcoin')), { room: 'bitcoin', @@ -129,7 +150,9 @@ describe('#handleTopicMessage topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get('bitcoin'), { room: 'bitcoin', postCount: 1, - lastHeight: 600300 + lastHeight: 600300, + lastSeen: 1, + followerCount: 0 }) assert.equal(adapters.topicRecencyDb.store.size, 1) }) @@ -173,7 +196,9 @@ describe('#handleTopicMessage topic indexes', () => { assert.deepEqual(adapters.topicSummaryDb.store.get(room), { room, postCount: 1, - lastHeight: 600100 + lastHeight: 600100, + lastSeen: 1, + followerCount: 0 }) })