mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-23 01:32:00 -07:00
Merge architect post-heights-index work
By specifier.
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,88 @@
|
||||
/*
|
||||
Normal acceptance runner for psf-memo-db.
|
||||
|
||||
Orchestrates the acceptance pipeline:
|
||||
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
|
||||
generator -> generated test entry points -> node test runner
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const specsDir = path.join(root, 'specs')
|
||||
const buildDir = path.join(root, 'build', 'acceptance')
|
||||
const irDir = path.join(buildDir, 'ir')
|
||||
const genDir = path.join(buildDir, 'generated')
|
||||
const apsDir = path.join(root, '..', 'tmp', 'aps-spec')
|
||||
|
||||
function sh (cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...opts
|
||||
}).toString()
|
||||
}
|
||||
|
||||
function ensureAps () {
|
||||
if (fs.existsSync(apsDir)) return
|
||||
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
|
||||
sh('git', ['clone', '--depth', '1',
|
||||
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
|
||||
}
|
||||
|
||||
function main () {
|
||||
ensureAps()
|
||||
|
||||
const features = fs
|
||||
.readdirSync(specsDir)
|
||||
.filter((f) => f.endsWith('.feature'))
|
||||
.sort()
|
||||
|
||||
if (features.length === 0) {
|
||||
console.log('No feature files found under specs/.')
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(irDir, { recursive: true })
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
for (const featureFile of features) {
|
||||
const base = featureFile.replace(/\.feature$/i, '')
|
||||
const featurePath = path.join(specsDir, featureFile)
|
||||
const irPath = path.join(irDir, `${base}.json`)
|
||||
|
||||
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
|
||||
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
|
||||
}
|
||||
|
||||
const tests = fs
|
||||
.readdirSync(genDir)
|
||||
.filter((f) => f.endsWith('.acceptance.test.js'))
|
||||
.sort()
|
||||
|
||||
let failures = 0
|
||||
for (const testFile of tests) {
|
||||
try {
|
||||
const out = sh('node', [path.join(genDir, testFile)])
|
||||
process.stdout.write(out)
|
||||
console.log(`ACCEPTANCE PASS: ${testFile}`)
|
||||
} catch (err) {
|
||||
failures++
|
||||
process.stdout.write(err.stdout || '')
|
||||
process.stderr.write(err.stderr || '')
|
||||
console.error(`ACCEPTANCE FAIL: ${testFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
Project-specific acceptance entrypoint generator for psf-memo-db.
|
||||
|
||||
Reads parser JSON IR and writes executable generated test entry points plus
|
||||
per-feature metadata. Generated tests delegate all step behavior to the
|
||||
acceptance runtime and project step handlers.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function metadataName (featureName) {
|
||||
const slug = featureName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return `${slug || 'feature'}.json`
|
||||
}
|
||||
|
||||
function relativeImport (fromDir, targetFile) {
|
||||
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
|
||||
if (!rel.startsWith('.')) rel = `./${rel}`
|
||||
return rel
|
||||
}
|
||||
|
||||
function main () {
|
||||
const irArg = process.argv[2]
|
||||
const outArg = process.argv[3]
|
||||
|
||||
if (!irArg || !outArg) {
|
||||
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let ir
|
||||
try {
|
||||
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
|
||||
} catch (err) {
|
||||
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const genDir = outArg
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
|
||||
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
|
||||
const relRuntime = relativeImport(genDir, path.join(__dirname, 'runtime.js'))
|
||||
|
||||
const body = `import { runFeature } from '${relRuntime}'
|
||||
const ir = ${JSON.stringify(ir, null, 2)}
|
||||
async function main () {
|
||||
const report = await runFeature(ir)
|
||||
for (const r of report.results) {
|
||||
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
|
||||
if (r.detail) console.log(' ' + r.detail)
|
||||
}
|
||||
if (report.failures > 0) {
|
||||
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(testFile, body)
|
||||
} catch (err) {
|
||||
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const metaDir = path.join(genDir, 'metadata')
|
||||
fs.mkdirSync(metaDir, { recursive: true })
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(testFile))
|
||||
.digest('hex')
|
||||
|
||||
const metadata = {
|
||||
schema_version: 1,
|
||||
feature_path: `${featureKey}.feature`,
|
||||
ir_path: path.resolve(irArg),
|
||||
implementation_hash: `sha256:${hash}`,
|
||||
hash_scope: 'generated_files',
|
||||
generated_files: [testFile]
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(metaDir, metadataName(featureKey)),
|
||||
JSON.stringify(metadata, null, 2)
|
||||
)
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,289 @@
|
||||
/*
|
||||
Project step handlers for the psf-memo-db acceptance pipeline.
|
||||
|
||||
These handlers spin up a real psf-memo-db adapter set against a temporary
|
||||
LevelDB directory, load the Gherkin fixtures, and exercise the real use cases
|
||||
for recent posts and posts-by-address. Iterators and get calls are wrapped
|
||||
so the efficiency steps can assert bounded reads.
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { DB_NAMES } from '../../src/adapters/level-db.js'
|
||||
import Adapters from '../../src/adapters/index.js'
|
||||
import ListRecentPosts from '../../src/use-cases/list-recent-posts.js'
|
||||
import ListPostsByAddr from '../../src/use-cases/list-posts-by-addr.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
|
||||
|
||||
function resolveParam (value, example) {
|
||||
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
|
||||
if (match) {
|
||||
const param = match[1]
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
return example[param]
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function wrapIterator (db, counter) {
|
||||
const original = db.iterator.bind(db)
|
||||
db.iterator = function (...args) {
|
||||
counter.calls++
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
|
||||
function wrapGet (db, counter) {
|
||||
const original = db.get.bind(db)
|
||||
db.get = async function (...args) {
|
||||
counter.calls++
|
||||
return original(...args)
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorld () {
|
||||
const levelDir = path.join(tmpDir, `level-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
const adapters = new Adapters()
|
||||
|
||||
// Force the LevelDB adapter to use a temporary directory for this scenario.
|
||||
adapters.levelDb.openDbs = function () {
|
||||
const dbs = {}
|
||||
fs.mkdirSync(levelDir, { recursive: true })
|
||||
for (const name of DB_NAMES) {
|
||||
const prop = `${name}Db`
|
||||
const storeDir = path.join(levelDir, name)
|
||||
fs.mkdirSync(storeDir, { recursive: true })
|
||||
dbs[prop] = adapters.levelDb.level(storeDir, {
|
||||
valueEncoding: 'json',
|
||||
cacheSize: name === 'posts' ? 512 * 1024 * 1024 : 64 * 1024 * 1024
|
||||
})
|
||||
this[prop] = dbs[prop]
|
||||
}
|
||||
return dbs
|
||||
}
|
||||
|
||||
adapters.start()
|
||||
|
||||
const postHeightsIteratorCounter = { calls: 0 }
|
||||
const postChildrenIteratorCounter = { calls: 0 }
|
||||
const postsGetCounter = { calls: 0 }
|
||||
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
|
||||
wrapIterator(adapters.level.postChildrenDb, postChildrenIteratorCounter)
|
||||
wrapGet(adapters.level.postsDb, postsGetCounter)
|
||||
|
||||
const listRecentPosts = new ListRecentPosts({ adapters })
|
||||
const listPostsByAddr = new ListPostsByAddr({ adapters })
|
||||
|
||||
let lastResponse = null
|
||||
|
||||
return {
|
||||
adapters,
|
||||
listRecentPosts,
|
||||
listPostsByAddr,
|
||||
postHeightsIteratorCounter,
|
||||
postChildrenIteratorCounter,
|
||||
postsGetCounter,
|
||||
getLastResponse: () => lastResponse,
|
||||
setLastResponse: (resp) => { lastResponse = resp },
|
||||
close: async () => {
|
||||
try {
|
||||
await adapters.levelDb.closeDbs()
|
||||
} catch (err) {
|
||||
// ignore close errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFixture (world, name) {
|
||||
if (name !== 'three-top-level-posts-and-one-reply') {
|
||||
throw new Error(`Unknown fixture: ${name}`)
|
||||
}
|
||||
|
||||
const posts = [
|
||||
{ txid: 'post-200-b', addr: 'bitcoincash:qaddr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
{ txid: 'post-200-a', addr: 'bitcoincash:qaddr-a', text: 'a', seen: 100, blockHeight: 600200 },
|
||||
{ txid: 'post-100', addr: 'bitcoincash:qaddr-a', text: 'c', seen: 50, blockHeight: 600100 }
|
||||
]
|
||||
const reply = { txid: 'reply-1', parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600050 }
|
||||
|
||||
for (const post of posts) {
|
||||
await world.adapters.level.postsDb.put(post.txid, {
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight
|
||||
})
|
||||
await world.adapters.level.postHeightsDb.put(
|
||||
String(post.blockHeight).padStart(12, '0') + ':' + post.txid,
|
||||
{ txid: post.txid, blockHeight: post.blockHeight }
|
||||
)
|
||||
}
|
||||
|
||||
await world.adapters.level.postsDb.put('reply-1', {
|
||||
addr: 'bitcoincash:qaddr-a',
|
||||
text: 'reply body',
|
||||
seen: 10,
|
||||
blockHeight: 600050
|
||||
})
|
||||
await world.adapters.level.postHeightsDb.put(
|
||||
'000000600050:reply-1',
|
||||
{ txid: 'reply-1', blockHeight: 600050 }
|
||||
)
|
||||
await world.adapters.level.postParentsDb.put('reply-1', reply)
|
||||
await world.adapters.level.postChildrenDb.put('post-200-a:reply-1', reply)
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
{
|
||||
name: 'db instance with posts and postHeights stores',
|
||||
pattern: /^a psf-memo-db instance with a posts store and a postHeights secondary index$/,
|
||||
async run () {
|
||||
// World is already created with both stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'load fixture',
|
||||
pattern: /^the fixture "(.+)" is loaded into the posts store$/,
|
||||
async run (m, example, world) {
|
||||
await loadFixture(world, m[1])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request recent posts',
|
||||
pattern: /^the client requests \/posts\/recent with limit (<limit>) and offset (<offset>)$/,
|
||||
async run (m, example, world) {
|
||||
const limit = parseInt(resolveParam(m[1], example), 10)
|
||||
const offset = parseInt(resolveParam(m[2], example), 10)
|
||||
const resp = await world.listRecentPosts.execute({ limit, offset })
|
||||
world.setLastResponse(resp)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request posts by addr',
|
||||
pattern: /^the client requests \/posts\/by\/(<addr>) with limit (<limit>) and offset (<offset>)$/,
|
||||
async run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const limit = parseInt(resolveParam(m[2], example), 10)
|
||||
const offset = parseInt(resolveParam(m[3], example), 10)
|
||||
const resp = await world.listPostsByAddr.execute({ addr, limit, offset })
|
||||
world.setLastResponse(resp)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts sorted by block height descending',
|
||||
pattern: /^the response posts are sorted by block height descending$/,
|
||||
run (m, example, world) {
|
||||
const posts = world.getLastResponse().posts
|
||||
for (let i = 1; i < posts.length; i++) {
|
||||
if (posts[i].blockHeight > posts[i - 1].blockHeight) {
|
||||
throw new Error(`Posts not sorted by descending block height at index ${i}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response contains expected txids',
|
||||
pattern: /^the response contains the txids (<expected_txids>)$/,
|
||||
run (m, example, world) {
|
||||
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim())
|
||||
const actual = world.getLastResponse().posts.map((p) => p.txid)
|
||||
if (expected.join(',') !== actual.join(',')) {
|
||||
throw new Error(`Expected txids ${expected.join(',')}, got ${actual.join(',')}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response contains only posts by addr',
|
||||
pattern: /^the response contains only posts by (<addr>)$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const posts = world.getLastResponse().posts
|
||||
for (const post of posts) {
|
||||
if (post.addr !== addr) {
|
||||
throw new Error(`Expected post by ${addr}, got ${post.addr}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'response pagination metadata',
|
||||
pattern: /^the response pagination shows total (<total>) and hasMore (<hasMore>)$/,
|
||||
run (m, example, world) {
|
||||
const expectedTotal = parseInt(resolveParam(m[1], example), 10)
|
||||
const expectedHasMore = resolveParam(m[2], example) === 'true'
|
||||
const pagination = world.getLastResponse().pagination
|
||||
if (pagination.total !== expectedTotal) {
|
||||
throw new Error(`Expected total ${expectedTotal}, got ${pagination.total}`)
|
||||
}
|
||||
if (pagination.hasMore !== expectedHasMore) {
|
||||
throw new Error(`Expected hasMore ${expectedHasMore}, got ${pagination.hasMore}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'bounded postHeights reads',
|
||||
pattern: /^no more than (<limit>) postHeights entries are read after applying the offset$/,
|
||||
run (m, example, world) {
|
||||
const limit = parseInt(resolveParam(m[1], example), 10)
|
||||
const reads = world.postHeightsIteratorCounter.calls
|
||||
if (reads > limit) {
|
||||
throw new Error(`Read ${reads} postHeights entries, expected at most ${limit}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'bounded posts loaded by txid',
|
||||
pattern: /^no more than (<limit>) posts are loaded by txid$/,
|
||||
run (m, example, world) {
|
||||
const limit = parseInt(resolveParam(m[1], example), 10)
|
||||
const reads = world.postsGetCounter.calls
|
||||
if (reads > limit) {
|
||||
throw new Error(`Loaded ${reads} posts by txid, expected at most ${limit}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post has replyCount',
|
||||
pattern: /^the response post with txid (<txid>) has replyCount (<replyCount>)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const post = world.getLastResponse().posts.find((p) => p.txid === txid)
|
||||
if (!post) {
|
||||
throw new Error(`Post ${txid} not found in response`)
|
||||
}
|
||||
if (post.replyCount !== expected) {
|
||||
throw new Error(`Expected replyCount ${expected} for ${txid}, got ${post.replyCount}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'single postChildren scan',
|
||||
pattern: /^the postChildren store was iterated exactly once$/,
|
||||
run (m, example, world) {
|
||||
const calls = world.postChildrenIteratorCounter.calls
|
||||
if (calls !== 1) {
|
||||
throw new Error(`Expected exactly one postChildren scan, got ${calls}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
async function handleStep (step, example, world) {
|
||||
for (const handler of handlers) {
|
||||
const match = handler.pattern.exec(step.text)
|
||||
if (match) {
|
||||
await handler.run(match, example, world, step)
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
|
||||
}
|
||||
|
||||
export { createWorld, handleStep }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
Acceptance runtime for psf-memo-db.
|
||||
|
||||
Expands each scenario (and each example row) from parser JSON IR into
|
||||
scenario executions, prepends background steps, and dispatches every step to
|
||||
the project step handlers.
|
||||
*/
|
||||
|
||||
import { createWorld, handleStep } from './handlers.js'
|
||||
|
||||
// Expand the IR scenarios into concrete executions.
|
||||
function expandScenarios (ir) {
|
||||
const executions = []
|
||||
const background = ir.background || []
|
||||
|
||||
for (const scenario of ir.scenarios) {
|
||||
const examples = (scenario.examples && scenario.examples.length > 0)
|
||||
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
|
||||
: [{ example: {}, suffix: 'example_1' }]
|
||||
|
||||
for (const { example, suffix } of examples) {
|
||||
executions.push({
|
||||
name: `${scenario.name}/${suffix}`,
|
||||
steps: [...background, ...scenario.steps],
|
||||
example
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return executions
|
||||
}
|
||||
|
||||
async function runFeature (ir) {
|
||||
const executions = expandScenarios(ir)
|
||||
const results = []
|
||||
let failures = 0
|
||||
|
||||
for (const ex of executions) {
|
||||
const world = await createWorld()
|
||||
let failure = null
|
||||
|
||||
for (const step of ex.steps) {
|
||||
try {
|
||||
await handleStep(step, ex.example, world)
|
||||
} catch (err) {
|
||||
failure = err.message
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await world.close()
|
||||
|
||||
if (failure) {
|
||||
failures++
|
||||
results.push({ name: ex.name, status: 'failed', detail: failure })
|
||||
} else {
|
||||
results.push({ name: ex.name, status: 'passed' })
|
||||
}
|
||||
}
|
||||
|
||||
return { feature: ir.name, results, failures, total: results.length }
|
||||
}
|
||||
|
||||
export { expandScenarios, runFeature }
|
||||
@@ -8,6 +8,8 @@
|
||||
"prestart": "npm run docs",
|
||||
"start": "node index.js",
|
||||
"test": "export SVC_ENV=test && c8 --reporter=text mocha --exit --timeout 15000 --recursive test/unit/",
|
||||
"property": "node --test \"test/property/*.test.js\"",
|
||||
"acceptance": "node acceptance/acceptance.js",
|
||||
"lint": "standard --env mocha --fix",
|
||||
"docs": "./node_modules/.bin/apidoc -i src/ -o docs"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ class Adapters {
|
||||
})
|
||||
this.postQuery = new PostQuery({
|
||||
postsDb: level.postsDb,
|
||||
postHeightsDb: level.postHeightsDb,
|
||||
postParentsDb: level.postParentsDb,
|
||||
postChildrenDb: level.postChildrenDb
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ const dbDir = `${__dirname}/../../leveldb`
|
||||
const DB_NAMES = [
|
||||
'status',
|
||||
'posts',
|
||||
'postHeights',
|
||||
'postParents',
|
||||
'postChildren',
|
||||
'likes',
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
/*
|
||||
Adapter for scanning posts with stored block height.
|
||||
Excludes reply posts (txids present in postParentsDb).
|
||||
Adapter for efficient post queries using a postHeights secondary index.
|
||||
*/
|
||||
|
||||
const HEIGHT_PAD = 12
|
||||
|
||||
class PostQuery {
|
||||
constructor (localConfig = {}) {
|
||||
const { postsDb, postParentsDb, postChildrenDb } = localConfig
|
||||
const { postsDb, postHeightsDb, postParentsDb, postChildrenDb } = localConfig
|
||||
if (!postsDb) {
|
||||
throw new Error('postsDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
if (!postHeightsDb) {
|
||||
throw new Error('postHeightsDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
if (!postParentsDb) {
|
||||
throw new Error('postParentsDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
@@ -16,12 +20,34 @@ class PostQuery {
|
||||
throw new Error('postChildrenDb required when instantiating PostQuery adapter.')
|
||||
}
|
||||
this.postsDb = postsDb
|
||||
this.postHeightsDb = postHeightsDb
|
||||
this.postParentsDb = postParentsDb
|
||||
this.postChildrenDb = postChildrenDb
|
||||
this.scanPostsWithBlockHeight = this.scanPostsWithBlockHeight.bind(this)
|
||||
this.scanPostsByAddr = this.scanPostsByAddr.bind(this)
|
||||
|
||||
this.scanRecentPostTxids = this.scanRecentPostTxids.bind(this)
|
||||
this.scanPostsByAddrTxids = this.scanPostsByAddrTxids.bind(this)
|
||||
this.loadPostsByTxids = this.loadPostsByTxids.bind(this)
|
||||
this.countTopLevelPosts = this.countTopLevelPosts.bind(this)
|
||||
this.countTopLevelPostsByAddr = this.countTopLevelPostsByAddr.bind(this)
|
||||
this.loadReplyTxids = this.loadReplyTxids.bind(this)
|
||||
this.buildReplyCountMap = this.buildReplyCountMap.bind(this)
|
||||
this.txidFromPostHeight = this.txidFromPostHeight.bind(this)
|
||||
this.getPostOrNull = this.getPostOrNull.bind(this)
|
||||
this.topLevelPostTxids = this.topLevelPostTxids.bind(this)
|
||||
}
|
||||
|
||||
static padHeight (height) {
|
||||
return String(height).padStart(HEIGHT_PAD, '0')
|
||||
}
|
||||
|
||||
static postHeightKey (blockHeight, txid) {
|
||||
return `${PostQuery.padHeight(blockHeight)}:${txid}`
|
||||
}
|
||||
|
||||
txidFromPostHeight (key, value) {
|
||||
if (value && typeof value.txid === 'string') return value.txid
|
||||
const parts = String(key).split(':')
|
||||
return parts[parts.length - 1]
|
||||
}
|
||||
|
||||
async loadReplyTxids () {
|
||||
@@ -38,7 +64,7 @@ class PostQuery {
|
||||
const counts = new Map()
|
||||
|
||||
for await (const [, child] of this.postChildrenDb.iterator()) {
|
||||
const parentTxid = child.parentTxid
|
||||
const parentTxid = child?.parentTxid
|
||||
if (!parentTxid) continue
|
||||
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
|
||||
}
|
||||
@@ -46,74 +72,110 @@ class PostQuery {
|
||||
return counts
|
||||
}
|
||||
|
||||
async scanPostsWithBlockHeight () {
|
||||
const [replyTxids, replyCounts] = await Promise.all([
|
||||
this.loadReplyTxids(),
|
||||
this.buildReplyCountMap()
|
||||
])
|
||||
// Fetch a post by txid, returning null when the post is not found.
|
||||
async getPostOrNull (txid) {
|
||||
try {
|
||||
return await this.postsDb.get(txid)
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate the txids of top-level posts (replies excluded) in postHeights
|
||||
// key order. Pass { reverse: true } for newest-first iteration.
|
||||
async * topLevelPostTxids ({ reverse = false } = {}) {
|
||||
const replyTxids = await this.loadReplyTxids()
|
||||
|
||||
for await (const [key, value] of this.postHeightsDb.iterator({ reverse })) {
|
||||
const txid = this.txidFromPostHeight(key, value)
|
||||
if (replyTxids.has(txid)) continue
|
||||
yield txid
|
||||
}
|
||||
}
|
||||
|
||||
async scanRecentPostTxids ({ limit, offset }) {
|
||||
const txids = []
|
||||
let skipped = 0
|
||||
|
||||
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
|
||||
if (skipped < offset) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
txids.push(txid)
|
||||
if (txids.length >= limit) break
|
||||
}
|
||||
|
||||
return txids
|
||||
}
|
||||
|
||||
async scanPostsByAddrTxids (addr, { limit, offset }) {
|
||||
const txids = []
|
||||
let skipped = 0
|
||||
|
||||
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
|
||||
const post = await this.getPostOrNull(txid)
|
||||
if (!post || post.addr !== addr) continue
|
||||
|
||||
if (skipped < offset) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
txids.push(txid)
|
||||
if (txids.length >= limit) break
|
||||
}
|
||||
|
||||
return txids
|
||||
}
|
||||
|
||||
async loadPostsByTxids (txids) {
|
||||
const posts = []
|
||||
|
||||
for await (const [txid, post] of this.postsDb.iterator()) {
|
||||
if (replyTxids.has(txid)) continue
|
||||
for (const txid of txids) {
|
||||
const post = await this.getPostOrNull(txid)
|
||||
if (!post) continue
|
||||
posts.push({
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0,
|
||||
replyCount: replyCounts.get(txid) ?? 0
|
||||
blockHeight: post.blockHeight ?? 0
|
||||
})
|
||||
}
|
||||
|
||||
return posts
|
||||
}
|
||||
|
||||
async scanPostsByAddr (addr) {
|
||||
const [replyTxids, replyCounts] = await Promise.all([
|
||||
this.loadReplyTxids(),
|
||||
this.buildReplyCountMap()
|
||||
])
|
||||
const posts = []
|
||||
async countTopLevelPosts () {
|
||||
let count = 0
|
||||
const iterator = this.topLevelPostTxids()
|
||||
|
||||
for await (const [txid, post] of this.postsDb.iterator()) {
|
||||
if (post.addr !== addr) continue
|
||||
if (replyTxids.has(txid)) continue
|
||||
posts.push({
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0,
|
||||
replyCount: replyCounts.get(txid) ?? 0
|
||||
})
|
||||
for (;;) {
|
||||
const { done } = await iterator.next()
|
||||
if (done) break
|
||||
count++
|
||||
}
|
||||
|
||||
return posts
|
||||
return count
|
||||
}
|
||||
|
||||
async buildReplyCountMap () {
|
||||
const counts = new Map()
|
||||
let total = 0
|
||||
async countTopLevelPostsByAddr (addr) {
|
||||
let count = 0
|
||||
|
||||
for await (const [childTxid, child] of this.postChildrenDb.iterator()) {
|
||||
total++
|
||||
for await (const txid of this.topLevelPostTxids()) {
|
||||
const post = await this.getPostOrNull(txid)
|
||||
if (post && post.addr === addr) count++
|
||||
}
|
||||
|
||||
console.log('Indexed reply:', {
|
||||
childTxid,
|
||||
child,
|
||||
parentTxid: child?.parentTxid
|
||||
})
|
||||
|
||||
const parentTxid = child?.parentTxid
|
||||
if (!parentTxid) continue
|
||||
|
||||
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
|
||||
return count
|
||||
}
|
||||
|
||||
console.log(`Total postChildrenDb records: ${total}`)
|
||||
|
||||
return counts
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -36,6 +36,7 @@ export function makeCrudHandlers ({ dbProp, keyParam, bodyIdField, bodyDataField
|
||||
|
||||
export const ENTITY_CONFIG = [
|
||||
{ route: 'post', dbProp: 'postsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'postData' },
|
||||
{ route: 'postheight', dbProp: 'postHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'postHeightData' },
|
||||
{ route: 'postparent', dbProp: 'postParentsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'parentData' },
|
||||
{ route: 'postchild', dbProp: 'postChildrenDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'childData' },
|
||||
{ route: 'like', dbProp: 'likesDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'likeData' },
|
||||
|
||||
@@ -26,7 +26,7 @@ class PostsRouter {
|
||||
attach (app) {
|
||||
this.router.get('/recent', this.postsRESTController.getRecentPosts)
|
||||
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
|
||||
this.router.get('/:txid/thread',this.postsRESTController.getPostThread)
|
||||
this.router.get('/:txid/thread', this.postsRESTController.getPostThread)
|
||||
app.use(this.router.routes())
|
||||
app.use(this.router.allowedMethods())
|
||||
}
|
||||
|
||||
@@ -98,4 +98,4 @@ class GetPostThread {
|
||||
}
|
||||
}
|
||||
|
||||
export default GetPostThread
|
||||
export default GetPostThread
|
||||
|
||||
@@ -47,4 +47,3 @@ class UseCases {
|
||||
}
|
||||
|
||||
export default UseCases
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Shared pagination parsing and post-list enrichment for post use cases.
|
||||
|
||||
Both the recent-posts and posts-by-address list use cases share identical
|
||||
limit/offset validation and reply-count enrichment. Centralizing them keeps
|
||||
the validation behavior identical across all list endpoints and avoids
|
||||
duplicated error handling.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
|
||||
function isEmpty (value) {
|
||||
return value === undefined || value === null || value === ''
|
||||
}
|
||||
|
||||
function httpError (message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export function parseLimit (limit) {
|
||||
if (isEmpty(limit)) return DEFAULT_LIMIT
|
||||
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
throw httpError('limit must be a positive integer', 400)
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
throw httpError(`limit cannot exceed ${MAX_LIMIT}`, 400)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function parseOffset (offset) {
|
||||
if (isEmpty(offset)) return 0
|
||||
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
throw httpError('offset must be a non-negative integer', 400)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function attachReplyCounts (posts, replyCounts) {
|
||||
return posts.map((post) => ({
|
||||
...post,
|
||||
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
|
||||
@@ -1,51 +1,14 @@
|
||||
/*
|
||||
Use case: list posts for an address ordered by block height (most recent first), paginated.
|
||||
Uses the postHeights secondary index for efficient sorting and pagination.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
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)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
super(localConfig, { useCaseName: 'ListPostsByAddr', adapterName: 'postQuery' })
|
||||
}
|
||||
|
||||
parseAddr (addr) {
|
||||
@@ -57,35 +20,24 @@ class ListPostsByAddr {
|
||||
return addr
|
||||
}
|
||||
|
||||
sortPosts (posts) {
|
||||
return posts.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) {
|
||||
return b.blockHeight - a.blockHeight
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
})
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const addr = this.parseAddr(inObj.addr)
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
const limit = parseLimit(inObj.limit)
|
||||
const offset = parseOffset(inObj.offset)
|
||||
|
||||
const allPosts = await this.adapters.postQuery.scanPostsByAddr(addr)
|
||||
const sorted = this.sortPosts(allPosts)
|
||||
const total = sorted.length
|
||||
const posts = sorted.slice(offset, offset + limit)
|
||||
const txids = await this.adapters.postQuery.scanPostsByAddrTxids(addr, { limit, offset })
|
||||
const [posts, replyCounts, total] = await Promise.all([
|
||||
this.adapters.postQuery.loadPostsByTxids(txids),
|
||||
this.adapters.postQuery.buildReplyCountMap(),
|
||||
this.adapters.postQuery.countTopLevelPostsByAddr(addr)
|
||||
])
|
||||
|
||||
return {
|
||||
posts,
|
||||
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
|
||||
|
||||
@@ -1,81 +1,33 @@
|
||||
/*
|
||||
Use case: list posts ordered by block height (most recent first), paginated.
|
||||
Uses the postHeights secondary index for efficient sorting and pagination.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
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)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
sortPosts (posts) {
|
||||
return posts.sort((a, b) => {
|
||||
if (b.blockHeight !== a.blockHeight) {
|
||||
return b.blockHeight - a.blockHeight
|
||||
}
|
||||
return (b.seen || 0) - (a.seen || 0)
|
||||
})
|
||||
super(localConfig, { useCaseName: 'ListRecentPosts', adapterName: 'postQuery' })
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
const limit = parseLimit(inObj.limit)
|
||||
const offset = parseOffset(inObj.offset)
|
||||
|
||||
const allPosts = await this.adapters.postQuery.scanPostsWithBlockHeight()
|
||||
const sorted = this.sortPosts(allPosts)
|
||||
const total = sorted.length
|
||||
const posts = sorted.slice(offset, offset + limit)
|
||||
const txids = await this.adapters.postQuery.scanRecentPostTxids({ limit, offset })
|
||||
const [posts, replyCounts, total] = await Promise.all([
|
||||
this.adapters.postQuery.loadPostsByTxids(txids),
|
||||
this.adapters.postQuery.buildReplyCountMap(),
|
||||
this.adapters.postQuery.countTopLevelPosts()
|
||||
])
|
||||
|
||||
return {
|
||||
posts,
|
||||
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
|
||||
|
||||
@@ -2,50 +2,12 @@
|
||||
Use case: list profiles ordered by block height (most recent first), paginated.
|
||||
*/
|
||||
|
||||
const DEFAULT_LIMIT = 100
|
||||
const MAX_LIMIT = 100
|
||||
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)
|
||||
}
|
||||
|
||||
parseLimit (limit) {
|
||||
if (limit === undefined || limit === null || limit === '') {
|
||||
return DEFAULT_LIMIT
|
||||
}
|
||||
const parsed = parseInt(limit, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 1) {
|
||||
const err = new Error('limit must be a positive integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
if (parsed > MAX_LIMIT) {
|
||||
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
parseOffset (offset) {
|
||||
if (offset === undefined || offset === null || offset === '') {
|
||||
return 0
|
||||
}
|
||||
const parsed = parseInt(offset, 10)
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
const err = new Error('offset must be a non-negative integer')
|
||||
err.status = 400
|
||||
throw err
|
||||
}
|
||||
return parsed
|
||||
super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' })
|
||||
}
|
||||
|
||||
sortProfiles (profiles) {
|
||||
@@ -58,8 +20,8 @@ class ListRecentProfiles {
|
||||
}
|
||||
|
||||
async execute (inObj = {}) {
|
||||
const limit = this.parseLimit(inObj.limit)
|
||||
const offset = this.parseOffset(inObj.offset)
|
||||
const limit = parseLimit(inObj.limit)
|
||||
const offset = parseOffset(inObj.offset)
|
||||
|
||||
const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight()
|
||||
const sorted = this.sortProfiles(allProfiles)
|
||||
@@ -79,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
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Small property-testing harness for psf-memo-db.
|
||||
|
||||
The DB runs its unit tests with mocha and has no property-based generator,
|
||||
so this module provides a deterministic, seeded pseudo-random generator plus
|
||||
a helper to run a property across many samples and report a counterexample.
|
||||
All generation is seeded, so runs are reproducible.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// A small deterministic PRNG (mulberry32). Same seed => same stream.
|
||||
export function seededRandom (seed = 12345) {
|
||||
let a = seed >>> 0
|
||||
return function next () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Run a property across N samples. `gen` returns a fresh input; `check`
|
||||
// returns true when the property holds. Asserts a counterexample on failure.
|
||||
export async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const input = gen(i)
|
||||
const ok = await check(input)
|
||||
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Uniform integer in [min, max] inclusive using a seeded rng.
|
||||
export function intGen (rng, min, max) {
|
||||
return () => min + Math.floor(rng() * (max - min + 1))
|
||||
}
|
||||
|
||||
// Random 64-char hex txid using a seeded rng.
|
||||
export function txidGen (rng) {
|
||||
const hex = '0123456789abcdef'
|
||||
let out = ''
|
||||
for (let i = 0; i < 64; i++) {
|
||||
out += hex[Math.floor(rng() * hex.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
Property tests for the postHeights secondary index key encoding.
|
||||
|
||||
The postHeights index stores a key of `<padded-height>:<txid>`. Efficient
|
||||
pagination depends on two invariants that unit tests only probe at a few
|
||||
fixed heights:
|
||||
|
||||
- round-trip: txidFromPostHeight(postHeightKey(h, txid)) recovers txid.
|
||||
- ordering: padded heights preserve numeric order lexicographically, so a
|
||||
reverse iterate over the keys yields newest posts first.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
|
||||
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
|
||||
import PostQuery from '../../src/adapters/post-query.js'
|
||||
|
||||
const rng = seededRandom(20260826)
|
||||
|
||||
test('postHeightKey round-trips the txid for a broad range of heights', async () => {
|
||||
const heightGen = intGen(rng, 0, 9000000)
|
||||
const query = new PostQuery({
|
||||
postsDb: {},
|
||||
postHeightsDb: {},
|
||||
postParentsDb: {},
|
||||
postChildrenDb: {}
|
||||
})
|
||||
|
||||
await forAll(
|
||||
(i) => ({ height: heightGen(), txid: txidGen(rng) }),
|
||||
({ height, txid }) => {
|
||||
const key = PostQuery.postHeightKey(height, txid)
|
||||
const fromValue = query.txidFromPostHeight(key, { txid })
|
||||
const fromKey = query.txidFromPostHeight(key)
|
||||
return fromValue === txid && fromKey === txid
|
||||
},
|
||||
{ label: 'postHeightKey round-trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('padded heights preserve numeric order lexicographically', async () => {
|
||||
const heightGen = intGen(rng, 0, 9000000)
|
||||
|
||||
await forAll(
|
||||
(i) => {
|
||||
const a = heightGen()
|
||||
const b = heightGen()
|
||||
return { a: Math.min(a, b), b: Math.max(a, b), txidA: txidGen(rng), txidB: txidGen(rng) }
|
||||
},
|
||||
({ a, b, txidA, txidB }) => {
|
||||
if (a === b) return PostQuery.postHeightKey(a, txidA) === PostQuery.postHeightKey(a, txidB)
|
||||
return PostQuery.postHeightKey(a, txidA) < PostQuery.postHeightKey(b, txidB)
|
||||
},
|
||||
{ label: 'postHeight key ordering' }
|
||||
)
|
||||
})
|
||||
|
||||
test('padded heights are fixed width and equal to their numeric value', async () => {
|
||||
const heightGen = intGen(rng, 0, 999999999999)
|
||||
|
||||
await forAll(
|
||||
(i) => heightGen(),
|
||||
(height) => {
|
||||
const padded = PostQuery.padHeight(height)
|
||||
return padded.length === 12 && Number.parseInt(padded, 10) === height
|
||||
},
|
||||
{ label: 'postHeight fixed-width padding' }
|
||||
)
|
||||
})
|
||||
@@ -8,11 +8,13 @@ describe('#PostQuery', () => {
|
||||
let postsDb
|
||||
let postParentsDb
|
||||
let postChildrenDb
|
||||
let postHeightsDb
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
postsDb = {
|
||||
iterator: sandbox.stub()
|
||||
iterator: sandbox.stub(),
|
||||
get: sandbox.stub()
|
||||
}
|
||||
postParentsDb = {
|
||||
iterator: sandbox.stub()
|
||||
@@ -20,125 +22,265 @@ describe('#PostQuery', () => {
|
||||
postChildrenDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
postHeightsDb = {
|
||||
iterator: sandbox.stub()
|
||||
}
|
||||
|
||||
async function * emptyParents () {}
|
||||
async function * emptyChildren () {}
|
||||
async function * emptyHeights () {}
|
||||
postParentsDb.iterator.returns(emptyParents())
|
||||
postChildrenDb.iterator.returns(emptyChildren())
|
||||
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb })
|
||||
postHeightsDb.iterator.returns(emptyHeights())
|
||||
|
||||
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb, postHeightsDb })
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it('should scan posts and read block height from stored document', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'hello', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'world', seen: 2000, blockHeight: 600200 }]
|
||||
it('should throw when postHeightsDb is missing', () => {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new PostQuery({ postsDb, postParentsDb, postChildrenDb })
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.include(err.message, 'postHeightsDb required')
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[0].blockHeight, 600100)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
assert.equal(result[1].txid, 'tx2')
|
||||
assert.equal(result[1].blockHeight, 600200)
|
||||
assert.equal(result[1].replyCount, 0)
|
||||
})
|
||||
|
||||
it('should use block height 0 when field is missing', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx-missing', { addr: 'addr1', text: 'hi', seen: 1000 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
describe('#txidFromPostHeight', () => {
|
||||
it('should return the txid from the value when present', () => {
|
||||
assert.equal(uut.txidFromPostHeight('any-key', { txid: 'abc123' }), 'abc123')
|
||||
})
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
|
||||
assert.equal(result[0].blockHeight, 0)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
it('should scan posts for a single address', async () => {
|
||||
async function * mockIterator () {
|
||||
yield ['tx1', { addr: 'addr-a', text: 'hello', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr-b', text: 'world', seen: 2000, blockHeight: 600200 }]
|
||||
yield ['tx3', { addr: 'addr-a', text: 'again', seen: 3000, blockHeight: 600300 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockIterator())
|
||||
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 result = await uut.scanPostsByAddr('addr-a')
|
||||
const txids = []
|
||||
for await (const txid of uut.topLevelPostTxids()) txids.push(txid)
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[1].txid, 'tx3')
|
||||
assert.deepEqual(txids, ['post-100', 'post-200-a'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude reply posts from recent scan', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx-reply', { addr: 'addr1', text: 'reply', seen: 1500, blockHeight: 600150 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
describe('#scanRecentPostTxids', () => {
|
||||
it('should return top-level post txids sorted by block height descending', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[1].txid, 'tx2')
|
||||
assert.deepEqual(result, ['post-200-b', 'post-200-a'])
|
||||
})
|
||||
|
||||
it('should skip replies when selecting recent posts', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['reply-1', { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600150:reply-1', { txid: 'reply-1' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
|
||||
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
|
||||
|
||||
assert.deepEqual(result, ['post-200-b', 'post-200-a'])
|
||||
})
|
||||
|
||||
it('should apply offset after skipping replies', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
|
||||
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 1 })
|
||||
|
||||
assert.deepEqual(result, ['post-200-a', 'post-100'])
|
||||
})
|
||||
|
||||
it('should stop reading after collecting limit top-level posts', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
|
||||
await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
|
||||
|
||||
assert.isTrue(postHeightsDb.iterator.calledOnce)
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude reply posts from address scan', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr-a', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx-reply', { addr: 'addr-a', text: 'reply', seen: 1500, blockHeight: 600150 }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
describe('#scanPostsByAddrTxids', () => {
|
||||
it('should return txids for the address sorted by block height descending', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
postsDb.get.callsFake(async (txid) => {
|
||||
const posts = {
|
||||
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
|
||||
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
|
||||
}
|
||||
return posts[txid]
|
||||
})
|
||||
|
||||
const result = await uut.scanPostsByAddr('addr-a')
|
||||
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.deepEqual(result, ['post-200-a', 'post-100'])
|
||||
})
|
||||
|
||||
it('should apply offset and limit for the address', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
|
||||
postsDb.get.callsFake(async (txid) => {
|
||||
const posts = {
|
||||
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
|
||||
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
|
||||
}
|
||||
return posts[txid]
|
||||
})
|
||||
|
||||
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 1, offset: 1 })
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
it('should include replyCount from postChildren scan', async () => {
|
||||
async function * mockChildren () {
|
||||
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
|
||||
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
|
||||
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
|
||||
}
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
|
||||
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
|
||||
}
|
||||
postChildrenDb.iterator.returns(mockChildren())
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
describe('#loadPostsByTxids', () => {
|
||||
it('should load posts by txid', async () => {
|
||||
postsDb.get.withArgs('tx1').resolves({ addr: 'a1', text: 'hello', seen: 1000, blockHeight: 600100 })
|
||||
postsDb.get.withArgs('tx2').resolves({ addr: 'a2', text: 'world', seen: 2000, blockHeight: 600200 })
|
||||
|
||||
const result = await uut.scanPostsWithBlockHeight()
|
||||
const result = await uut.loadPostsByTxids(['tx1', 'tx2'])
|
||||
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result.find((p) => p.txid === 'tx1').replyCount, 2)
|
||||
assert.equal(result.find((p) => p.txid === 'tx2').replyCount, 1)
|
||||
assert.equal(result.length, 2)
|
||||
assert.equal(result[0].txid, 'tx1')
|
||||
assert.equal(result[0].blockHeight, 600100)
|
||||
assert.equal(result[1].txid, 'tx2')
|
||||
})
|
||||
|
||||
it('should skip missing posts', async () => {
|
||||
const err = new Error('not found')
|
||||
err.notFound = true
|
||||
postsDb.get.withArgs('tx1').rejects(err)
|
||||
postsDb.get.withArgs('tx2').resolves({ addr: 'a2', text: 'world', seen: 2000, blockHeight: 600200 })
|
||||
|
||||
const result = await uut.loadPostsByTxids(['tx1', 'tx2'])
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it('should default replyCount to 0 when post has no replies', async () => {
|
||||
async function * mockPosts () {
|
||||
yield ['tx1', { addr: 'addr1', text: 'solo', seen: 1000, blockHeight: 600100 }]
|
||||
}
|
||||
postsDb.iterator.returns(mockPosts())
|
||||
describe('#countTopLevelPosts', () => {
|
||||
it('should count top-level posts excluding replies', async () => {
|
||||
async function * mockParents () {
|
||||
yield ['reply-1', { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600150 }]
|
||||
}
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600150:reply-1', { txid: 'reply-1' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postParentsDb.iterator.returns(mockParents())
|
||||
postHeightsDb.iterator.returns(mockHeights())
|
||||
|
||||
const result = await uut.scanPostsByAddr('addr1')
|
||||
const result = await uut.countTopLevelPosts()
|
||||
|
||||
assert.equal(result.length, 1)
|
||||
assert.equal(result[0].replyCount, 0)
|
||||
assert.equal(result, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#countTopLevelPostsByAddr', () => {
|
||||
it('should count top-level posts for an address', async () => {
|
||||
async function * mockHeights () {
|
||||
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
|
||||
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
|
||||
yield ['000000600100:post-100', { txid: 'post-100' }]
|
||||
}
|
||||
postHeightsDb.iterator.returns(mockHeights())
|
||||
postsDb.get.callsFake(async (txid) => {
|
||||
const posts = {
|
||||
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
|
||||
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
|
||||
}
|
||||
return posts[txid]
|
||||
})
|
||||
|
||||
const result = await uut.countTopLevelPostsByAddr('addr-a')
|
||||
|
||||
assert.equal(result, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('#buildReplyCountMap', () => {
|
||||
it('should count replies per parent from postChildren', async () => {
|
||||
async function * mockChildren () {
|
||||
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
|
||||
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
|
||||
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
|
||||
}
|
||||
postChildrenDb.iterator.returns(mockChildren())
|
||||
|
||||
const result = await uut.buildReplyCountMap()
|
||||
|
||||
assert.equal(result.get('tx1'), 2)
|
||||
assert.equal(result.get('tx2'), 1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('#LevelRESTController', () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
uut = new LevelRESTControllerLib({
|
||||
adapters: {
|
||||
level: { postsDb: mockDb, statusDb: mockDb },
|
||||
level: { postsDb: mockDb, postHeightsDb: mockDb, statusDb: mockDb },
|
||||
dbBackup: { zipDb: sandbox.stub().resolves(true) }
|
||||
},
|
||||
useCases: {}
|
||||
@@ -44,4 +44,15 @@ describe('#LevelRESTController', () => {
|
||||
assert.equal(ctx.body.success, true)
|
||||
assert.equal(ctx.body.txid, 'abc')
|
||||
})
|
||||
|
||||
it('should expose a postheight entity handler', async () => {
|
||||
const ctx = {
|
||||
params: {},
|
||||
request: { body: { key: '600000:abc', postHeightData: { txid: 'abc', blockHeight: 600000 } } },
|
||||
body: null
|
||||
}
|
||||
await uut.entityHandlers.postheight.create(ctx)
|
||||
assert.equal(ctx.body.success, true)
|
||||
assert.equal(ctx.body.key, '600000:abc')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { assert } from 'chai'
|
||||
import { parseLimit, parseOffset, attachReplyCounts, assemblePostPage } from '../../../../src/use-cases/lib/pagination.js'
|
||||
|
||||
describe('#pagination', () => {
|
||||
describe('parseLimit', () => {
|
||||
it('should default to 100 when limit is absent', () => {
|
||||
assert.equal(parseLimit(undefined), 100)
|
||||
assert.equal(parseLimit(null), 100)
|
||||
assert.equal(parseLimit(''), 100)
|
||||
})
|
||||
|
||||
it('should parse a valid positive integer limit', () => {
|
||||
assert.equal(parseLimit('10'), 10)
|
||||
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')
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit must be a positive integer')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a limit below 1', () => {
|
||||
try {
|
||||
parseLimit(0)
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit must be a positive integer')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a limit over 100', () => {
|
||||
try {
|
||||
parseLimit(101)
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'limit cannot exceed 100')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseOffset', () => {
|
||||
it('should default to 0 when offset is absent', () => {
|
||||
assert.equal(parseOffset(undefined), 0)
|
||||
assert.equal(parseOffset(null), 0)
|
||||
assert.equal(parseOffset(''), 0)
|
||||
})
|
||||
|
||||
it('should parse a valid non-negative integer offset', () => {
|
||||
assert.equal(parseOffset('0'), 0)
|
||||
assert.equal(parseOffset(25), 25)
|
||||
})
|
||||
|
||||
it('should reject a non-numeric offset', () => {
|
||||
try {
|
||||
parseOffset('xyz')
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'offset must be a non-negative integer')
|
||||
}
|
||||
})
|
||||
|
||||
it('should reject a negative offset', () => {
|
||||
try {
|
||||
parseOffset(-1)
|
||||
assert.fail('Expected error')
|
||||
} catch (err) {
|
||||
assert.equal(err.status, 400)
|
||||
assert.include(err.message, 'offset must be a non-negative integer')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachReplyCounts', () => {
|
||||
it('should attach the reply count for each post', () => {
|
||||
const posts = [{ txid: 'a', text: 'x' }, { txid: 'b', text: 'y' }]
|
||||
const counts = new Map([['a', 3]])
|
||||
|
||||
const result = attachReplyCounts(posts, counts)
|
||||
|
||||
assert.equal(result[0].replyCount, 3)
|
||||
assert.equal(result[0].text, 'x')
|
||||
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 }))
|
||||
})
|
||||
})
|
||||
@@ -5,23 +5,32 @@ import ListPostsByAddr from '../../../src/use-cases/list-posts-by-addr.js'
|
||||
describe('#ListPostsByAddr', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
let postQuery
|
||||
|
||||
const mockPosts = [
|
||||
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
{ txid: 'tx-c', addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
]
|
||||
const mockPosts = {
|
||||
'tx-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
'tx-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
'tx-c': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
postQuery = {
|
||||
scanPostsByAddrTxids: sandbox.stub().callsFake(async (addr, { limit, offset }) => {
|
||||
const all = Object.entries(mockPosts)
|
||||
.filter(([txid, post]) => post.addr === addr)
|
||||
.sort((a, b) => b[1].blockHeight - a[1].blockHeight)
|
||||
.map(([txid]) => txid)
|
||||
return all.slice(offset, offset + limit)
|
||||
}),
|
||||
loadPostsByTxids: sandbox.stub().callsFake(async (txids) => {
|
||||
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
|
||||
}),
|
||||
buildReplyCountMap: sandbox.stub().resolves(new Map()),
|
||||
countTopLevelPostsByAddr: sandbox.stub().resolves(2)
|
||||
}
|
||||
uut = new ListPostsByAddr({
|
||||
adapters: {
|
||||
postQuery: {
|
||||
scanPostsByAddr: sandbox.stub().callsFake(async (addr) => {
|
||||
return mockPosts.filter((post) => post.addr === addr)
|
||||
})
|
||||
}
|
||||
}
|
||||
adapters: { postQuery }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,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 () => {
|
||||
@@ -45,4 +70,22 @@ describe('#ListPostsByAddr', () => {
|
||||
assert.include(err.message, 'addr is required')
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
|
||||
assert.equal(postQuery.scanPostsByAddrTxids.calledOnce, true)
|
||||
assert.equal(postQuery.scanPostsByAddrTxids.firstCall.args[0], 'addr-a')
|
||||
assert.deepEqual(postQuery.scanPostsByAddrTxids.firstCall.args[1], { limit: 5, offset: 10 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,21 +5,26 @@ import ListRecentPosts from '../../../src/use-cases/list-recent-posts.js'
|
||||
describe('#ListRecentPosts', () => {
|
||||
let uut
|
||||
let sandbox
|
||||
let postQuery
|
||||
|
||||
const mockPosts = [
|
||||
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
{ txid: 'tx-c', addr: 'addr-c', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
]
|
||||
const mockPosts = {
|
||||
'tx-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
|
||||
'tx-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
|
||||
'tx-c': { addr: 'addr-c', text: 'c', seen: 50, blockHeight: 600200 }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
postQuery = {
|
||||
scanRecentPostTxids: sandbox.stub().resolves(['tx-b', 'tx-c', 'tx-a']),
|
||||
loadPostsByTxids: sandbox.stub().callsFake(async (txids) => {
|
||||
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
|
||||
}),
|
||||
buildReplyCountMap: sandbox.stub().resolves(new Map([['tx-b', 1]])),
|
||||
countTopLevelPosts: sandbox.stub().resolves(3)
|
||||
}
|
||||
uut = new ListRecentPosts({
|
||||
adapters: {
|
||||
postQuery: {
|
||||
scanPostsWithBlockHeight: sandbox.stub().resolves([...mockPosts])
|
||||
}
|
||||
}
|
||||
adapters: { postQuery }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,11 +37,14 @@ describe('#ListRecentPosts', () => {
|
||||
assert.equal(result.posts[0].txid, 'tx-b')
|
||||
assert.equal(result.posts[1].txid, 'tx-c')
|
||||
assert.equal(result.posts[2].txid, 'tx-a')
|
||||
assert.equal(result.posts[0].replyCount, 1)
|
||||
assert.equal(result.posts[1].replyCount, 0)
|
||||
assert.equal(result.pagination.total, 3)
|
||||
assert.equal(result.pagination.hasMore, false)
|
||||
})
|
||||
|
||||
it('should paginate with limit and offset', async () => {
|
||||
postQuery.scanRecentPostTxids.resolves(['tx-c'])
|
||||
const result = await uut.execute({ limit: 1, offset: 1 })
|
||||
|
||||
assert.equal(result.posts.length, 1)
|
||||
@@ -62,4 +70,11 @@ describe('#ListRecentPosts', () => {
|
||||
assert.include(err.message, 'limit cannot exceed')
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass limit and offset to postQuery', async () => {
|
||||
await uut.execute({ limit: 5, offset: 10 })
|
||||
|
||||
assert.equal(postQuery.scanRecentPostTxids.calledOnce, true)
|
||||
assert.deepEqual(postQuery.scanRecentPostTxids.firstCall.args[0], { 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,84 @@
|
||||
/*
|
||||
Normal acceptance runner for psf-memo-indexer.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const specsDir = path.join(root, 'specs')
|
||||
const buildDir = path.join(root, 'build', 'acceptance')
|
||||
const irDir = path.join(buildDir, 'ir')
|
||||
const genDir = path.join(buildDir, 'generated')
|
||||
const apsDir = path.join(root, '..', 'tmp', 'aps-spec')
|
||||
|
||||
function sh (cmd, args, opts = {}) {
|
||||
return execFileSync(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...opts
|
||||
}).toString()
|
||||
}
|
||||
|
||||
function ensureAps () {
|
||||
if (fs.existsSync(apsDir)) return
|
||||
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
|
||||
sh('git', ['clone', '--depth', '1',
|
||||
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
|
||||
}
|
||||
|
||||
function main () {
|
||||
ensureAps()
|
||||
|
||||
const features = fs
|
||||
.readdirSync(specsDir)
|
||||
.filter((f) => f.endsWith('.feature'))
|
||||
.sort()
|
||||
|
||||
if (features.length === 0) {
|
||||
console.log('No feature files found under specs/.')
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(irDir, { recursive: true })
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
for (const featureFile of features) {
|
||||
const base = featureFile.replace(/\.feature$/i, '')
|
||||
const featurePath = path.join(specsDir, featureFile)
|
||||
const irPath = path.join(irDir, `${base}.json`)
|
||||
|
||||
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
|
||||
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
|
||||
}
|
||||
|
||||
const tests = fs
|
||||
.readdirSync(genDir)
|
||||
.filter((f) => f.endsWith('.acceptance.test.js'))
|
||||
.sort()
|
||||
|
||||
let failures = 0
|
||||
for (const testFile of tests) {
|
||||
try {
|
||||
const out = sh('node', [path.join(genDir, testFile)])
|
||||
process.stdout.write(out)
|
||||
console.log(`ACCEPTANCE PASS: ${testFile}`)
|
||||
} catch (err) {
|
||||
failures++
|
||||
process.stdout.write(err.stdout || '')
|
||||
process.stderr.write(err.stderr || '')
|
||||
console.error(`ACCEPTANCE FAIL: ${testFile}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Project-specific acceptance entrypoint generator for psf-memo-indexer.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function metadataName (featureName) {
|
||||
const slug = featureName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
return `${slug || 'feature'}.json`
|
||||
}
|
||||
|
||||
function relativeImport (fromDir, targetFile) {
|
||||
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
|
||||
if (!rel.startsWith('.')) rel = `./${rel}`
|
||||
return rel
|
||||
}
|
||||
|
||||
function main () {
|
||||
const irArg = process.argv[2]
|
||||
const outArg = process.argv[3]
|
||||
|
||||
if (!irArg || !outArg) {
|
||||
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let ir
|
||||
try {
|
||||
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
|
||||
} catch (err) {
|
||||
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const genDir = outArg
|
||||
fs.mkdirSync(genDir, { recursive: true })
|
||||
|
||||
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
|
||||
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
|
||||
const relRuntime = relativeImport(genDir, path.join(__dirname, 'runtime.js'))
|
||||
|
||||
const body = `import { runFeature } from '${relRuntime}'
|
||||
const ir = ${JSON.stringify(ir, null, 2)}
|
||||
async function main () {
|
||||
const report = await runFeature(ir)
|
||||
for (const r of report.results) {
|
||||
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
|
||||
if (r.detail) console.log(' ' + r.detail)
|
||||
}
|
||||
if (report.failures > 0) {
|
||||
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
main().catch((err) => { console.error(err); process.exit(1) })
|
||||
`
|
||||
|
||||
try {
|
||||
fs.writeFileSync(testFile, body)
|
||||
} catch (err) {
|
||||
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const metaDir = path.join(genDir, 'metadata')
|
||||
fs.mkdirSync(metaDir, { recursive: true })
|
||||
const hash = crypto
|
||||
.createHash('sha256')
|
||||
.update(fs.readFileSync(testFile))
|
||||
.digest('hex')
|
||||
|
||||
const metadata = {
|
||||
schema_version: 1,
|
||||
feature_path: `${featureKey}.feature`,
|
||||
ir_path: path.resolve(irArg),
|
||||
implementation_hash: `sha256:${hash}`,
|
||||
hash_scope: 'generated_files',
|
||||
generated_files: [testFile]
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(metaDir, metadataName(featureKey)),
|
||||
JSON.stringify(metadata, null, 2)
|
||||
)
|
||||
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
Project step handlers for the psf-memo-indexer acceptance pipeline.
|
||||
|
||||
These handlers exercise the real Memo action handlers (handlePost, handleReply)
|
||||
against an in-memory database that exposes the same CRUD surface as the
|
||||
psf-memo-db entity routes used by the indexer.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { handlePost } from '../../src/use-cases/action-types/post.js'
|
||||
import { handleReply } from '../../src/use-cases/action-types/reply.js'
|
||||
|
||||
function makeInMemoryDb () {
|
||||
const store = new Map()
|
||||
return {
|
||||
async get (key) {
|
||||
if (!store.has(key)) {
|
||||
const err = new Error('not found')
|
||||
err.notFound = true
|
||||
throw err
|
||||
}
|
||||
return store.get(key)
|
||||
},
|
||||
async create (key, data) {
|
||||
store.set(key, data)
|
||||
return { success: true }
|
||||
},
|
||||
async update (key, data) {
|
||||
store.set(key, data)
|
||||
return { success: true }
|
||||
},
|
||||
async delete (key) {
|
||||
store.delete(key)
|
||||
return { success: true }
|
||||
},
|
||||
entries () {
|
||||
return Array.from(store.entries())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveParam (value, example) {
|
||||
const match = /^\u003c([A-Za-z0-9_]+)\u003e$/.exec(String(value).trim())
|
||||
if (match) {
|
||||
const param = match[1]
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
return example[param]
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function deriveTxid (symbolic) {
|
||||
// Produce a deterministic 32-byte buffer from a symbolic test txid and return
|
||||
// its big-endian hex representation, which is what the indexer stores.
|
||||
return crypto.createHash('sha256').update(symbolic).digest().toString('hex')
|
||||
}
|
||||
|
||||
function resolveTxid (value, example, world) {
|
||||
const resolved = resolveParam(value, example)
|
||||
if (!world.txidMap) world.txidMap = new Map()
|
||||
if (!world.txidMap.has(resolved)) {
|
||||
world.txidMap.set(resolved, deriveTxid(resolved))
|
||||
}
|
||||
return world.txidMap.get(resolved)
|
||||
}
|
||||
|
||||
async function createWorld () {
|
||||
const postsDb = makeInMemoryDb()
|
||||
const postHeightsDb = makeInMemoryDb()
|
||||
const postParentsDb = makeInMemoryDb()
|
||||
const postChildrenDb = makeInMemoryDb()
|
||||
|
||||
const adapters = {
|
||||
postDb: postsDb,
|
||||
postHeightDb: postHeightsDb,
|
||||
postParentDb: postParentsDb,
|
||||
postChildDb: postChildrenDb,
|
||||
processErrorDb: makeInMemoryDb()
|
||||
}
|
||||
|
||||
return {
|
||||
adapters,
|
||||
postsDb,
|
||||
postHeightsDb,
|
||||
postParentsDb,
|
||||
postChildrenDb,
|
||||
txidMap: new Map(),
|
||||
lastTxid: null,
|
||||
lastHeight: null,
|
||||
lastAddr: null
|
||||
}
|
||||
}
|
||||
|
||||
const handlers = [
|
||||
{
|
||||
name: 'db instance with posts and postHeights stores',
|
||||
pattern: /^a psf-memo-db instance with posts and postHeights stores$/,
|
||||
async run () {
|
||||
// World is already created with both stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'indexer configured to write to db',
|
||||
pattern: /^a psf-memo-indexer configured to write to that database$/,
|
||||
async run () {
|
||||
// Adapters object is already configured.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process a Memo post transaction',
|
||||
pattern: /^the indexer processes a Memo post transaction (.+) from (.+) at block height (.+) with text "(.+)"$/,
|
||||
async run (m, example, world) {
|
||||
const txid = resolveTxid(m[1], example, world)
|
||||
const addr = resolveParam(m[2], example)
|
||||
const height = parseInt(resolveParam(m[3], example), 10)
|
||||
const text = resolveParam(m[4], example)
|
||||
|
||||
world.lastTxid = txid
|
||||
world.lastHeight = height
|
||||
world.lastAddr = addr
|
||||
|
||||
const prefix = Buffer.from('6d02', 'hex')
|
||||
const message = Buffer.from(text, 'utf8')
|
||||
|
||||
await handlePost({
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
seen: Date.now(),
|
||||
blockHeight: height,
|
||||
decoded: {
|
||||
action: 'post',
|
||||
prefix,
|
||||
pushDatas: [prefix, message]
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process a Memo reply transaction',
|
||||
pattern: /^the indexer processes a Memo reply transaction (.+) to parent (.+) from (.+) at block height (.+) with text "(.+)"$/,
|
||||
async run (m, example, world) {
|
||||
const txid = resolveTxid(m[1], example, world)
|
||||
const parentTxid = resolveTxid(m[2], example, world)
|
||||
const addr = resolveParam(m[3], example)
|
||||
const height = parseInt(resolveParam(m[4], example), 10)
|
||||
const text = resolveParam(m[5], example)
|
||||
|
||||
world.lastTxid = txid
|
||||
world.lastHeight = height
|
||||
world.lastAddr = addr
|
||||
|
||||
const prefix = Buffer.from('6d03', 'hex')
|
||||
// handleReply expects the parent tx hash as a 32-byte buffer in the
|
||||
// little-endian wire format; txHashFromPush reverses it to big-endian hex.
|
||||
const parentHash = Buffer.from(parentTxid, 'hex').reverse()
|
||||
const message = Buffer.from(text, 'utf8')
|
||||
|
||||
await handleReply({
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: addr,
|
||||
seen: Date.now(),
|
||||
blockHeight: height,
|
||||
decoded: {
|
||||
action: 'reply',
|
||||
prefix,
|
||||
pushDatas: [prefix, parentHash, message]
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'process the same Memo post transaction again',
|
||||
pattern: /^the indexer processes the same Memo post transaction (.+) again$/,
|
||||
async run (m, example, world) {
|
||||
const txid = resolveTxid(m[1], example, world)
|
||||
const post = await world.postsDb.get(txid)
|
||||
|
||||
const prefix = Buffer.from('6d02', 'hex')
|
||||
const message = Buffer.from(post.text, 'utf8')
|
||||
|
||||
await handlePost({
|
||||
adapters: world.adapters,
|
||||
txid,
|
||||
signerAddr: post.addr,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight,
|
||||
decoded: {
|
||||
action: 'post',
|
||||
prefix,
|
||||
pushDatas: [prefix, message]
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts store contains post document',
|
||||
pattern: /^the posts store contains (.+) post document for (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const txid = resolveTxid(m[2], example, world)
|
||||
const matching = world.postsDb.entries().filter(([key]) => key === txid)
|
||||
if (matching.length !== expectedCount) {
|
||||
throw new Error(`Expected ${expectedCount} post document(s) for ${txid}, got ${matching.length}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postHeights store contains entry',
|
||||
pattern: /^the postHeights store contains (.+) entry whose key starts with the block height (.+) and ends with (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const height = resolveParam(m[2], example)
|
||||
const txid = resolveTxid(m[3], example, world)
|
||||
const prefix = String(height).padStart(12, '0')
|
||||
const matching = world.postHeightsDb.entries().filter(([key, value]) => {
|
||||
return key.startsWith(prefix) && (key.endsWith(`:${txid}`) || value?.txid === txid)
|
||||
})
|
||||
if (matching.length !== expectedCount) {
|
||||
throw new Error(`Expected ${expectedCount} postHeights entry/entries for height ${height} txid ${txid}, got ${matching.length}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postParents store contains link',
|
||||
pattern: /^the postParents store contains a link from (.+) to (.+)$/,
|
||||
run (m, example, world) {
|
||||
const childTxid = resolveTxid(m[1], example, world)
|
||||
const parentTxid = resolveTxid(m[2], example, world)
|
||||
const link = world.postParentsDb.entries().find(([key, value]) => {
|
||||
return key === childTxid && value?.parentTxid === parentTxid
|
||||
})
|
||||
if (!link) {
|
||||
throw new Error(`Expected postParents link from ${childTxid} to ${parentTxid}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postChildren store contains link',
|
||||
pattern: /^the postChildren store contains a link from (.+) to (.+)$/,
|
||||
run (m, example, world) {
|
||||
const parentTxid = resolveTxid(m[1], example, world)
|
||||
const childTxid = resolveTxid(m[2], example, world)
|
||||
const link = world.postChildrenDb.entries().find(([key, value]) => {
|
||||
return value?.parentTxid === parentTxid && value?.childTxid === childTxid
|
||||
})
|
||||
if (!link) {
|
||||
throw new Error(`Expected postChildren link from ${parentTxid} to ${childTxid}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
async function handleStep (step, example, world) {
|
||||
for (const handler of handlers) {
|
||||
const match = handler.pattern.exec(step.text)
|
||||
if (match) {
|
||||
await handler.run(match, example, world, step)
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
|
||||
}
|
||||
|
||||
export { createWorld, handleStep }
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Acceptance runtime for psf-memo-indexer.
|
||||
*/
|
||||
|
||||
import { createWorld, handleStep } from './handlers.js'
|
||||
|
||||
function expandScenarios (ir) {
|
||||
const executions = []
|
||||
const background = ir.background || []
|
||||
|
||||
for (const scenario of ir.scenarios) {
|
||||
const examples = (scenario.examples && scenario.examples.length > 0)
|
||||
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
|
||||
: [{ example: {}, suffix: 'example_1' }]
|
||||
|
||||
for (const { example, suffix } of examples) {
|
||||
executions.push({
|
||||
name: `${scenario.name}/${suffix}`,
|
||||
steps: [...background, ...scenario.steps],
|
||||
example
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return executions
|
||||
}
|
||||
|
||||
async function runFeature (ir) {
|
||||
const executions = expandScenarios(ir)
|
||||
const results = []
|
||||
let failures = 0
|
||||
|
||||
for (const ex of executions) {
|
||||
const world = await createWorld()
|
||||
let failure = null
|
||||
|
||||
for (const step of ex.steps) {
|
||||
try {
|
||||
await handleStep(step, ex.example, world)
|
||||
} catch (err) {
|
||||
failure = err.message
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
failures++
|
||||
results.push({ name: ex.name, status: 'failed', detail: failure })
|
||||
} else {
|
||||
results.push({ name: ex.name, status: 'passed' })
|
||||
}
|
||||
}
|
||||
|
||||
return { feature: ir.name, results, failures, total: results.length }
|
||||
}
|
||||
|
||||
export { expandScenarios, runFeature }
|
||||
@@ -7,6 +7,8 @@
|
||||
"block-indexer": "node --max-old-space-size=8192 psf-memo-block-indexer.js",
|
||||
"tx-indexer": "node --max-old-space-size=4096 psf-memo-tx-indexer.js",
|
||||
"test": "c8 --reporter=text mocha --exit --recursive test/unit/",
|
||||
"property": "node --test \"test/property/*.test.js\"",
|
||||
"acceptance": "node acceptance/acceptance.js",
|
||||
"lint": "standard --env mocha --fix"
|
||||
},
|
||||
"author": "Chris Troutner",
|
||||
|
||||
@@ -20,6 +20,7 @@ class Adapters {
|
||||
this.dbCtrl = new DbCtrl()
|
||||
|
||||
this.postDb = createEntityDb('post', 'txid', 'postData')
|
||||
this.postHeightDb = createEntityDb('postheight', 'key', 'postHeightData')
|
||||
this.postParentDb = createEntityDb('postparent', 'txid', 'parentData')
|
||||
this.postChildDb = createEntityDb('postchild', 'key', 'childData')
|
||||
this.likeDb = createEntityDb('like', 'txid', 'likeData')
|
||||
|
||||
@@ -62,6 +62,11 @@ export function roomKey (roomName, txid) {
|
||||
return `${roomName}:${txid}`
|
||||
}
|
||||
|
||||
export function postHeightKey (blockHeight, txid) {
|
||||
const padded = String(blockHeight).padStart(12, '0')
|
||||
return `${padded}:${txid}`
|
||||
}
|
||||
|
||||
export function postChildKey (parentTxid, childTxid) {
|
||||
return `${parentTxid}:${childTxid}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas } from './helpers.js'
|
||||
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas, postHeightKey } from './helpers.js'
|
||||
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
|
||||
|
||||
// Create a record only when it does not already exist (idempotent writes).
|
||||
async function createIfMissing (db, key, value) {
|
||||
try {
|
||||
await db.get(key)
|
||||
} catch (err) {
|
||||
await db.create(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
export async function handlePost (ctx) {
|
||||
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
|
||||
const pushDatas = normalizeTwoPushMemoDatas(decoded.pushDatas)
|
||||
@@ -21,9 +30,11 @@ export async function handlePost (ctx) {
|
||||
}
|
||||
|
||||
const postData = { addr: signerAddr, text, seen, blockHeight }
|
||||
try {
|
||||
await adapters.postDb.get(txid)
|
||||
} catch (err) {
|
||||
await adapters.postDb.create(txid, postData)
|
||||
}
|
||||
const heightKey = postHeightKey(blockHeight, txid)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Small property-testing harness for psf-memo-indexer.
|
||||
|
||||
Mirrors psf-memo-db/test/property/harness.js: a deterministic, seeded PRNG
|
||||
plus a forAll helper so property runs are reproducible without a property
|
||||
framework dependency.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
// A small deterministic PRNG (mulberry32). Same seed => same stream.
|
||||
export function seededRandom (seed = 12345) {
|
||||
let a = seed >>> 0
|
||||
return function next () {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
export async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const input = gen(i)
|
||||
const ok = await check(input)
|
||||
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function intGen (rng, min, max) {
|
||||
return () => min + Math.floor(rng() * (max - min + 1))
|
||||
}
|
||||
|
||||
export function txidGen (rng) {
|
||||
const hex = '0123456789abcdef'
|
||||
let out = ''
|
||||
for (let i = 0; i < 64; i++) {
|
||||
out += hex[Math.floor(rng() * hex.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Property tests for the indexer's postHeightKey encoding.
|
||||
|
||||
The indexer writes the postHeights secondary index, and the DB reads it
|
||||
back by the same key scheme. These properties pin the encoding invariants:
|
||||
fixed-width zero-padded height, numeric-order preservation, and a stable
|
||||
key format shared with the DB.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
|
||||
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
|
||||
import { postHeightKey } from '../../src/use-cases/action-types/helpers.js'
|
||||
|
||||
const rng = seededRandom(20260826)
|
||||
|
||||
test('postHeightKey is fixed-width and encodes the height and txid', async () => {
|
||||
const heightGen = intGen(rng, 0, 999999999999)
|
||||
|
||||
await forAll(
|
||||
(i) => ({ height: heightGen(), txid: txidGen(rng) }),
|
||||
({ height, txid }) => {
|
||||
const key = postHeightKey(height, txid)
|
||||
const [padded, txidPart] = key.split(':')
|
||||
return padded.length === 12 &&
|
||||
Number.parseInt(padded, 10) === height &&
|
||||
txidPart === txid
|
||||
},
|
||||
{ label: 'postHeightKey fixed-width encoding' }
|
||||
)
|
||||
})
|
||||
|
||||
test('postHeightKey preserves numeric height order lexicographically', async () => {
|
||||
const heightGen = intGen(rng, 0, 9000000)
|
||||
|
||||
await forAll(
|
||||
(i) => {
|
||||
const a = heightGen()
|
||||
const b = heightGen()
|
||||
return { a: Math.min(a, b), b: Math.max(a, b), txidA: txidGen(rng), txidB: txidGen(rng) }
|
||||
},
|
||||
({ a, b, txidA, txidB }) => {
|
||||
if (a === b) return postHeightKey(a, txidA) === postHeightKey(a, txidB)
|
||||
return postHeightKey(a, txidA) < postHeightKey(b, txidB)
|
||||
},
|
||||
{ label: 'postHeightKey ordering' }
|
||||
)
|
||||
})
|
||||
@@ -1,15 +1,18 @@
|
||||
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 () => {
|
||||
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 adapters = {
|
||||
postDb: { create, get },
|
||||
postHeightDb: { create: postHeightCreate, get: postHeightGet },
|
||||
processErrorDb: { create: sinon.stub() }
|
||||
}
|
||||
|
||||
@@ -31,5 +34,67 @@ describe('#handlePost', () => {
|
||||
assert.equal(create.firstCall.args[0], 'abc123')
|
||||
assert.equal(create.firstCall.args[1].text, 'hello memo')
|
||||
assert.equal(create.firstCall.args[1].blockHeight, 600100)
|
||||
|
||||
assert.equal(postHeightCreate.callCount, 1)
|
||||
assert.equal(postHeightCreate.firstCall.args[0], '000000600100:abc123')
|
||||
assert.equal(postHeightCreate.firstCall.args[1].txid, 'abc123')
|
||||
assert.equal(postHeightCreate.firstCall.args[1].blockHeight, 600100)
|
||||
})
|
||||
|
||||
it('should not duplicate postHeight entries when reprocessing', async () => {
|
||||
const create = sinon.stub().resolves({ success: true })
|
||||
const get = sinon.stub().resolves({ addr: 'bitcoincash:qptest', text: 'hello memo', seen: 1000, blockHeight: 600100 })
|
||||
const postHeightCreate = sinon.stub().resolves({ success: true })
|
||||
const postHeightGet = sinon.stub().resolves({ txid: 'abc123', blockHeight: 600100 })
|
||||
|
||||
const adapters = {
|
||||
postDb: { create, get },
|
||||
postHeightDb: { create: postHeightCreate, get: postHeightGet },
|
||||
processErrorDb: { create: sinon.stub() }
|
||||
}
|
||||
|
||||
const message = Buffer.from('hello memo')
|
||||
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, 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { assert } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { handleReply } from '../../../../src/use-cases/action-types/reply.js'
|
||||
import { PREFIX_REPLY } from '../../../../src/lib/memo-codes.js'
|
||||
|
||||
describe('#handleReply', () => {
|
||||
it('should save a reply and its postHeight index entry', async () => {
|
||||
const parentTxid = Buffer.alloc(32, 0xab)
|
||||
const message = Buffer.from('hi there')
|
||||
|
||||
const postParentCreate = sinon.stub().resolves({ success: true })
|
||||
const postChildCreate = sinon.stub().resolves({ success: true })
|
||||
const postDbGet = sinon.stub().rejects(new Error('not found'))
|
||||
const postDbCreate = sinon.stub().resolves({ success: true })
|
||||
const postHeightGet = sinon.stub().rejects(new Error('not found'))
|
||||
const postHeightCreate = sinon.stub().resolves({ success: true })
|
||||
|
||||
const adapters = {
|
||||
postParentDb: { create: postParentCreate },
|
||||
postChildDb: { create: postChildCreate },
|
||||
postDb: { get: postDbGet, create: postDbCreate },
|
||||
postHeightDb: { get: postHeightGet, create: postHeightCreate },
|
||||
processErrorDb: { create: sinon.stub() }
|
||||
}
|
||||
|
||||
await handleReply({
|
||||
adapters,
|
||||
txid: 'reply-abc',
|
||||
signerAddr: 'bitcoincash:qptest',
|
||||
seen: 1000,
|
||||
blockHeight: 600150,
|
||||
decoded: {
|
||||
action: 'reply',
|
||||
prefix: PREFIX_REPLY,
|
||||
pushDatas: [PREFIX_REPLY, parentTxid, message]
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(postParentCreate.callCount, 1)
|
||||
assert.equal(postParentCreate.firstCall.args[0], 'reply-abc')
|
||||
assert.equal(postParentCreate.firstCall.args[1].parentTxid, parentTxid.toString('hex'))
|
||||
|
||||
assert.equal(postChildCreate.callCount, 1)
|
||||
assert.include(postChildCreate.firstCall.args[0], 'reply-abc')
|
||||
|
||||
assert.equal(postDbCreate.callCount, 1)
|
||||
assert.equal(postDbCreate.firstCall.args[0], 'reply-abc')
|
||||
assert.equal(postDbCreate.firstCall.args[1].text, 'hi there')
|
||||
|
||||
assert.equal(postHeightCreate.callCount, 1)
|
||||
assert.equal(postHeightCreate.firstCall.args[0], '000000600150:reply-abc')
|
||||
assert.equal(postHeightCreate.firstCall.args[1].txid, 'reply-abc')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user