From 236bb5a9fa4a3a64aa2ffbdcb7a4102e60e7ae05 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 07:11:08 -0700 Subject: [PATCH 1/6] Update specifier briefing for task search By specifier. --- specifier-prompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index e65f17a..adf0196 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -368,5 +368,5 @@ At the end of each session, update this file: - Note the current `master` HEAD commit. - State the next feature to work on. -Current `master` HEAD: `ac46807` (task `mute-user` — P4.1/P4.2 mute/unmute user — merged from the pipeline and verified: client build/test/lint, DB test/lint, and indexer test/lint all pass). -Next action: **spec P5.1** — Send money with memo (`0x6d24`). +Current `master` HEAD: `d11365c057` (task `search` — P6.4 Search — spec committed and handed off to coder). +Next action: **wait for the coder → refactorer → architect pipeline to complete task `search`**, then merge the architect branch into `master` and verify the affected components (client + DB). Send money (P5.1) was skipped by user decision (no clear use case). From 96956f3ef3d64a01bb6578f1fb41ea15e18c1d57 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 09:25:52 -0700 Subject: [PATCH 2/6] Mark Search (P6.4) shipped; update briefing By specifier. --- specifier-prompt.md | 4 ++-- specs/feature-backlog.md | 14 ++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index adf0196..c042894 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -368,5 +368,5 @@ At the end of each session, update this file: - Note the current `master` HEAD commit. - State the next feature to work on. -Current `master` HEAD: `d11365c057` (task `search` — P6.4 Search — spec committed and handed off to coder). -Next action: **wait for the coder → refactorer → architect pipeline to complete task `search`**, then merge the architect branch into `master` and verify the affected components (client + DB). Send money (P5.1) was skipped by user decision (no clear use case). +Current `master` HEAD: `20328ea` (task `search` — P6.4 Search — merged from the pipeline and verified: client build/test/lint and DB test/lint all pass). +Next action: **pick the next feature** from the backlog. Send money (P5.1) was skipped by user decision (no clear use case). Remaining candidates: P5.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags, P6.6 Following feed. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 8b3c5ec..8a877c2 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -205,7 +205,7 @@ Polls require a new data model and rendering. The indexer has no handler yet. | 6.1 | Repost a memo | C, I, D | `0x6d0b` is marked *planned* in the protocol | | 6.2 | Ranked feed | C, D | memo.cash "ranked" post ordering | | 6.3 | Notifications | C, D | replies / likes / follows to my posts | -| 6.4 | Search | C, D | posts / profiles / topics — spec drafted (task `search`), in pipeline | +| 6.4 | Search | C, D | posts / profiles / topics — ✅ shipped (task `search`) | | 6.5 | Tags / hashtags | C, D | link + filter by tag | | 6.6 | Following feed | C, D | feed filtered to followed users | @@ -213,15 +213,9 @@ Polls require a new data model and rendering. The indexer has no handler yet. ## Suggested next spec -**Search (P6.4)** — spec drafted (task `search`), in pipeline: -- `psf-memo-db`: new `GET /search?q=&limit=&offset=` returning - `{ posts, profiles, pagination }`. A `SearchQuery` adapter scans `postsDb` - (top-level posts only, replies excluded via `postParentsDb`), `namesDb` - (profile name), and `profilesDb` (profile bio) with case-insensitive - substring matching; a `SearchAll` use case; a `/search` router + controller. -- `psf-memo-client`: a `/search` page with a search box; `MemoDb.search(q)`; - a `SearchPage` service; render matching posts like the feed and matching - profiles like recent-profiles. +**Search (P6.4)** — ✅ shipped (task `search`), merged from the pipeline and verified (client build/test/lint, DB test/lint): +- `psf-memo-db`: `GET /search?q=&limit=&offset=` returning `{ posts, profiles, pagination }`. A `SearchQuery` adapter scans `postsDb` (top-level posts only, replies excluded via `postParentsDb`), `namesDb` (profile name), and `profilesDb` (profile bio) with case-insensitive substring matching; a `SearchAll` use case; a `/search` router + controller. +- `psf-memo-client`: a `/search` page with a search box; `MemoDb.search(q)`; a `SearchPage` service; render matching posts like the feed and matching profiles like recent-profiles. - Empty queries and no-match queries both return an empty result set (no error). - Spec: `psf-memo-client/specs/search.feature`. From 6d94372438dcb30ee87b89df86bafe0a586c0214 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 10:21:28 -0700 Subject: [PATCH 3/6] Spec Following feed (P6.6) By specifier. --- psf-memo-client/specs/following-feed.feature | 67 ++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 psf-memo-client/specs/following-feed.feature diff --git a/psf-memo-client/specs/following-feed.feature b/psf-memo-client/specs/following-feed.feature new file mode 100644 index 0000000..f2df3e8 --- /dev/null +++ b/psf-memo-client/specs/following-feed.feature @@ -0,0 +1,67 @@ +# Scenarios: Following Feed - 1, Following Feed - 2, Following Feed - 3, Following Feed - 4, Following Feed - 5, Following Feed - 6, Following Feed - 7 +# +# The Following feed shows top-level posts (replies excluded) authored only by +# profiles the viewer follows, newest first. The viewer's own posts are never +# shown, nor are posts from people the viewer does not follow. It is a read-only +# feature: the psf-memo-db API joins the follows index with the posts index and +# returns a paginated page. The client identifies the viewer from the wallet and +# renders the page like the recent feed; it broadcasts no Memo action. +Feature: Following Feed + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d + + Scenario Outline: Following Feed - 1 the feed shows posts from followed profiles only + Given my wallet follows the address + Given the psf-memo-db API serves a post with txid authored by the address with text + Given the psf-memo-db API serves a post with txid authored by the address with text + When I open the Following feed + Then the feed shows the post with text + And the feed does not show the post with text + + Examples: + | followee | txid_a | text_a | other | txid_b | text_b | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 1111111111111111111111111111111111111111111111111111111111111111 | hello from alice | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 2222222222222222222222222222222222222222222222222222222222222222 | hello from bob | + | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 3333333333333333333333333333333333333333333333333333333333333333 | bitcoin rocks | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 4444444444444444444444444444444444444444444444444444444444444444 | memo is fun | + + Scenario: Following Feed - 2 the feed does not include the viewer's own posts + Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "hello from alice" + Given the psf-memo-db API serves a post with txid 5555555555555555555555555555555555555555555555555555555555555555 authored by my wallet address with text "my own post" + When I open the Following feed + Then the feed shows the post with text "hello from alice" + And the feed does not show the post with text "my own post" + + Scenario: Following Feed - 3 the feed orders followed posts newest first + Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API serves a post with txid 0000000000000000000000000000000000000000000000000000000000000001 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "older post" at block height 100 + Given the psf-memo-db API serves a post with txid 0000000000000000000000000000000000000000000000000000000000000002 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "newer post" at block height 300 + When I open the Following feed + Then the feed shows the post with txid 0000000000000000000000000000000000000000000000000000000000000002 before the post with txid 0000000000000000000000000000000000000000000000000000000000000001 + + Scenario: Following Feed - 4 the feed excludes replies + Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "top level post" + Given the psf-memo-db API serves a reply with txid 6666666666666666666666666666666666666666666666666666666666666666 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 with text "a reply" + When I open the Following feed + Then the feed shows the post with text "top level post" + And the feed does not show the post with text "a reply" + + Scenario: Following Feed - 5 the feed paginates followed posts + Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post one" + Given the psf-memo-db API serves a post with txid 2222222222222222222222222222222222222222222222222222222222222222 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post two" + Given the psf-memo-db API serves a post with txid 3333333333333333333333333333333333333333333333333333333333333333 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post three" + When I open the Following feed with page size 2 + Then the feed shows 2 posts + And the feed can load more posts + + Scenario: Following Feed - 6 the feed shows a message when the viewer follows no one + Given I follow no one + When I open the Following feed + Then the feed shows a message that I am not following anyone + + Scenario: Following Feed - 7 the feed is empty when followed profiles have no posts + Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + When I open the Following feed + Then the feed shows no posts From 9f5357ba291e115439b166f7a027c3890abf06aa Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 10:22:14 -0700 Subject: [PATCH 4/6] Record following-feed handoff in backlog and briefing By specifier. --- specifier-prompt.md | 4 ++-- specs/feature-backlog.md | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index c042894..6d0b574 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -368,5 +368,5 @@ At the end of each session, update this file: - Note the current `master` HEAD commit. - State the next feature to work on. -Current `master` HEAD: `20328ea` (task `search` — P6.4 Search — merged from the pipeline and verified: client build/test/lint and DB test/lint all pass). -Next action: **pick the next feature** from the backlog. Send money (P5.1) was skipped by user decision (no clear use case). Remaining candidates: P5.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags, P6.6 Following feed. +Current `master` HEAD: `6d94372` (task `following-feed` — P6.6 Following Feed — spec'd and handed off to the coder. Search `20328ea`/`96956f3` was already verified.). +Next action: **await the coder → refactorer → architect pipeline for task `following-feed`**, then merge the architect branch into `master` and verify the affected components (client build/test/lint, DB test/lint). Send money (P5.1) was skipped by user decision (no clear use case). Future candidates after following-feed lands: P5.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 8a877c2..51bec1e 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -207,12 +207,17 @@ Polls require a new data model and rendering. The indexer has no handler yet. | 6.3 | Notifications | C, D | replies / likes / follows to my posts | | 6.4 | Search | C, D | posts / profiles / topics — ✅ shipped (task `search`) | | 6.5 | Tags / hashtags | C, D | link + filter by tag | -| 6.6 | Following feed | C, D | feed filtered to followed users | +| 6.6 | Following feed | C, D | feed filtered to followed users — 🔜 in pipeline (task `following-feed`) | --- ## Suggested next spec +**Following feed (P6.6)** — 🔜 spec'd and handed off to the coder (task `following-feed`, commit `6d94372`). Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts. +- `psf-memo-db`: new `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index. Empty following or no posts → empty result set. +- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls the new endpoint (a `MemoDb.getFollowingFeed`), and renders posts like the recent feed; shows "you aren't following anyone" when following nobody. +- Spec: `psf-memo-client/specs/following-feed.feature`. + **Search (P6.4)** — ✅ shipped (task `search`), merged from the pipeline and verified (client build/test/lint, DB test/lint): - `psf-memo-db`: `GET /search?q=&limit=&offset=` returning `{ posts, profiles, pagination }`. A `SearchQuery` adapter scans `postsDb` (top-level posts only, replies excluded via `postParentsDb`), `namesDb` (profile name), and `profilesDb` (profile bio) with case-insensitive substring matching; a `SearchAll` use case; a `/search` router + controller. - `psf-memo-client`: a `/search` page with a search box; `MemoDb.search(q)`; a `SearchPage` service; render matching posts like the feed and matching profiles like recent-profiles. From 6d1bd148e97b6a47cce52eb019e6f646cf7615bf Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 10:30:54 -0700 Subject: [PATCH 5/6] Implement Following feed Add psf-memo-db endpoint GET /posts/following/:addr that returns top-level posts from followed profiles, excluding replies and the viewer's own posts, newest first and paginated. Add PostQuery.scanFollowingFeedTxidsAndCount, ListFollowingFeed use case, and REST controller/router wiring. Add psf-memo-client Following feed page, route /posts/following, nav link, MemoDb.getFollowingFeed, and FollowingFeedPage service. Update acceptance handlers for the new following-feed.feature. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 192 +++++++++++++++++- .../app-body/following-feed/index.js | 180 ++++++++++++++++ .../src/components/app-body/index.js | 2 + .../src/components/nav-menu/index.js | 8 + .../src/services/following-feed-page.js | 54 +++++ psf-memo-client/src/services/memo-db.js | 4 + .../test/unit/following-feed-page.test.js | 145 +++++++++++++ psf-memo-db/src/adapters/post-query.js | 34 ++++ .../controllers/rest-api/posts/controller.js | 36 ++++ .../src/controllers/rest-api/posts/index.js | 1 + psf-memo-db/src/use-cases/index.js | 6 + .../src/use-cases/list-following-feed.js | 55 +++++ .../test/unit/adapters/post-query.unit.js | 104 ++++++++++ .../unit/controllers/posts.controller.unit.js | 25 +++ .../use-cases/list-following-feed.unit.js | 116 +++++++++++ 15 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 psf-memo-client/src/components/app-body/following-feed/index.js create mode 100644 psf-memo-client/src/services/following-feed-page.js create mode 100644 psf-memo-client/test/unit/following-feed-page.test.js create mode 100644 psf-memo-db/src/use-cases/list-following-feed.js create mode 100644 psf-memo-db/test/unit/use-cases/list-following-feed.unit.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 7b98301..6cfae7a 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -35,6 +35,7 @@ const LikeTipPage = require('../../src/services/like-tip-page') const MemoFollow = require('../../src/services/memo-follow') const MemoMute = require('../../src/services/memo-mute') const RecentFeedPage = require('../../src/services/recent-feed-page') +const FollowingFeedPage = require('../../src/services/following-feed-page') const ProfilePage = require('../../src/services/profile-page') const ThreadPage = require('../../src/services/thread-page') const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') @@ -188,6 +189,7 @@ function makeMemoDb () { const threads = {} const followState = {} const muteState = {} + const replyTxids = new Set() const topics = [] const topicPosts = {} const topicCounts = new Map() @@ -206,6 +208,10 @@ function makeMemoDb () { addPost (post) { posts.push(post) }, + addReply (reply) { + replyTxids.add(reply.txid) + posts.push(reply) + }, addSearchPost (post) { searchPosts.push(post) }, @@ -234,7 +240,7 @@ function makeMemoDb () { threads[txid] = thread }, setFollowState (followerAddr, followeeAddr, following) { - followState[`${followerAddr}:${followeeAddr}`] = following + followState[`${followerAddr}|${followeeAddr}`] = following }, setMuteState (muterAddr, muteeAddr, muted) { muteState[`${muterAddr}:${muteeAddr}`] = muted @@ -285,7 +291,7 @@ function makeMemoDb () { return threads[txid] || { post: null } }, async getFollowState (followerAddr, followeeAddr) { - return followState[`${followerAddr}:${followeeAddr}`] || false + return followState[`${followerAddr}|${followeeAddr}`] || false }, async getMuteState (muterAddr, muteeAddr) { return muteState[`${muterAddr}:${muteeAddr}`] || false @@ -302,6 +308,19 @@ function makeMemoDb () { 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 } } + }, + async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) { + const followees = new Set() + for (const [key, following] of Object.entries(followState)) { + if (!following) continue + const [follower, followee] = key.split('|') + if (follower === addr) followees.add(followee) + } + const all = posts + .filter((p) => followees.has(p.addr) && p.addr !== addr && !replyTxids.has(p.txid)) + .sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0)) + const page = all.slice(offset, offset + limit) + return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } } } } @@ -342,6 +361,7 @@ function createWorld () { // Read-only page controllers backed by the fake psf-memo-db API. world.recentFeedPage = new RecentFeedPage({ memoDb }) + world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet }) world.profilePage = new ProfilePage({ memoDb }) world.threadPage = new ThreadPage({ memoDb }) world.topicDiscoveryPage = new TopicDiscoveryPage({ @@ -434,6 +454,15 @@ function resolveParam (value, example) { return String(value).trim() } +// Resolve a step value that may be a quoted literal or a placeholder. +function resolveText (value, example) { + const trimmed = String(value).trim() + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1) + } + return resolveParam(value, example) +} + // 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) @@ -2204,6 +2233,165 @@ const handlers = [ throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`) } } + }, + { + name: 'wallet follows address', + pattern: /^my wallet follows the address (.+)$/, + run (m, example, world) { + const followee = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setFollowState(myAddr, followee, true) + } + }, + { + name: 'API serves post with txid address text and block height', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+) at block height (\d+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const text = resolveText(m[3], example) + const blockHeight = parseInt(m[4], 10) + world.memoDb.addPost({ txid, addr, text, blockHeight }) + } + }, + { + name: 'API serves post with txid address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const text = resolveText(m[3], example) + world.memoDb.addPost({ txid, addr, text, blockHeight: 100 }) + } + }, + { + name: 'API serves post with txid my address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by my wallet address with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + const text = resolveText(m[2], example) + world.memoDb.addPost({ txid, addr: myAddr, text, blockHeight: 100 }) + } + }, + { + name: 'API serves reply with txid parent and text', + pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const parentTxid = resolveParam(m[2], example) + const text = resolveText(m[3], example) + world.memoDb.addReply({ txid, parentTxid, text, addr: 'bitcoincash:reply-author', blockHeight: 100 }) + } + }, + { + name: 'follow no one', + pattern: /^I follow no one$/, + run (m, example, world) { + const myAddr = world.wallet.walletInfo.cashAddress + for (const key of Object.keys(world.memoDb.followState)) { + if (key.startsWith(`${myAddr}|`)) { + world.memoDb.followState[key] = false + } + } + } + }, + { + name: 'open following feed', + pattern: /^I open the Following feed$/, + async run (m, example, world) { + await world.followingFeedPage.load() + world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH + } + }, + { + name: 'open following feed with page size', + pattern: /^I open the Following feed with page size (\d+)$/, + async run (m, example, world) { + const limit = parseInt(m[1], 10) + await world.followingFeedPage.load({ limit }) + world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH + } + }, + { + name: 'feed shows post with text', + pattern: /^the feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.followingFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Following feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'feed does not show post with text', + pattern: /^the feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.followingFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Following feed unexpectedly shows a post with text "${expected}".`) + } + } + }, + { + name: 'feed shows post txid before txid', + pattern: /^the feed shows the post with txid (.+) before the post with txid (.+)$/, + run (m, example, world) { + const firstTxid = resolveParam(m[1], example) + const secondTxid = resolveParam(m[2], example) + const posts = world.followingFeedPage.posts + const firstIndex = posts.findIndex((p) => p.txid === firstTxid) + const secondIndex = posts.findIndex((p) => p.txid === secondTxid) + if (firstIndex === -1) { + throw new Error(`Following feed does not show post ${firstTxid}.`) + } + if (secondIndex === -1) { + throw new Error(`Following feed does not show post ${secondTxid}.`) + } + if (firstIndex >= secondIndex) { + throw new Error(`Expected post ${firstTxid} before ${secondTxid}, but found at indices ${firstIndex}, ${secondIndex}.`) + } + } + }, + { + name: 'feed shows N posts', + pattern: /^the feed shows (\d+) posts$/, + run (m, example, world) { + const expected = parseInt(m[1], 10) + const actual = world.followingFeedPage.posts.length + if (actual !== expected) { + throw new Error(`Expected ${expected} posts in following feed, got ${actual}.`) + } + } + }, + { + name: 'feed can load more posts', + pattern: /^the feed can load more posts$/, + run (m, example, world) { + if (!world.followingFeedPage.canLoadMore()) { + throw new Error('Expected following feed to have more posts, but pagination says there are none.') + } + } + }, + { + name: 'feed shows no posts', + pattern: /^the feed shows no posts$/, + run (m, example, world) { + if (world.followingFeedPage.posts.length !== 0) { + throw new Error(`Expected no posts in following feed, got ${world.followingFeedPage.posts.length}.`) + } + } + }, + { + name: 'feed shows not following anyone message', + pattern: /^the feed shows a message that I am not following anyone$/, + run (m, example, world) { + if (!world.followingFeedPage.emptyBecauseNoFollows) { + throw new Error('Expected following feed to show the not-following-anyone message.') + } + } } ] diff --git a/psf-memo-client/src/components/app-body/following-feed/index.js b/psf-memo-client/src/components/app-body/following-feed/index.js new file mode 100644 index 0000000..dd9bae3 --- /dev/null +++ b/psf-memo-client/src/components/app-body/following-feed/index.js @@ -0,0 +1,180 @@ +/* + Display the Following feed: top-level posts from profiles the viewer follows. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import FollowingFeedPage from '../../../services/following-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 FollowingFeed (props) { + const { appData } = props + const wallet = appData?.wallet + + 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 [emptyBecauseNoFollows, setEmptyBecauseNoFollows] = useState(false) + + const openThread = (txid) => { + setThreadTxid(txid) + setShowThreadModal(true) + } + + const closeThread = () => { + setShowThreadModal(false) + setThreadTxid(null) + } + + useEffect(() => { + const loadFeed = async () => { + setLoading(true) + setError(null) + setProfiles({}) + setEmptyBecauseNoFollows(false) + + try { + const memoDb = new MemoDb() + const page = new FollowingFeedPage({ memoDb, wallet }) + 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) + setEmptyBecauseNoFollows(data.emptyBecauseNoFollows === true) + } catch (err) { + setError(err.message || 'Failed to load following feed') + setPosts([]) + setProfiles({}) + setPagination(null) + setEmptyBecauseNoFollows(false) + } + + setLoading(false) + } + + loadFeed() + }, [offset, wallet]) + + 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 ( + + + +
+

Following

+

Posts from profiles you follow.

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

+ {error} +

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

+ {emptyBecauseNoFollows + ? 'You are not following anyone.' + : 'No posts from profiles you follow.'} +

+ )} + + {!loading && !error && posts.length > 0 && ( +
+ {posts.map((post) => ( + openThread(post.txid)} + showFooterMeta + /> + ))} +
+ )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} + +
+ + +
+ ) +} + +export default FollowingFeed diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 884009c..d879bbc 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -34,6 +34,7 @@ import Account from './account' import Topics from './topics' import TopicFeed from './topic-feed' import Search from './search' +import FollowingFeed from './following-feed' function AppBody (props) { // Dependency injection through props @@ -54,6 +55,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 7baa082..4d42093 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -86,6 +86,14 @@ function NavMenu (props) { Search + + Following + + post.txid === txid) || null + } +} + +FollowingFeedPage.FOLLOWING_FEED_PATH = FOLLOWING_FEED_PATH + +module.exports = FollowingFeedPage diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 02f7602..9813888 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -153,6 +153,10 @@ class MemoDb { throw err } } + + async getFollowingFeed (addr, opts = {}) { + return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts) + } } export default MemoDb diff --git a/psf-memo-client/test/unit/following-feed-page.test.js b/psf-memo-client/test/unit/following-feed-page.test.js new file mode 100644 index 0000000..9329145 --- /dev/null +++ b/psf-memo-client/test/unit/following-feed-page.test.js @@ -0,0 +1,145 @@ +/* + Unit tests for the following feed page controller. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const FollowingFeedPage = require('../../src/services/following-feed-page') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + +function makeWallet () { + return { + walletInfo: { cashAddress: MY_ADDRESS } + } +} + +function makeMemoDb (posts, pagination) { + return { + async getFollowingFeed (addr, { limit, offset }) { + return { posts, pagination } + } + } +} + +test('load returns posts from the following feed', async () => { + const posts = [ + { txid: 'a'.repeat(64), text: 'hello from followee' }, + { txid: 'b'.repeat(64), text: 'another post' } + ] + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.posts, posts) + assert.equal(result.pagination.total, 2) + assert.equal(result.emptyBecauseNoFollows, false) +}) + +test('load marks empty feed at offset zero as no-follows state', async () => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb([], { total: 0 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.posts, []) + assert.equal(result.emptyBecauseNoFollows, true) +}) + +test('load does not mark empty paginated page as no-follows state', async () => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb([], { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load({ offset: 100 }) + + assert.equal(result.emptyBecauseNoFollows, false) +}) + +test('getPost returns a loaded post by txid', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello' }] + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, {}), + wallet: makeWallet() + }) + + await page.load() + + assert.equal(page.getPost('a'.repeat(64)).text, 'hello') +}) + +test('load forwards limit and offset to the memo db client', async () => { + const calls = [] + const memoDb = { + async getFollowingFeed (addr, params) { + calls.push({ addr, params }) + return { posts: [], pagination: {} } + } + } + const page = new FollowingFeedPage({ memoDb, wallet: makeWallet() }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }]) +}) + +test('load defaults limit to 100 and offset to 0', async () => { + const calls = [] + const memoDb = { + async getFollowingFeed (addr, params) { + calls.push({ addr, params }) + return { posts: [], pagination: {} } + } + } + const page = new FollowingFeedPage({ memoDb, wallet: makeWallet() }) + + await page.load() + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }]) +}) + +test('load throws when no memo db client is provided', async () => { + const page = new FollowingFeedPage({ wallet: makeWallet() }) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) + +test('load throws when no wallet is provided', async () => { + const page = new FollowingFeedPage({ memoDb: makeMemoDb([], {}) }) + + await assert.rejects( + () => page.load(), + /requires an authenticated wallet/ + ) +}) + +test('canLoadMore reflects pagination.hasMore', async () => { + const pageMore = new FollowingFeedPage({ + memoDb: makeMemoDb([], { hasMore: true }), + wallet: makeWallet() + }) + await pageMore.load() + assert.equal(pageMore.canLoadMore(), true) + + const pageDone = new FollowingFeedPage({ + memoDb: makeMemoDb([], { hasMore: false }), + wallet: makeWallet() + }) + await pageDone.load() + assert.equal(pageDone.canLoadMore(), false) +}) + +test('exposes the following feed path', () => { + assert.equal(FollowingFeedPage.FOLLOWING_FEED_PATH, '/posts/following') +}) diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index b718d68..d515f1e 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -56,6 +56,7 @@ class PostQuery { this.topLevelPostTxids = this.topLevelPostTxids.bind(this) this.loadReplyTxids = this.loadReplyTxids.bind(this) this.isReply = this.isReply.bind(this) + this.scanFollowingFeedTxidsAndCount = this.scanFollowingFeedTxidsAndCount.bind(this) } static padHeight (height) { @@ -306,6 +307,39 @@ class PostQuery { return count } + + // Iterate the global postHeights index newest first, returning only top-level + // posts (replies excluded) authored by addresses the viewer follows, excluding + // the viewer's own posts. Returns both the page txids and total matching count. + async scanFollowingFeedTxidsAndCount (viewerAddr, followingAddrs, { limit, offset }) { + const followeeSet = new Set(followingAddrs.filter((addr) => addr !== viewerAddr)) + const replyTxids = await this.loadReplyTxids() + const txids = [] + let skipped = 0 + let total = 0 + + for await (const [key, value] of this.postHeightsDb.iterator({ reverse: true })) { + const txid = this.txidFromPostHeight(key, value) + if (replyTxids.has(txid)) continue + + const post = await this.getPostOrNull(txid) + if (!post) continue + if (!followeeSet.has(post.addr)) continue + + total++ + + if (skipped < offset) { + skipped++ + continue + } + + if (txids.length < limit) { + txids.push(txid) + } + } + + return { txids, total } + } } export default PostQuery diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 51e7726..451fc98 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -17,6 +17,7 @@ class PostsRESTControllerLib { this.getRecentPosts = this.getRecentPosts.bind(this) this.getPostsByAddr = this.getPostsByAddr.bind(this) + this.getFollowingFeed = this.getFollowingFeed.bind(this) this.getPostThread = this.getPostThread.bind(this) this.handleError = this.handleError.bind(this) } @@ -100,6 +101,41 @@ class PostsRESTControllerLib { } } + /** + * @api {get} /posts/following/:addr List posts from followed profiles + * @apiPermission public + * @apiName GetFollowingFeed + * @apiGroup REST Posts + * + * @apiDescription Returns top-level posts from profiles the viewer follows (replies and the viewer's own posts excluded), sorted by block height (newest first). + * + * @apiParam {String} addr Viewer cash address + * @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/posts/following/bitcoincash:q...?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 {Number} posts.likeCount Number of likes for this post + * @apiSuccess {Object} pagination Pagination metadata + */ + async getFollowingFeed (ctx) { + try { + const { addr } = ctx.params + const { limit, offset } = ctx.query + ctx.body = await this.useCases.listFollowingFeed.execute({ addr, limit, offset }) + } catch (err) { + this.handleError(ctx, err) + } + } + async getPostThread (ctx) { try { const { txid } = ctx.params diff --git a/psf-memo-db/src/controllers/rest-api/posts/index.js b/psf-memo-db/src/controllers/rest-api/posts/index.js index 88c35e0..713e087 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/index.js +++ b/psf-memo-db/src/controllers/rest-api/posts/index.js @@ -26,6 +26,7 @@ class PostsRouter { attach (app) { this.router.get('/recent', this.postsRESTController.getRecentPosts) this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr) + this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed) this.router.get('/:txid/thread', this.postsRESTController.getPostThread) app.use(this.router.routes()) app.use(this.router.allowedMethods()) diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index 2022e09..61ff709 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -5,6 +5,7 @@ import ListRecentProfiles from './list-recent-profiles.js' import ListRecentPosts from './list-recent-posts.js' import ListPostsByAddr from './list-posts-by-addr.js' +import ListFollowingFeed from './list-following-feed.js' import GetPostThread from './get-post-thread.js' import FollowState from './follow-state.js' import ListFollowing from './list-following.js' @@ -33,6 +34,7 @@ class UseCases { this.listRecentProfiles = null this.listRecentPosts = null this.listPostsByAddr = null + this.listFollowingFeed = null this.getPostThread = null this.followState = null this.listFollowing = null @@ -62,6 +64,10 @@ class UseCases { adapters: this.adapters }) + this.listFollowingFeed = new ListFollowingFeed({ + adapters: this.adapters + }) + this.getPostThread = new GetPostThread({ adapters: this.adapters }) diff --git a/psf-memo-db/src/use-cases/list-following-feed.js b/psf-memo-db/src/use-cases/list-following-feed.js new file mode 100644 index 0000000..9d3ea30 --- /dev/null +++ b/psf-memo-db/src/use-cases/list-following-feed.js @@ -0,0 +1,55 @@ +/* + Use case: list top-level posts from profiles the viewer follows, newest first. + + Joins the follows index with the global postHeights index. Replies and the + viewer's own posts are excluded. Results are paginated with limit/offset. +*/ + +import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js' + +class ListFollowingFeed { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error('Adapters required when instantiating ListFollowingFeed use case.') + } + if (!this.adapters.postQuery) { + throw new Error('postQuery adapter required for ListFollowingFeed use case.') + } + if (!this.adapters.followQuery) { + throw new Error('followQuery adapter required for ListFollowingFeed use case.') + } + this.execute = this.execute.bind(this) + } + + parseAddr (addr) { + if (!addr || typeof addr !== 'string') { + const err = new Error('addr is required') + err.status = 400 + throw err + } + return addr + } + + async execute (inObj = {}) { + const addr = this.parseAddr(inObj.addr) + const limit = parseLimit(inObj.limit) + const offset = parseOffset(inObj.offset) + + const followingAddrs = await this.adapters.followQuery.listFollowing(addr) + const { txids, total } = await this.adapters.postQuery.scanFollowingFeedTxidsAndCount( + addr, + followingAddrs, + { limit, offset } + ) + const [posts, replyCounts, likeCounts] = await Promise.all([ + this.adapters.postQuery.loadPostsByTxids(txids), + this.adapters.postQuery.countRepliesForTxids(txids), + this.adapters.postQuery.countLikesForTxids(txids) + ]) + + return assemblePostPage({ posts, replyCounts, likeCounts, total, limit, offset }) + } +} + +export default ListFollowingFeed diff --git a/psf-memo-db/test/unit/adapters/post-query.unit.js b/psf-memo-db/test/unit/adapters/post-query.unit.js index a7c678b..00d0b75 100644 --- a/psf-memo-db/test/unit/adapters/post-query.unit.js +++ b/psf-memo-db/test/unit/adapters/post-query.unit.js @@ -270,6 +270,110 @@ describe('#PostQuery', () => { }) }) + describe('#scanFollowingFeedTxidsAndCount', () => { + const viewerAddr = 'bitcoincash:viewer' + const followeeA = 'bitcoincash:followee-a' + const followeeB = 'bitcoincash:followee-b' + + it('should return posts only from followed addresses excluding the viewer', async () => { + async function * mockHeights () { + yield ['000000600300:post-a', { txid: 'post-a' }] + yield ['000000600250:post-viewer', { txid: 'post-viewer' }] + yield ['000000600200:post-b', { txid: 'post-b' }] + yield ['000000600100:post-other', { txid: 'post-other' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + const map = { + 'post-a': { addr: followeeA, text: 'a' }, + 'post-viewer': { addr: viewerAddr, text: 'mine' }, + 'post-b': { addr: followeeB, text: 'b' }, + 'post-other': { addr: 'bitcoincash:other', text: 'other' } + } + return map[txid] + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA, followeeB], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a', 'post-b']) + assert.equal(result.total, 2) + }) + + it('should exclude replies from the following feed', async () => { + async function * mockParents () { + yield ['reply-a', { parentTxid: 'post-a', childTxid: 'reply-a' }] + } + async function * mockHeights () { + yield ['000000600300:reply-a', { txid: 'reply-a' }] + yield ['000000600200:post-a', { txid: 'post-a' }] + } + postParentsDb.iterator.returns(mockParents()) + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a']) + assert.equal(result.total, 1) + }) + + it('should apply limit and offset', async () => { + async function * mockHeights () { + yield ['000000600400:post-a', { txid: 'post-a' }] + yield ['000000600300:post-b', { txid: 'post-b' }] + yield ['000000600200:post-c', { txid: 'post-c' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 1, offset: 1 } + ) + + assert.deepEqual(result.txids, ['post-b']) + assert.equal(result.total, 3) + }) + + it('should skip missing posts', async () => { + async function * mockHeights () { + yield ['000000600200:post-a', { txid: 'post-a' }] + yield ['000000600100:missing', { txid: 'missing' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'missing') { + const err = new Error('not found') + err.notFound = true + throw err + } + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a']) + assert.equal(result.total, 1) + }) + }) + describe('#isReply', () => { it('should return true when the txid has a parent post', async () => { postParentsDb.get.withArgs('reply-1').resolves({ parentTxid: 'parent-1' }) diff --git a/psf-memo-db/test/unit/controllers/posts.controller.unit.js b/psf-memo-db/test/unit/controllers/posts.controller.unit.js index d148356..3bfbb89 100644 --- a/psf-memo-db/test/unit/controllers/posts.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/posts.controller.unit.js @@ -22,6 +22,12 @@ describe('#PostsRESTController', () => { posts: [{ txid: 'tx2', addr: 'addr-a', blockHeight: 600100 }], pagination: { limit: 100, offset: 0, total: 1, hasMore: false } }) + }, + listFollowingFeed: { + execute: sandbox.stub().resolves({ + posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }], + pagination: { limit: 100, offset: 0, total: 1, hasMore: false } + }) } } }) @@ -60,4 +66,23 @@ describe('#PostsRESTController', () => { assert.equal(ctx.body.posts.length, 1) assert.equal(ctx.body.posts[0].txid, 'tx2') }) + + it('should return following feed from use case', async () => { + const ctx = { + params: { addr: 'addr-b' }, + query: { limit: '25', offset: '0' }, + body: null, + throw: sandbox.stub() + } + await uut.getFollowingFeed(ctx) + + assert.equal(uut.useCases.listFollowingFeed.execute.callCount, 1) + assert.deepEqual(uut.useCases.listFollowingFeed.execute.firstCall.args[0], { + addr: 'addr-b', + limit: '25', + offset: '0' + }) + assert.equal(ctx.body.posts.length, 1) + assert.equal(ctx.body.posts[0].txid, 'tx3') + }) }) diff --git a/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js b/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js new file mode 100644 index 0000000..4a0d9c5 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js @@ -0,0 +1,116 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListFollowingFeed from '../../../src/use-cases/list-following-feed.js' + +describe('#ListFollowingFeed', () => { + let uut + let sandbox + let postQuery + let followQuery + + const viewerAddr = 'bitcoincash:viewer' + const followeeAddr = 'bitcoincash:followee' + const otherAddr = 'bitcoincash:other' + + const mockPosts = { + 'tx-a': { addr: followeeAddr, text: 'a', seen: 100, blockHeight: 600100 }, + 'tx-b': { addr: followeeAddr, text: 'b', seen: 200, blockHeight: 600200 }, + 'tx-c': { addr: otherAddr, text: 'c', seen: 50, blockHeight: 600300 } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + postQuery = { + scanFollowingFeedTxidsAndCount: sandbox.stub().callsFake(async (viewer, followees, { limit, offset }) => { + const all = Object.entries(mockPosts) + .filter(([txid, post]) => followees.includes(post.addr) && post.addr !== viewer) + .sort((a, b) => b[1].blockHeight - a[1].blockHeight) + .map(([txid]) => txid) + return { + txids: all.slice(offset, offset + limit), + total: all.length + } + }), + 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([['tx-a', 2]])) + } + followQuery = { + listFollowing: sandbox.stub().resolves([followeeAddr]) + } + uut = new ListFollowingFeed({ + adapters: { postQuery, followQuery } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should return posts from followed addresses sorted by block height descending', async () => { + const result = await uut.execute({ addr: viewerAddr, limit: 10, offset: 0 }) + + assert.equal(result.posts.length, 2) + assert.equal(result.posts[0].txid, 'tx-b') + assert.equal(result.posts[1].txid, 'tx-a') + assert.equal(result.posts[1].likeCount, 2) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, false) + }) + + it('should list who the viewer follows', async () => { + await uut.execute({ addr: viewerAddr }) + + assert.equal(followQuery.listFollowing.calledOnce, true) + assert.equal(followQuery.listFollowing.firstCall.args[0], viewerAddr) + }) + + it('should reject missing addr', async () => { + try { + await uut.execute({ limit: 10 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) + + it('should reject a non-string addr', async () => { + try { + await uut.execute({ addr: 12345, limit: 10 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) + + it('should pass addr, limit, and offset to postQuery', async () => { + await uut.execute({ addr: viewerAddr, limit: 5, offset: 10 }) + + assert.equal(postQuery.scanFollowingFeedTxidsAndCount.calledOnce, true) + assert.equal(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[0], viewerAddr) + assert.deepEqual(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[1], [followeeAddr]) + assert.deepEqual(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[2], { limit: 5, offset: 10 }) + }) + + it('should require the postQuery adapter', () => { + try { + // eslint-disable-next-line no-new + new ListFollowingFeed({ adapters: { followQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postQuery adapter required') + } + }) + + it('should require the followQuery adapter', () => { + try { + // eslint-disable-next-line no-new + new ListFollowingFeed({ adapters: { postQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'followQuery adapter required') + } + }) +}) From d606ee6608c9ddb1c7c2ca543b54b5e9efc2af63 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 10:44:28 -0700 Subject: [PATCH 6/6] Refactor following feed: reduce CRAP/DRY, add property coverage - Extract shared runUseCase and listPostsForAddr in the posts controller to remove the duplicated try/catch error wrapper (CRAP 14.7 -> 4.0) and DRY the addr-scoped handlers. - Add error-path unit tests for the posts controller covering both branches of handleError. - Consolidate ListFollowingFeed onto the shared ListUseCase constructor to remove constructor duplication across list use cases. - Add property tests for scanFollowingFeedTxidsAndCount (ordering, reply/ viewer/membership exclusion, pagination conservation) and for the client FollowingFeedPage.load state invariants. By refactorer. --- .../following-feed-page.property.test.js | 134 +++++++++++++ .../controllers/rest-api/posts/controller.js | 62 +++--- .../src/use-cases/list-following-feed.js | 12 +- .../following-feed-query.property.test.js | 186 ++++++++++++++++++ .../unit/controllers/posts.controller.unit.js | 50 +++++ 5 files changed, 406 insertions(+), 38 deletions(-) create mode 100644 psf-memo-client/test/property/following-feed-page.property.test.js create mode 100644 psf-memo-db/test/property/following-feed-query.property.test.js diff --git a/psf-memo-client/test/property/following-feed-page.property.test.js b/psf-memo-client/test/property/following-feed-page.property.test.js new file mode 100644 index 0000000..c60117c --- /dev/null +++ b/psf-memo-client/test/property/following-feed-page.property.test.js @@ -0,0 +1,134 @@ +/* + Property tests for the FollowingFeedPage controller. + + The unit tests probe a few fixed pagination shapes. These properties cover + the load/`emptyBecauseNoFollows`/`canLoadMore` invariants over broad random + inputs so they hold everywhere: + + - empty state: emptyBecauseNoFollows is true exactly when no posts are + returned and the requested offset is zero. + - has more: canLoadMore always mirrors pagination.hasMore. + - forwarding: the load limits and offset forwarded to the memo-db client + match exactly what the caller requested. + - lookup: getPost returns a loaded post by txid and null otherwise. +*/ + +'use strict' + +const test = require('node:test') +const { seededRandom, forAll, intGen } = require('./harness') +const FollowingFeedPage = require('../../src/services/following-feed-page') + +const rng = seededRandom(20260902) + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const HEX = '0123456789abcdef' + +function txidGen () { + let out = '' + for (let i = 0; i < 64; i++) { + out += HEX[Math.floor(rng() * HEX.length)] + } + return out +} + +function makeMemoDb (posts, pagination) { + return { + async getFollowingFeed (addr, { limit, offset }) { + return { posts, pagination } + } + } +} + +function fixtureGen () { + return () => { + const n = intGen(rng, 0, 5)() + const posts = [] + for (let i = 0; i < n; i++) { + posts.push({ txid: txidGen(), text: 'post ' + i }) + } + return { + posts, + pagination: { hasMore: rng() < 0.5 }, + limit: intGen(rng, 1, 100)(), + offset: intGen(rng, 0, 200)() + } + } +} + +test('emptyBecauseNoFollows is true exactly for an empty feed at offset zero', async () => { + await forAll( + fixtureGen(), + async ({ posts, pagination, limit, offset }) => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, pagination), + wallet: { walletInfo: { cashAddress: MY_ADDRESS } } + }) + const result = await page.load({ limit, offset }) + + return result.emptyBecauseNoFollows === (posts.length === 0 && offset === 0) && + page.emptyBecauseNoFollows === (posts.length === 0 && offset === 0) + }, + { label: 'emptyBecauseNoFollows state invariant' } + ) +}) + +test('canLoadMore always mirrors pagination.hasMore', async () => { + await forAll( + fixtureGen(), + async ({ posts, pagination, limit, offset }) => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, pagination), + wallet: { walletInfo: { cashAddress: MY_ADDRESS } } + }) + await page.load({ limit, offset }) + + return page.canLoadMore() === (pagination.hasMore === true) && + page.pagination.hasMore === pagination.hasMore + }, + { label: 'canLoadMore mirrors hasMore' } + ) +}) + +test('load forwards exactly the requested limit and offset to the memo-db client', async () => { + await forAll( + fixtureGen(), + async ({ posts, pagination, limit, offset }) => { + const calls = [] + const memoDb = { + async getFollowingFeed (addr, params) { + calls.push({ addr, params }) + return { posts, pagination } + } + } + const page = new FollowingFeedPage({ memoDb, wallet: { walletInfo: { cashAddress: MY_ADDRESS } } }) + await page.load({ limit, offset }) + + return calls.length === 1 && + calls[0].addr === MY_ADDRESS && + calls[0].params.limit === limit && + calls[0].params.offset === offset + }, + { label: 'load forwards limit and offset' } + ) +}) + +test('getPost returns a loaded post by txid, otherwise null', async () => { + await forAll( + fixtureGen(), + async ({ posts, pagination, limit, offset }) => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, pagination), + wallet: { walletInfo: { cashAddress: MY_ADDRESS } } + }) + await page.load({ limit, offset }) + + if (posts.length === 0) return true + + const any = page.getPost(posts[0].txid) + if (!any || any.text !== posts[0].text) return false + return page.getPost('0'.repeat(64)) === null + }, + { label: 'getPost lookup' } + ) +}) diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 451fc98..64fa2ef 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -19,9 +19,34 @@ class PostsRESTControllerLib { this.getPostsByAddr = this.getPostsByAddr.bind(this) this.getFollowingFeed = this.getFollowingFeed.bind(this) this.getPostThread = this.getPostThread.bind(this) + this.runUseCase = this.runUseCase.bind(this) + this.listPostsForAddr = this.listPostsForAddr.bind(this) this.handleError = this.handleError.bind(this) } + /* + Run a use-case delegate against a Koa context, storing the resolved value + on ctx.body and routing any rejection through the shared handleError. + */ + async runUseCase (ctx, fn) { + try { + ctx.body = await fn() + } catch (err) { + this.handleError(ctx, err) + } + } + + /* + Shared handler body for the addr-scoped post listings (/by/:addr and + /following/:addr): read the address route param plus pagination query + params and delegate to the given use case. + */ + async listPostsForAddr (ctx, useCase) { + const { addr } = ctx.params + const { limit, offset } = ctx.query + await this.runUseCase(ctx, () => useCase.execute({ addr, limit, offset })) + } + handleError (ctx, err) { if (err.status) { ctx.throw(err.status, err.message || err) @@ -59,12 +84,8 @@ class PostsRESTControllerLib { * @apiSuccess {Boolean} pagination.hasMore True if more pages exist */ async getRecentPosts (ctx) { - try { - const { limit, offset } = ctx.query - ctx.body = await this.useCases.listRecentPosts.execute({ limit, offset }) - } catch (err) { - this.handleError(ctx, err) - } + const { limit, offset } = ctx.query + await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute({ limit, offset })) } /** @@ -92,13 +113,7 @@ class PostsRESTControllerLib { * @apiSuccess {Object} pagination Pagination metadata */ async getPostsByAddr (ctx) { - try { - const { addr } = ctx.params - const { limit, offset } = ctx.query - ctx.body = await this.useCases.listPostsByAddr.execute({ addr, limit, offset }) - } catch (err) { - this.handleError(ctx, err) - } + await this.listPostsForAddr(ctx, this.useCases.listPostsByAddr) } /** @@ -127,25 +142,14 @@ class PostsRESTControllerLib { * @apiSuccess {Object} pagination Pagination metadata */ async getFollowingFeed (ctx) { - try { - const { addr } = ctx.params - const { limit, offset } = ctx.query - ctx.body = await this.useCases.listFollowingFeed.execute({ addr, limit, offset }) - } catch (err) { - this.handleError(ctx, err) - } + await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed) } async getPostThread (ctx) { - try { - const { txid } = ctx.params - - ctx.body = await this.useCases.getPostThread.execute({ - txid - }) - } catch (err) { - this.handleError(ctx, err) - } + const { txid } = ctx.params + await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({ + txid + })) } } diff --git a/psf-memo-db/src/use-cases/list-following-feed.js b/psf-memo-db/src/use-cases/list-following-feed.js index 9d3ea30..8dbb6ca 100644 --- a/psf-memo-db/src/use-cases/list-following-feed.js +++ b/psf-memo-db/src/use-cases/list-following-feed.js @@ -6,20 +6,14 @@ */ import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js' +import { ListUseCase } from './lib/use-case.js' -class ListFollowingFeed { +class ListFollowingFeed extends ListUseCase { constructor (localConfig = {}) { - this.adapters = localConfig.adapters - if (!this.adapters) { - throw new Error('Adapters required when instantiating ListFollowingFeed use case.') - } - if (!this.adapters.postQuery) { - throw new Error('postQuery adapter required for ListFollowingFeed use case.') - } + super(localConfig, { useCaseName: 'ListFollowingFeed', adapterName: 'postQuery' }) if (!this.adapters.followQuery) { throw new Error('followQuery adapter required for ListFollowingFeed use case.') } - this.execute = this.execute.bind(this) } parseAddr (addr) { diff --git a/psf-memo-db/test/property/following-feed-query.property.test.js b/psf-memo-db/test/property/following-feed-query.property.test.js new file mode 100644 index 0000000..34d8ec9 --- /dev/null +++ b/psf-memo-db/test/property/following-feed-query.property.test.js @@ -0,0 +1,186 @@ +/* + Property tests for the following-feed query scan. + + The unit tests probe scanFollowingFeedTxidsAndCount at a few fixed + fixtures. These properties pin down invariants that hold over broad random + record sets: + + - newest-first ordering: the feed is iterated by postHeights key order + reversed, so returned posts are newest first. + - reply exclusion: a reply is never returned as a top-level feed item. + - viewer exclusion: the viewer's own posts are never returned. + - membership: every returned post was authored by a followed address + (ignoring the viewer in the follow list). + - pagination conservation: applying offset/limit returns exactly the full + matching set sliced to the page, and reports an exact total. +*/ + +import test from 'node:test' +import { seededRandom, forAll, intGen, txidGen } from './harness.js' +import PostQuery from '../../src/adapters/post-query.js' + +const rng = seededRandom(20260901) + +const VIEWER = 'bitcoincash:viewer' +const ADDRS = ['bitcoincash:viewer', 'bitcoincash:f1', 'bitcoincash:f2', 'bitcoincash:o1', 'bitcoincash:o2'] + +// In-memory postHeights store mirroring the LevelDB iterator contract +// (reverse ordering over the padded-height key string). +function makePostHeightsDb (entries) { + const store = new Map(entries.map((e) => [e.key, e.value])) + return { + async * iterator (opts = {}) { + const { reverse = false } = opts + let keys = Array.from(store.keys()).sort() + if (reverse) keys = keys.reverse() + for (const key of keys) { + yield [key, store.get(key)] + } + } + } +} + +function makePostsDb (posts) { + const store = new Map(posts.map((p) => [p.txid, p])) + return { + async get (txid) { + const post = store.get(txid) + if (!post) { + const err = new Error('not found') + err.notFound = true + throw err + } + return post + } + } +} + +function makeParentsDb (replyTxids) { + return { + async * iterator () { + for (const txid of replyTxids) { + yield [txid, { parentTxid: 'parent' }] + } + } + } +} + +function makeQuery (postHeightsEntries, postsDb, replyTxids) { + return new PostQuery({ + postsDb: makePostsDb(postsDb), + postHeightsDb: makePostHeightsDb(postHeightsEntries), + addrPostHeightsDb: {}, + postParentsDb: makeParentsDb(replyTxids), + postChildrenDb: {}, + likesDb: {}, + postLikesDb: {} + }) +} + +function fixtureGen () { + return () => { + const n = intGen(rng, 0, 14)() + const posts = [] // every post exists in the DB + const postHeights = [] // every post appears in the global index + const replyTxids = new Set() + + for (let i = 0; i < n; i++) { + const txid = txidGen(rng) + const height = intGen(rng, 0, 9000000)() + const addr = ADDRS[Math.floor(rng() * ADDRS.length)] + const isReply = rng() < 0.3 + const dangling = rng() < 0.15 // in index but missing from posts DB + const post = dangling + ? null + : { + txid, + addr, + text: 'post ' + i, + seen: intGen(rng, 0, 1000000)(), + blockHeight: height + } + + if (isReply) replyTxids.add(txid) + postHeights.push({ + key: PostQuery.postHeightKey(height, txid), + value: { txid } + }) + // A dangling entry contributes no post record but stays in the index. + if (!dangling) posts.push({ txid, ...post }) + } + + // A follow list may also include the viewer (which must be ignored). + const followed = [] + const fCount = intGen(rng, 0, 6)() + for (let i = 0; i < fCount; i++) { + followed.push(ADDRS[Math.floor(rng() * ADDRS.length)]) + } + + return { + postHeights, + posts, + replyTxids, + followed, + limit: intGen(rng, 1, 8)(), + offset: intGen(rng, 0, 10)() + } + } +} + +function buildExpected (query, posts, replyTxids, followed, { limit, offset }) { + const followeeSet = new Set(followed.filter((a) => a !== VIEWER)) + const matching = posts + .filter((p) => !replyTxids.has(p.txid)) + .filter((p) => followeeSet.has(p.addr)) + + const ordered = [...matching].sort((a, b) => { + const ka = PostQuery.postHeightKey(a.blockHeight, a.txid) + const kb = PostQuery.postHeightKey(b.blockHeight, b.txid) + return ka < kb ? 1 : ka > kb ? -1 : 0 + }) + + return { + total: ordered.length, + txids: ordered.slice(offset, offset + limit).map((p) => p.txid) + } +} + +test('following-feed scan returns followed top-level posts newest first with an exact total', async () => { + await forAll( + fixtureGen(), + async ({ postHeights, posts, replyTxids, followed, limit, offset }) => { + const query = makeQuery(postHeights, posts, replyTxids) + const { txids, total } = await query.scanFollowingFeedTxidsAndCount( + VIEWER, + followed, + { limit, offset } + ) + const expected = buildExpected(query, posts, replyTxids, followed, { limit, offset }) + + return total === expected.total && JSON.stringify(txids) === JSON.stringify(expected.txids) + }, + { label: 'following-feed pagination conservation and ordering' } + ) +}) + +test('following-feed scan never returns replies, the viewer, or un-followed authors', async () => { + await forAll( + fixtureGen(), + async ({ postHeights, posts, replyTxids, followed, limit }) => { + const query = makeQuery(postHeights, posts, replyTxids) + const { txids } = await query.scanFollowingFeedTxidsAndCount(VIEWER, followed, { limit, offset: 0 }) + + const postByTxid = new Map(posts.map((p) => [p.txid, p])) + const followeeSet = new Set(followed.filter((a) => a !== VIEWER)) + + return txids.every((txid) => { + const post = postByTxid.get(txid) + if (!post) return false + if (replyTxids.has(txid)) return false + if (post.addr === VIEWER) return false + return followeeSet.has(post.addr) + }) + }, + { label: 'following-feed reply/viewer/membership exclusion' } + ) +}) diff --git a/psf-memo-db/test/unit/controllers/posts.controller.unit.js b/psf-memo-db/test/unit/controllers/posts.controller.unit.js index 3bfbb89..9405793 100644 --- a/psf-memo-db/test/unit/controllers/posts.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/posts.controller.unit.js @@ -85,4 +85,54 @@ describe('#PostsRESTController', () => { assert.equal(ctx.body.posts.length, 1) assert.equal(ctx.body.posts[0].txid, 'tx3') }) + + it('should map a use-case error with a status to that status', async () => { + uut.useCases.listFollowingFeed.execute = sandbox.stub().rejects( + Object.assign(new Error('boom'), { status: 400 }) + ) + const ctx = { + params: { addr: 'addr-b' }, + query: {}, + body: null, + throw: sandbox.stub() + } + await uut.getFollowingFeed(ctx) + + assert.equal(ctx.body, null) + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 400) + assert.include(ctx.throw.firstCall.args[1], 'boom') + }) + + it('should map an unknown use-case error to a 500', async () => { + uut.useCases.listFollowingFeed.execute = sandbox.stub().rejects(new Error('boom')) + const ctx = { + params: { addr: 'addr-b' }, + query: {}, + body: null, + throw: sandbox.stub() + } + await uut.getFollowingFeed(ctx) + + assert.equal(ctx.body, null) + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 500) + assert.include(ctx.throw.firstCall.args[1], 'boom') + }) + + it('should return a post thread from use case', async () => { + const ctx = { + params: { txid: 'tx4' }, + body: null, + throw: sandbox.stub() + } + uut.useCases.getPostThread = { + execute: sandbox.stub().resolves({ txid: 'tx4', text: 'thread' }) + } + await uut.getPostThread(ctx) + + assert.equal(uut.useCases.getPostThread.execute.callCount, 1) + assert.deepEqual(uut.useCases.getPostThread.execute.firstCall.args[0], { txid: 'tx4' }) + assert.equal(ctx.body.text, 'thread') + }) })