diff --git a/psf-memo-db/src/adapters/lib/load-reply-txids.js b/psf-memo-db/src/adapters/lib/load-reply-txids.js new file mode 100644 index 0000000..e212211 --- /dev/null +++ b/psf-memo-db/src/adapters/lib/load-reply-txids.js @@ -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 +} diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index 23383a5..b718d68 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -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 diff --git a/psf-memo-db/src/adapters/search-query.js b/psf-memo-db/src/adapters/search-query.js index a051d4c..8f8a3e4 100644 --- a/psf-memo-db/src/adapters/search-query.js +++ b/psf-memo-db/src/adapters/search-query.js @@ -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) { diff --git a/psf-memo-db/src/lib/search.js b/psf-memo-db/src/lib/search.js new file mode 100644 index 0000000..43baf7e --- /dev/null +++ b/psf-memo-db/src/lib/search.js @@ -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) +} diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index 8664432..294936f 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -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 diff --git a/psf-memo-db/src/use-cases/search-all.js b/psf-memo-db/src/use-cases/search-all.js index c4d2c7d..f3d33ee 100644 --- a/psf-memo-db/src/use-cases/search-all.js +++ b/psf-memo-db/src/use-cases/search-all.js @@ -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 = {}) { diff --git a/psf-memo-db/test/property/search-all.property.test.js b/psf-memo-db/test/property/search-all.property.test.js new file mode 100644 index 0000000..98b33ed --- /dev/null +++ b/psf-memo-db/test/property/search-all.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-db/test/property/search-query.property.test.js b/psf-memo-db/test/property/search-query.property.test.js new file mode 100644 index 0000000..7301748 --- /dev/null +++ b/psf-memo-db/test/property/search-query.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-db/test/unit/controllers/search.controller.unit.js b/psf-memo-db/test/unit/controllers/search.controller.unit.js index 38e75bc..41b4df8 100644 --- a/psf-memo-db/test/unit/controllers/search.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/search.controller.unit.js @@ -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) + }) })