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 (
+
+
+
+
+
+
+ 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')
+ })
+})