From 8508fdaff062d853a6517916df521ddd0595c7f4 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 07:18:39 -0700 Subject: [PATCH] Implement Search feature (P6.4) - Add psf-memo-db /search endpoint with SearchQuery adapter, SearchAll use case, and REST controller/router. - Add psf-memo-client SearchPage service, Search component, route, and nav link. - Wire search support into the acceptance handler fake MemoDb. - Add unit tests for the new adapter, use case, controller, and page service. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 118 +++++++++++++- .../src/components/app-body/index.js | 2 + .../src/components/app-body/search/index.js | 132 ++++++++++++++++ .../src/components/nav-menu/index.js | 8 + psf-memo-client/src/services/memo-db.js | 13 ++ psf-memo-client/src/services/search-page.js | 54 +++++++ psf-memo-client/test/unit/search-page.test.js | 83 ++++++++++ psf-memo-db/src/adapters/index.js | 7 + psf-memo-db/src/adapters/search-query.js | 139 ++++++++++++++++ psf-memo-db/src/controllers/rest-api/index.js | 4 + .../controllers/rest-api/search/controller.js | 62 ++++++++ .../src/controllers/rest-api/search/index.js | 41 +++++ psf-memo-db/src/use-cases/index.js | 6 + psf-memo-db/src/use-cases/search-all.js | 79 ++++++++++ .../test/unit/adapters/search-query.unit.js | 149 ++++++++++++++++++ .../controllers/search.controller.unit.js | 52 ++++++ .../test/unit/use-cases/search-all.unit.js | 105 ++++++++++++ 17 files changed, 1053 insertions(+), 1 deletion(-) create mode 100644 psf-memo-client/src/components/app-body/search/index.js create mode 100644 psf-memo-client/src/services/search-page.js create mode 100644 psf-memo-client/test/unit/search-page.test.js create mode 100644 psf-memo-db/src/adapters/search-query.js create mode 100644 psf-memo-db/src/controllers/rest-api/search/controller.js create mode 100644 psf-memo-db/src/controllers/rest-api/search/index.js create mode 100644 psf-memo-db/src/use-cases/search-all.js create mode 100644 psf-memo-db/test/unit/adapters/search-query.unit.js create mode 100644 psf-memo-db/test/unit/controllers/search.controller.unit.js create mode 100644 psf-memo-db/test/unit/use-cases/search-all.unit.js 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/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 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..e74ec29 --- /dev/null +++ b/psf-memo-client/test/unit/search-page.test.js @@ -0,0 +1,83 @@ +/* + 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') +}) 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/search-query.js b/psf-memo-db/src/adapters/search-query.js new file mode 100644 index 0000000..a051d4c --- /dev/null +++ b/psf-memo-db/src/adapters/search-query.js @@ -0,0 +1,139 @@ +/* + 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 +*/ + +function normalizeQuery (query) { + return String(query ?? '').trim().toLowerCase() +} + +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 loadReplyTxids () { + const replyTxids = new Set() + + for await (const [childTxid] of this.postParentsDb.iterator()) { + replyTxids.add(childTxid) + } + + return replyTxids + } + + async searchPosts (query) { + const normalized = normalizeQuery(query) + if (normalized.length === 0) return [] + + const replyTxids = await this.loadReplyTxids() + const matches = [] + + for await (const [txid, post] of this.postsDb.iterator()) { + if (replyTxids.has(txid)) continue + if (!post || typeof post.text !== 'string') continue + if (post.text.toLowerCase().includes(normalized)) { + matches.push({ + txid, + addr: post.addr, + text: post.text, + seen: post.seen, + blockHeight: post.blockHeight ?? 0 + }) + } + } + + return matches + } + + async searchProfiles (query) { + const normalized = normalizeQuery(query) + if (normalized.length === 0) return [] + + const names = new Map() + for await (const [addr, nameData] of this.namesDb.iterator()) { + if (!nameData) continue + names.set(addr, { + name: nameData.name, + txid: nameData.txid, + seen: nameData.seen, + blockHeight: nameData.blockHeight ?? 0 + }) + } + + const profiles = new Map() + for await (const [addr, profile] of this.profilesDb.iterator()) { + if (!profile) continue + profiles.set(addr, { + text: profile.text, + txid: profile.txid, + seen: profile.seen, + blockHeight: profile.blockHeight ?? 0 + }) + } + + 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)) + } + } + + for (const [addr, profileRecord] of profiles.entries()) { + if (typeof profileRecord.text === 'string' && profileRecord.text.toLowerCase().includes(normalized)) { + if (!matches.has(addr)) { + const nameRecord = names.get(addr) || {} + matches.set(addr, this.profileMatches(addr, nameRecord, profileRecord)) + } + } + } + + return Array.from(matches.values()) + } + + 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 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..de13a31 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/search/controller.js @@ -0,0 +1,62 @@ +/* + 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 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..d8b8314 --- /dev/null +++ b/psf-memo-db/src/controllers/rest-api/search/index.js @@ -0,0 +1,41 @@ +/* + 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 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/search-all.js b/psf-memo-db/src/use-cases/search-all.js new file mode 100644 index 0000000..c4d2c7d --- /dev/null +++ b/psf-memo-db/src/use-cases/search-all.js @@ -0,0 +1,79 @@ +/* + 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' + +function normalizeQuery (query) { + return String(query ?? '').trim().toLowerCase() +} + +function sortByHeightDesc (a, b) { + if (b.blockHeight !== a.blockHeight) { + return b.blockHeight - a.blockHeight + } + return (b.seen || 0) - (a.seen || 0) +} + +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 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..4789d36 --- /dev/null +++ b/psf-memo-db/test/unit/adapters/search-query.unit.js @@ -0,0 +1,149 @@ +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) + }) +}) 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..38e75bc --- /dev/null +++ b/psf-memo-db/test/unit/controllers/search.controller.unit.js @@ -0,0 +1,52 @@ +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 + }) + }) +}) 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..f1ae1b3 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/search-all.unit.js @@ -0,0 +1,105 @@ +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') + }) +})