mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge commit '652c70e4ea' into swarmforge-refactorer
This commit is contained in:
@@ -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 |
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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'}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user