Merge commit '652c70e4ea' into swarmforge-refactorer

This commit is contained in:
Chris Troutner
2026-08-28 06:32:02 -07:00
26 changed files with 1613 additions and 2 deletions
+161 -2
View File
@@ -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 (<topic>) with (<count>) 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 (<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 (<topic>)$/,
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 "?(<topic>|[^"]+)"?$/,
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 (<txid>) with text (<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.`)
}
}
}
]
@@ -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 <topic> with <count> 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 <topic>
Then the app navigates to the topic feed for <topic>
Examples:
| topic |
| bitcoin |
| cash |
+23
View File
@@ -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 <topic>
Then the feed shows the post with txid <txid> with text <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
@@ -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) {
<Route path='/profile/:addr' element={<Profile />} />
<Route path='/posts/recent' element={<RecentPosts appData={appData} />} />
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/topics' element={<Topics />} />
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -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 (
<Container className='topic-feed-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='topic-feed-heading'>
<h1>#{room}</h1>
<p>Posts published in the {room} topic.</p>
{pagination && posts.length > 0 && (
<span className='topic-feed-count'>
Showing {pagination.offset + 1}
{pagination.offset + posts.length} of {pagination.total}
</span>
)}
</header>
{error && (
<p className='topic-feed-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && !error && posts.length === 0 && (
<p className='topic-feed-empty'>There are no posts for this topic.</p>
)}
{!loading && !error && posts.length > 0 && (
<div className='posts-feed'>
{posts.map((post) => (
<PostFeedItem
key={post.txid}
post={post}
profiles={profiles}
wallet={appData?.wallet}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='topic-feed-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={appData?.wallet}
profiles={profiles}
/>
</Container>
)
}
export default TopicFeed
@@ -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 (
<Container className='topics-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='topics-heading'>
<h1>Topics</h1>
<p>Discover Memo conversations organized by topic.</p>
</header>
{error && (
<p className='topics-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && !error && topics.length === 0 && (
<p className='topics-empty'>No topics available.</p>
)}
{!loading && !error && topics.length > 0 && (
<ListGroup>
{topics.map((topic) => (
<ListGroup.Item
key={topic.room}
action
onClick={() => handleClick(topic.room)}
>
#{topic.room}{' '}
<span className='text-muted'>({topic.postCount} posts)</span>
</ListGroup.Item>
))}
</ListGroup>
)}
</Col>
</Row>
</Container>
)
}
export default Topics
@@ -70,6 +70,14 @@ function NavMenu (props) {
Posts
</NavLink>
<NavLink
className={currentPath === '/topics' ? 'nav-link-active' : 'nav-link-inactive'}
to='/topics'
onClick={handleClickEvent}
>
Topics
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
+17
View File
@@ -48,6 +48,23 @@ class MemoDb {
}
}
async getTopics () {
return this.getRecent('/topics', 'getTopics', {})
}
async getTopicPosts (room, { limit = 100, offset = 0 } = {}) {
try {
const result = await this.axios.get(
`${config.backend}/topics/${encodeURIComponent(room)}/posts`,
{ params: { limit, offset } }
)
return result.data
} catch (err) {
console.error('Error in getTopicPosts()')
throw err
}
}
// GET a paginated 'recent' listing endpoint.
async getRecent (path, name, params) {
try {
@@ -0,0 +1,45 @@
/*
Topic Discovery Page behavior: load and display the list of Memo topics.
This is the testable controller behind the React "Topics" page. It wraps
the MemoDb client and exposes the loaded topics so the view can render each
topic name and post count.
*/
const TOPICS_PATH = '/topics'
class TopicDiscoveryPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.navigate = deps.navigate || (() => {})
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
@@ -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
@@ -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')
})
@@ -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')
})
+102
View File
@@ -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 (<topic>) with post count (<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 (<limit>) and offset (<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'}`)
}
}
}
]
+61
View File
@@ -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 <topic> with post count <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/<room>/posts
Then the response posts are sorted by block height descending
And the response contains the txids <expected_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/<room>/posts with limit <limit> and offset <offset>
Then the response contains the txids <expected_txids>
And the response pagination shows total <total> and hasMore <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
+5
View File
@@ -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
}
+80
View File
@@ -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
@@ -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)
}
}
@@ -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
@@ -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
+12
View File
@@ -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
@@ -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
+18
View File
@@ -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
@@ -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)
})
})
})
@@ -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')
})
})
@@ -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')
}
})
})
@@ -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 }
])
})
})