Refactor search: reduce CRAP, DRY, add property coverage

- Split SearchQuery.searchProfiles/searchPosts into small helpers to bring
  CRAP to 5.0 or below across the search feature.
- Extract shared normalizeQuery/sortByHeightDesc (src/lib/search.js) and
  loadReplyTxids (src/adapters/lib/load-reply-txids.js) to remove duplication
  with search-all, list-recent-profiles, and post-query.
- Add error-path unit tests for the search controller.
- Add property tests for SearchQuery and SearchAll (pagination, ordering,
  case-insensitivity, reply exclusion, substring containment).
- Refresh mutation manifests for post-query and list-recent-profiles.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-29 07:35:26 -07:00
parent 8508fdaff0
commit d3e3b23bbe
9 changed files with 416 additions and 73 deletions
@@ -0,0 +1,17 @@
/*
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
}
+4 -8
View File
@@ -6,6 +6,8 @@
- postLikes: likes grouped by liked post txid
*/
import { loadReplyTxids } from './lib/load-reply-txids.js'
const HEIGHT_PAD = 12
class PostQuery {
@@ -88,13 +90,7 @@ class PostQuery {
}
async loadReplyTxids () {
const replyTxids = new Set()
for await (const [childTxid] of this.postParentsDb.iterator()) {
replyTxids.add(childTxid)
}
return replyTxids
return loadReplyTxids(this.postParentsDb)
}
async isReply (txid) {
@@ -315,5 +311,5 @@ class PostQuery {
export default PostQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T03:10:29.020Z","module_hash":"021ee6327c28eae9b940a6af05d940c1a47ec64fc1b2f1d1e24aea833945777d","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":12,"end_line":57,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":59,"end_line":61,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":63,"end_line":65,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":67,"end_line":69,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":71,"end_line":73,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":75,"end_line":78,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":80,"end_line":82,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":85,"end_line":88,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":90,"end_line":98,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":100,"end_line":108,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":111,"end_line":125,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":128,"end_line":138,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":141,"end_line":156,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":158,"end_line":162,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":166,"end_line":178,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":180,"end_line":184,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":187,"end_line":194,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":198,"end_line":206,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":208,"end_line":223,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":229,"end_line":257,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":260,"end_line":263,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":265,"end_line":281,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":283,"end_line":294,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":297,"end_line":312,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]}
// {"version":1,"tested_at":"2026-08-29T14:30:59.518Z","module_hash":"48f80ca7bfd456c9cf4c373e5ed22db42826d44df7a720babbd4e6b8e80a8e76","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":14,"end_line":59,"hash":"d7104ee46a40c256bc93c795c04a71f9ff212d723480cbc75ec6d497a0995e84"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":61,"end_line":63,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":65,"end_line":67,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.addrPostHeightKey","name":"PostQuery.addrPostHeightKey","line":69,"end_line":71,"hash":"48579f08593cee36cc2f107c2526ac9def687b354cc5e54089defa3fd37117eb"},{"id":"func/PostQuery.postLikeKey","name":"PostQuery.postLikeKey","line":73,"end_line":75,"hash":"5d16caac2cf88932702b28f9d95927183f8904948eb8c16c7e8c672cd3a0780c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":77,"end_line":80,"hash":"0fd135c51089dcc9bd2f166bb9f28faaa6a03d7eb84cf03cb9b36e97cdb3139c"},{"id":"func/PostQuery.txidFromAddrPostHeight","name":"PostQuery.txidFromAddrPostHeight","line":82,"end_line":84,"hash":"314d5432292a78b273e3165343d3e09276600cbaf239f76bcd1b36fe5fcf5e10"},{"id":"func/PostQuery.txidFromKeyParts","name":"PostQuery.txidFromKeyParts","line":87,"end_line":90,"hash":"59fb8173599070095d87e5d6f56eeb999f79e75e4d3f3e435515e9db8ac71868"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":92,"end_line":94,"hash":"74621495a3affc6ef8688b9d6a814a91c99b22c86c348d261c952aad25df1661"},{"id":"func/PostQuery.isReply","name":"PostQuery.isReply","line":96,"end_line":104,"hash":"be2e3729bd5f05cbfbab3630678eb5c389bd04c616d53b1e0c48c852f4cb25b1"},{"id":"func/PostQuery.countRepliesForTxids","name":"PostQuery.countRepliesForTxids","line":107,"end_line":121,"hash":"16268cd2a0f8db020a099b704c3d5a3cea5daf6a0936dd1f8d85c38809f006aa"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":124,"end_line":134,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.countLikesForTxids","name":"PostQuery.countLikesForTxids","line":137,"end_line":152,"hash":"54e6e3e6467e72d9dd033c9861b3b9ef5e426a76aed7a13e8ac1a0f0389468a3"},{"id":"func/PostQuery.likeTxidFromPostLike","name":"PostQuery.likeTxidFromPostLike","line":154,"end_line":158,"hash":"7e07a9c278a9abae8f646b4f1950f88760f2d5c6445600a42fa42f36d029a45a"},{"id":"func/PostQuery.buildLikeCountMap","name":"PostQuery.buildLikeCountMap","line":162,"end_line":174,"hash":"a82ca3b46d68426edbe25f176c4a6019ea5fb6abea4c5d5e84aff3b381f63c61"},{"id":"func/PostQuery.postTxidFromPostLike","name":"PostQuery.postTxidFromPostLike","line":176,"end_line":180,"hash":"da7f8d0c63dbc074fb25da1a31dde5075c2c3469b84a5c700bd4a2961879d8e9"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":183,"end_line":190,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":194,"end_line":202,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":204,"end_line":219,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxidsAndCount","name":"PostQuery.scanPostsByAddrTxidsAndCount","line":225,"end_line":253,"hash":"a872d7a1f71ce1ba6a1dee46a820989cbbb7287dfc433051be1e15890dbc010b"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":256,"end_line":259,"hash":"3498f2b9615e79b9472a5c7be26520c78db7c1661071c90a882f981d8acca477"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":261,"end_line":277,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":279,"end_line":290,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":293,"end_line":308,"hash":"6e12c0b66758cd0103ff6abad1379b3883bea82f084d2382991b4643dfb63360"}]}
// mutate4javascript-manifest-end
+42 -43
View File
@@ -7,9 +7,8 @@
- profilesDb: address -> { text, ... } used for profile-bio search
*/
function normalizeQuery (query) {
return String(query ?? '').trim().toLowerCase()
}
import { normalizeQuery } from '../lib/search.js'
import { loadReplyTxids } from './lib/load-reply-txids.js'
class SearchQuery {
constructor (localConfig = {}) {
@@ -36,27 +35,15 @@ class SearchQuery {
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 replyTxids = await loadReplyTxids(this.postParentsDb)
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)) {
if (this.isMatchingPost(txid, post, replyTxids, normalized)) {
matches.push({
txid,
addr: post.addr,
@@ -70,32 +57,42 @@ class SearchQuery {
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 = 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 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())
}
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
})
}
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)) {
@@ -103,17 +100,19 @@ class SearchQuery {
matches.set(addr, this.profileMatches(addr, nameRecord, profileRecord))
}
}
return matches
}
matchByText (names, profiles, normalized, matches) {
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))
}
if (this.textMatches(profileRecord, normalized) && !matches.has(addr)) {
matches.set(addr, this.profileMatches(addr, names.get(addr) || {}, profileRecord))
}
}
}
return Array.from(matches.values())
textMatches (profileRecord, normalized) {
return typeof profileRecord.text === 'string' && profileRecord.text.toLowerCase().includes(normalized)
}
profileMatches (addr, nameRecord, profileRecord) {
+19
View File
@@ -0,0 +1,19 @@
/*
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)
}
@@ -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
+1 -11
View File
@@ -8,17 +8,7 @@
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)
}
import { normalizeQuery, sortByHeightDesc } from '../lib/search.js'
class SearchAll extends ListUseCase {
constructor (localConfig = {}) {
@@ -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' }
)
})
@@ -49,4 +49,27 @@ describe('#SearchRESTController', () => {
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)
})
})