mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 17:22:01 -07:00
Display like counts on feed, profile, and thread
- Add testable RecentFeedPage, ProfilePage, and ThreadPage controllers. - Wire acceptance handlers for the like-count-display feature. - Render like counts on the profile page with a read-only LikeButton. - Add unit tests for the new display page services. By coder.
This commit is contained in:
@@ -28,6 +28,9 @@ const SetNamePage = require('../../src/services/set-name-page')
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
const RecentFeedPage = require('../../src/services/recent-feed-page')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
||||
@@ -37,6 +40,11 @@ const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
|
||||
// Default author address used by Gherkin steps that refer to "the author address".
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
|
||||
// Placeholder addresses used by Gherkin steps that refer to 'a second address'
|
||||
// or 'a third address'.
|
||||
const SECOND_ADDRESS = 'bitcoincash:second-address'
|
||||
const THIRD_ADDRESS = 'bitcoincash:third-address'
|
||||
|
||||
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||
function makeWallet (address) {
|
||||
const wallet = {
|
||||
@@ -85,6 +93,36 @@ function makeThread () {
|
||||
}
|
||||
}
|
||||
|
||||
// A fake psf-memo-db API backing the read-only feed, profile, and thread
|
||||
// pages used to verify like count display.
|
||||
function makeMemoDb () {
|
||||
const posts = []
|
||||
const threads = {}
|
||||
|
||||
return {
|
||||
posts,
|
||||
threads,
|
||||
addPost (post) {
|
||||
posts.push(post)
|
||||
},
|
||||
addThread (txid, thread) {
|
||||
threads[txid] = thread
|
||||
},
|
||||
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
|
||||
const page = posts.slice(offset, offset + limit)
|
||||
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
|
||||
},
|
||||
async getPostsByAddr (addr, { limit = 100, offset = 0 } = {}) {
|
||||
const filtered = posts.filter((p) => p.addr === addr)
|
||||
const page = filtered.slice(offset, offset + limit)
|
||||
return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } }
|
||||
},
|
||||
async getPostThread (txid) {
|
||||
return threads[txid] || { post: null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh world/state object for a single scenario execution.
|
||||
function createWorld () {
|
||||
const wallet = makeWallet('')
|
||||
@@ -93,6 +131,7 @@ function createWorld () {
|
||||
const thread = makeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
const memoDb = makeMemoDb()
|
||||
|
||||
const world = {
|
||||
wallet,
|
||||
@@ -101,11 +140,17 @@ function createWorld () {
|
||||
memoPost,
|
||||
memoReply,
|
||||
memoLike,
|
||||
memoDb,
|
||||
currentPath: null,
|
||||
menuOpen: false,
|
||||
likedTxids: new Set()
|
||||
}
|
||||
|
||||
// Read-only page controllers backed by the fake psf-memo-db API.
|
||||
world.recentFeedPage = new RecentFeedPage({ memoDb })
|
||||
world.profilePage = new ProfilePage({ memoDb })
|
||||
world.threadPage = new ThreadPage({ memoDb })
|
||||
|
||||
// The New Post Page controller wraps the memo post behavior. Its navigate
|
||||
// adapter updates the world's current path so navigation can be asserted.
|
||||
world.newPage = new NewPostPage({
|
||||
@@ -167,6 +212,20 @@ function resolveParam (value, example) {
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
// Look up a post that has been loaded onto one of the read-only pages.
|
||||
function findDisplayedPost (txid, world) {
|
||||
const fromThread = world.threadPage.getPost(txid)
|
||||
if (fromThread) return fromThread
|
||||
|
||||
const fromProfile = world.profilePage.getPost(txid)
|
||||
if (fromProfile) return fromProfile
|
||||
|
||||
const fromFeed = world.recentFeedPage.getPost(txid)
|
||||
if (fromFeed) return fromFeed
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
@@ -328,10 +387,11 @@ const handlers = [
|
||||
{
|
||||
name: 'open reply thread',
|
||||
pattern: /^I open the thread for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = m[1].trim()
|
||||
async run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
world.thread.rootTxid = txid
|
||||
world.replyPage.setParent(txid)
|
||||
await world.threadPage.load(txid)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -796,10 +856,129 @@ const handlers = [
|
||||
throw new Error('Expected like/tip modal to be closed.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post with explicit address and like count',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const addr = resolveParam(m[2], example)
|
||||
const likeCount = parseInt(resolveParam(m[3], example), 10)
|
||||
world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post with explicit address and no like count',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with no like count recorded$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const addr = resolveParam(m[2], example)
|
||||
world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post with second/third address and like count',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with a like count of (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
|
||||
const likeCount = parseInt(resolveParam(m[3], example), 10)
|
||||
world.memoDb.addPost({ txid, addr, likeCount, text: 'A sample post', blockHeight: 100 })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post with second/third address and no like count',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by a (second|third) address with no like count recorded$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const addr = m[2] === 'second' ? SECOND_ADDRESS : THIRD_ADDRESS
|
||||
world.memoDb.addPost({ txid, addr, likeCount: undefined, text: 'A sample post', blockHeight: 100 })
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open recent posts feed',
|
||||
pattern: /^I open the recent posts feed$/,
|
||||
async run (m, example, world) {
|
||||
await world.recentFeedPage.load()
|
||||
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open profile page for author',
|
||||
pattern: /^I open the profile page for the author of the post with txid (.+)$/,
|
||||
async run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const post = world.memoDb.posts.find((p) => p.txid === txid)
|
||||
if (!post) {
|
||||
throw new Error(`No API post found for txid ${txid}.`)
|
||||
}
|
||||
world.profilePage.addr = post.addr
|
||||
await world.profilePage.load()
|
||||
world.currentPath = `${ProfilePage.PROFILE_PATH_PREFIX}/${encodeURIComponent(post.addr)}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'thread contains a reply',
|
||||
pattern: /^the thread for the post with txid (.+) contains a reply with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const replyTxid = resolveParam(m[2], example)
|
||||
const expectedReplyCount = parseInt(example.expected_reply, 10)
|
||||
const rootPost = world.memoDb.posts.find((p) => p.txid === txid)
|
||||
if (!rootPost) {
|
||||
throw new Error(`No API post found for txid ${txid}.`)
|
||||
}
|
||||
const thread = {
|
||||
post: {
|
||||
...rootPost,
|
||||
replies: [{
|
||||
txid: replyTxid,
|
||||
addr: 'bitcoincash:reply-author',
|
||||
text: 'A sample reply',
|
||||
likeCount: Number.isNaN(expectedReplyCount) ? 0 : expectedReplyCount,
|
||||
blockHeight: rootPost.blockHeight + 1,
|
||||
replies: []
|
||||
}]
|
||||
}
|
||||
}
|
||||
world.memoDb.addThread(txid, thread)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post shows like count',
|
||||
pattern: /^the post with txid (.+) shows the like count (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const post = findDisplayedPost(txid, world)
|
||||
if (!post) {
|
||||
throw new Error(`Post ${txid} is not displayed.`)
|
||||
}
|
||||
const actual = post.likeCount ?? 0
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected like count ${expected} for ${txid}, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'reply shows like count',
|
||||
pattern: /^the reply with txid (.+) shows the like count (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const post = world.threadPage.getPost(txid)
|
||||
if (!post) {
|
||||
throw new Error(`Reply ${txid} is not displayed.`)
|
||||
}
|
||||
const actual = post.likeCount ?? 0
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected like count ${expected} for reply ${txid}, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// Route a single step to its handler. Throws on unsupported step text.
|
||||
// Route a single step to its handler. Throws on unsupported step text. Throws on unsupported step text.
|
||||
async function handleStep (step, example, world) {
|
||||
for (const handler of handlers) {
|
||||
const match = handler.pattern.exec(step.text)
|
||||
|
||||
@@ -9,6 +9,7 @@ import Jdenticon from '@chris.troutner/react-jdenticon'
|
||||
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import PostReplyCount from '../../post-reply-count'
|
||||
import LikeButton from '../../post-feed/like-button'
|
||||
import PostThreadModal from '../../post-thread-modal'
|
||||
import '../../../App.css'
|
||||
import './profile.css'
|
||||
@@ -154,10 +155,13 @@ function Profile (props) {
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
</div>
|
||||
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={() => openThread(post.txid)}
|
||||
/>
|
||||
<div className='profile-post-actions d-flex gap-3 align-items-center'>
|
||||
<LikeButton count={post.likeCount ?? 0} liked={false} readOnly />
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={() => openThread(post.txid)}
|
||||
/>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
import './post-feed.css'
|
||||
|
||||
function LikeButton ({ count = 0, liked = false, onClick }) {
|
||||
function LikeButton ({ count = 0, liked = false, onClick, readOnly = false }) {
|
||||
const label = count === 1 ? '1 like' : `${count} likes`
|
||||
const icon = liked ? faHeartSolid : faHeartRegular
|
||||
const className = [
|
||||
@@ -20,6 +20,19 @@ function LikeButton ({ count = 0, liked = false, onClick }) {
|
||||
liked ? 'post-like-button-liked' : ''
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className='post-like-button-icon' />
|
||||
<span className='post-like-button-count'>{count}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
Profile Page behavior: load and display a single address's Memo posts.
|
||||
|
||||
This is the testable controller behind the React "Profile" page. It wraps
|
||||
the MemoDb client, targets a specific address, and exposes the loaded posts so
|
||||
the view can render per-post data such as the like count.
|
||||
|
||||
The memoDb and address concerns are injected so this module stays free of
|
||||
UI/network concerns; environmentally unsuitable I/O lives behind those small
|
||||
adapter boundaries.
|
||||
*/
|
||||
|
||||
const PROFILE_PATH_PREFIX = '/profile'
|
||||
|
||||
class ProfilePage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.addr = deps.addr || null
|
||||
this.posts = []
|
||||
this.pagination = null
|
||||
}
|
||||
|
||||
async load ({ limit = 100, offset = 0 } = {}) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Profile page requires a memo db client.')
|
||||
}
|
||||
if (!this.addr) {
|
||||
throw new Error('Profile page requires an address.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getPostsByAddr(this.addr, { 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
|
||||
}
|
||||
}
|
||||
|
||||
ProfilePage.PROFILE_PATH_PREFIX = PROFILE_PATH_PREFIX
|
||||
|
||||
module.exports = ProfilePage
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Recent Feed Page behavior: load and display the list of recent Memo posts.
|
||||
|
||||
This is the testable controller behind the React "Recent Posts" page. It
|
||||
wraps the MemoDb client and exposes the loaded posts so the view can render
|
||||
per-post data such as the like count.
|
||||
|
||||
The memoDb concern is injected so this module stays free of UI/network
|
||||
concerns; environmentally unsuitable I/O lives behind that small adapter
|
||||
boundary.
|
||||
*/
|
||||
|
||||
const RECENT_FEED_PATH = '/posts/recent'
|
||||
|
||||
class RecentFeedPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.posts = []
|
||||
this.pagination = null
|
||||
}
|
||||
|
||||
async load ({ limit = 100, offset = 0 } = {}) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Recent feed page requires a memo db client.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getRecentPosts({ 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
|
||||
}
|
||||
}
|
||||
|
||||
RecentFeedPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||
|
||||
module.exports = RecentFeedPage
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Thread Page behavior: load and display a post and its nested replies.
|
||||
|
||||
This is the testable controller behind the React "Post Thread" modal. It
|
||||
wraps the MemoDb client, targets a specific root post txid, and exposes the
|
||||
loaded posts so the view can render per-post data such as the like count.
|
||||
|
||||
The memoDb concern is injected so this module stays free of UI/network
|
||||
concerns; environmentally unsuitable I/O lives behind that small adapter
|
||||
boundary.
|
||||
*/
|
||||
|
||||
const THREAD_PATH_PREFIX = '/posts/thread'
|
||||
|
||||
class ThreadPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.rootPost = null
|
||||
this.allPosts = []
|
||||
}
|
||||
|
||||
async load (txid) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Thread page requires a memo db client.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getPostThread(txid)
|
||||
this.rootPost = data.post || null
|
||||
this.allPosts = []
|
||||
if (this.rootPost) {
|
||||
this._flatten(this.rootPost)
|
||||
}
|
||||
|
||||
return { post: this.rootPost, allPosts: this.allPosts }
|
||||
}
|
||||
|
||||
getPost (txid) {
|
||||
return this.allPosts.find((post) => post.txid === txid) || null
|
||||
}
|
||||
|
||||
_flatten (post) {
|
||||
this.allPosts.push(post)
|
||||
for (const reply of post.replies || []) {
|
||||
this._flatten(reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ThreadPage.THREAD_PATH_PREFIX = THREAD_PATH_PREFIX
|
||||
|
||||
module.exports = ThreadPage
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Unit tests for the profile page controller.
|
||||
|
||||
The profile page loads a single address's posts from the MemoDb client. The
|
||||
like count returned by the API must be preserved so the view can display it.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
|
||||
function makeMemoDb (postsByAddr) {
|
||||
return {
|
||||
async getPostsByAddr (addr, { limit, offset }) {
|
||||
return { posts: postsByAddr[addr] || [], pagination: { total: (postsByAddr[addr] || []).length } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('load returns posts with like counts for the address', async () => {
|
||||
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const posts = [{ txid: 'a'.repeat(64), likeCount: 17 }]
|
||||
const page = new ProfilePage({ memoDb: makeMemoDb({ [addr]: posts }), addr })
|
||||
|
||||
const result = await page.load()
|
||||
|
||||
assert.equal(result.posts[0].likeCount, 17)
|
||||
})
|
||||
|
||||
test('getPost returns the like count for a loaded post', async () => {
|
||||
const addr = 'bitcoincash:second'
|
||||
const posts = [{ txid: 'b'.repeat(64), likeCount: 3 }]
|
||||
const page = new ProfilePage({ memoDb: makeMemoDb({ [addr]: posts }), addr })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getPost('b'.repeat(64)).likeCount, 3)
|
||||
})
|
||||
|
||||
test('load throws when no memo db client is provided', async () => {
|
||||
const page = new ProfilePage({ addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' })
|
||||
|
||||
await assert.rejects(
|
||||
() => page.load(),
|
||||
/requires a memo db client/
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Unit tests for the recent feed page controller.
|
||||
|
||||
The recent feed page is a thin, testable wrapper around the MemoDb client.
|
||||
It loads the paginated list of recent posts and exposes each post so the
|
||||
view can render properties such as the like count.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const RecentFeedPage = require('../../src/services/recent-feed-page')
|
||||
|
||||
function makeMemoDb (posts, pagination) {
|
||||
return {
|
||||
async getRecentPosts ({ limit, offset }) {
|
||||
return { posts, pagination }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('load returns posts with like counts', async () => {
|
||||
const posts = [
|
||||
{ txid: 'a'.repeat(64), likeCount: 17 },
|
||||
{ txid: 'b'.repeat(64), likeCount: 3 }
|
||||
]
|
||||
const page = new RecentFeedPage({ memoDb: makeMemoDb(posts, { total: 2 }) })
|
||||
|
||||
const result = await page.load()
|
||||
|
||||
assert.deepEqual(result.posts, posts)
|
||||
assert.equal(result.pagination.total, 2)
|
||||
})
|
||||
|
||||
test('getPost returns the like count for a loaded post', async () => {
|
||||
const posts = [{ txid: 'a'.repeat(64), likeCount: 17 }]
|
||||
const page = new RecentFeedPage({ memoDb: makeMemoDb(posts, {}) })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getPost('a'.repeat(64)).likeCount, 17)
|
||||
})
|
||||
|
||||
test('load forwards limit and offset to the memo db client', async () => {
|
||||
const calls = []
|
||||
const memoDb = {
|
||||
async getRecentPosts (params) {
|
||||
calls.push(params)
|
||||
return { posts: [], pagination: {} }
|
||||
}
|
||||
}
|
||||
const page = new RecentFeedPage({ memoDb })
|
||||
|
||||
await page.load({ limit: 10, offset: 20 })
|
||||
|
||||
assert.deepEqual(calls, [{ limit: 10, offset: 20 }])
|
||||
})
|
||||
|
||||
test('load throws when no memo db client is provided', async () => {
|
||||
const page = new RecentFeedPage({})
|
||||
|
||||
await assert.rejects(
|
||||
() => page.load(),
|
||||
/requires a memo db client/
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Unit tests for the thread page controller.
|
||||
|
||||
The thread page loads a post and its nested replies from the MemoDb client.
|
||||
The like count returned for the root post and for every reply must be
|
||||
preserved so the view can display it.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
|
||||
function makeMemoDb (threads) {
|
||||
return {
|
||||
async getPostThread (txid) {
|
||||
return threads[txid]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('load preserves the root post like count', async () => {
|
||||
const txid = 'a'.repeat(64)
|
||||
const memoDb = makeMemoDb({
|
||||
[txid]: { post: { txid, likeCount: 17, replies: [] } }
|
||||
})
|
||||
const page = new ThreadPage({ memoDb })
|
||||
|
||||
await page.load(txid)
|
||||
|
||||
assert.equal(page.rootPost.likeCount, 17)
|
||||
})
|
||||
|
||||
test('load flattens replies and preserves their like counts', async () => {
|
||||
const txid = 'a'.repeat(64)
|
||||
const replyTxid = 'd'.repeat(64)
|
||||
const memoDb = makeMemoDb({
|
||||
[txid]: {
|
||||
post: {
|
||||
txid,
|
||||
likeCount: 17,
|
||||
replies: [{ txid: replyTxid, likeCount: 5, replies: [] }]
|
||||
}
|
||||
}
|
||||
})
|
||||
const page = new ThreadPage({ memoDb })
|
||||
|
||||
await page.load(txid)
|
||||
|
||||
assert.equal(page.getPost(replyTxid).likeCount, 5)
|
||||
})
|
||||
|
||||
test('load recursively flattens nested replies', async () => {
|
||||
const txid = 'a'.repeat(64)
|
||||
const replyTxid = 'd'.repeat(64)
|
||||
const nestedTxid = 'n'.repeat(64)
|
||||
const memoDb = makeMemoDb({
|
||||
[txid]: {
|
||||
post: {
|
||||
txid,
|
||||
likeCount: 17,
|
||||
replies: [{
|
||||
txid: replyTxid,
|
||||
likeCount: 5,
|
||||
replies: [{ txid: nestedTxid, likeCount: 9, replies: [] }]
|
||||
}]
|
||||
}
|
||||
}
|
||||
})
|
||||
const page = new ThreadPage({ memoDb })
|
||||
|
||||
await page.load(txid)
|
||||
|
||||
assert.equal(page.getPost(replyTxid).likeCount, 5)
|
||||
assert.equal(page.getPost(nestedTxid).likeCount, 9)
|
||||
})
|
||||
|
||||
test('load throws when no memo db client is provided', async () => {
|
||||
const page = new ThreadPage({})
|
||||
|
||||
await assert.rejects(
|
||||
() => page.load('a'.repeat(64)),
|
||||
/requires a memo db client/
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user