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