mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Review and harden search: kill mutation survivors, refresh manifests
Add hardening tests to kill mutation survivors in SearchQuery guards and profileMatches defaults, SearchAll pagination/empty-query guards, the search REST controller 500-error path, and the client SearchPage constructor/getPost/ getProfile. Refresh mutation manifests and the Gherkin acceptance-mutation stamp. By architect.
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-29T15:42:33.013729780Z","feature_name":"Search","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/search.feature","background_hash":"9e7fd3c0f7630e1c8dbe0201df1c30b7b45c1e9a208ab42cf7ff005bdb7881a6","implementation_hash":"unknown","scenarios":[]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Search - 1, Search - 2, Search - 3, Search - 4, Search - 5
|
||||
#
|
||||
# Search is a public read-only feature; it does not require a wallet and does
|
||||
|
||||
@@ -52,3 +52,7 @@ class SearchPage {
|
||||
SearchPage.SEARCH_PATH = SEARCH_PATH
|
||||
|
||||
module.exports = SearchPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:29:31.910Z","module_hash":"5ff867161b6e0af82435936924bfc3f2843e94b60354bbb39833714adb2a7448","functions":[{"id":"func/SearchPage.constructor","name":"SearchPage.constructor","line":12,"end_line":19,"hash":"6e48e42aae340ec24012bd86529d4493664e896d0d86a0bcfaf75e813d196da7"},{"id":"func/SearchPage.setQuery","name":"SearchPage.setQuery","line":21,"end_line":24,"hash":"45cdbb5a6cc4329f2ae2918130de995482f5587efdfa8bf48191672c0917cab3"},{"id":"func/SearchPage.submit","name":"SearchPage.submit","line":26,"end_line":41,"hash":"2e1394b99ec30f3b0fb867ab45c07c439c0d71ecef868dd37bfd7af11c584a2a"},{"id":"func/SearchPage.getPost","name":"SearchPage.getPost","line":43,"end_line":45,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"},{"id":"func/SearchPage.getProfile","name":"SearchPage.getProfile","line":47,"end_line":49,"hash":"c93f06a279f8976938fc8b91ce24e9e742c700ac6ff271328192dba9140ae195"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -81,3 +81,50 @@ test('setQuery stores the trimmed query', () => {
|
||||
|
||||
assert.equal(page.query, 'hello')
|
||||
})
|
||||
|
||||
test('constructor preserves an injected navigate handler', () => {
|
||||
const navigate = () => 'x'
|
||||
const page = new SearchPage({ memoDb: { async search () { return {} } }, navigate })
|
||||
|
||||
assert.equal(page.navigate, navigate)
|
||||
})
|
||||
|
||||
test('getPost returns the matching post', async () => {
|
||||
const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
|
||||
const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) })
|
||||
|
||||
page.setQuery('hello')
|
||||
await page.submit()
|
||||
|
||||
assert.equal(page.getPost('a'.repeat(64)).text, 'hello world')
|
||||
})
|
||||
|
||||
test('getPost returns null when no post matches', async () => {
|
||||
const posts = [{ txid: 'a'.repeat(64), text: 'hello world' }]
|
||||
const page = new SearchPage({ memoDb: makeMemoDb(posts, [], {}) })
|
||||
|
||||
page.setQuery('hello')
|
||||
await page.submit()
|
||||
|
||||
assert.equal(page.getPost('b'.repeat(64)), null)
|
||||
})
|
||||
|
||||
test('getProfile returns the matching profile', async () => {
|
||||
const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
|
||||
const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) })
|
||||
|
||||
page.setQuery('alice')
|
||||
await page.submit()
|
||||
|
||||
assert.equal(page.getProfile('addr1').name, 'Alice Trout')
|
||||
})
|
||||
|
||||
test('getProfile returns null when no profile matches', async () => {
|
||||
const profiles = [{ addr: 'addr1', name: 'Alice Trout' }]
|
||||
const page = new SearchPage({ memoDb: makeMemoDb([], profiles, {}) })
|
||||
|
||||
page.setQuery('alice')
|
||||
await page.submit()
|
||||
|
||||
assert.equal(page.getProfile('addr2'), null)
|
||||
})
|
||||
|
||||
@@ -15,3 +15,7 @@ export async function loadReplyTxids (postParentsDb) {
|
||||
|
||||
return replyTxids
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:18:40.417Z","module_hash":"4024492a889a87fa3669b0daf955e2a906c75407b2b1f83008458710f1206c9e","functions":[{"id":"func/loadReplyTxids","name":"loadReplyTxids","line":9,"end_line":17,"hash":"17ac31205b8d5e508bda758939bcde282f79add47236c27de07402654ae0b2df"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -136,3 +136,7 @@ class SearchQuery {
|
||||
}
|
||||
|
||||
export default SearchQuery
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:24:14.179Z","module_hash":"0c6f365ce684e05ae34df5de2fcacaad023d8578a9e228c91a6d3b799f84c3c8","functions":[{"id":"func/SearchQuery.constructor","name":"SearchQuery.constructor","line":14,"end_line":36,"hash":"dc2625c23df7e50c35810379846377d4af23ea56470d7c9308631a74c415cb51"},{"id":"func/SearchQuery.searchPosts","name":"SearchQuery.searchPosts","line":38,"end_line":58,"hash":"d49a70791e72e76f82842226b1a314e803ca5fce772d78beeb73a99069a537ae"},{"id":"func/SearchQuery.isMatchingPost","name":"SearchQuery.isMatchingPost","line":60,"end_line":64,"hash":"1148a189ef5574cb4789a4b12889269212c4bbf97202bccd2fd817d9d40b51a0"},{"id":"func/SearchQuery.searchProfiles","name":"SearchQuery.searchProfiles","line":66,"end_line":75,"hash":"0570020fc12640e30918accca2ea28157fb9a686183f1e3855d209e840fce82a"},{"id":"func/SearchQuery.loadProfileRecords","name":"SearchQuery.loadProfileRecords","line":77,"end_line":84,"hash":"fb67ceac97d5f08e47eca5c136f0b4afe099c4f3425fd83c72e521f9b1871a27"},{"id":"func/SearchQuery.loadRecords","name":"SearchQuery.loadRecords","line":86,"end_line":93,"hash":"2382749d3be862a2b7713550e680f61d7ef64355632f9f3f0fbeeccbf947e13c"},{"id":"func/SearchQuery.matchByName","name":"SearchQuery.matchByName","line":95,"end_line":104,"hash":"5e6e821883e767ec1b3a2ff03d6e19b032d0e4b3d491b59ffe9ab7924abd8edf"},{"id":"func/SearchQuery.matchByText","name":"SearchQuery.matchByText","line":106,"end_line":112,"hash":"4889f4aba2e7c8951b6899a93b2ebefdea6a8b9ee74f4ef2c112c08e3458459c"},{"id":"func/SearchQuery.textMatches","name":"SearchQuery.textMatches","line":114,"end_line":116,"hash":"3db2504b793c5f6648537f1d63969cbe496b9f42c31d4d0060dd527417561129"},{"id":"func/SearchQuery.profileMatches","name":"SearchQuery.profileMatches","line":118,"end_line":135,"hash":"03af6e62f654560d8361ea0e3f508c53e8e7024e65132974c7202b69461441c6"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -60,3 +60,7 @@ class SearchRESTControllerLib {
|
||||
}
|
||||
|
||||
export default SearchRESTControllerLib
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:29:31.872Z","module_hash":"754b83cf1a4999e989084ba332d88924472370a14df989910d85bcea04deb001","functions":[{"id":"func/SearchRESTControllerLib.constructor","name":"SearchRESTControllerLib.constructor","line":8,"end_line":20,"hash":"75f8c48a39c4449f5b8c8a455d1bc6cdcd762fb035018f75b5e808f093c70c7b"},{"id":"func/SearchRESTControllerLib.handleError","name":"SearchRESTControllerLib.handleError","line":22,"end_line":29,"hash":"d8a071b5d797e24bacd19f63ca74b8eda35dc8d1f4bed97b9e5d37af7bee6831"},{"id":"func/SearchRESTControllerLib.search","name":"SearchRESTControllerLib.search","line":52,"end_line":59,"hash":"814f10f06f8406a61e59aa3678794414a41e8ddecd9693f5c3fa92242349722e"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -39,3 +39,7 @@ class SearchRouter {
|
||||
}
|
||||
|
||||
export default SearchRouter
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:20:33.643Z","module_hash":"a32dfbdaa55df013a2d49f84a3089f66f38021248e12efcb11407118c94c9bc6","functions":[{"id":"func/SearchRouter.constructor","name":"SearchRouter.constructor","line":9,"end_line":16,"hash":"1eb5af36a47b22f3a868e5b9919dd1014e3a02ad9433f8eb30dda1ea30fdc046"},{"id":"func/SearchRouter.attach","name":"SearchRouter.attach","line":18,"end_line":32,"hash":"5b86e45767e913c1917b019cc2a3a46a150e9830022064012d72c0d6157f0591"},{"id":"func/SearchRouter.search","name":"SearchRouter.search","line":34,"end_line":38,"hash":"5dc63a71388481ce121a1eb449311e6bf09f98ebded6ed8dd5ea62922f2297c0"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -17,3 +17,7 @@ export function sortByHeightDesc (a, b) {
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:18:40.416Z","module_hash":"de3b455b6e4376f7a860f74318a42fbe21734f8be5cbcd1a2c261d7c67d1910d","functions":[{"id":"func/normalizeQuery","name":"normalizeQuery","line":10,"end_line":12,"hash":"8257496a9daf753f6928fc7ae709f319231a86dbbd4058727346e6be8cd12896"},{"id":"func/sortByHeightDesc","name":"sortByHeightDesc","line":14,"end_line":19,"hash":"6c1f80c4b6362b75769a45106c24df2dabfd885dec67acd6d3226729905d7c43"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -67,3 +67,7 @@ class SearchAll extends ListUseCase {
|
||||
}
|
||||
|
||||
export default SearchAll
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-29T15:24:14.116Z","module_hash":"c6145beec460f82393ed11ff0d01b72696f557e0e40c2b991ca15e45334cf150","functions":[{"id":"func/SearchAll.constructor","name":"SearchAll.constructor","line":14,"end_line":16,"hash":"adc0dcca1ed5263c0f1e13e8a19657e332982ca6e4f2093080a6a970901ed4d3"},{"id":"func/SearchAll.execute","name":"SearchAll.execute","line":18,"end_line":53,"hash":"ade6f878cca2e20b6e8500cbc6f5650de797767520b31c0d1c29c43762e4ad53"},{"id":"func/SearchAll.emptyResult","name":"SearchAll.emptyResult","line":55,"end_line":66,"hash":"518a3c1be0814ec39ef86770db6f462dfb52bbb622e72b913cb9313cf3b7cc33"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -146,4 +146,87 @@ describe('#SearchQuery', () => {
|
||||
|
||||
assert.equal(result.length, 0)
|
||||
})
|
||||
|
||||
it('should return a match for a single-character query', async () => {
|
||||
postsDb.iterator = () => makeIterator([
|
||||
['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }]
|
||||
])()
|
||||
postParentsDb.iterator = () => makeIterator([])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchPosts('h')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
})
|
||||
|
||||
it('should skip a null post without throwing', async () => {
|
||||
postsDb.iterator = () => makeIterator([
|
||||
['tx1', null],
|
||||
['tx2', { addr: 'addr2', text: 'hello world', seen: 100, blockHeight: 600100 }]
|
||||
])()
|
||||
postParentsDb.iterator = () => makeIterator([])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchPosts('hello')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx2')
|
||||
})
|
||||
|
||||
it('should exclude posts that do not match the query', async () => {
|
||||
postsDb.iterator = () => makeIterator([
|
||||
['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }]
|
||||
])()
|
||||
postParentsDb.iterator = () => makeIterator([])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchPosts('zzz')
|
||||
|
||||
assert.equal(result.length, 0)
|
||||
})
|
||||
|
||||
it('should default blockHeight to 0 for a profile record without one', async () => {
|
||||
namesDb.iterator = () => makeIterator([])()
|
||||
profilesDb.iterator = () => makeIterator([
|
||||
['addr1', { text: 'bitcoin cash', txid: 'tx1', seen: 100 }]
|
||||
])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchProfiles('bitcoin')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
})
|
||||
|
||||
it('should default blockHeight and seen to 0 when profile records lack them', async () => {
|
||||
namesDb.iterator = () => makeIterator([
|
||||
['addr1', { name: 'Alice', txid: 'tx1' }]
|
||||
])()
|
||||
profilesDb.iterator = () => makeIterator([
|
||||
['addr1', { text: 'bitcoin cash' }]
|
||||
])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchProfiles('bitcoin')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
assert.equal(result[0].seen, 0)
|
||||
})
|
||||
|
||||
it('should fall back to the profile txid when the name record has none', async () => {
|
||||
namesDb.iterator = () => makeIterator([
|
||||
['addr1', { name: 'Alice' }]
|
||||
])()
|
||||
profilesDb.iterator = () => makeIterator([
|
||||
['addr1', { text: 'bitcoin cash', txid: 'tx2', seen: 100, blockHeight: 600100 }]
|
||||
])()
|
||||
|
||||
const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb })
|
||||
const result = await uut.searchProfiles('bitcoin')
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx2')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -71,5 +71,6 @@ describe('#SearchRESTController', () => {
|
||||
|
||||
assert.equal(ctx.throw.callCount, 1)
|
||||
assert.equal(ctx.throw.firstCall.args[0], 500)
|
||||
assert.equal(ctx.throw.firstCall.args[1], 'boom')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,4 +102,34 @@ describe('#SearchAll', () => {
|
||||
assert.equal(searchQuery.searchPosts.firstCall.args[0], 'hello')
|
||||
assert.equal(searchQuery.searchProfiles.firstCall.args[0], 'hello')
|
||||
})
|
||||
|
||||
it('should search for a single-character query', async () => {
|
||||
searchQuery.searchPosts.resolves([
|
||||
{ txid: 'tx1', addr: 'addr1', text: 'hello', seen: 100, blockHeight: 600100 }
|
||||
])
|
||||
searchQuery.searchProfiles.resolves([])
|
||||
|
||||
const result = await uut.execute({ q: 'h' })
|
||||
|
||||
assert.equal(result.posts.length, 1)
|
||||
assert.equal(searchQuery.searchPosts.firstCall.args[0], 'h')
|
||||
})
|
||||
|
||||
it('should report hasMore false when the page reaches the end', async () => {
|
||||
searchQuery.searchPosts.resolves([
|
||||
{ txid: 'tx1', addr: 'addr1', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
{ txid: 'tx2', addr: 'addr2', text: 'b', seen: 200, blockHeight: 600200 }
|
||||
])
|
||||
searchQuery.searchProfiles.resolves([
|
||||
{ addr: 'addr1', name: 'Alice', text: 'bio', seen: 100, blockHeight: 600100 },
|
||||
{ addr: 'addr2', name: 'Bob', text: 'bio', seen: 200, blockHeight: 600200 }
|
||||
])
|
||||
|
||||
const result = await uut.execute({ q: 'test', limit: 4, offset: 0 })
|
||||
|
||||
assert.equal(result.posts.length, 2)
|
||||
assert.equal(result.profiles.length, 2)
|
||||
assert.equal(result.pagination.total, 4)
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user