mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Harden and review post-heights-index refactor
Architectural review of the refactorer's postHeights index work. Extract a shared assemblePostPage helper and a ListUseCase base class to remove DRY duplication in the post-list use cases. Build the gherkin-mutator runner adapter for psf-memo-db and psf-memo-indexer. Add boundary and tie-break tests to kill all surviving language mutations across the changed files (post-query, pagination, list use cases, indexer post handler). Mutation: all changed source files fully killed. DRY: clean. CRAP <= 6. Soft Gherkin acceptance mutation documented survivors (see review report). By architect.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# Review summary: post-heights-index
|
||||
|
||||
**Architect review of the refactorer handoff for task `post-heights-index`.**
|
||||
|
||||
## Commits reviewed
|
||||
- `6a8cf6653e` (refactorer): "Refactor postHeights index code and add property tests" —
|
||||
deduplicated postHeights query iteration into a shared generator, extracted shared
|
||||
pagination parsing/enrichment and the idempotent create-if-missing write, added property
|
||||
tests for postHeight key round-trips and ordering in both `psf-memo-db` and
|
||||
`psf-memo-indexer`, exposed them via a separate `property` command.
|
||||
- Merged into the architect worktree with the prior chain commits (specifier features,
|
||||
coder acceptance pipelines) that the refactorer branch carried.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
The refactorer's module structure was sound (shared `lib/pagination.js`, generator
|
||||
encapsulation of the secondary-index iteration, `createIfMissing` for idempotent writes).
|
||||
I applied the following structural fixes:
|
||||
|
||||
- **Extracted `assemblePostPage`** into `psf-memo-db/src/use-cases/lib/pagination.js` and
|
||||
used it from `list-posts-by-addr` and `list-recent-posts`, removing the duplicated
|
||||
post-page assembly tail (attach-reply-counts + pagination object) flagged by DRY (0.84).
|
||||
- **Extracted `ListUseCase`** base class into `psf-memo-db/src/use-cases/lib/use-case.js`
|
||||
and made the three list use cases extend it, removing the identical constructor
|
||||
validation boilerplate flagged by DRY at 1.00 across all three files.
|
||||
- **Built the runner adapter** required by the APS `gherkin-mutator` for `psf-memo-db` and
|
||||
`psf-memo-indexer` (`acceptance/lib/runner-worker.js` in each). These route library
|
||||
stdout logging to stderr so the persistent worker's stdout carries only JSON responses.
|
||||
|
||||
## Verification results
|
||||
|
||||
### Language mutation (`mutate4javascript`, `--max-workers 8`, all covered sites)
|
||||
Every changed source file is fully killed (0 survivors, 0 uncovered):
|
||||
- `psf-memo-db/src/adapters/post-query.js` — 22/22 killed
|
||||
- `psf-memo-db/src/use-cases/lib/pagination.js` — 13/13 killed
|
||||
- `psf-memo-db/src/use-cases/lib/use-case.js` — 0 sites (constructor only)
|
||||
- `psf-memo-db/src/use-cases/list-posts-by-addr.js` — killed
|
||||
- `psf-memo-db/src/use-cases/list-recent-posts.js` — 0 sites (post-constructor)
|
||||
- `psf-memo-db/src/use-cases/list-recent-profiles.js` — 9/9 killed
|
||||
- `psf-memo-indexer/src/use-cases/action-types/post.js` — 2/2 killed
|
||||
|
||||
Tests added to kill all initially-surviving mutants: `parseLimit`/`parseOffset` boundary
|
||||
values (1 and 100), `hasMore` exact-page boundaries, the `sortProfiles` `seen` tie-break
|
||||
with falsy values, `scanPostsByAddrTxids`/`scanRecentPostTxids` limit boundary,
|
||||
`txidFromPostHeight` with absent value, `loadPostsByTxids` `blockHeight` fallback, and the
|
||||
`MAX_POST_SIZE` exact-boundary in the indexer.
|
||||
|
||||
### DRY (`dry4javascript`)
|
||||
No duplicate candidates found on any changed source file.
|
||||
|
||||
### CRAP / cyclomatic complexity (`crap4javascript`)
|
||||
All changed functions within threshold (max CC 6, CRAP ≤ 6.0). Highest:
|
||||
`PostQuery.scanPostsByAddrTxids` CC 6 / CRAP 6.0; `handlePost` CC 4 / CRAP 4.8.
|
||||
|
||||
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
|
||||
- **psf-memo-db** `efficient-post-pagination.feature`: 45 mutations discovered, 27
|
||||
executed (1 scenario reused from an earlier fully-killed run, 18 skipped). 23 killed,
|
||||
**4 survived** — all `limit` example-value mutations (`2→6`, `3→7`, `3→11`, `3→5`).
|
||||
Documented equivalents: increasing `limit` above the fixture's post count produces the
|
||||
same accepted page because the scenarios assert the returned posts plus a "no more than
|
||||
limit" bound rather than an exact page size.
|
||||
- **psf-memo-indexer** `efficient-post-indexing.feature`: 27 mutations executed, 5 killed,
|
||||
**22 survived** across `addr`, `height`, `text`, `txid`, `parentTxid`. The scenarios
|
||||
process a transaction with the example values and then assert those same values appear in
|
||||
the store, so any mutated input echoes back into the assertion (weak/tautological
|
||||
example-to-assertion connection). These are specifier-side feature-quality improvements.
|
||||
|
||||
## Suite status
|
||||
- `psf-memo-db`: unit **58 passing**, property **3 passing**, acceptance **pass**.
|
||||
- `psf-memo-indexer`: unit **29 passing**, property **2 passing**, acceptance **pass**.
|
||||
|
||||
## Handoffs sent
|
||||
- `git_handoff` priority 00 to **coder** and **refactorer** (follow-up review of the
|
||||
architectural changes).
|
||||
- No specifier handoff: no specification changes in this commit (the feature-file manifest
|
||||
churn is tool-generated metadata). The weak-scenario findings above are recorded here for
|
||||
the specifier in the durable report.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Persistent runner adapter for the APS gherkin-mutator (psf-memo-db).
|
||||
|
||||
The mutator starts this process (once per worker) and sends mutation jobs
|
||||
over newline-delimited JSON on stdin. Each job carries the path to a mutated
|
||||
feature JSON IR; this worker evaluates it through the same acceptance runtime
|
||||
and step handlers used by the normal acceptance pipeline and replies with the
|
||||
runner outcome.
|
||||
|
||||
Protocol (mutator-spec.md):
|
||||
request: { "id", "feature_json", "generated_dir", "work_dir" }
|
||||
response: { "id", "outcome", "output", "error", "duration" }
|
||||
outcome: test_success | test_failure | infrastructure_error
|
||||
|
||||
test_failure (acceptance failed) -> mutation killed
|
||||
test_success (acceptance passed) -> mutation survived
|
||||
|
||||
stdout is reserved for JSON responses. The db libraries (config, adapters)
|
||||
log to stdout via console.log, so that logging is redirected to stderr below;
|
||||
otherwise the mutator would read non-JSON lines as worker responses.
|
||||
*/
|
||||
|
||||
import readline from 'node:readline'
|
||||
import fs from 'node:fs'
|
||||
|
||||
console.log = (...args) => console.error('[worker]', ...args)
|
||||
|
||||
const { runFeature } = await import('./runtime.js')
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
})
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
const started = Date.now()
|
||||
const respond = (payload) => {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
||||
}
|
||||
|
||||
let job
|
||||
try {
|
||||
job = JSON.parse(line)
|
||||
} catch (err) {
|
||||
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
|
||||
const report = await runFeature(ir)
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
|
||||
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
|
||||
error: '',
|
||||
duration: Date.now() - started
|
||||
})
|
||||
} catch (err) {
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: 'infrastructure_error',
|
||||
output: '',
|
||||
error: err.message,
|
||||
duration: Date.now() - started
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rl.on('close', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -1,3 +1,7 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-08-26T18:32:03.661642374Z","feature_name":"Efficient post pagination","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/efficient-post-pagination.feature","background_hash":"09cc76dccab8b5ac80dd4f72dfbca640087c6bd80ed29d9b9687a41fa20a038f","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Efficient post pagination - 2 GET /posts/by/:addr returns a page of top-level posts for that address sorted by block height descending","scenario_hash":"1d87e78064f9c2b2f227b4cc405e2ea898b15ab34dc245a97f205a8edf1cc8fc","mutation_count":18,"result":{"Total":18,"Killed":18,"Survived":0,"Errors":0},"tested_at":"2026-08-26T18:23:09.918773322Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Efficient post pagination - 1, Efficient post pagination - 2, Efficient post pagination - 3
|
||||
Feature: Efficient post pagination
|
||||
|
||||
|
||||
@@ -175,3 +175,7 @@ class PostQuery {
|
||||
}
|
||||
|
||||
export default PostQuery
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:11:36.339Z","module_hash":"9d849ae3741dcb65f12ea75f911bf873e36f5748183df777be6296c709c5e598","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":8,"end_line":37,"hash":"75efd21f33ee6a78e6c41e71b2b7dea75a7b66bb2f7e27569864d0c9a4843383"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":39,"end_line":41,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":43,"end_line":45,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":47,"end_line":51,"hash":"691bcf6f1ab70e0608e3f05da9b9f7d88cc8ac710cdcb14e6dc4a1c1c0546744"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":53,"end_line":61,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":63,"end_line":73,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":76,"end_line":83,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":87,"end_line":95,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":97,"end_line":112,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":114,"end_line":132,"hash":"6485e255e529be0172debb67749fc2fc1757e5c41f6d03fd14f8d277aea4b91a"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":134,"end_line":150,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":152,"end_line":163,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":165,"end_line":174,"hash":"d5bcd0c7140f6c05cc3e66037ca96627ff262dd975a5553483bbd8f91bfdd7f5"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -49,3 +49,19 @@ export function attachReplyCounts (posts, replyCounts) {
|
||||
replyCount: replyCounts.get(post.txid) ?? 0
|
||||
}))
|
||||
}
|
||||
|
||||
export function assemblePostPage ({ posts, replyCounts, total, limit, offset }) {
|
||||
return {
|
||||
posts: attachReplyCounts(posts, replyCounts),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + posts.length < total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:14:53.748Z","module_hash":"b9ba853191484c924ee7003cee18435e2aa00057155d94e573939f2856cf006c","functions":[{"id":"func/isEmpty","name":"isEmpty","line":13,"end_line":15,"hash":"209ecce14d6de3b7500065dd3091a7f2c006d1b08305078720ddd2ab250bc501"},{"id":"func/httpError","name":"httpError","line":17,"end_line":21,"hash":"270f69e0007e397fbd6beac1f2fd7d3a1156f9ba1cdf2d230e4e867c8b221aeb"},{"id":"func/parseLimit","name":"parseLimit","line":23,"end_line":34,"hash":"20c64d382dedbd2b65e68d66a35ccba8ecfc78dc32534a697e3865f05021eaf2"},{"id":"func/parseOffset","name":"parseOffset","line":36,"end_line":44,"hash":"55b13c976e2d53e6b69658555d97f4e63d425b5cb6b7b47b57081bd18ef16845"},{"id":"func/attachReplyCounts","name":"attachReplyCounts","line":46,"end_line":51,"hash":"99b9e818ac032752eb76fc9fcdcba0dcd1d94bee830921131a18fd01a4848f76"},{"id":"func/assemblePostPage","name":"assemblePostPage","line":53,"end_line":63,"hash":"c605049c633f08e0bd8247d6147b4a2a0ba6652f258f3f44852775f08d6c1e35"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Shared construction contract for list use cases.
|
||||
|
||||
Each list use case validates that an adapters bundle is supplied and that the
|
||||
specific adapter it depends on is present, then binds its execute method.
|
||||
Centralizing this keeps construction validation identical across list
|
||||
endpoints and removes per-class constructor boilerplate.
|
||||
*/
|
||||
|
||||
export class ListUseCase {
|
||||
constructor (localConfig = {}, { useCaseName, adapterName } = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error(`Adapters required when instantiating ${useCaseName} use case.`)
|
||||
}
|
||||
if (!this.adapters[adapterName]) {
|
||||
throw new Error(`${adapterName} adapter required for ${useCaseName} use case.`)
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
}
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:14:47.462Z","module_hash":"595f78478e9b0d12ecf176473430aafe51ed53ea16a69d66225cb448a91b119c","functions":[{"id":"func/ListUseCase.constructor","name":"ListUseCase.constructor","line":11,"end_line":20,"hash":"03c09b5cf853135ed7acd73f025878c78ee572891b73341ce54f3cac805d0cf6"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -3,18 +3,12 @@
|
||||
Uses the postHeights secondary index for efficient sorting and pagination.
|
||||
*/
|
||||
|
||||
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
|
||||
import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js'
|
||||
import { ListUseCase } from './lib/use-case.js'
|
||||
|
||||
class ListPostsByAddr {
|
||||
class ListPostsByAddr extends ListUseCase {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListPostsByAddr use case.')
|
||||
}
|
||||
if (!this.adapters.postQuery) {
|
||||
throw new Error('postQuery adapter required for ListPostsByAddr use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
super(localConfig, { useCaseName: 'ListPostsByAddr', adapterName: 'postQuery' })
|
||||
}
|
||||
|
||||
parseAddr (addr) {
|
||||
@@ -38,16 +32,12 @@ class ListPostsByAddr {
|
||||
this.adapters.postQuery.countTopLevelPostsByAddr(addr)
|
||||
])
|
||||
|
||||
return {
|
||||
posts: attachReplyCounts(posts, replyCounts),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + posts.length < total
|
||||
}
|
||||
}
|
||||
return assemblePostPage({ posts, replyCounts, total, limit, offset })
|
||||
}
|
||||
}
|
||||
|
||||
export default ListPostsByAddr
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:15:16.107Z","module_hash":"b925b4bf1307be2f9d7b4e5d9590f6d189c5ec7632102bb65cdb525ffe19f7b3","functions":[{"id":"func/ListPostsByAddr.constructor","name":"ListPostsByAddr.constructor","line":10,"end_line":12,"hash":"f777f3685c5b2199ec3c3a7043b3cf288a9bbf583232bd1fc58cc346373f03d6"},{"id":"func/ListPostsByAddr.parseAddr","name":"ListPostsByAddr.parseAddr","line":14,"end_line":21,"hash":"0f02fc6ebf05826429d57bf2fd64bf66dfde96ee159f6c1d4814f41cc7a25aca"},{"id":"func/ListPostsByAddr.execute","name":"ListPostsByAddr.execute","line":23,"end_line":36,"hash":"899d7d2e5c9c31a05c34f00c063ffd4f88a6d0a779f6d47000f2fb770029dc86"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -3,18 +3,12 @@
|
||||
Uses the postHeights secondary index for efficient sorting and pagination.
|
||||
*/
|
||||
|
||||
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
|
||||
import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js'
|
||||
import { ListUseCase } from './lib/use-case.js'
|
||||
|
||||
class ListRecentPosts {
|
||||
class ListRecentPosts extends ListUseCase {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListRecentPosts use case.')
|
||||
}
|
||||
if (!this.adapters.postQuery) {
|
||||
throw new Error('postQuery adapter required for ListRecentPosts use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
super(localConfig, { useCaseName: 'ListRecentPosts', adapterName: 'postQuery' })
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
@@ -28,16 +22,12 @@ class ListRecentPosts {
|
||||
this.adapters.postQuery.countTopLevelPosts()
|
||||
])
|
||||
|
||||
return {
|
||||
posts: attachReplyCounts(posts, replyCounts),
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total,
|
||||
hasMore: offset + posts.length < total
|
||||
}
|
||||
}
|
||||
return assemblePostPage({ posts, replyCounts, total, limit, offset })
|
||||
}
|
||||
}
|
||||
|
||||
export default ListRecentPosts
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:15:24.894Z","module_hash":"35af93129e7bf3eddc46fa5909fdf71538afc29182af5a1a2ec06e25193fa7d3","functions":[{"id":"func/ListRecentPosts.constructor","name":"ListRecentPosts.constructor","line":10,"end_line":12,"hash":"0abcf69664af3b707dfe95a9db5caa65e3bbec6dfb740393cafb923e81aad9ae"},{"id":"func/ListRecentPosts.execute","name":"ListRecentPosts.execute","line":14,"end_line":26,"hash":"ff4bd895e3dad7ec3ed58832588e53f049de4f210a19f81ef3e074abf694dd33"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -3,17 +3,11 @@
|
||||
*/
|
||||
|
||||
import { parseLimit, parseOffset } from './lib/pagination.js'
|
||||
import { ListUseCase } from './lib/use-case.js'
|
||||
|
||||
class ListRecentProfiles {
|
||||
class ListRecentProfiles extends ListUseCase {
|
||||
constructor (localConfig = {}) {
|
||||
this.adapters = localConfig.adapters
|
||||
if (!this.adapters) {
|
||||
throw new Error('Adapters required when instantiating ListRecentProfiles use case.')
|
||||
}
|
||||
if (!this.adapters.profileQuery) {
|
||||
throw new Error('profileQuery adapter required for ListRecentProfiles use case.')
|
||||
}
|
||||
this.execute = this.execute.bind(this)
|
||||
super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' })
|
||||
}
|
||||
|
||||
sortProfiles (profiles) {
|
||||
@@ -47,3 +41,7 @@ class ListRecentProfiles {
|
||||
}
|
||||
|
||||
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"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -48,6 +48,32 @@ describe('#PostQuery', () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('#txidFromPostHeight', () => {
|
||||
it('should return the txid from the value when present', () => {
|
||||
assert.equal(uut.txidFromPostHeight('any-key', { txid: 'abc123' }), 'abc123')
|
||||
})
|
||||
|
||||
it('should parse the txid from the key when value is absent', () => {
|
||||
assert.equal(uut.txidFromPostHeight('000000600200:post-200-a', null), 'post-200-a')
|
||||
assert.equal(uut.txidFromPostHeight('000000600200:post-200-a', undefined), 'post-200-a')
|
||||
})
|
||||
})
|
||||
|
||||
describe('#topLevelPostTxids', () => {
|
||||
it('should iterate top-level txids in forward postHeights order by default', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: false }).returns(mockHeights())
|
||||
|
||||
const txids = []
|
||||
for await (const txid of uut.topLevelPostTxids()) txids.push(txid)
|
||||
|
||||
assert.deepEqual(txids, ['post-100', 'post-200-a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('#scanRecentPostTxids', () => {
|
||||
it('should return top-level post txids sorted by block height descending', async () => {
|
||||
async function * mockHeights () {
|
||||
@@ -149,6 +175,20 @@ describe('#PostQuery', () => {
|
||||
|
||||
assert.deepEqual(result, ['post-100'])
|
||||
})
|
||||
|
||||
it('should stop at limit even when more matching posts remain', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600300:post-300', { txid: 'post-300' }]
|
||||
yield ['000000600200:post-200', { txid: 'post-200' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
postsDb.get.callsFake(async (txid) => ({ addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 }))
|
||||
|
||||
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
|
||||
|
||||
assert.deepEqual(result, ['post-300', 'post-200'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('#loadPostsByTxids', () => {
|
||||
@@ -175,6 +215,14 @@ describe('#PostQuery', () => {
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx2')
|
||||
})
|
||||
|
||||
it('should default blockHeight to 0 when a post has no blockHeight', async () => {
|
||||
postsDb.get.withArgs('tx1').resolves({ addr: 'a1', text: 'hello', seen: 1000 })
|
||||
|
||||
const result = await uut.loadPostsByTxids(['tx1'])
|
||||
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#countTopLevelPosts', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { assert } from 'chai'
|
||||
import { parseLimit, parseOffset, attachReplyCounts } from '../../../../src/use-cases/lib/pagination.js'
|
||||
import { parseLimit, parseOffset, attachReplyCounts, assemblePostPage } from '../../../../src/use-cases/lib/pagination.js'
|
||||
|
||||
describe('#pagination', () => {
|
||||
describe('parseLimit', () => {
|
||||
@@ -14,6 +14,16 @@ describe('#pagination', () => {
|
||||
assert.equal(parseLimit(50), 50)
|
||||
})
|
||||
|
||||
it('should accept the minimum limit boundary of 1', () => {
|
||||
assert.equal(parseLimit(1), 1)
|
||||
assert.equal(parseLimit('1'), 1)
|
||||
})
|
||||
|
||||
it('should accept the maximum limit boundary of 100', () => {
|
||||
assert.equal(parseLimit(100), 100)
|
||||
assert.equal(parseLimit('100'), 100)
|
||||
})
|
||||
|
||||
it('should reject a non-numeric limit', () => {
|
||||
try {
|
||||
parseLimit('abc')
|
||||
@@ -90,4 +100,28 @@ describe('#pagination', () => {
|
||||
assert.equal(result[1].replyCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assemblePostPage', () => {
|
||||
it('should attach reply counts and pagination metadata', () => {
|
||||
const posts = [{ txid: 'a', text: 'x' }, { txid: 'b', text: 'y' }]
|
||||
const replyCounts = new Map([['a', 3]])
|
||||
|
||||
const result = assemblePostPage({ posts, replyCounts, total: 2, limit: 10, offset: 0 })
|
||||
|
||||
assert.equal(result.posts[0].replyCount, 3)
|
||||
assert.equal(result.posts[1].replyCount, 0)
|
||||
assert.equal(result.pagination.limit, 10)
|
||||
assert.equal(result.pagination.offset, 0)
|
||||
assert.equal(result.pagination.total, 2)
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
|
||||
it('should report hasMore when a further page exists', () => {
|
||||
const posts = [{ txid: 'a', text: 'x' }]
|
||||
|
||||
const result = assemblePostPage({ posts, replyCounts: new Map(), total: 2, limit: 1, offset: 0 })
|
||||
|
||||
assert.equal(result.pagination.hasMore, true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { assert } from 'chai'
|
||||
import { ListUseCase } from '../../../../src/use-cases/lib/use-case.js'
|
||||
|
||||
class DummyUseCase extends ListUseCase {
|
||||
constructor (localConfig = {}) {
|
||||
super(localConfig, { useCaseName: 'DummyUseCase', adapterName: 'postQuery' })
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
return inObj
|
||||
}
|
||||
}
|
||||
|
||||
describe('#ListUseCase', () => {
|
||||
it('should throw when adapters is missing', () => {
|
||||
try {
|
||||
new DummyUseCase({})
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'Adapters required when instantiating DummyUseCase use case.')
|
||||
}
|
||||
})
|
||||
|
||||
it('should throw when the required adapter is missing', () => {
|
||||
try {
|
||||
new DummyUseCase({ adapters: {} })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postQuery adapter required for DummyUseCase use case.')
|
||||
}
|
||||
})
|
||||
|
||||
it('should bind execute to the instance', () => {
|
||||
const uut = new DummyUseCase({ adapters: { postQuery: {} } })
|
||||
assert.equal(typeof uut.execute, 'function')
|
||||
const bound = uut.execute
|
||||
return bound({ ok: true }).then((result) => assert.deepEqual(result, { ok: true }))
|
||||
})
|
||||
})
|
||||
@@ -43,6 +43,22 @@ describe('#ListPostsByAddr', () => {
|
||||
assert.equal(result.posts[0].txid, 'tx-c')
|
||||
assert.equal(result.posts[1].txid, 'tx-a')
|
||||
assert.equal(result.pagination.total, 2)
|
||||
// Page is exactly full (offset 0 + 2 returned == 2 total), so hasMore must be false.
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
|
||||
it('should report hasMore when a further page exists', async () => {
|
||||
// One page of one item still leaves one more page available.
|
||||
postQuery.scanPostsByAddrTxids.callsFake(async (addr, { limit, offset }) => {
|
||||
return Object.entries(mockPosts)
|
||||
.filter(([txid, post]) => post.addr === addr)
|
||||
.map(([txid]) => txid)
|
||||
.slice(offset, offset + limit)
|
||||
})
|
||||
const result = await uut.execute({ addr: 'addr-a', limit: 1, offset: 0 })
|
||||
|
||||
assert.equal(result.posts.length, 1)
|
||||
assert.equal(result.pagination.hasMore, true)
|
||||
})
|
||||
|
||||
it('should reject missing addr', async () => {
|
||||
@@ -55,6 +71,16 @@ describe('#ListPostsByAddr', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a non-string addr', async () => {
|
||||
try {
|
||||
await uut.execute({ addr: 12345, limit: 10 })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'addr is required')
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass addr, limit, and offset to postQuery', async () => {
|
||||
await uut.execute({ addr: 'addr-a', limit: 5, offset: 10 })
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ describe('#ListRecentProfiles', () => {
|
||||
assert.equal(result.pagination.offset, 0)
|
||||
})
|
||||
|
||||
it('should sort equal-height profiles by seen descending, falsy seen last', async () => {
|
||||
// Same blockHeight so only the `seen` tie-break matters. The dataset mixes
|
||||
// truthy and falsy (0) seen values; a broken comparator is observable here
|
||||
// because the falsy profiles are not already in descending input order.
|
||||
const ties = [
|
||||
{ addr: 'addr-a', txid: 't-a', seen: 0, blockHeight: 700000 },
|
||||
{ addr: 'addr-b', txid: 't-b', seen: 0, blockHeight: 700000 },
|
||||
{ addr: 'addr-c', txid: 't-c', seen: 1, blockHeight: 700000 }
|
||||
]
|
||||
uut.adapters.profileQuery.scanProfilesWithBlockHeight.resolves(ties)
|
||||
|
||||
const result = await uut.execute({ limit: 10, offset: 0 })
|
||||
|
||||
assert.deepEqual(result.profiles.map((p) => p.addr), ['addr-c', 'addr-a', 'addr-b'])
|
||||
})
|
||||
|
||||
it('should reject limit over 100', async () => {
|
||||
try {
|
||||
await uut.execute({ limit: 101 })
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Persistent runner adapter for the APS gherkin-mutator (psf-memo-indexer).
|
||||
|
||||
The mutator starts this process (once per worker) and sends mutation jobs
|
||||
over newline-delimited JSON on stdin. Each job carries the path to a mutated
|
||||
feature JSON IR; this worker evaluates it through the same acceptance runtime
|
||||
and step handlers used by the normal acceptance pipeline and replies with the
|
||||
runner outcome.
|
||||
|
||||
Protocol (mutator-spec.md):
|
||||
request: { "id", "feature_json", "generated_dir", "work_dir" }
|
||||
response: { "id", "outcome", "output", "error", "duration" }
|
||||
outcome: test_success | test_failure | infrastructure_error
|
||||
|
||||
test_failure (acceptance failed) -> mutation killed
|
||||
test_success (acceptance passed) -> mutation survived
|
||||
|
||||
stdout is reserved for JSON responses. The indexer libraries log to stdout
|
||||
via console.log, so that logging is redirected to stderr below; otherwise the
|
||||
mutator would read non-JSON lines as worker responses.
|
||||
*/
|
||||
|
||||
import readline from 'node:readline'
|
||||
import fs from 'node:fs'
|
||||
|
||||
console.log = (...args) => console.error('[worker]', ...args)
|
||||
|
||||
const { runFeature } = await import('./runtime.js')
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
terminal: false
|
||||
})
|
||||
|
||||
rl.on('line', async (line) => {
|
||||
const started = Date.now()
|
||||
const respond = (payload) => {
|
||||
process.stdout.write(`${JSON.stringify(payload)}\n`)
|
||||
}
|
||||
|
||||
let job
|
||||
try {
|
||||
job = JSON.parse(line)
|
||||
} catch (err) {
|
||||
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
|
||||
const report = await runFeature(ir)
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
|
||||
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
|
||||
error: '',
|
||||
duration: Date.now() - started
|
||||
})
|
||||
} catch (err) {
|
||||
respond({
|
||||
id: job.id,
|
||||
outcome: 'infrastructure_error',
|
||||
output: '',
|
||||
error: err.message,
|
||||
duration: Date.now() - started
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
rl.on('close', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
@@ -34,3 +34,7 @@ export async function handlePost (ctx) {
|
||||
await createIfMissing(adapters.postDb, txid, postData)
|
||||
await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight })
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T18:12:57.389Z","module_hash":"4f16a88cac95e1533eeaa9ee78917bd2f69195d909f552f24f232b465aba7e33","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":5,"end_line":11,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/handlePost","name":"handlePost","line":13,"end_line":36,"hash":"64f7e048100212a96ca90d5161271a7055d7b7957d7c38f50f5c22062a9eee43"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { handlePost } from '../../../../src/use-cases/action-types/post.js'
|
||||
import { PREFIX_POST } from '../../../../src/lib/memo-codes.js'
|
||||
import { PREFIX_POST, MAX_POST_SIZE } from '../../../../src/lib/memo-codes.js'
|
||||
|
||||
describe('#handlePost', () => {
|
||||
it('should save a post to the database', async () => {
|
||||
@@ -70,4 +70,31 @@ describe('#handlePost', () => {
|
||||
assert.equal(create.callCount, 0)
|
||||
assert.equal(postHeightCreate.callCount, 0)
|
||||
})
|
||||
|
||||
it('should accept a post whose text is exactly at the maximum size', async () => {
|
||||
const create = sinon.stub().resolves({ success: true })
|
||||
const get = sinon.stub().rejects(new Error('not found'))
|
||||
const postHeightCreate = sinon.stub().resolves({ success: true })
|
||||
const postHeightGet = sinon.stub().rejects(new Error('not found'))
|
||||
const processErrorDb = { create: sinon.stub() }
|
||||
|
||||
const adapters = {
|
||||
postDb: { create, get },
|
||||
postHeightDb: { create: postHeightCreate, get: postHeightGet },
|
||||
processErrorDb
|
||||
}
|
||||
|
||||
const message = Buffer.alloc(MAX_POST_SIZE, 'x')
|
||||
await handlePost({
|
||||
adapters,
|
||||
txid: 'abc123',
|
||||
signerAddr: 'bitcoincash:qptest',
|
||||
seen: 1000,
|
||||
blockHeight: 600100,
|
||||
decoded: { action: 'post', prefix: PREFIX_POST, pushDatas: [PREFIX_POST, message] }
|
||||
})
|
||||
|
||||
assert.equal(create.callCount, 1)
|
||||
assert.equal(processErrorDb.create.callCount, 0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user