mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user