Implement Search feature (P6.4)

- Add psf-memo-db /search endpoint with SearchQuery adapter, SearchAll use case,
  and REST controller/router.
- Add psf-memo-client SearchPage service, Search component, route, and nav link.
- Wire search support into the acceptance handler fake MemoDb.
- Add unit tests for the new adapter, use case, controller, and page service.

By coder.
This commit is contained in:
Chris Troutner
2026-08-29 07:18:39 -07:00
parent d11365c057
commit 8508fdaff0
17 changed files with 1053 additions and 1 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}.`)
}
}
}
]
@@ -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,54 @@
/*
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
@@ -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')
})
+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
}
+139
View File
@@ -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
@@ -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,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
@@ -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
+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
+79
View File
@@ -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
@@ -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)
})
})
@@ -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
})
})
})
@@ -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')
})
})