Merge Search feature (P6.4) from architect

Merge swarmforge-architect into master. Verified client (build/test/lint)
and DB (test/lint) after merge.

By specifier.
This commit is contained in:
Chris Troutner
2026-08-29 09:25:43 -07:00
24 changed files with 1608 additions and 20 deletions
+117 -1
View File
@@ -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}.`)
}
}
}
]
+4
View File
@@ -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
@@ -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) {
<Route path='/posts/new' element={<NewPost appData={appData} />} />
<Route path='/topics' element={<Topics />} />
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/search' element={<Search />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -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 <p className='search-empty mt-4'>No results found.</p>
}
return (
<>
{posts.length > 0 && (
<>
<h2 className='mt-4'>Posts</h2>
<ListGroup>
{posts.map((post) => (
<ListGroup.Item key={post.txid}>
<p>{post.text}</p>
<p className='text-muted' style={{ fontFamily: 'monospace' }}>
{post.addr}
</p>
</ListGroup.Item>
))}
</ListGroup>
</>
)}
{profiles.length > 0 && (
<>
<h2 className='mt-4'>Profiles</h2>
<ListGroup>
{profiles.map((profile) => (
<ListGroup.Item key={profile.addr}>
<Link to={`/profile/${encodeURIComponent(profile.addr)}`}>
{profile.name || profile.addr}
</Link>
{profile.text && <p className='text-muted'>{profile.text}</p>}
</ListGroup.Item>
))}
</ListGroup>
</>
)}
</>
)
}
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 (
<Container className='search-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='search-heading'>
<h1>Search</h1>
<p>Find posts and profiles on Memo.</p>
</header>
<Form onSubmit={handleSubmit}>
<Form.Group controlId='searchQuery' className='mb-3'>
<Form.Control
type='text'
placeholder='Search posts and profiles...'
value={query}
onChange={(e) => setQuery(e.target.value)}
disabled={loading}
/>
</Form.Group>
<Button type='submit' variant='primary' disabled={loading}>
Search
</Button>
</Form>
{error && <p className='text-danger mt-3'>{error}</p>}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>Loading...</span>
</Spinner>
</div>
)}
{!loading && <SearchResults posts={posts} profiles={profiles} searched={searched} />}
</Col>
</Row>
</Container>
)
}
export default Search
@@ -78,6 +78,14 @@ function NavMenu (props) {
Topics
</NavLink>
<NavLink
className={currentPath === '/search' ? 'nav-link-active' : 'nav-link-inactive'}
to='/search'
onClick={handleClickEvent}
>
Search
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
+13
View File
@@ -58,6 +58,19 @@ class MemoDb {
return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers')
}
async search (q, { limit = 100, offset = 0 } = {}) {
try {
const result = await this.axios.get(`${config.backend}/search`, {
params: { q, limit, offset }
})
return result.data
} catch (err) {
console.error('Error in search()')
throw err
}
}
// GET a boolean state endpoint and coerce the named field to a boolean.
async _getState (path, name, params, field) {
try {
@@ -0,0 +1,58 @@
/*
Search Page behavior: capture a query, submit it, and display results.
This is the testable controller behind the React Search page. It wraps the
MemoDb client and exposes the returned posts and profiles so the view can
render them. Search is read-only and does not require a wallet.
*/
const SEARCH_PATH = '/search'
class SearchPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.navigate = deps.navigate || (() => {})
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
@@ -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)
})
+7
View File
@@ -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
}
@@ -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
+4 -8
View File
@@ -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
+142
View File
@@ -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
@@ -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)
}
}
@@ -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
@@ -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
+23
View File
@@ -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
+6
View File
@@ -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
@@ -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
+73
View File
@@ -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
@@ -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' }
)
})
@@ -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' }
)
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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)
})
})