From d3c3a80bbf4a4c0a2005b4a4e1eecfba3e042e41 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 28 Aug 2026 06:15:58 -0700 Subject: [PATCH 1/2] Spec topic list/discovery and topic feed By specifier. --- psf-memo-client/specs/topic-discovery.feature | 30 +++++++++ psf-memo-client/specs/topic-feed.feature | 23 +++++++ psf-memo-db/specs/topic-read.feature | 61 +++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 psf-memo-client/specs/topic-discovery.feature create mode 100644 psf-memo-client/specs/topic-feed.feature create mode 100644 psf-memo-db/specs/topic-read.feature diff --git a/psf-memo-client/specs/topic-discovery.feature b/psf-memo-client/specs/topic-discovery.feature new file mode 100644 index 0000000..ddc74f2 --- /dev/null +++ b/psf-memo-client/specs/topic-discovery.feature @@ -0,0 +1,30 @@ +# Scenarios: Topic Discovery - 1, Topic Discovery - 2 +# +# The topics page lists the topics served by the psf-memo-db /topics endpoint +# and links each one to its feed. +Feature: Topic Discovery + + Background: + Given the psf-memo-db API serves a topic named "bitcoin" with 2 posts + Given the psf-memo-db API serves a topic named "cash" with 1 post + Given the psf-memo-db API serves a topic named "dev" with 1 post + + Scenario Outline: Topic Discovery - 1 the topics page lists each topic with its post count + When I open the topics page + Then the topics page shows the topic with posts + + Examples: + | topic | count | + | bitcoin | 2 | + | cash | 1 | + | dev | 1 | + + Scenario Outline: Topic Discovery - 2 clicking a topic opens its feed + Given I open the topics page + When I click the topic + Then the app navigates to the topic feed for + + Examples: + | topic | + | bitcoin | + | cash | diff --git a/psf-memo-client/specs/topic-feed.feature b/psf-memo-client/specs/topic-feed.feature new file mode 100644 index 0000000..55f6bfe --- /dev/null +++ b/psf-memo-client/specs/topic-feed.feature @@ -0,0 +1,23 @@ +# Scenarios: Topic Feed - 1, Topic Feed - 2 +# +# The topic feed page shows the posts served by the psf-memo-db +# /topics/:room/posts endpoint for a single topic. +Feature: Topic Feed + + Background: + Given the psf-memo-db API serves a post with txid aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa in the topic "bitcoin" authored by the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d with text "hello bitcoin" + Given the psf-memo-db API serves a post with txid bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb in the topic "bitcoin" authored by a second address with text "bitcoin again" + + Scenario Outline: Topic Feed - 1 the topic feed shows the posts for the topic + When I open the topic feed for + Then the feed shows the post with txid with text + + Examples: + | topic | txid | text | + | bitcoin | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | hello bitcoin | + | bitcoin | bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb | bitcoin again | + + Scenario: Topic Feed - 2 a topic with no posts shows an empty message + Given the psf-memo-db API serves no posts for the topic "lone" + When I open the topic feed for "lone" + Then the feed shows a message that there are no posts diff --git a/psf-memo-db/specs/topic-read.feature b/psf-memo-db/specs/topic-read.feature new file mode 100644 index 0000000..79d5d18 --- /dev/null +++ b/psf-memo-db/specs/topic-read.feature @@ -0,0 +1,61 @@ +# Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4 +# +# The indexer stores topic activity in the rooms store. Topic messages are +# keyed `${room}:${txid}` with type 'post'; topic follows are keyed +# `${room}:${addr}` with type 'follow'. The read side lists distinct topics +# with their post counts and returns a topic's posts ordered by block height. +# +# Fixture "topics-with-posts": +# rooms store: +# bitcoin:post-300 { room: bitcoin, txid: post-300, type: post, blockHeight: 300 } +# bitcoin:post-200 { room: bitcoin, txid: post-200, type: post, blockHeight: 200 } +# bitcoin:addr-f { room: bitcoin, addr: addr-f, type: follow, unfollow: false } +# cash:post-250 { room: cash, txid: post-250, type: post, blockHeight: 250 } +# dev:post-100 { room: dev, txid: post-100, type: post, blockHeight: 100 } +# lone:addr-f { room: lone, addr: addr-f, type: follow, unfollow: false } +# posts store: +# post-300 { txid: post-300, addr: addr-a, text: hello bitcoin, blockHeight: 300 } +# post-200 { txid: post-200, addr: addr-b, text: bitcoin again, blockHeight: 200 } +# post-250 { txid: post-250, addr: addr-a, text: cash rules, blockHeight: 250 } +# post-100 { txid: post-100, addr: addr-c, text: dev stuff, blockHeight: 100 } +Feature: Topic Read + + Background: + Given a psf-memo-db instance with a rooms store and a posts store + Given the fixture "topics-with-posts" is loaded into the rooms and posts stores + + Scenario Outline: Topic Read - 1 GET /topics lists distinct topics with their post counts + When the client requests /topics + Then the response contains the topic with post count + + Examples: + | topic | count | + | bitcoin | 2 | + | cash | 1 | + | dev | 1 | + | lone | 0 | + + Scenario Outline: Topic Read - 2 GET /topics/:room/posts returns the posts for a topic sorted by block height descending + When the client requests /topics//posts + Then the response posts are sorted by block height descending + And the response contains the txids + + Examples: + | room | expected_txids | + | bitcoin | post-300,post-200 | + | cash | post-250 | + + Scenario Outline: Topic Read - 3 GET /topics/:room/posts paginates + When the client requests /topics//posts with limit and offset + Then the response contains the txids + And the response pagination shows total and hasMore + + Examples: + | room | limit | offset | expected_txids | total | hasMore | + | bitcoin | 1 | 0 | post-300 | 2 | true | + | bitcoin | 2 | 0 | post-300,post-200 | 2 | false | + | bitcoin | 1 | 1 | post-200 | 2 | true | + + Scenario: Topic Read - 4 GET /topics/:room/posts returns no posts for a topic with no posts + When the client requests /topics/lone/posts + Then the response contains no posts From 652c70e4ea3fa34ab7c4f2862b212b882c54d568 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 28 Aug 2026 06:31:43 -0700 Subject: [PATCH 2/2] Implement topic discovery and topic feed - Add psf-memo-db /topics and /topics/:room/posts read endpoints - Add TopicQuery adapter, ListTopics and ListTopicPosts use cases - Add topic read acceptance fixture and step handlers - Add psf-memo-client topic services, UI routes, nav link - Add generated topic discovery/feed acceptance handlers By coder. --- psf-memo-client/acceptance/lib/handlers.js | 163 ++++++++++++++++- .../src/components/app-body/index.js | 4 + .../components/app-body/topic-feed/index.js | 170 ++++++++++++++++++ .../src/components/app-body/topics/index.js | 93 ++++++++++ .../src/components/nav-menu/index.js | 8 + psf-memo-client/src/services/memo-db.js | 17 ++ .../src/services/topic-discovery-page.js | 45 +++++ .../src/services/topic-feed-page.js | 41 +++++ .../test/unit/topic-discovery-page.test.js | 62 +++++++ .../test/unit/topic-feed-page.test.js | 83 +++++++++ psf-memo-db/acceptance/lib/handlers.js | 102 +++++++++++ psf-memo-db/src/adapters/index.js | 5 + psf-memo-db/src/adapters/topic-query.js | 80 +++++++++ psf-memo-db/src/controllers/rest-api/index.js | 4 + .../controllers/rest-api/topics/controller.js | 91 ++++++++++ .../src/controllers/rest-api/topics/index.js | 34 ++++ psf-memo-db/src/use-cases/index.js | 12 ++ psf-memo-db/src/use-cases/list-topic-posts.js | 58 ++++++ psf-memo-db/src/use-cases/list-topics.js | 18 ++ .../test/unit/adapters/topic-query.unit.js | 168 +++++++++++++++++ .../controllers/topics.controller.unit.js | 61 +++++++ .../unit/use-cases/list-topic-posts.unit.js | 129 +++++++++++++ .../test/unit/use-cases/list-topics.unit.js | 53 ++++++ 23 files changed, 1499 insertions(+), 2 deletions(-) create mode 100644 psf-memo-client/src/components/app-body/topic-feed/index.js create mode 100644 psf-memo-client/src/components/app-body/topics/index.js create mode 100644 psf-memo-client/src/services/topic-discovery-page.js create mode 100644 psf-memo-client/src/services/topic-feed-page.js create mode 100644 psf-memo-client/test/unit/topic-discovery-page.test.js create mode 100644 psf-memo-client/test/unit/topic-feed-page.test.js create mode 100644 psf-memo-db/src/adapters/topic-query.js create mode 100644 psf-memo-db/src/controllers/rest-api/topics/controller.js create mode 100644 psf-memo-db/src/controllers/rest-api/topics/index.js create mode 100644 psf-memo-db/src/use-cases/list-topic-posts.js create mode 100644 psf-memo-db/src/use-cases/list-topics.js create mode 100644 psf-memo-db/test/unit/adapters/topic-query.unit.js create mode 100644 psf-memo-db/test/unit/controllers/topics.controller.unit.js create mode 100644 psf-memo-db/test/unit/use-cases/list-topic-posts.unit.js create mode 100644 psf-memo-db/test/unit/use-cases/list-topics.unit.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 6b0a676..73f7164 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -36,6 +36,8 @@ const MemoFollow = require('../../src/services/memo-follow') const RecentFeedPage = require('../../src/services/recent-feed-page') const ProfilePage = require('../../src/services/profile-page') const ThreadPage = require('../../src/services/thread-page') +const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') +const TopicFeedPage = require('../../src/services/topic-feed-page') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX @@ -125,20 +127,44 @@ function makeThread () { } } -// A fake psf-memo-db API backing the read-only feed, profile, and thread -// pages used to verify like count display. +// A fake psf-memo-db API backing the read-only feed, profile, thread, +// and topic pages used to verify read-side behavior. function makeMemoDb () { const posts = [] const threads = {} const followState = {} + const topics = [] + const topicPosts = {} + const topicCounts = new Map() return { posts, threads, followState, + topics, + topicPosts, + topicCounts, addPost (post) { posts.push(post) }, + addTopic (room, postCount) { + topicCounts.set(room, postCount) + topicPosts[room] = [] + for (let i = 0; i < postCount; i++) { + const txid = `${room}-post-${i + 1}`.padEnd(64, '0') + topicPosts[room].push({ + txid, + addr: `addr-${i + 1}`, + text: `Sample post ${i + 1}`, + blockHeight: 100 + i + }) + } + }, + addTopicPost (room, post) { + if (!topicPosts[room]) topicPosts[room] = [] + topicPosts[room].push(post) + topicCounts.set(room, (topicCounts.get(room) || 0) + 1) + }, addThread (txid, thread) { threads[txid] = thread }, @@ -159,6 +185,19 @@ function makeMemoDb () { }, async getFollowState (followerAddr, followeeAddr) { return followState[`${followerAddr}:${followeeAddr}`] || false + }, + async getTopics () { + const list = [] + for (const [room, postCount] of topicCounts.entries()) { + list.push({ room, postCount }) + } + list.sort((a, b) => a.room.localeCompare(b.room)) + return { topics: list } + }, + async getTopicPosts (room, { limit = 100, offset = 0 } = {}) { + const all = topicPosts[room] || [] + const page = all.slice(offset, offset + limit) + return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } } } } @@ -193,6 +232,10 @@ function createWorld () { world.recentFeedPage = new RecentFeedPage({ memoDb }) world.profilePage = new ProfilePage({ memoDb }) world.threadPage = new ThreadPage({ memoDb }) + world.topicDiscoveryPage = new TopicDiscoveryPage({ + memoDb, + navigate: (path) => { world.currentPath = path } + }) // The New Post Page controller wraps the memo post behavior. Its navigate // adapter updates the world's current path so navigation can be asserted. @@ -1344,6 +1387,122 @@ const handlers = [ throw new Error(`Broadcast unfollow hash160 did not match ${addr}.`) } } + }, + { + name: 'API serves topic with post count', + pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/, + run (m, example, world) { + const room = m[1] + const count = parseInt(m[2], 10) + world.memoDb.addTopic(room, count) + } + }, + { + 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 "(.+)"$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const room = m[2] + const addr = resolveParam(m[3], example) + const text = m[4] + world.memoDb.addTopicPost(room, { txid, addr, text, blockHeight: 100 }) + } + }, + { + name: 'API serves post in topic with second address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by a second address with text "(.+)"$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const room = m[2] + const text = m[3] + world.memoDb.addTopicPost(room, { txid, addr: SECOND_ADDRESS, text, blockHeight: 101 }) + } + }, + { + name: 'API serves no posts for topic', + pattern: /^the psf-memo-db API serves no posts for the topic "([^"]+)"$/, + run (m, example, world) { + const room = m[1] + world.memoDb.topicCounts.set(room, 0) + world.memoDb.topicPosts[room] = [] + } + }, + { + name: 'open topics page', + pattern: /^I open the topics page$/, + async run (m, example, world) { + await world.topicDiscoveryPage.load() + world.currentPath = TopicDiscoveryPage.TOPICS_PATH + } + }, + { + name: 'topics page shows topic count', + pattern: /^the topics page shows the topic () with () posts$/, + 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.postCount !== expected) { + throw new Error(`Expected ${room} to have ${expected} posts, got ${topic.postCount}.`) + } + } + }, + { + name: 'click topic', + pattern: /^I click the topic ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + world.topicDiscoveryPage.openTopic(room) + } + }, + { + name: 'navigate to topic feed', + pattern: /^the app navigates to the topic feed for ()$/, + run (m, example, world) { + const room = resolveParam(m[1], example) + const expected = TopicFeedPage.topicFeedPath(room) + if (world.currentPath !== expected) { + throw new Error(`Expected to navigate to ${expected}, but current path is ${world.currentPath}.`) + } + } + }, + { + name: 'open topic feed', + pattern: /^I open the topic feed for "?(|[^"]+)"?$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + world.topicFeedPage = new TopicFeedPage({ memoDb: world.memoDb, room }) + await world.topicFeedPage.load() + world.currentPath = TopicFeedPage.topicFeedPath(room) + } + }, + { + name: 'topic feed shows post text', + pattern: /^the feed shows the post with txid () with text ()$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const expected = resolveParam(m[2], example) + const post = world.topicFeedPage.getPost(txid) + if (!post) { + throw new Error(`Post ${txid} is not shown in the topic feed.`) + } + if (post.text !== expected) { + throw new Error(`Expected post ${txid} text "${expected}", got "${post.text}".`) + } + } + }, + { + name: 'topic feed shows empty message', + pattern: /^the feed shows a message that there are no posts$/, + run (m, example, world) { + const posts = world.topicFeedPage.posts + if (!Array.isArray(posts) || posts.length !== 0) { + throw new Error(`Expected topic feed to be empty, but found ${Array.isArray(posts) ? posts.length : 'non-array'} posts.`) + } + } } ] diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 8ee8a5b..a017a41 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -31,6 +31,8 @@ import SetName from './set-name' import SetBio from './set-bio' import SetAvatarUrl from './set-avatar-url' import Account from './account' +import Topics from './topics' +import TopicFeed from './topic-feed' function AppBody (props) { // Dependency injection through props @@ -48,6 +50,8 @@ function AppBody (props) { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/topic-feed/index.js b/psf-memo-client/src/components/app-body/topic-feed/index.js new file mode 100644 index 0000000..33ae1ad --- /dev/null +++ b/psf-memo-client/src/components/app-body/topic-feed/index.js @@ -0,0 +1,170 @@ +/* + Display the posts for a single Memo topic. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' +import { useParams } from 'react-router-dom' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import TopicFeedPage from '../../../services/topic-feed-page' +import PostFeedItem from '../../post-feed/post-feed-item' +import PostThreadModal from '../../post-thread-modal' +import { + collectPostAddrs, + loadThreadProfiles +} from '../../post-thread-modal/thread-profiles' +import '../../../App.css' +import '../../post-feed/post-feed.css' + +const PAGE_SIZE = 100 + +function TopicFeed (props) { + const { appData } = props + const { room } = useParams() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [posts, setPosts] = useState([]) + const [profiles, setProfiles] = useState({}) + const [pagination, setPagination] = useState(null) + const [offset, setOffset] = useState(0) + const [threadTxid, setThreadTxid] = useState(null) + const [showThreadModal, setShowThreadModal] = useState(false) + + const openThread = (txid) => { + setThreadTxid(txid) + setShowThreadModal(true) + } + + const closeThread = () => { + setShowThreadModal(false) + setThreadTxid(null) + } + + useEffect(() => { + const loadPosts = async () => { + setLoading(true) + setError(null) + setProfiles({}) + + try { + const memoDb = new MemoDb() + const page = new TopicFeedPage({ memoDb, room }) + const data = await page.load({ limit: PAGE_SIZE, offset }) + + const loadedPosts = data.posts || [] + const addrs = collectPostAddrs(loadedPosts) + const profileMap = await loadThreadProfiles(addrs, memoDb) + + setPosts(loadedPosts) + setProfiles(profileMap) + setPagination(data.pagination || null) + } catch (err) { + setError(err.message || `Failed to load posts for topic ${room}`) + setPosts([]) + setProfiles({}) + setPagination(null) + } + + setLoading(false) + } + + loadPosts() + }, [room, offset]) + + const canGoBack = offset > 0 + const canGoNext = pagination?.hasMore ?? false + + const handlePrevious = () => { + setOffset((prev) => Math.max(0, prev - PAGE_SIZE)) + } + + const handleNext = () => { + setOffset((prev) => prev + PAGE_SIZE) + } + + return ( + + + +
+

#{room}

+

Posts published in the {room} topic.

+ + {pagination && posts.length > 0 && ( + + Showing {pagination.offset + 1}– + {pagination.offset + posts.length} of {pagination.total} + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ + Loading... + +
+ )} + + {!loading && !error && posts.length === 0 && ( +

There are no posts for this topic.

+ )} + + {!loading && !error && posts.length > 0 && ( +
+ {posts.map((post) => ( + openThread(post.txid)} + showFooterMeta + /> + ))} +
+ )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} + +
+ + +
+ ) +} + +export default TopicFeed diff --git a/psf-memo-client/src/components/app-body/topics/index.js b/psf-memo-client/src/components/app-body/topics/index.js new file mode 100644 index 0000000..eb3cbf4 --- /dev/null +++ b/psf-memo-client/src/components/app-body/topics/index.js @@ -0,0 +1,93 @@ +/* + Display the list of Memo topics served by psf-memo-db. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Spinner, ListGroup } from 'react-bootstrap' +import { useNavigate } from 'react-router-dom' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import TopicDiscoveryPage from '../../../services/topic-discovery-page' +import '../../../App.css' + +function Topics (props) { + const navigate = useNavigate() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [topics, setTopics] = useState([]) + + useEffect(() => { + const loadTopics = async () => { + setLoading(true) + setError(null) + + try { + const memoDb = new MemoDb() + const page = new TopicDiscoveryPage({ memoDb, navigate }) + const result = await page.load() + setTopics(result.topics || []) + } catch (err) { + setError(err.message || 'Failed to load topics') + setTopics([]) + } + + setLoading(false) + } + + loadTopics() + }, [navigate]) + + const handleClick = (room) => { + navigate(TopicDiscoveryPage.topicFeedPath(room)) + } + + return ( + + + +
+

Topics

+

Discover Memo conversations organized by topic.

+
+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ + Loading... + +
+ )} + + {!loading && !error && topics.length === 0 && ( +

No topics available.

+ )} + + {!loading && !error && topics.length > 0 && ( + + {topics.map((topic) => ( + handleClick(topic.room)} + > + #{topic.room}{' '} + ({topic.postCount} posts) + + ))} + + )} + +
+
+ ) +} + +export default Topics diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 1aa563e..95a511a 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -70,6 +70,14 @@ function NavMenu (props) { Posts + + Topics + + {}) + this.topics = [] + } + + async load () { + if (!this.memoDb) { + throw new Error('Topic discovery page requires a memo db client.') + } + + const data = await this.memoDb.getTopics() + this.topics = data.topics || [] + + return { topics: this.topics } + } + + getTopic (room) { + return this.topics.find((topic) => topic.room === room) || null + } + + openTopic (room) { + const path = TopicDiscoveryPage.topicFeedPath(room) + this.navigate(path) + return { path } + } +} + +TopicDiscoveryPage.TOPICS_PATH = TOPICS_PATH +TopicDiscoveryPage.topicFeedPath = function (room) { + return `${TOPICS_PATH}/${encodeURIComponent(room)}` +} + +module.exports = TopicDiscoveryPage diff --git a/psf-memo-client/src/services/topic-feed-page.js b/psf-memo-client/src/services/topic-feed-page.js new file mode 100644 index 0000000..719b4a7 --- /dev/null +++ b/psf-memo-client/src/services/topic-feed-page.js @@ -0,0 +1,41 @@ +/* + Topic Feed Page behavior: load and display the posts for a single Memo topic. + + This is the testable controller behind the React "Topic Feed" page. It wraps + the MemoDb client, targets a specific topic room, and exposes the loaded posts + so the view can render per-post data such as the like count. +*/ + +class TopicFeedPage { + constructor (deps = {}) { + this.memoDb = deps.memoDb || null + this.room = deps.room || null + this.posts = [] + this.pagination = null + } + + async load ({ limit = 100, offset = 0 } = {}) { + if (!this.memoDb) { + throw new Error('Topic feed page requires a memo db client.') + } + if (!this.room) { + throw new Error('Topic feed page requires a topic room.') + } + + const data = await this.memoDb.getTopicPosts(this.room, { limit, offset }) + this.posts = data.posts || [] + this.pagination = data.pagination || null + + return { posts: this.posts, pagination: this.pagination } + } + + getPost (txid) { + return this.posts.find((post) => post.txid === txid) || null + } +} + +TopicFeedPage.topicFeedPath = function (room) { + return `/topics/${encodeURIComponent(room)}` +} + +module.exports = TopicFeedPage diff --git a/psf-memo-client/test/unit/topic-discovery-page.test.js b/psf-memo-client/test/unit/topic-discovery-page.test.js new file mode 100644 index 0000000..5877bd7 --- /dev/null +++ b/psf-memo-client/test/unit/topic-discovery-page.test.js @@ -0,0 +1,62 @@ +/* + Unit tests for the topic discovery page controller. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') + +function makeMemoDb (topics) { + return { + async getTopics () { + return { topics } + } + } +} + +test('load returns topics with post counts', async () => { + const topics = [ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 } + ] + const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) }) + + const result = await page.load() + + assert.deepEqual(result.topics, topics) +}) + +test('load throws when no memo db client is provided', async () => { + const page = new TopicDiscoveryPage({}) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) + +test('getTopic returns the matching topic', async () => { + const topics = [ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 } + ] + const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) }) + + await page.load() + + 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([]) }) + + await page.load() + + assert.equal(page.getTopic('bitcoin'), null) +}) + +test('exposes the topics page path', () => { + assert.equal(TopicDiscoveryPage.TOPICS_PATH, '/topics') +}) diff --git a/psf-memo-client/test/unit/topic-feed-page.test.js b/psf-memo-client/test/unit/topic-feed-page.test.js new file mode 100644 index 0000000..4b0512e --- /dev/null +++ b/psf-memo-client/test/unit/topic-feed-page.test.js @@ -0,0 +1,83 @@ +/* + Unit tests for the topic feed page controller. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const TopicFeedPage = require('../../src/services/topic-feed-page') + +function makeMemoDb (posts, pagination) { + return { + async getTopicPosts (room, { limit, offset }) { + return { posts, pagination } + } + } +} + +test('load returns posts for the topic', async () => { + const posts = [ + { txid: 'a'.repeat(64), text: 'hello bitcoin' }, + { txid: 'b'.repeat(64), text: 'bitcoin again' } + ] + const page = new TopicFeedPage({ memoDb: makeMemoDb(posts, { total: 2 }), room: 'bitcoin' }) + + const result = await page.load() + + assert.deepEqual(result.posts, posts) +}) + +test('load forwards limit and offset to the memo db client', async () => { + const calls = [] + const memoDb = { + async getTopicPosts (room, params) { + calls.push({ room, params }) + return { posts: [], pagination: {} } + } + } + const page = new TopicFeedPage({ memoDb, room: 'bitcoin' }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ room: 'bitcoin', params: { limit: 10, offset: 20 } }]) +}) + +test('load throws when no memo db client is provided', async () => { + const page = new TopicFeedPage({ room: 'bitcoin' }) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) + +test('load throws when no room is provided', async () => { + const page = new TopicFeedPage({ memoDb: makeMemoDb([], {}) }) + + await assert.rejects( + () => page.load(), + /requires a topic room/ + ) +}) + +test('getPost returns a loaded post by txid', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello bitcoin' }] + const page = new TopicFeedPage({ memoDb: makeMemoDb(posts, {}), room: 'bitcoin' }) + + await page.load() + + assert.equal(page.getPost('a'.repeat(64)).text, 'hello bitcoin') +}) + +test('getPost returns null for an unknown txid', async () => { + const page = new TopicFeedPage({ memoDb: makeMemoDb([], {}), room: 'bitcoin' }) + + await page.load() + + assert.equal(page.getPost('c'.repeat(64)), null) +}) + +test('exposes the topic feed path for a room', () => { + assert.equal(TopicFeedPage.topicFeedPath('bitcoin'), '/topics/bitcoin') +}) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 608a565..78c20ea 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -18,6 +18,8 @@ import GetPostThread from '../../src/use-cases/get-post-thread.js' import FollowState from '../../src/use-cases/follow-state.js' import ListFollowing from '../../src/use-cases/list-following.js' import ListFollowers from '../../src/use-cases/list-followers.js' +import ListTopics from '../../src/use-cases/list-topics.js' +import ListTopicPosts from '../../src/use-cases/list-topic-posts.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance') @@ -92,6 +94,8 @@ async function createWorld () { const followState = new FollowState({ adapters }) const listFollowing = new ListFollowing({ adapters }) const listFollowers = new ListFollowers({ adapters }) + const listTopics = new ListTopics({ adapters }) + const listTopicPosts = new ListTopicPosts({ adapters }) let lastResponse = null @@ -103,6 +107,8 @@ async function createWorld () { followState, listFollowing, listFollowers, + listTopics, + listTopicPosts, postHeightsIteratorCounter, addrPostHeightsIteratorCounter, postChildrenIteratorCounter, @@ -142,6 +148,11 @@ async function loadFixture (world, name) { return } + if (name === 'topics-with-posts') { + await loadTopicsWithPosts(world) + return + } + if (name !== 'three-top-level-posts-and-one-reply') { throw new Error(`Unknown fixture: ${name}`) } @@ -320,6 +331,39 @@ async function loadFollows (world) { } } +async function loadTopicsWithPosts (world) { + const roomEntries = [ + { key: 'bitcoin:post-300', room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }, + { key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }, + { key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }, + { key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 }, + { key: 'dev:post-100', room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }, + { key: 'lone:addr-f', room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false } + ] + + const posts = { + 'post-300': { addr: 'addr-a', text: 'hello bitcoin', seen: 1, blockHeight: 300 }, + 'post-200': { addr: 'addr-b', text: 'bitcoin again', seen: 2, blockHeight: 200 }, + 'post-250': { addr: 'addr-a', text: 'cash rules', seen: 3, blockHeight: 250 }, + 'post-100': { addr: 'addr-c', text: 'dev stuff', seen: 4, blockHeight: 100 } + } + + for (const entry of roomEntries) { + await world.adapters.level.roomsDb.put(entry.key, { + room: entry.room, + txid: entry.txid, + addr: entry.addr, + type: entry.type, + unfollow: entry.unfollow, + blockHeight: entry.blockHeight + }) + } + + for (const [txid, post] of Object.entries(posts)) { + await world.adapters.level.postsDb.put(txid, post) + } +} + const handlers = [ { name: 'db instance with posts and postHeights stores', @@ -678,6 +722,64 @@ const handlers = [ throw new Error(`Expected followers ${expected.join(',')}, got ${actual.join(',')}`) } } + }, + { + name: 'db instance with rooms and posts stores', + pattern: /^a psf-memo-db instance with a rooms store and a posts store$/, + async run () { + // World is already created with both stores. + } + }, + { + name: 'load fixture into rooms and posts stores', + pattern: /^the fixture "(.+)" is loaded into the rooms and posts stores$/, + async run (m, example, world) { + await loadFixture(world, m[1]) + } + }, + { + name: 'request topics', + pattern: /^the client requests \/topics$/, + async run (m, example, world) { + const resp = await world.listTopics.execute() + world.setLastResponse(resp) + } + }, + { + name: 'response contains topic with post count', + pattern: /^the response contains the topic () with post count ()$/, + run (m, example, world) { + const topic = resolveParam(m[1], example) + const expectedCount = 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.postCount !== expectedCount) { + throw new Error(`Expected post count ${expectedCount} for ${topic}, got ${found.postCount}`) + } + } + }, + { + name: 'request topic posts', + pattern: /^the client requests \/topics\/([^/]+)\/posts(?: with limit () and offset ())?$/, + async run (m, example, world) { + const room = resolveParam(m[1], example) + const limit = m[2] ? parseInt(resolveParam(m[2], example), 10) : undefined + const offset = m[3] ? parseInt(resolveParam(m[3], example), 10) : undefined + const resp = await world.listTopicPosts.execute({ room, limit, offset }) + world.setLastResponse(resp) + } + }, + { + name: 'response contains no posts', + pattern: /^the response contains no posts$/, + run (m, example, world) { + const posts = world.getLastResponse().posts + if (!Array.isArray(posts) || posts.length !== 0) { + throw new Error(`Expected no posts, got ${Array.isArray(posts) ? posts.length : 'non-array'}`) + } + } } ] diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index c877db4..3e67c63 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -7,6 +7,7 @@ import DbBackup from './db-backup.js' import ProfileQuery from './profile-query.js' import PostQuery from './post-query.js' import FollowQuery from './follow-query.js' +import TopicQuery from './topic-query.js' class Adapters { constructor () { @@ -35,6 +36,10 @@ class Adapters { this.followQuery = new FollowQuery({ followsDb: level.followsDb }) + this.topicQuery = new TopicQuery({ + roomsDb: level.roomsDb, + postsDb: level.postsDb + }) return true } diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js new file mode 100644 index 0000000..8a27676 --- /dev/null +++ b/psf-memo-db/src/adapters/topic-query.js @@ -0,0 +1,80 @@ +/* + Adapter for querying Memo topics from the rooms LevelDB store. + + The indexer stores topic activity in the rooms store: + - Topic posts are keyed `${room}:${txid}` with type 'post'. + - Topic follows are keyed `${room}:${addr}` with type 'follow'. + + This adapter exposes: + - listTopics() - distinct rooms with post counts + - getTopicPostTxids() - paginated txids for a room sorted by block height +*/ + +class TopicQuery { + constructor (localConfig = {}) { + const { roomsDb, postsDb } = localConfig + if (!roomsDb) { + throw new Error('roomsDb required when instantiating TopicQuery adapter.') + } + if (!postsDb) { + throw new Error('postsDb required when instantiating TopicQuery adapter.') + } + this.roomsDb = roomsDb + this.postsDb = postsDb + + this.listTopics = this.listTopics.bind(this) + this.getTopicPostTxids = this.getTopicPostTxids.bind(this) + this.roomFromKey = this.roomFromKey.bind(this) + this.txidFromKey = this.txidFromKey.bind(this) + } + + roomFromKey (key, value) { + if (value && typeof value.room === 'string') return value.room + return String(key).split(':')[0] + } + + txidFromKey (key) { + const parts = String(key).split(':') + return parts[parts.length - 1] + } + + async listTopics () { + const counts = new Map() + + for await (const [key, value] of this.roomsDb.iterator()) { + const room = this.roomFromKey(key, value) + if (!counts.has(room)) { + counts.set(room, 0) + } + if (value?.type === 'post') { + counts.set(room, counts.get(room) + 1) + } + } + + return Array.from(counts.entries()) + .map(([room, postCount]) => ({ room, postCount })) + .sort((a, b) => a.room.localeCompare(b.room)) + } + + async getTopicPostTxids (room, { limit, offset }) { + const start = `${room}:` + const end = `${room}:\uffff` + const entries = [] + + for await (const [key, value] of this.roomsDb.iterator({ gte: start, lte: end })) { + if (value?.type !== 'post') continue + const txid = (value && typeof value.txid === 'string') ? value.txid : this.txidFromKey(key) + const blockHeight = value?.blockHeight ?? 0 + entries.push({ txid, blockHeight }) + } + + entries.sort((a, b) => b.blockHeight - a.blockHeight) + + const total = entries.length + const txids = entries.slice(offset, offset + limit).map((entry) => entry.txid) + + return { txids, total } + } +} + +export default TopicQuery diff --git a/psf-memo-db/src/controllers/rest-api/index.js b/psf-memo-db/src/controllers/rest-api/index.js index 7e59853..455ad27 100644 --- a/psf-memo-db/src/controllers/rest-api/index.js +++ b/psf-memo-db/src/controllers/rest-api/index.js @@ -7,6 +7,7 @@ import HealthRouter from './health/index.js' import ProfileRouter from './profile/index.js' import PostsRouter from './posts/index.js' import FollowRouter from './follow/index.js' +import TopicsRouter from './topics/index.js' class RESTControllers { constructor (localConfig = {}) { @@ -35,6 +36,9 @@ class RESTControllers { const followRouter = new FollowRouter(dependencies) followRouter.attach(app) + + const topicsRouter = new TopicsRouter(dependencies) + topicsRouter.attach(app) } } diff --git a/psf-memo-db/src/controllers/rest-api/topics/controller.js b/psf-memo-db/src/controllers/rest-api/topics/controller.js new file mode 100644 index 0000000..bdecc8a --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/topics/controller.js @@ -0,0 +1,91 @@ +/* + REST API controller for /topics routes. +*/ + +import wlogger from '../../../adapters/wlogger.js' + +class TopicsRESTControllerLib { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + this.useCases = localConfig.useCases + if (!this.adapters) { + throw new Error('Adapters required for Topics REST Controller.') + } + if (!this.useCases) { + throw new Error('Use Cases required for Topics REST Controller.') + } + + this.getTopics = this.getTopics.bind(this) + this.getTopicPosts = this.getTopicPosts.bind(this) + this.handleError = this.handleError.bind(this) + } + + handleError (ctx, err) { + if (err.status) { + ctx.throw(err.status, err.message || err) + } else { + wlogger.error('Error in topics controller: ', err) + ctx.throw(500, err.message || 'Internal server error') + } + } + + /** + * @api {get} /topics List topics + * @apiPermission public + * @apiName GetTopics + * @apiGroup REST Topics + * + * @apiDescription Returns all distinct Memo topics with their post counts. + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/topics" + * + * @apiSuccess {Object[]} topics Array of topic objects + * @apiSuccess {String} topics.room Topic name + * @apiSuccess {Number} topics.postCount Number of posts in the topic + */ + async getTopics (ctx) { + try { + ctx.body = await this.useCases.listTopics.execute() + } catch (err) { + this.handleError(ctx, err) + } + } + + /** + * @api {get} /topics/:room/posts List posts for a topic + * @apiPermission public + * @apiName GetTopicPosts + * @apiGroup REST Topics + * + * @apiDescription Returns posts for a single topic sorted by block height + * (newest first). + * + * @apiParam {String} room Topic name + * @apiQuery {Number} [limit=100] Page size (max 100) + * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/topics/bitcoin/posts?limit=50&offset=0" + * + * @apiSuccess {Object[]} posts Array of post objects + * @apiSuccess {String} posts.txid Post transaction id + * @apiSuccess {String} posts.addr Author cash address + * @apiSuccess {String} posts.text Post text + * @apiSuccess {Number} posts.seen Unix epoch milliseconds + * @apiSuccess {Number} posts.blockHeight Block height when indexed + * @apiSuccess {Number} posts.replyCount Number of replies to this post + * @apiSuccess {Object} pagination Pagination metadata + */ + async getTopicPosts (ctx) { + try { + const { room } = ctx.params + const { limit, offset } = ctx.query + ctx.body = await this.useCases.listTopicPosts.execute({ room, limit, offset }) + } catch (err) { + this.handleError(ctx, err) + } + } +} + +export default TopicsRESTControllerLib diff --git a/psf-memo-db/src/controllers/rest-api/topics/index.js b/psf-memo-db/src/controllers/rest-api/topics/index.js new file mode 100644 index 0000000..e07e36a --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/topics/index.js @@ -0,0 +1,34 @@ +/* + REST API router for /topics routes. +*/ + +import Router from 'koa-router' +import TopicsRESTControllerLib from './controller.js' + +class TopicsRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + this.useCases = localConfig.useCases + if (!this.adapters) { + throw new Error('Adapters required when instantiating Topics REST Controller.') + } + if (!this.useCases) { + throw new Error('Use Cases required when instantiating Topics REST Controller.') + } + + this.topicsRESTController = new TopicsRESTControllerLib({ + adapters: this.adapters, + useCases: this.useCases + }) + this.router = new Router({ prefix: '/topics' }) + } + + attach (app) { + this.router.get('/', this.topicsRESTController.getTopics) + this.router.get('/:room/posts', this.topicsRESTController.getTopicPosts) + app.use(this.router.routes()) + app.use(this.router.allowedMethods()) + } +} + +export default TopicsRouter diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index cab7027..3f9b371 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -9,6 +9,8 @@ import GetPostThread from './get-post-thread.js' import FollowState from './follow-state.js' import ListFollowing from './list-following.js' import ListFollowers from './list-followers.js' +import ListTopics from './list-topics.js' +import ListTopicPosts from './list-topic-posts.js' class UseCases { constructor (localConfig = {}) { @@ -27,6 +29,8 @@ class UseCases { this.followState = null this.listFollowing = null this.listFollowers = null + this.listTopics = null + this.listTopicPosts = null } async start () { @@ -58,6 +62,14 @@ class UseCases { adapters: this.adapters }) + this.listTopics = new ListTopics({ + adapters: this.adapters + }) + + this.listTopicPosts = new ListTopicPosts({ + adapters: this.adapters + }) + console.log('Use cases initialized.') return true diff --git a/psf-memo-db/src/use-cases/list-topic-posts.js b/psf-memo-db/src/use-cases/list-topic-posts.js new file mode 100644 index 0000000..d53cb43 --- /dev/null +++ b/psf-memo-db/src/use-cases/list-topic-posts.js @@ -0,0 +1,58 @@ +/* + Use case: list the posts for a single Memo topic, ordered by block height + (newest first), paginated. +*/ + +import { parseLimit, parseOffset, attachReplyCounts, attachLikeCounts } from './lib/pagination.js' + +class ListTopicPosts { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error('Adapters required when instantiating ListTopicPosts use case.') + } + if (!this.adapters.topicQuery) { + throw new Error('topicQuery adapter required for ListTopicPosts use case.') + } + if (!this.adapters.postQuery) { + throw new Error('postQuery adapter required for ListTopicPosts use case.') + } + + this.execute = this.execute.bind(this) + } + + parseRoom (room) { + if (!room || typeof room !== 'string') { + const err = new Error('room is required') + err.status = 400 + throw err + } + return room + } + + async execute (inObj = {}) { + const room = this.parseRoom(inObj.room) + const limit = parseLimit(inObj.limit) + const offset = parseOffset(inObj.offset) + + const { txids, total } = await this.adapters.topicQuery.getTopicPostTxids(room, { limit, offset }) + const [posts, replyCounts, likeCounts] = await Promise.all([ + this.adapters.postQuery.loadPostsByTxids(txids), + this.adapters.postQuery.countRepliesForTxids(txids), + this.adapters.postQuery.countLikesForTxids(txids) + ]) + + const enriched = attachLikeCounts(attachReplyCounts(posts, replyCounts), likeCounts) + return { + posts: enriched, + pagination: { + limit, + offset, + total, + hasMore: total > enriched.length + } + } + } +} + +export default ListTopicPosts diff --git a/psf-memo-db/src/use-cases/list-topics.js b/psf-memo-db/src/use-cases/list-topics.js new file mode 100644 index 0000000..5152e7f --- /dev/null +++ b/psf-memo-db/src/use-cases/list-topics.js @@ -0,0 +1,18 @@ +/* + Use case: list all distinct Memo topics with their post counts. +*/ + +import { ListUseCase } from './lib/use-case.js' + +class ListTopics extends ListUseCase { + constructor (localConfig = {}) { + super(localConfig, { useCaseName: 'ListTopics', adapterName: 'topicQuery' }) + } + + async execute (inObj = {}) { + const topics = await this.adapters.topicQuery.listTopics() + return { topics } + } +} + +export default ListTopics diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js new file mode 100644 index 0000000..eb43ce2 --- /dev/null +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -0,0 +1,168 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import TopicQuery from '../../../src/adapters/topic-query.js' + +describe('#TopicQuery', () => { + let uut + let sandbox + let roomsDb + let postsDb + + beforeEach(() => { + sandbox = sinon.createSandbox() + roomsDb = { + iterator: sandbox.stub() + } + postsDb = { + get: sandbox.stub() + } + + uut = new TopicQuery({ roomsDb, postsDb }) + }) + + afterEach(() => sandbox.restore()) + + it('should throw when roomsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new TopicQuery({ postsDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'roomsDb required') + } + }) + + it('should throw when postsDb is missing', () => { + try { + // eslint-disable-next-line no-new + new TopicQuery({ roomsDb }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postsDb required') + } + }) + + describe('#roomFromKey', () => { + it('should return the room from the value when present', () => { + assert.equal(uut.roomFromKey('ignored:post-1', { room: 'bitcoin' }), 'bitcoin') + }) + + it('should fall back to the first segment of the key', () => { + assert.equal(uut.roomFromKey('cash:post-1', {}), 'cash') + }) + }) + + describe('#txidFromKey', () => { + it('should return the last segment of the key', () => { + assert.equal(uut.txidFromKey('bitcoin:post-300'), 'post-300') + }) + }) + + describe('#listTopics', () => { + it('should return distinct topics with post counts', 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 }] + yield ['cash:post-250', { room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 }] + yield ['dev:post-100', { room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }] + yield ['lone:addr-f', { room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false }] + } + roomsDb.iterator.returns(mockRooms()) + + const result = await uut.listTopics() + + assert.deepEqual(result, [ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 }, + { room: 'dev', postCount: 1 }, + { room: 'lone', postCount: 0 } + ]) + }) + + it('should sort topics by room name', async () => { + async function * mockRooms () { + yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }] + yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 1 }] + } + roomsDb.iterator.returns(mockRooms()) + + const result = await uut.listTopics() + + assert.deepEqual(result.map((t) => t.room), ['alpha', 'zoo']) + }) + }) + + describe('#getTopicPostTxids', () => { + it('should return txids for a topic sorted by block height descending', async () => { + async function * mockRooms () { + yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] + yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] + yield ['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }] + } + roomsDb.iterator + .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) + .returns(mockRooms()) + + const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 }) + + assert.deepEqual(result.txids, ['post-300', 'post-200']) + 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()) + + const result = await uut.getTopicPostTxids('bitcoin', { limit: 1, offset: 0 }) + + 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) + }) + + it('should return empty result for a topic with no posts', async () => { + async function * empty () {} + roomsDb.iterator + .withArgs(sinon.match({ gte: 'lone:', lte: 'lone:\uffff' })) + .returns(empty()) + + const result = await uut.getTopicPostTxids('lone', { limit: 100, offset: 0 }) + + assert.deepEqual(result.txids, []) + assert.equal(result.total, 0) + }) + + it('should ignore entries that are not posts', async () => { + async function * mockRooms () { + yield ['bitcoin:addr-f', { room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }] + } + roomsDb.iterator + .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) + .returns(mockRooms()) + + const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 }) + + assert.deepEqual(result.txids, []) + assert.equal(result.total, 0) + }) + }) +}) diff --git a/psf-memo-db/test/unit/controllers/topics.controller.unit.js b/psf-memo-db/test/unit/controllers/topics.controller.unit.js new file mode 100644 index 0000000..aa4d53c --- /dev/null +++ b/psf-memo-db/test/unit/controllers/topics.controller.unit.js @@ -0,0 +1,61 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import TopicsRESTControllerLib from '../../../src/controllers/rest-api/topics/controller.js' + +describe('#TopicsRESTController', () => { + let uut + let sandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + uut = new TopicsRESTControllerLib({ + adapters: {}, + useCases: { + listTopics: { + execute: sandbox.stub().resolves({ + topics: [ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 } + ] + }) + }, + listTopicPosts: { + execute: sandbox.stub().resolves({ + posts: [{ txid: 'post-300', blockHeight: 300 }], + pagination: { limit: 100, offset: 0, total: 1, hasMore: false } + }) + } + } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should return topics from use case', async () => { + const ctx = { body: null, throw: sandbox.stub() } + await uut.getTopics(ctx) + + assert.equal(uut.useCases.listTopics.execute.callCount, 1) + assert.equal(ctx.body.topics.length, 2) + assert.equal(ctx.body.topics[0].room, 'bitcoin') + }) + + it('should return topic posts from use case', async () => { + const ctx = { + params: { room: 'bitcoin' }, + query: { limit: '50', offset: '0' }, + body: null, + throw: sandbox.stub() + } + await uut.getTopicPosts(ctx) + + assert.equal(uut.useCases.listTopicPosts.execute.callCount, 1) + assert.deepEqual(uut.useCases.listTopicPosts.execute.firstCall.args[0], { + room: 'bitcoin', + limit: '50', + offset: '0' + }) + assert.equal(ctx.body.posts.length, 1) + assert.equal(ctx.body.posts[0].txid, 'post-300') + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/list-topic-posts.unit.js b/psf-memo-db/test/unit/use-cases/list-topic-posts.unit.js new file mode 100644 index 0000000..b862e82 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-topic-posts.unit.js @@ -0,0 +1,129 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListTopicPosts from '../../../src/use-cases/list-topic-posts.js' + +describe('#ListTopicPosts', () => { + let uut + let sandbox + let topicQuery + let postQuery + + const mockPosts = { + 'post-300': { addr: 'addr-a', text: 'hello bitcoin', seen: 100, blockHeight: 300 }, + 'post-200': { addr: 'addr-b', text: 'bitcoin again', seen: 200, blockHeight: 200 } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + topicQuery = { + getTopicPostTxids: sandbox.stub().resolves({ txids: ['post-300', 'post-200'], total: 2 }) + } + postQuery = { + loadPostsByTxids: sandbox.stub().callsFake(async (txids) => { + return txids.map((txid) => ({ txid, ...mockPosts[txid] })) + }), + countRepliesForTxids: sandbox.stub().resolves(new Map()), + countLikesForTxids: sandbox.stub().resolves(new Map([['post-300', 1]])) + } + uut = new ListTopicPosts({ + adapters: { topicQuery, postQuery } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should throw when adapters are missing', () => { + try { + // eslint-disable-next-line no-new + new ListTopicPosts({}) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'Adapters required') + } + }) + + it('should throw when topicQuery adapter is missing', () => { + try { + // eslint-disable-next-line no-new + new ListTopicPosts({ adapters: { postQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'topicQuery adapter required') + } + }) + + it('should throw when postQuery adapter is missing', () => { + try { + // eslint-disable-next-line no-new + new ListTopicPosts({ adapters: { topicQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postQuery adapter required') + } + }) + + it('should reject a missing room', async () => { + try { + await uut.execute({}) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'room is required') + } + }) + + it('should return topic posts sorted by block height descending', async () => { + const result = await uut.execute({ room: 'bitcoin', limit: 10, offset: 0 }) + + assert.equal(result.posts.length, 2) + assert.equal(result.posts[0].txid, 'post-300') + assert.equal(result.posts[1].txid, 'post-200') + assert.equal(result.posts[0].likeCount, 1) + assert.equal(result.posts[1].likeCount, 0) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, false) + }) + + it('should report hasMore true on a partial last page', async () => { + topicQuery.getTopicPostTxids.resolves({ txids: ['post-200'], total: 2 }) + postQuery.loadPostsByTxids.callsFake(async (txids) => { + return txids.map((txid) => ({ txid, ...mockPosts[txid] })) + }) + + const result = await uut.execute({ room: 'bitcoin', limit: 1, offset: 1 }) + + assert.deepEqual(result.posts.map((p) => p.txid), ['post-200']) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, true) + }) + + it('should paginate topic posts', async () => { + topicQuery.getTopicPostTxids.resolves({ txids: ['post-300'], total: 2 }) + postQuery.loadPostsByTxids.callsFake(async (txids) => { + return txids.map((txid) => ({ txid, ...mockPosts[txid] })) + }) + + const result = await uut.execute({ room: 'bitcoin', limit: 1, offset: 0 }) + + assert.deepEqual(result.posts.map((p) => p.txid), ['post-300']) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, true) + }) + + it('should default limit and offset', async () => { + await uut.execute({ room: 'bitcoin' }) + + assert.equal(topicQuery.getTopicPostTxids.firstCall.args[1].limit, 100) + assert.equal(topicQuery.getTopicPostTxids.firstCall.args[1].offset, 0) + }) + + it('should reject limit over 100', async () => { + try { + await uut.execute({ room: 'bitcoin', limit: 101 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'limit cannot exceed') + } + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/list-topics.unit.js b/psf-memo-db/test/unit/use-cases/list-topics.unit.js new file mode 100644 index 0000000..d4c1b5a --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-topics.unit.js @@ -0,0 +1,53 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListTopics from '../../../src/use-cases/list-topics.js' + +describe('#ListTopics', () => { + let uut + let sandbox + let topicQuery + + beforeEach(() => { + sandbox = sinon.createSandbox() + topicQuery = { + listTopics: sandbox.stub().resolves([ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 } + ]) + } + uut = new ListTopics({ + adapters: { topicQuery } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should throw when adapters are missing', () => { + try { + // eslint-disable-next-line no-new + new ListTopics({}) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'Adapters required') + } + }) + + it('should throw when topicQuery adapter is missing', () => { + try { + // eslint-disable-next-line no-new + new ListTopics({ adapters: {} }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'topicQuery adapter required') + } + }) + + it('should return topics from the adapter', async () => { + const result = await uut.execute() + + assert.deepEqual(result.topics, [ + { room: 'bitcoin', postCount: 2 }, + { room: 'cash', postCount: 1 } + ]) + }) +})