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