diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index c9ab489..7b98301 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -39,6 +39,7 @@ const ProfilePage = require('../../src/services/profile-page') const ThreadPage = require('../../src/services/thread-page') const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') const TopicFeedPage = require('../../src/services/topic-feed-page') +const SearchPage = require('../../src/services/search-page') const MemoTopicFollow = require('../../src/services/memo-topic-follow') const MemoTopicPost = require('../../src/services/memo-topic-post') const TopicPostPage = require('../../src/services/topic-post-page') @@ -178,9 +179,12 @@ function makeThread () { } // A fake psf-memo-db API backing the read-only feed, profile, thread, -// and topic pages used to verify read-side behavior. +// topic, and search pages used to verify read-side behavior. function makeMemoDb () { const posts = [] + const profiles = [] + const searchPosts = [] + const searchProfiles = [] const threads = {} const followState = {} const muteState = {} @@ -191,6 +195,9 @@ function makeMemoDb () { return { posts, + profiles, + searchPosts, + searchProfiles, threads, followState, topics, @@ -199,6 +206,12 @@ function makeMemoDb () { addPost (post) { posts.push(post) }, + addSearchPost (post) { + searchPosts.push(post) + }, + addSearchProfile (profile) { + searchProfiles.push(profile) + }, addTopic (room, postCount) { topicCounts.set(room, postCount) topicPosts[room] = [] @@ -240,6 +253,25 @@ function makeMemoDb () { } return addrs }, + async search (q) { + const normalized = String(q).trim().toLowerCase() + if (normalized.length === 0) { + return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } } + } + const matchedPosts = searchPosts.filter((p) => + typeof p.text === 'string' && p.text.toLowerCase().includes(normalized) + ) + const matchedProfiles = searchProfiles.filter((p) => + (typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) || + (typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)) + ) + const total = matchedPosts.length + matchedProfiles.length + return { + posts: matchedPosts, + profiles: matchedProfiles, + pagination: { total, hasMore: false } + } + }, 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 } } @@ -316,6 +348,10 @@ function createWorld () { memoDb, navigate: (path) => { world.currentPath = path } }) + world.searchPage = new SearchPage({ + memoDb, + navigate: (path) => { world.currentPath = path } + }) // The New Post Page controller wraps the memo post behavior. Its navigate // adapter updates the world's current path so navigation can be asserted. @@ -2088,6 +2124,86 @@ const handlers = [ throw new Error(`Expected ${expectedCode}, got ${world.pollVotePage.submitError}.`) } } + }, + { + name: 'API has search post', + pattern: /^the psf-memo-db API has a post with the text "(.+)"$/, + run (m, example, world) { + const text = resolveParam(m[1], example) + const txid = require('crypto').createHash('sha256').update(text).digest('hex') + world.memoDb.addSearchPost({ + txid, + addr: `addr-${txid.slice(0, 8)}`, + text, + blockHeight: 100 + }) + } + }, + { + name: 'API has search profile', + pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/, + run (m, example, world) { + const name = resolveParam(m[1], example) + const text = resolveParam(m[2], example) + const addr = `addr-${require('crypto').createHash('sha256').update(name).digest('hex').slice(0, 8)}` + world.memoDb.addSearchProfile({ addr, name, text, blockHeight: 100 }) + } + }, + { + name: 'open search page', + pattern: /^I open the Search page$/, + async run (m, example, world) { + world.currentPath = SearchPage.SEARCH_PATH + } + }, + { + name: 'submit search', + pattern: /^I submit a search for (.+)$/, + async run (m, example, world) { + const query = resolveParam(m[1], example) + world.searchPage.setQuery(query) + await world.searchPage.submit() + } + }, + { + name: 'search results include post text', + pattern: /^the search results include a post with the text (.+)$/, + run (m, example, world) { + const expected = resolveParam(m[1], example) + const found = world.searchPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Search results do not include a post with text "${expected}".`) + } + } + }, + { + name: 'search results include profile name', + pattern: /^the search results include a profile named (.+)$/, + run (m, example, world) { + const expected = resolveParam(m[1], example) + const found = world.searchPage.profiles.find((p) => p.name === expected) + if (!found) { + throw new Error(`Search results do not include a profile named "${expected}".`) + } + } + }, + { + name: 'search results include no posts', + pattern: /^the search results include no posts$/, + run (m, example, world) { + if (world.searchPage.posts.length !== 0) { + throw new Error(`Expected no posts in search results, got ${world.searchPage.posts.length}.`) + } + } + }, + { + name: 'search results include no profiles', + pattern: /^the search results include no profiles$/, + run (m, example, world) { + if (world.searchPage.profiles.length !== 0) { + throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`) + } + } } ] diff --git a/psf-memo-client/specs/search.feature b/psf-memo-client/specs/search.feature index 8f50dea..345311a 100644 --- a/psf-memo-client/specs/search.feature +++ b/psf-memo-client/specs/search.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-08-29T15:42:33.013729780Z","feature_name":"Search","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/search.feature","background_hash":"9e7fd3c0f7630e1c8dbe0201df1c30b7b45c1e9a208ab42cf7ff005bdb7881a6","implementation_hash":"unknown","scenarios":[]} +# acceptance-mutation-manifest-end + # Scenarios: Search - 1, Search - 2, Search - 3, Search - 4, Search - 5 # # Search is a public read-only feature; it does not require a wallet and does diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index a017a41..884009c 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -33,6 +33,7 @@ import SetAvatarUrl from './set-avatar-url' import Account from './account' import Topics from './topics' import TopicFeed from './topic-feed' +import Search from './search' function AppBody (props) { // Dependency injection through props @@ -52,6 +53,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/search/index.js b/psf-memo-client/src/components/app-body/search/index.js new file mode 100644 index 0000000..c6a96b3 --- /dev/null +++ b/psf-memo-client/src/components/app-body/search/index.js @@ -0,0 +1,132 @@ +/* + Search page: submit a query and display matching posts and profiles. +*/ + +// Global npm libraries +import React, { useState } from 'react' +import { Container, Row, Col, Form, Button, Spinner, ListGroup } from 'react-bootstrap' +import { Link } from 'react-router-dom' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import SearchPage from '../../../services/search-page' +import '../../../App.css' + +function SearchResults (props) { + const { posts, profiles, searched } = props + + if (!searched) return null + + if (posts.length === 0 && profiles.length === 0) { + return

No results found.

+ } + + return ( + <> + {posts.length > 0 && ( + <> +

Posts

+ + {posts.map((post) => ( + +

{post.text}

+

+ {post.addr} +

+
+ ))} +
+ + )} + + {profiles.length > 0 && ( + <> +

Profiles

+ + {profiles.map((profile) => ( + + + {profile.name || profile.addr} + + {profile.text &&

{profile.text}

} +
+ ))} +
+ + )} + + ) +} + +function Search (props) { + const [query, setQuery] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [posts, setPosts] = useState([]) + const [profiles, setProfiles] = useState([]) + const [searched, setSearched] = useState(false) + + const handleSubmit = async (event) => { + event.preventDefault() + setLoading(true) + setError(null) + setSearched(true) + + try { + const memoDb = new MemoDb() + const page = new SearchPage({ memoDb }) + page.setQuery(query) + const result = await page.submit() + setPosts(result.posts || []) + setProfiles(result.profiles || []) + } catch (err) { + setError(err.message || 'Search failed') + setPosts([]) + setProfiles([]) + } + + setLoading(false) + } + + return ( + + + +
+

Search

+

Find posts and profiles on Memo.

+
+ +
+ + setQuery(e.target.value)} + disabled={loading} + /> + + +
+ + {error &&

{error}

} + + {loading && ( +
+ + Loading... + +
+ )} + + {!loading && } + +
+
+ ) +} + +export default Search diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 95a511a..7baa082 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -78,6 +78,14 @@ function NavMenu (props) { Topics + + Search + + {}) + this.query = '' + this.posts = [] + this.profiles = [] + this.pagination = null + } + + setQuery (q) { + this.query = typeof q === 'string' ? q.trim() : '' + return this + } + + async submit ({ limit = 100, offset = 0 } = {}) { + if (!this.memoDb) { + throw new Error('Search page requires a memo db client.') + } + + const data = await this.memoDb.search(this.query, { limit, offset }) + this.posts = data.posts || [] + this.profiles = data.profiles || [] + this.pagination = data.pagination || null + + return { + posts: this.posts, + profiles: this.profiles, + pagination: this.pagination + } + } + + getPost (txid) { + return this.posts.find((post) => post.txid === txid) || null + } + + getProfile (addr) { + return this.profiles.find((profile) => profile.addr === addr) || null + } +} + +SearchPage.SEARCH_PATH = SEARCH_PATH + +module.exports = SearchPage + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:29:31.910Z","module_hash":"5ff867161b6e0af82435936924bfc3f2843e94b60354bbb39833714adb2a7448","functions":[{"id":"func/SearchPage.constructor","name":"SearchPage.constructor","line":12,"end_line":19,"hash":"6e48e42aae340ec24012bd86529d4493664e896d0d86a0bcfaf75e813d196da7"},{"id":"func/SearchPage.setQuery","name":"SearchPage.setQuery","line":21,"end_line":24,"hash":"45cdbb5a6cc4329f2ae2918130de995482f5587efdfa8bf48191672c0917cab3"},{"id":"func/SearchPage.submit","name":"SearchPage.submit","line":26,"end_line":41,"hash":"2e1394b99ec30f3b0fb867ab45c07c439c0d71ecef868dd37bfd7af11c584a2a"},{"id":"func/SearchPage.getPost","name":"SearchPage.getPost","line":43,"end_line":45,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/SearchPage.getProfile","name":"SearchPage.getProfile","line":47,"end_line":49,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/test/unit/search-page.test.js b/psf-memo-client/test/unit/search-page.test.js new file mode 100644 index 0000000..4791d63 --- /dev/null +++ b/psf-memo-client/test/unit/search-page.test.js @@ -0,0 +1,130 @@ +/* + Unit tests for the Search page controller. + + The search page is a thin, testable wrapper around the MemoDb client. It + captures a query, submits it to the search endpoint, and exposes the returned + posts and profiles so the view can render them. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const SearchPage = require('../../src/services/search-page') + +function makeMemoDb (posts, profiles, pagination) { + return { + async search (q, { limit, offset }) { + return { posts, profiles, pagination } + } + } +} + +test('load returns posts and profiles', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }] + const profiles = [{ addr: 'addr1', name: 'Alice Trout' }] + const page = new SearchPage({ memoDb: makeMemoDb(posts, profiles, { total: 2 }) }) + + page.setQuery('hello') + const result = await page.submit() + + assert.deepEqual(result.posts, posts) + assert.deepEqual(result.profiles, profiles) + assert.equal(result.pagination.total, 2) +}) + +test('submit forwards query, limit and offset to the memo db client', async () => { + const calls = [] + const memoDb = { + async search (q, params) { + calls.push({ q, params }) + return { posts: [], profiles: [], pagination: {} } + } + } + const page = new SearchPage({ memoDb }) + + page.setQuery('alice') + await page.submit({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }]) +}) + +test('submit defaults limit to 100 and offset to 0', async () => { + const calls = [] + const memoDb = { + async search (q, params) { + calls.push({ q, params }) + return { posts: [], profiles: [], pagination: {} } + } + } + const page = new SearchPage({ memoDb }) + + page.setQuery('memo') + await page.submit() + + assert.deepEqual(calls, [{ q: 'memo', params: { limit: 100, offset: 0 } }]) +}) + +test('submit throws when no memo db client is provided', async () => { + const page = new SearchPage({}) + + await assert.rejects( + () => page.submit(), + /requires a memo db client/ + ) +}) + +test('setQuery stores the trimmed query', () => { + const page = new SearchPage({ memoDb: { async search () { return {} } } }) + + page.setQuery(' hello ') + + assert.equal(page.query, 'hello') +}) + +test('constructor preserves an injected navigate handler', () => { + const navigate = () => 'x' + const page = new SearchPage({ memoDb: { async search () { return {} } }, navigate }) + + assert.equal(page.navigate, navigate) +}) + +test('getPost returns the matching post', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }] + const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) }) + + page.setQuery('hello') + await page.submit() + + assert.equal(page.getPost('a'.repeat(64)).text, 'hello world') +}) + +test('getPost returns null when no post matches', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }] + const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) }) + + page.setQuery('hello') + await page.submit() + + assert.equal(page.getPost('b'.repeat(64)), null) +}) + +test('getProfile returns the matching profile', async () => { + const profiles = [{ addr: 'addr1', name: 'Alice Trout' }] + const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) }) + + page.setQuery('alice') + await page.submit() + + assert.equal(page.getProfile('addr1').name, 'Alice Trout') +}) + +test('getProfile returns null when no profile matches', async () => { + const profiles = [{ addr: 'addr1', name: 'Alice Trout' }] + const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) }) + + page.setQuery('alice') + await page.submit() + + assert.equal(page.getProfile('addr2'), null) +}) diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index e17ab83..f18df0c 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -10,6 +10,7 @@ import FollowQuery from './follow-query.js' import MuteQuery from './mute-query.js' import TopicQuery from './topic-query.js' import PollQuery from './poll-query.js' +import SearchQuery from './search-query.js' class Adapters { constructor () { @@ -50,6 +51,12 @@ class Adapters { pollOptionsDb: level.pollOptionsDb, pollVotesDb: level.pollVotesDb }) + this.searchQuery = new SearchQuery({ + postsDb: level.postsDb, + postParentsDb: level.postParentsDb, + namesDb: level.namesDb, + profilesDb: level.profilesDb + }) return true } diff --git a/psf-memo-db/src/adapters/lib/load-reply-txids.js b/psf-memo-db/src/adapters/lib/load-reply-txids.js new file mode 100644 index 0000000..ebc1565 --- /dev/null +++ b/psf-memo-db/src/adapters/lib/load-reply-txids.js @@ -0,0 +1,21 @@ +/* + Shared helper to collect the set of child txids that are replies. + + Both the post query and search query adapters need to know which posts are + replies so they can exclude them from top-level listings. Centralizing the + scan keeps reply-detection behavior identical across adapters. +*/ + +export async function loadReplyTxids (postParentsDb) { + const replyTxids = new Set() + + for await (const [childTxid] of postParentsDb.iterator()) { + replyTxids.add(childTxid) + } + + return replyTxids +} + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:18:40.417Z","module_hash":"4024492a889a87fa3669b0daf955e2a906c75407b2b1f83008458710f1206c9e","functions":[{"id":"func/loadReplyTxids","name":"loadReplyTxids","line":9,"end_line":17,"hash":"17ac31205b8d5e508bda758939bcde282f79add47236c27de07402654ae0b2df"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index 23383a5..b718d68 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -6,6 +6,8 @@ - postLikes: likes grouped by liked post txid */ +import { loadReplyTxids } from './lib/load-reply-txids.js' + const HEIGHT_PAD = 12 class PostQuery { @@ -88,13 +90,7 @@ class PostQuery { } async loadReplyTxids () { - const replyTxids = new Set() - - for await (const [childTxid] of this.postParentsDb.iterator()) { - replyTxids.add(childTxid) - } - - return replyTxids + return loadReplyTxids(this.postParentsDb) } async isReply (txid) { @@ -315,5 +311,5 @@ class PostQuery { export default PostQuery // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-28T03:10:29.020Z","module_hash":"021ee6327c28eae9b940a6af05d940c1a47ec64fc1b2f1d1e24aea833945777d","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":12,"end_line":57,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":59,"end_line":61,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":63,"end_line":65,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":67,"end_line":69,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":71,"end_line":73,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":75,"end_line":78,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":80,"end_line":82,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":85,"end_line":88,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":90,"end_line":98,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":100,"end_line":108,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":111,"end_line":125,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":128,"end_line":138,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":141,"end_line":156,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":158,"end_line":162,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":166,"end_line":178,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":180,"end_line":184,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":187,"end_line":194,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":198,"end_line":206,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":208,"end_line":223,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":229,"end_line":257,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":260,"end_line":263,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":265,"end_line":281,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":283,"end_line":294,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":297,"end_line":312,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]} +// {"version":1,"tested_at":"2026-08-29T14:30:59.518Z","module_hash":"48f80ca7bfd456c9cf4c373e5ed22db42826d44df7a720babbd4e6b8e80a8e76","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":14,"end_line":59,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":61,"end_line":63,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":65,"end_line":67,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":69,"end_line":71,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":73,"end_line":75,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":77,"end_line":80,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":82,"end_line":84,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":87,"end_line":90,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":92,"end_line":94,"hash":"74621495a3affc6ef8688b9d6a814a91c99b22c86c348d261c952aad25df1661"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":96,"end_line":104,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":107,"end_line":121,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":124,"end_line":134,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":137,"end_line":152,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":154,"end_line":158,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":162,"end_line":174,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":176,"end_line":180,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":183,"end_line":190,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":194,"end_line":202,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":204,"end_line":219,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":225,"end_line":253,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":256,"end_line":259,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":261,"end_line":277,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":279,"end_line":290,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":293,"end_line":308,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-db/src/adapters/search-query.js b/psf-memo-db/src/adapters/search-query.js new file mode 100644 index 0000000..e6497d0 --- /dev/null +++ b/psf-memo-db/src/adapters/search-query.js @@ -0,0 +1,142 @@ +/* + Adapter for case-insensitive substring search across posts and profiles. + + - postsDb: raw post documents keyed by txid + - postParentsDb: child-txid -> parent mapping used to exclude replies + - namesDb: address -> { name, ... } used for profile-name search + - profilesDb: address -> { text, ... } used for profile-bio search +*/ + +import { normalizeQuery } from '../lib/search.js' +import { loadReplyTxids } from './lib/load-reply-txids.js' + +class SearchQuery { + constructor (localConfig = {}) { + const { postsDb, postParentsDb, namesDb, profilesDb } = localConfig + if (!postsDb) { + throw new Error('postsDb required when instantiating SearchQuery adapter.') + } + if (!postParentsDb) { + throw new Error('postParentsDb required when instantiating SearchQuery adapter.') + } + if (!namesDb) { + throw new Error('namesDb required when instantiating SearchQuery adapter.') + } + if (!profilesDb) { + throw new Error('profilesDb required when instantiating SearchQuery adapter.') + } + this.postsDb = postsDb + this.postParentsDb = postParentsDb + this.namesDb = namesDb + this.profilesDb = profilesDb + + this.searchPosts = this.searchPosts.bind(this) + this.searchProfiles = this.searchProfiles.bind(this) + this.profileMatches = this.profileMatches.bind(this) + } + + async searchPosts (query) { + const normalized = normalizeQuery(query) + if (normalized.length === 0) return [] + + const replyTxids = await loadReplyTxids(this.postParentsDb) + const matches = [] + + for await (const [txid, post] of this.postsDb.iterator()) { + if (this.isMatchingPost(txid, post, replyTxids, normalized)) { + matches.push({ + txid, + addr: post.addr, + text: post.text, + seen: post.seen, + blockHeight: post.blockHeight ?? 0 + }) + } + } + + return matches + } + + isMatchingPost (txid, post, replyTxids, normalized) { + if (replyTxids.has(txid)) return false + if (!post || typeof post.text !== 'string') return false + return post.text.toLowerCase().includes(normalized) + } + + async searchProfiles (query) { + const normalized = normalizeQuery(query) + if (normalized.length === 0) return [] + + const names = await this.loadProfileRecords(this.namesDb, 'name') + const profiles = await this.loadProfileRecords(this.profilesDb, 'text') + const matches = this.matchByName(names, profiles, normalized) + this.matchByText(names, profiles, normalized, matches) + return Array.from(matches.values()) + } + + async loadProfileRecords (db, field) { + return this.loadRecords(db, (record) => ({ + [field]: record[field], + txid: record.txid, + seen: record.seen, + blockHeight: record.blockHeight ?? 0 + })) + } + + async loadRecords (db, mapper) { + const records = new Map() + for await (const [addr, record] of db.iterator()) { + if (!record) continue + records.set(addr, mapper(record)) + } + return records + } + + matchByName (names, profiles, normalized) { + const matches = new Map() + for (const [addr, nameRecord] of names.entries()) { + if (typeof nameRecord.name === 'string' && nameRecord.name.toLowerCase().includes(normalized)) { + const profileRecord = profiles.get(addr) || {} + matches.set(addr, this.profileMatches(addr, nameRecord, profileRecord)) + } + } + return matches + } + + matchByText (names, profiles, normalized, matches) { + for (const [addr, profileRecord] of profiles.entries()) { + if (this.textMatches(profileRecord, normalized) && !matches.has(addr)) { + matches.set(addr, this.profileMatches(addr, names.get(addr) || {}, profileRecord)) + } + } + } + + textMatches (profileRecord, normalized) { + return typeof profileRecord.text === 'string' && profileRecord.text.toLowerCase().includes(normalized) + } + + profileMatches (addr, nameRecord, profileRecord) { + const blockHeight = Math.max( + nameRecord.blockHeight ?? 0, + profileRecord.blockHeight ?? 0 + ) + const seen = Math.max( + nameRecord.seen ?? 0, + profileRecord.seen ?? 0 + ) + return { + addr, + name: nameRecord.name || null, + text: profileRecord.text || null, + txid: nameRecord.txid || profileRecord.txid || null, + seen, + blockHeight + } + } +} + +export default SearchQuery + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:24:14.179Z","module_hash":"0c6f365ce684e05ae34df5de2fcacaad023d8578a9e228c91a6d3b799f84c3c8","functions":[{"id":"func/SearchQuery.constructor","name":"SearchQuery.constructor","line":14,"end_line":36,"hash":"dc2625c23df7e50c35810379846377d4af23ea56470d7c9308631a74c415cb51"},{"id":"func/SearchQuery.searchPosts","name":"SearchQuery.searchPosts","line":38,"end_line":58,"hash":"d49a70791e72e76f82842226b1a314e803ca5fce772d78beeb73a99069a537ae"},{"id":"func/SearchQuery.isMatchingPost","name":"SearchQuery.isMatchingPost","line":60,"end_line":64,"hash":"1148a189ef5574cb4789a4b12889269212c4bbf97202bccd2fd817d9d40b51a0"},{"id":"func/SearchQuery.searchProfiles","name":"SearchQuery.searchProfiles","line":66,"end_line":75,"hash":"0570020fc12640e30918accca2ea28157fb9a686183f1e3855d209e840fce82a"},{"id":"func/SearchQuery.loadProfileRecords","name":"SearchQuery.loadProfileRecords","line":77,"end_line":84,"hash":"fb67ceac97d5f08e47eca5c136f0b4afe099c4f3425fd83c72e521f9b1871a27"},{"id":"func/SearchQuery.loadRecords","name":"SearchQuery.loadRecords","line":86,"end_line":93,"hash":"2382749d3be862a2b7713550e680f61d7ef64355632f9f3f0fbeeccbf947e13c"},{"id":"func/SearchQuery.matchByName","name":"SearchQuery.matchByName","line":95,"end_line":104,"hash":"5e6e821883e767ec1b3a2ff03d6e19b032d0e4b3d491b59ffe9ab7924abd8edf"},{"id":"func/SearchQuery.matchByText","name":"SearchQuery.matchByText","line":106,"end_line":112,"hash":"4889f4aba2e7c8951b6899a93b2ebefdea6a8b9ee74f4ef2c112c08e3458459c"},{"id":"func/SearchQuery.textMatches","name":"SearchQuery.textMatches","line":114,"end_line":116,"hash":"3db2504b793c5f6648537f1d63969cbe496b9f42c31d4d0060dd527417561129"},{"id":"func/SearchQuery.profileMatches","name":"SearchQuery.profileMatches","line":118,"end_line":135,"hash":"03af6e62f654560d8361ea0e3f508c53e8e7024e65132974c7202b69461441c6"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/controllers/rest-api/index.js b/psf-memo-db/src/controllers/rest-api/index.js index 6689924..9be1a1d 100644 --- a/psf-memo-db/src/controllers/rest-api/index.js +++ b/psf-memo-db/src/controllers/rest-api/index.js @@ -10,6 +10,7 @@ import FollowRouter from './follow/index.js' import MuteRouter from './mute/index.js' import TopicsRouter from './topics/index.js' import PollsRouter from './polls/index.js' +import SearchRouter from './search/index.js' class RESTControllers { constructor (localConfig = {}) { @@ -47,6 +48,9 @@ class RESTControllers { const pollsRouter = new PollsRouter(dependencies) pollsRouter.attach(app) + + const searchRouter = new SearchRouter(dependencies) + searchRouter.attach(app) } } diff --git a/psf-memo-db/src/controllers/rest-api/search/controller.js b/psf-memo-db/src/controllers/rest-api/search/controller.js new file mode 100644 index 0000000..2b452c3 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/search/controller.js @@ -0,0 +1,66 @@ +/* + REST API controller for /search routes. +*/ + +import wlogger from '../../../adapters/wlogger.js' + +class SearchRESTControllerLib { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + this.useCases = localConfig.useCases + if (!this.adapters) { + throw new Error('Adapters required for Search REST Controller.') + } + if (!this.useCases) { + throw new Error('Use Cases required for Search REST Controller.') + } + + this.search = this.search.bind(this) + this.handleError = this.handleError.bind(this) + } + + handleError (ctx, err) { + if (err.status) { + ctx.throw(err.status, err.message || err) + } else { + wlogger.error('Error in search controller: ', err) + ctx.throw(500, err.message || 'Internal server error') + } + } + + /** + * @api {get} /search Search posts and profiles + * @apiPermission public + * @apiName Search + * @apiGroup REST Search + * + * @apiDescription Searches top-level posts and profiles by case-insensitive + * substring match. Empty queries and queries with no matches return an empty + * result set rather than an error. + * + * @apiQuery {String} q Search query + * @apiQuery {Number} [limit=100] Page size (max 100) + * @apiQuery {Number} [offset=0] Number of results to skip after sorting + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/search?q=hello&limit=50&offset=0" + * + * @apiSuccess {Object[]} posts Array of matching post objects + * @apiSuccess {Object[]} profiles Array of matching profile objects + * @apiSuccess {Object} pagination Pagination metadata + */ + async search (ctx) { + try { + const { q, limit, offset } = ctx.query + ctx.body = await this.useCases.searchAll.execute({ q, limit, offset }) + } catch (err) { + this.handleError(ctx, err) + } + } +} + +export default SearchRESTControllerLib + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:29:31.872Z","module_hash":"754b83cf1a4999e989084ba332d88924472370a14df989910d85bcea04deb001","functions":[{"id":"func/SearchRESTControllerLib.constructor","name":"SearchRESTControllerLib.constructor","line":8,"end_line":20,"hash":"75f8c48a39c4449f5b8c8a455d1bc6cdcd762fb035018f75b5e808f093c70c7b"},{"id":"func/SearchRESTControllerLib.handleError","name":"SearchRESTControllerLib.handleError","line":22,"end_line":29,"hash":"d8a071b5d797e24bacd19f63ca74b8eda35dc8d1f4bed97b9e5d37af7bee6831"},{"id":"func/SearchRESTControllerLib.search","name":"SearchRESTControllerLib.search","line":52,"end_line":59,"hash":"814f10f06f8406a61e59aa3678794414a41e8ddecd9693f5c3fa92242349722e"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/controllers/rest-api/search/index.js b/psf-memo-db/src/controllers/rest-api/search/index.js new file mode 100644 index 0000000..cac5994 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/search/index.js @@ -0,0 +1,45 @@ +/* + REST API route for /search. +*/ + +import Router from 'koa-router' +import SearchRESTControllerLib from './controller.js' + +class SearchRouter { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + this.useCases = localConfig.useCases + this.router = new Router({ prefix: '/search' }) + this.basev1 = '/' + this.attach = this.attach.bind(this) + this.search = this.search.bind(this) + } + + attach (app) { + if (!app) { + throw new Error('App object must be passed when attaching SearchRouter.') + } + + const searchRESTController = new SearchRESTControllerLib({ + adapters: this.adapters, + useCases: this.useCases + }) + + this.router.get(this.basev1, this.search(searchRESTController)) + + app.use(this.router.routes()) + app.use(this.router.allowedMethods({ throw: true })) + } + + search (searchRESTController) { + return async (ctx, next) => { + await searchRESTController.search(ctx, next) + } + } +} + +export default SearchRouter + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:20:33.643Z","module_hash":"a32dfbdaa55df013a2d49f84a3089f66f38021248e12efcb11407118c94c9bc6","functions":[{"id":"func/SearchRouter.constructor","name":"SearchRouter.constructor","line":9,"end_line":16,"hash":"1eb5af36a47b22f3a868e5b9919dd1014e3a02ad9433f8eb30dda1ea30fdc046"},{"id":"func/SearchRouter.attach","name":"SearchRouter.attach","line":18,"end_line":32,"hash":"5b86e45767e913c1917b019cc2a3a46a150e9830022064012d72c0d6157f0591"},{"id":"func/SearchRouter.search","name":"SearchRouter.search","line":34,"end_line":38,"hash":"5dc63a71388481ce121a1eb449311e6bf09f98ebded6ed8dd5ea62922f2297c0"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/lib/search.js b/psf-memo-db/src/lib/search.js new file mode 100644 index 0000000..9f61675 --- /dev/null +++ b/psf-memo-db/src/lib/search.js @@ -0,0 +1,23 @@ +/* + Shared pure helpers for the search feature. + + normalizeQuery normalizes a user query for case-insensitive substring + matching. sortByHeightDesc orders posts/profiles by block height descending, + breaking ties by seen timestamp. Both are shared by the search use case and + the search adapter so the behavior stays identical across callers. +*/ + +export function normalizeQuery (query) { + return String(query ?? '').trim().toLowerCase() +} + +export function sortByHeightDesc (a, b) { + if (b.blockHeight !== a.blockHeight) { + return b.blockHeight - a.blockHeight + } + return (b.seen || 0) - (a.seen || 0) +} + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:18:40.416Z","module_hash":"de3b455b6e4376f7a860f74318a42fbe21734f8be5cbcd1a2c261d7c67d1910d","functions":[{"id":"func/normalizeQuery","name":"normalizeQuery","line":10,"end_line":12,"hash":"8257496a9daf753f6928fc7ae709f319231a86dbbd4058727346e6be8cd12896"},{"id":"func/sortByHeightDesc","name":"sortByHeightDesc","line":14,"end_line":19,"hash":"6c1f80c4b6362b75769a45106c24df2dabfd885dec67acd6d3226729905d7c43"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index 9955d64..2022e09 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -18,6 +18,7 @@ import ListTopicFollowers from './list-topic-followers.js' import GetPoll from './get-poll.js' import GetPollOptions from './get-poll-options.js' import GetPollVotes from './get-poll-votes.js' +import SearchAll from './search-all.js' class UseCases { constructor (localConfig = {}) { @@ -45,6 +46,7 @@ class UseCases { this.getPoll = null this.getPollOptions = null this.getPollVotes = null + this.searchAll = null } async start () { @@ -112,6 +114,10 @@ class UseCases { adapters: this.adapters }) + this.searchAll = new SearchAll({ + adapters: this.adapters + }) + console.log('Use cases initialized.') return true diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index 8664432..294936f 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -4,27 +4,19 @@ import { parseLimit, parseOffset } from './lib/pagination.js' import { ListUseCase } from './lib/use-case.js' +import { sortByHeightDesc } from '../lib/search.js' class ListRecentProfiles extends ListUseCase { constructor (localConfig = {}) { super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' }) } - sortProfiles (profiles) { - return profiles.sort((a, b) => { - if (b.blockHeight !== a.blockHeight) { - return b.blockHeight - a.blockHeight - } - return (b.seen || 0) - (a.seen || 0) - }) - } - async execute (inObj = {}) { const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight() - const sorted = this.sortProfiles(allProfiles) + const sorted = allProfiles.sort(sortByHeightDesc) const total = sorted.length const profiles = sorted.slice(offset, offset + limit) @@ -43,5 +35,5 @@ class ListRecentProfiles extends ListUseCase { export default ListRecentProfiles // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T18:15:31.955Z","module_hash":"0a41c7c2086270d2f29266eb20b2313d2ab8782fc8a32ad6dd0b27d737a41b13","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":9,"end_line":11,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.sortProfiles","name":"ListRecentProfiles.sortProfiles","line":13,"end_line":20,"hash":"8dbe8da4b9a5c52230b5f865a6d0b5a5817a266277a72b09b93b2b0f76414d9e"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":22,"end_line":40,"hash":"d14afac95c39e7a311d9e3a1243b6a326de38906b5ad312f014ce6b6d5459de1"}]} +// {"version":1,"tested_at":"2026-08-29T14:25:23.929Z","module_hash":"4eee5bdcc428b107755e33bd6b2b6dd6477a01aa9feece327ba144ec4825442f","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":10,"end_line":12,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":14,"end_line":32,"hash":"f9fc78d274ac88aad570a32a45d20364974dde79538b414823241610cf2339dc"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-db/src/use-cases/search-all.js b/psf-memo-db/src/use-cases/search-all.js new file mode 100644 index 0000000..e551dba --- /dev/null +++ b/psf-memo-db/src/use-cases/search-all.js @@ -0,0 +1,73 @@ +/* + Use case: search across posts and profiles by case-insensitive substring. + + Returns a page of matching top-level posts and profiles sharing the same + pagination parameters. Empty or whitespace-only queries produce an empty + result set without error. +*/ + +import { parseLimit, parseOffset } from './lib/pagination.js' +import { ListUseCase } from './lib/use-case.js' +import { normalizeQuery, sortByHeightDesc } from '../lib/search.js' + +class SearchAll extends ListUseCase { + constructor (localConfig = {}) { + super(localConfig, { useCaseName: 'SearchAll', adapterName: 'searchQuery' }) + } + + async execute (inObj = {}) { + const q = normalizeQuery(inObj.q) + const limit = parseLimit(inObj.limit) + const offset = parseOffset(inObj.offset) + + if (q.length === 0) { + return this.emptyResult(limit, offset) + } + + const [allPosts, allProfiles] = await Promise.all([ + this.adapters.searchQuery.searchPosts(q), + this.adapters.searchQuery.searchProfiles(q) + ]) + + allPosts.sort(sortByHeightDesc) + allProfiles.sort(sortByHeightDesc) + + const totalPosts = allPosts.length + const totalProfiles = allProfiles.length + const total = totalPosts + totalProfiles + + const posts = allPosts.slice(offset, offset + limit) + const profiles = allProfiles.slice(offset, offset + limit) + const returnedCount = posts.length + profiles.length + + return { + posts, + profiles, + pagination: { + limit, + offset, + total, + hasMore: offset + returnedCount < total + } + } + } + + emptyResult (limit, offset) { + return { + posts: [], + profiles: [], + pagination: { + limit, + offset, + total: 0, + hasMore: false + } + } + } +} + +export default SearchAll + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-08-29T15:24:14.116Z","module_hash":"c6145beec460f82393ed11ff0d01b72696f557e0e40c2b991ca15e45334cf150","functions":[{"id":"func/SearchAll.constructor","name":"SearchAll.constructor","line":14,"end_line":16,"hash":"adc0dcca1ed5263c0f1e13e8a19657e332982ca6e4f2093080a6a970901ed4d3"},{"id":"func/SearchAll.execute","name":"SearchAll.execute","line":18,"end_line":53,"hash":"ade6f878cca2e20b6e8500cbc6f5650de797767520b31c0d1c29c43762e4ad53"},{"id":"func/SearchAll.emptyResult","name":"SearchAll.emptyResult","line":55,"end_line":66,"hash":"518a3c1be0814ec39ef86770db6f462dfb52bbb622e72b913cb9313cf3b7cc33"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-db/test/property/search-all.property.test.js b/psf-memo-db/test/property/search-all.property.test.js new file mode 100644 index 0000000..98b33ed --- /dev/null +++ b/psf-memo-db/test/property/search-all.property.test.js @@ -0,0 +1,132 @@ +/* + Property tests for the SearchAll use case. + + The unit tests probe a few fixed fixtures. These properties cover broad + random inputs so the invariants hold everywhere: + + - pagination consistency: total equals the combined result count and + hasMore matches the offset/returned-count arithmetic. + - empty query: an empty or whitespace query yields an empty result with + total 0 and hasMore false. + - ordering: returned posts and profiles are sorted by block height + descending. + - query normalization: the trimmed, lowercased query is passed to the + adapter. +*/ + +import test from 'node:test' +import { seededRandom, forAll, intGen } from './harness.js' +import SearchAll from '../../src/use-cases/search-all.js' + +const rng = seededRandom(20260831) + +function recordGen (kind) { + const n = intGen(rng, 0, 10)() + const records = [] + for (let i = 0; i < n; i++) { + const base = { + seen: intGen(rng, 0, 1000)(), + blockHeight: intGen(rng, 0, 1000000)() + } + if (kind === 'post') { + records.push({ txid: `tx${i}`, addr: `addr${i}`, text: `text ${i}`, ...base }) + } else { + records.push({ addr: `addr${i}`, name: `name ${i}`, text: `bio ${i}`, ...base }) + } + } + return records +} + +function makeUseCase (posts, profiles) { + return new SearchAll({ + adapters: { + searchQuery: { + searchPosts: async () => posts, + searchProfiles: async () => profiles + } + } + }) +} + +function isSortedDesc (records) { + for (let i = 1; i < records.length; i++) { + if (records[i - 1].blockHeight < records[i].blockHeight) return false + } + return true +} + +test('pagination metadata is consistent with the result set', async () => { + await forAll( + (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }), + async ({ posts, profiles }) => { + const uut = makeUseCase(posts, profiles) + const limit = intGen(rng, 1, 100)() + const offset = intGen(rng, 0, 20)() + const result = await uut.execute({ q: 'x', limit, offset }) + + const total = posts.length + profiles.length + const returnedCount = result.posts.length + result.profiles.length + const hasMore = offset + returnedCount < total + + return result.pagination.total === total && + result.pagination.hasMore === hasMore && + result.pagination.limit === limit && + result.pagination.offset === offset + }, + { label: 'pagination consistency' } + ) +}) + +test('empty and whitespace queries return an empty result', async () => { + await forAll( + (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }), + async ({ posts, profiles }) => { + const uut = makeUseCase(posts, profiles) + const empty = await uut.execute({ q: '' }) + const whitespace = await uut.execute({ q: ' ' }) + + return empty.posts.length === 0 && + empty.profiles.length === 0 && + empty.pagination.total === 0 && + empty.pagination.hasMore === false && + whitespace.posts.length === 0 && + whitespace.pagination.total === 0 + }, + { label: 'empty query result' } + ) +}) + +test('returned posts and profiles are sorted by block height descending', async () => { + await forAll( + (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }), + async ({ posts, profiles }) => { + const uut = makeUseCase(posts, profiles) + const result = await uut.execute({ q: 'x', limit: 100, offset: 0 }) + + return isSortedDesc(result.posts) && isSortedDesc(result.profiles) + }, + { label: 'block height ordering' } + ) +}) + +test('the trimmed, lowercased query is passed to the adapter', async () => { + await forAll( + (i) => ({ posts: recordGen('post'), profiles: recordGen('profile') }), + async ({ posts, profiles }) => { + let receivedPosts = null + let receivedProfiles = null + const uut = new SearchAll({ + adapters: { + searchQuery: { + searchPosts: async (q) => { receivedPosts = q; return posts }, + searchProfiles: async (q) => { receivedProfiles = q; return profiles } + } + } + }) + await uut.execute({ q: ' HeLLo ' }) + + return receivedPosts === 'hello' && receivedProfiles === 'hello' + }, + { label: 'query normalization' } + ) +}) diff --git a/psf-memo-db/test/property/search-query.property.test.js b/psf-memo-db/test/property/search-query.property.test.js new file mode 100644 index 0000000..7301748 --- /dev/null +++ b/psf-memo-db/test/property/search-query.property.test.js @@ -0,0 +1,175 @@ +/* + Property tests for the SearchQuery adapter. + + The unit tests probe a few fixed fixtures. These properties cover broad + random record sets so the invariants hold everywhere: + + - case-insensitivity: searching with a differently-cased query returns the + same result set as the lowercase query. + - reply exclusion: post search never returns a reply txid. + - substring containment: every returned post's text contains the query. + - empty query: empty and whitespace-only queries return no results. + - profile matching: every returned profile matches by name or bio. +*/ + +import test from 'node:test' +import { seededRandom, forAll, intGen, txidGen } from './harness.js' +import SearchQuery from '../../src/adapters/search-query.js' + +const rng = seededRandom(20260830) + +const WORDS = ['hello', 'bitcoin', 'cash', 'memo', 'protocol', 'trout', 'alice', 'bob', 'block', 'chain'] + +function randomText (rng) { + const n = intGen(rng, 1, 4)() + const words = [] + for (let i = 0; i < n; i++) { + words.push(WORDS[Math.floor(rng() * WORDS.length)]) + } + return words.join(' ') +} + +function makeIterator (entries) { + return async function * () { + for (const entry of entries) { + yield entry + } + } +} + +function makeDb (entries) { + return { iterator: () => makeIterator(entries)() } +} + +// Build a random set of posts (as [txid, post] pairs), some of which are replies. +function postSetGen () { + const posts = [] + const n = intGen(rng, 0, 8)() + for (let i = 0; i < n; i++) { + const txid = txidGen(rng) + posts.push([txid, { + addr: `addr${i}`, + text: randomText(rng), + seen: intGen(rng, 0, 1000)(), + blockHeight: intGen(rng, 0, 1000000)() + }]) + } + const replyTxids = new Set() + const parents = [] + for (const [txid] of posts) { + if (rng() < 0.3) { + replyTxids.add(txid) + parents.push([txid, { parentTxid: 'parent' }]) + } + } + return { posts, parents, replyTxids } +} + +// Build a random set of names and profiles (as [addr, record] pairs). +function profileSetGen () { + const names = [] + const profiles = [] + const n = intGen(rng, 0, 8)() + for (let i = 0; i < n; i++) { + const addr = `addr${i}` + if (rng() < 0.7) { + names.push([addr, { name: randomText(rng), txid: txidGen(rng), seen: intGen(rng, 0, 1000)(), blockHeight: intGen(rng, 0, 1000000)() }]) + } + if (rng() < 0.7) { + profiles.push([addr, { text: randomText(rng), txid: txidGen(rng), seen: intGen(rng, 0, 1000)(), blockHeight: intGen(rng, 0, 1000000)() }]) + } + } + return { names, profiles } +} + +function makeQuery (posts, parents, names, profiles) { + return new SearchQuery({ + postsDb: makeDb(posts), + postParentsDb: makeDb(parents), + namesDb: makeDb(names), + profilesDb: makeDb(profiles) + }) +} + +test('post search is case-insensitive', async () => { + await forAll( + (i) => postSetGen(), + async ({ posts, parents }) => { + const query = makeQuery(posts, parents, [], []) + const word = WORDS[Math.floor(rng() * WORDS.length)] + const a = (await query.searchPosts(word.toUpperCase())).map((p) => p.txid).sort() + const b = (await query.searchPosts(word.toLowerCase())).map((p) => p.txid).sort() + return JSON.stringify(a) === JSON.stringify(b) + }, + { label: 'post search case-insensitivity' } + ) +}) + +test('post search never returns reply txids', async () => { + await forAll( + (i) => postSetGen(), + async ({ posts, parents, replyTxids }) => { + const query = makeQuery(posts, parents, [], []) + const word = WORDS[Math.floor(rng() * WORDS.length)] + const results = await query.searchPosts(word) + return results.every((p) => !replyTxids.has(p.txid)) + }, + { label: 'post search reply exclusion' } + ) +}) + +test('every returned post contains the query substring', async () => { + await forAll( + (i) => postSetGen(), + async ({ posts, parents }) => { + const query = makeQuery(posts, parents, [], []) + const word = WORDS[Math.floor(rng() * WORDS.length)] + const results = await query.searchPosts(word) + return results.every((p) => p.text.toLowerCase().includes(word.toLowerCase())) + }, + { label: 'post search substring containment' } + ) +}) + +test('empty and whitespace queries return no posts', async () => { + await forAll( + (i) => postSetGen(), + async ({ posts, parents }) => { + const query = makeQuery(posts, parents, [], []) + const empty = await query.searchPosts('') + const whitespace = await query.searchPosts(' ') + return empty.length === 0 && whitespace.length === 0 + }, + { label: 'empty post query' } + ) +}) + +test('every returned profile matches by name or bio', async () => { + await forAll( + (i) => profileSetGen(), + async ({ names, profiles }) => { + const query = makeQuery([], [], names, profiles) + const word = WORDS[Math.floor(rng() * WORDS.length)] + const results = await query.searchProfiles(word) + return results.every((p) => { + const nameMatch = p.name && p.name.toLowerCase().includes(word.toLowerCase()) + const textMatch = p.text && p.text.toLowerCase().includes(word.toLowerCase()) + return nameMatch || textMatch + }) + }, + { label: 'profile search name/bio matching' } + ) +}) + +test('empty and whitespace queries return no profiles', async () => { + await forAll( + (i) => profileSetGen(), + async ({ names, profiles }) => { + const query = makeQuery([], [], names, profiles) + const empty = await query.searchProfiles('') + const whitespace = await query.searchProfiles(' ') + return empty.length === 0 && whitespace.length === 0 + }, + { label: 'empty profile query' } + ) +}) diff --git a/psf-memo-db/test/unit/adapters/search-query.unit.js b/psf-memo-db/test/unit/adapters/search-query.unit.js new file mode 100644 index 0000000..b2b6625 --- /dev/null +++ b/psf-memo-db/test/unit/adapters/search-query.unit.js @@ -0,0 +1,232 @@ +import { assert } from 'chai' +import SearchQuery from '../../../src/adapters/search-query.js' + +function makeIterator (entries) { + return async function * () { + for (const entry of entries) { + yield entry + } + } +} + +describe('#SearchQuery', () => { + let postsDb + let postParentsDb + let namesDb + let profilesDb + + beforeEach(() => { + postsDb = { iterator: () => makeIterator([])() } + postParentsDb = { iterator: () => makeIterator([])() } + namesDb = { iterator: () => makeIterator([])() } + profilesDb = { iterator: () => makeIterator([])() } + }) + + it('should require postsDb', () => { + assert.throws(() => { + return new SearchQuery({ postParentsDb, namesDb, profilesDb }) + }, /postsDb required/) + }) + + it('should require postParentsDb', () => { + assert.throws(() => { + return new SearchQuery({ postsDb, namesDb, profilesDb }) + }, /postParentsDb required/) + }) + + it('should require namesDb', () => { + assert.throws(() => { + return new SearchQuery({ postsDb, postParentsDb, profilesDb }) + }, /namesDb required/) + }) + + it('should require profilesDb', () => { + assert.throws(() => { + return new SearchQuery({ postsDb, postParentsDb, namesDb }) + }, /profilesDb required/) + }) + + it('should match top-level posts by text substring case-insensitively', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }], + ['tx2', { addr: 'addr2', text: 'bitcoin cash', seen: 200, blockHeight: 600200 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('HELLO') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx1') + assert.equal(result[0].text, 'hello world') + }) + + it('should exclude replies from post search results', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }], + ['tx2', { addr: 'addr2', text: 'hello reply', seen: 200, blockHeight: 600200 }] + ])() + postParentsDb.iterator = () => makeIterator([ + ['tx2', { parentTxid: 'tx1' }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('hello') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx1') + }) + + it('should match profiles by name case-insensitively', async () => { + namesDb.iterator = () => makeIterator([ + ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }], + ['addr2', { name: 'Bob Builder', txid: 'tx2', seen: 200, blockHeight: 600200 }] + ])() + profilesDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('alice') + + assert.equal(result.length, 1) + assert.equal(result[0].addr, 'addr1') + assert.equal(result[0].name, 'Alice Trout') + }) + + it('should match profiles by bio case-insensitively', async () => { + namesDb.iterator = () => makeIterator([])() + profilesDb.iterator = () => makeIterator([ + ['addr1', { text: 'bitcoin cash enthusiast', txid: 'tx1', seen: 100, blockHeight: 600100 }], + ['addr2', { text: 'building on BCH', txid: 'tx2', seen: 200, blockHeight: 600200 }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('enthusiast') + + assert.equal(result.length, 1) + assert.equal(result[0].addr, 'addr1') + assert.equal(result[0].text, 'bitcoin cash enthusiast') + }) + + it('should return profile name and bio together when both exist', async () => { + namesDb.iterator = () => makeIterator([ + ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }] + ])() + profilesDb.iterator = () => makeIterator([ + ['addr1', { text: 'bitcoin cash enthusiast', txid: 'tx2', seen: 200, blockHeight: 600200 }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('bitcoin') + + assert.equal(result.length, 1) + assert.equal(result[0].name, 'Alice Trout') + assert.equal(result[0].text, 'bitcoin cash enthusiast') + }) + + it('should return no posts when query is empty', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('') + + assert.equal(result.length, 0) + }) + + it('should return no profiles when query is empty', async () => { + namesDb.iterator = () => makeIterator([ + ['addr1', { name: 'Alice Trout', txid: 'tx1', seen: 100, blockHeight: 600100 }] + ])() + profilesDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('') + + assert.equal(result.length, 0) + }) + + it('should return a match for a single-character query', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('h') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx1') + }) + + it('should skip a null post without throwing', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', null], + ['tx2', { addr: 'addr2', text: 'hello world', seen: 100, blockHeight: 600100 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('hello') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx2') + }) + + it('should exclude posts that do not match the query', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchPosts('zzz') + + assert.equal(result.length, 0) + }) + + it('should default blockHeight to 0 for a profile record without one', async () => { + namesDb.iterator = () => makeIterator([])() + profilesDb.iterator = () => makeIterator([ + ['addr1', { text: 'bitcoin cash', txid: 'tx1', seen: 100 }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('bitcoin') + + assert.equal(result.length, 1) + assert.equal(result[0].blockHeight, 0) + }) + + it('should default blockHeight and seen to 0 when profile records lack them', async () => { + namesDb.iterator = () => makeIterator([ + ['addr1', { name: 'Alice', txid: 'tx1' }] + ])() + profilesDb.iterator = () => makeIterator([ + ['addr1', { text: 'bitcoin cash' }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('bitcoin') + + assert.equal(result.length, 1) + assert.equal(result[0].blockHeight, 0) + assert.equal(result[0].seen, 0) + }) + + it('should fall back to the profile txid when the name record has none', async () => { + namesDb.iterator = () => makeIterator([ + ['addr1', { name: 'Alice' }] + ])() + profilesDb.iterator = () => makeIterator([ + ['addr1', { text: 'bitcoin cash', txid: 'tx2', seen: 100, blockHeight: 600100 }] + ])() + + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb }) + const result = await uut.searchProfiles('bitcoin') + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx2') + }) +}) diff --git a/psf-memo-db/test/unit/controllers/search.controller.unit.js b/psf-memo-db/test/unit/controllers/search.controller.unit.js new file mode 100644 index 0000000..0f31598 --- /dev/null +++ b/psf-memo-db/test/unit/controllers/search.controller.unit.js @@ -0,0 +1,76 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import SearchRESTControllerLib from '../../../src/controllers/rest-api/search/controller.js' + +describe('#SearchRESTController', () => { + let uut + let sandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + uut = new SearchRESTControllerLib({ + adapters: {}, + useCases: { + searchAll: { + execute: sandbox.stub().resolves({ + posts: [{ txid: 'tx1', text: 'hello' }], + profiles: [{ addr: 'addr1', name: 'Alice' }], + pagination: { limit: 100, offset: 0, total: 2, hasMore: false } + }) + } + } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should return search results from use case', async () => { + const ctx = { query: { q: 'hello', limit: '50', offset: '0' }, body: null, throw: sandbox.stub() } + await uut.search(ctx) + + assert.equal(uut.useCases.searchAll.execute.callCount, 1) + assert.deepEqual(uut.useCases.searchAll.execute.firstCall.args[0], { + q: 'hello', + limit: '50', + offset: '0' + }) + assert.equal(ctx.body.posts.length, 1) + assert.equal(ctx.body.profiles.length, 1) + assert.equal(ctx.body.pagination.total, 2) + }) + + it('should handle an empty query', async () => { + const ctx = { query: { q: '' }, body: null, throw: sandbox.stub() } + await uut.search(ctx) + + assert.deepEqual(uut.useCases.searchAll.execute.firstCall.args[0], { + q: '', + limit: undefined, + offset: undefined + }) + }) + + it('should rethrow a status error from the use case', async () => { + const err = new Error('bad request') + err.status = 400 + uut.useCases.searchAll.execute.rejects(err) + + const ctx = { query: { q: 'hello' }, body: null, throw: sandbox.stub() } + await uut.search(ctx) + + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 400) + assert.equal(ctx.throw.firstCall.args[1], 'bad request') + }) + + it('should throw a 500 for an unexpected error', async () => { + uut.useCases.searchAll.execute.rejects(new Error('boom')) + + const ctx = { query: { q: 'hello' }, body: null, throw: sandbox.stub() } + await uut.search(ctx) + + assert.equal(ctx.throw.callCount, 1) + assert.equal(ctx.throw.firstCall.args[0], 500) + assert.equal(ctx.throw.firstCall.args[1], 'boom') + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/search-all.unit.js b/psf-memo-db/test/unit/use-cases/search-all.unit.js new file mode 100644 index 0000000..45873d8 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/search-all.unit.js @@ -0,0 +1,135 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import SearchAll from '../../../src/use-cases/search-all.js' + +describe('#SearchAll', () => { + let uut + let sandbox + let searchQuery + + beforeEach(() => { + sandbox = sinon.createSandbox() + searchQuery = { + searchPosts: sandbox.stub().resolves([]), + searchProfiles: sandbox.stub().resolves([]) + } + uut = new SearchAll({ + adapters: { searchQuery } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should require searchQuery adapter', () => { + assert.throws(() => { + return new SearchAll({ adapters: {} }) + }, /searchQuery adapter required/) + }) + + it('should return empty results for an empty query', async () => { + const result = await uut.execute({ q: '' }) + + assert.deepEqual(result.posts, []) + assert.deepEqual(result.profiles, []) + assert.equal(result.pagination.total, 0) + assert.equal(result.pagination.hasMore, false) + }) + + it('should pass query to the adapter and return results', async () => { + searchQuery.searchPosts.resolves([ + { txid: 'tx1', addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 } + ]) + searchQuery.searchProfiles.resolves([ + { addr: 'addr1', name: 'Alice Trout', text: 'bitcoin fan', seen: 100, blockHeight: 600100 } + ]) + + const result = await uut.execute({ q: 'hello' }) + + assert.equal(result.posts.length, 1) + assert.equal(result.posts[0].text, 'hello world') + assert.equal(result.profiles.length, 1) + assert.equal(result.profiles[0].name, 'Alice Trout') + assert.equal(searchQuery.searchPosts.firstCall.args[0], 'hello') + assert.equal(searchQuery.searchProfiles.firstCall.args[0], 'hello') + }) + + it('should default limit to 100 and offset to 0', async () => { + searchQuery.searchPosts.resolves([]) + searchQuery.searchProfiles.resolves([]) + + const result = await uut.execute({ q: 'test' }) + + assert.equal(result.pagination.limit, 100) + assert.equal(result.pagination.offset, 0) + }) + + it('should reject limit over 100', async () => { + try { + await uut.execute({ q: 'test', limit: 101 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'limit cannot exceed') + } + }) + + it('should paginate results', async () => { + searchQuery.searchPosts.resolves([ + { txid: 'tx1', addr: 'addr1', text: 'a', seen: 100, blockHeight: 600100 }, + { txid: 'tx2', addr: 'addr2', text: 'b', seen: 200, blockHeight: 600200 } + ]) + searchQuery.searchProfiles.resolves([ + { addr: 'addr1', name: 'Alice', text: 'bio', seen: 100, blockHeight: 600100 }, + { addr: 'addr2', name: 'Bob', text: 'bio', seen: 200, blockHeight: 600200 } + ]) + + const result = await uut.execute({ q: 'test', limit: 1, offset: 0 }) + + assert.equal(result.posts.length, 1) + assert.equal(result.profiles.length, 1) + assert.equal(result.pagination.limit, 1) + assert.equal(result.pagination.offset, 0) + assert.equal(result.pagination.total, 4) + assert.equal(result.pagination.hasMore, true) + }) + + it('should trim whitespace from query', async () => { + searchQuery.searchPosts.resolves([]) + searchQuery.searchProfiles.resolves([]) + + await uut.execute({ q: ' hello ' }) + + assert.equal(searchQuery.searchPosts.firstCall.args[0], 'hello') + assert.equal(searchQuery.searchProfiles.firstCall.args[0], 'hello') + }) + + it('should search for a single-character query', async () => { + searchQuery.searchPosts.resolves([ + { txid: 'tx1', addr: 'addr1', text: 'hello', seen: 100, blockHeight: 600100 } + ]) + searchQuery.searchProfiles.resolves([]) + + const result = await uut.execute({ q: 'h' }) + + assert.equal(result.posts.length, 1) + assert.equal(searchQuery.searchPosts.firstCall.args[0], 'h') + }) + + it('should report hasMore false when the page reaches the end', async () => { + searchQuery.searchPosts.resolves([ + { txid: 'tx1', addr: 'addr1', text: 'a', seen: 100, blockHeight: 600100 }, + { txid: 'tx2', addr: 'addr2', text: 'b', seen: 200, blockHeight: 600200 } + ]) + searchQuery.searchProfiles.resolves([ + { addr: 'addr1', name: 'Alice', text: 'bio', seen: 100, blockHeight: 600100 }, + { addr: 'addr2', name: 'Bob', text: 'bio', seen: 200, blockHeight: 600200 } + ]) + + const result = await uut.execute({ q: 'test', limit: 4, offset: 0 }) + + assert.equal(result.posts.length, 2) + assert.equal(result.profiles.length, 2) + assert.equal(result.pagination.total, 4) + assert.equal(result.pagination.hasMore, false) + }) +})