Skip to content

feat(api): add the @qbit-oss/api/testing mock backend - #9

Merged
kiwidream merged 6 commits into
release/0.1.1from
devex/testing-kit-pr
Oct 30, 2026
Merged

kiwidream merged 6 commits into
release/0.1.1from
devex/testing-kit-pr

Conversation

@kiwidream

@kiwidream kiwidream commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a @qbit-oss/api/testing entry point, for tests only. createMockEsplora returns an offline mock of the qbit-electrs or mempool-qbit backend, which an integrator passes to EsploraClient or MempoolQbitClient as fetchFn. Then their wallet's unit tests run without a node or indexer.

  • One declarative fixture serves every route the clients call: tip, blocks, transactions with their status, address history and UTXOs, outspends, fee estimates and recommended fees.

  • Backend differences: the mock answers as the chosen backend does where the two differ. For example, an unknown txid's status is 200 {"confirmed":false} on qbit-electrs and 404 on mempool-qbit, which is why broadcasting.md says to look a transaction up with getTx.

  • qbit Core's verdict on submissions. Without a fault, a submission gets qbit Core v1.0.0's sendrawtransaction answer, from the outputs that the fixture and the accepted submissions create and spend. It models these rules, each citing its source line; docs/testing.md gives the order Core applies them in:

    • maxburnamount;
    • -27 while the chain holds one of the transaction's own outputs unspent, or the txid when the transaction is already in the mempool;
    • every CheckTransaction check (bad-txns-vout-empty, bad-txns-vout-toolarge, bad-txns-inputs-duplicate, …);
    • -25 missing or spent inputs;
    • CheckTxInputs' value checks, which the mock can decide because it knows the coins (bad-txns-in-belowout, a premature coinbase spend);
    • mempool conflicts, which the mock never takes as replacements.

    So a payment rebuilt from the same input after unknown_outcome, the mistake broadcasting.md warns about, is refused as Core would refuse it. The docs list what the mock doesn't model: policy and standardness, fee floors, scripts and signatures, finality, and replacement.

  • Views that stay consistent. Accepted submissions show in the address views, /utxo and the transaction routes, as they do on qbit-electrs: first in the mempool, oldest arrival first, then confirmed after mock.mine(). Weight is the serialized size and version is unsigned, as qbit-electrs reports them. A fixture transaction can be marked coinbase: true, and its outputs then mature at 1,000 confirmations.

  • Faults per route: an HTTP status with Retry-After, a refused connection, a stalled body that errors when the request aborts, and a submission that is accepted, rejected with a node code, or accepted with its reply lost. Every request is recorded for assertions.

  • Strict options: they're validated up front, so nothing fails at request time. An explicit null, or a Retry-After value that Headers would refuse, is a TypeError. An inconsistent fixture is refused too: transactions at one height that disagree on hash or time, a tip hash that doesn't match the fixture block at the tip height, totals above MAX_MONEY, or a spend of an unspendable output.

Packaging

  • It's a second tsup entry, in both builds, with type declarations. It imports nothing from the client at runtime, so neither build gets a second QbitApiError.
  • A testing/package.json shim points resolvers that ignore exports at it. Each was proven with a packed tarball: Jest 26 and 27, Metro without package exports, and TypeScript's moduleResolution: "node10". Both entry points are built for ES2020, which webpack 4's parser does not support, so webpack 4 cannot bundle either.
  • pack:check requires the shim. consumer:check type-checks the subpath under node10, node16 and bundler resolution, and loads it from a copy of the package without exports. Its guard against a second QbitApiError matches esbuild's real output. The consumer smoke and the TypeDoc site cover it too.

Tests

The api tests use the kit in place of 99 hand-written stubs, and no test was dropped or weakened. Two tests keep bespoke stubs whose body ignores abort, because the kit's default stalled body errors on abort. Mutating the client to stop racing the signal, or to drop its body-read timeout, fails them again.

Docs: packages/api/docs/testing.md, the api README, CONTRIBUTING and the CHANGELOG.

Testing

  • pnpm verify passes: api 1,478 tests, 199 of them in the kit's two test files; protocol 1,460; examples 104. So do pnpm smoke:consumer and pnpm docs:api:check (113 links).
  • Every new test fails against the first version of the kit, and three targeted mutations of the fixes each fail exactly their test. A read-only audit checked all 120 qbit Core and qbit-electrs citations in the kit against the pinned sources.
  • pack:check lists the shim. consumer:check passes under node10, node16 and bundler resolution, and loads the subpath with exports ignored.
  • The guard against a second QbitApiError matches the forms esbuild emits, and checks itself. It must find the class in the client's lib/index.js and lib/index.mjs, and must find neither the class nor its brand in lib/testing.*.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

kiwidream and others added 2 commits October 29, 2026 16:17
createMockEsplora returns an offline mock of the qbit-electrs or
mempool-qbit backend to pass to EsploraClient or MempoolQbitClient as
fetchFn, for integrators' unit tests. It serves every route the clients
call from one declarative fixture, answers as the chosen backend does where
they differ (an unknown txid's status is 200 {"confirmed":false} on
qbit-electrs and 404 on mempool-qbit), records each request, and injects
per-route faults: an HTTP status with Retry-After, a refused connection, a
stalled body, and a submission that is accepted, rejected with a node code,
or accepted with its reply lost.

Without a fault, a submission gets qbit Core v1.0.0's verdict, from the
outputs the fixture and the submissions it took create and spend, in the
order sendrawtransaction reaches it: -27 only while the chain holds one of
the transaction's own outputs unspent, the txid for one in the mempool, -26
bad-txns-inputs-duplicate, -25 bad-txns-inputs-missingorspent for a spend
of an unknown, confirmed-spent or unspendable output, then -26 for a
conflict with a mempool transaction, which the mock never takes as a
replacement. Its options are validated up front, an explicit null is a
TypeError, and fixture blocks, times and the tip must agree.

It is a second tsup entry in both builds and imports nothing from the
client at runtime, so neither build gets a second QbitApiError. A
testing/package.json shim lets resolvers that ignore exports load it, so
typesVersions is not needed. pack:check requires the shim; consumer:check
type-checks the subpath under node10, node16 and bundler resolution, loads
it from a copy of the package without exports, and checks that the testing
build defines no QbitApiError, against the forms esbuild emits.

The api tests use it in place of 99 of their hand-written stubs; no test
was dropped or weakened. Two tests keep stubs whose body ignores the abort,
which the kit's stalled body does not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…views consistent

The mock's node now answers a submission as qbit Core's sendrawtransaction
does, in Core's order and with its codes and messages: the maxburnamount
check, every CheckTransaction rule, the CheckTxInputs rules it can decide
(premature coinbase spends, input values out of range, outputs worth more
than the inputs), bad-txns-spends-conflicting-tx, the insufficient-fee
refusal of a conflict, and the default maxfeerate. -22 carries Core's
exact text. Coins follow Core's views: a confirmed transaction's
unspendable outputs never join the UTXO set, while the mempool's view
holds every output of a mempool transaction.

Every address view now derives from the fixture and the submissions the
node took, as qbit-electrs derives it: in the mempool until mine(), then
confirmed, with the mempool listed oldest first, weight reported as the
serialized size and version as u32.

Fixtures gain coinbase: true with Core's 1,000-block maturity, refuse a
spend of an unspendable output and totals above MAX_MONEY (summed with
BigInt), and a Retry-After is validated by Headers itself up front.

The docs fix the 5xx fault row from the broadcasting outcome table, say
what the mock does not model, and list only resolvers that were checked:
TypeScript node10, Jest before 28 and Metro without package exports.
Both entry points are built for ES2020, which webpack 4 cannot parse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T17:05:07.644390Z 2fc5749 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

kiwidream and others added 2 commits October 30, 2026 11:25
…in the testing kit

- Refuse a spend of a mempool transaction's unspendable output where qbit
  Core's script check does, after the replacement checks and before the
  maxfeerate check: -26 mempool-script-verify-flag-failed (OP_RETURN was
  encountered, or Script is too big) with Core's input, txid, wtxid and
  prevout debug text. mine() can no longer confirm such a spend unforced.
- Derive coinbase-ness from a fixture's hex, refuse a coinbase flag the hex
  contradicts, and serve a hex coinbase's input witness as qbit-electrs does.
- Refuse a second coinbase in a fixture block, and a coinbase listed after
  another transaction of its block, so the coinbase is at position 0.
- Leave outputs qbit-electrs's default configuration does not index
  (rust-bitcoin's provably unspendable scripts, OP_RETURN among them) out of
  the address stats, unspent outputs and history.
- Say that the conflict reply is Core's reason and the start of its message,
  without the amounts compared; make the accepted fault's doc match the code,
  which leaves a confirmed transaction confirmed; cite the chain page size as
  qbit-electrs's --rest-default-chain-txs-per-page default.
- Decode a submission's scripts by slicing its hex and skip the legacy
  decoding when it cannot differ, which cuts the 2 MB oversize test from
  about 1.3 s to 0.13 s, and give the transaction-check tests a 60 s
  timeout for slow CI runners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eout

Several cases in raw-tx-canonical.test.ts build and decode a MAX_SIZE
(32 MiB) witness item. On the GitHub runner they took 2.8 to 4.3 s each,
near vitest's 5 s default, so a slower run would fail them for time alone.
Both describe blocks that hold them get a 60 s timeout, as the testing
kit's scale cases do; with the timeout set to 50 ms, exactly those four
cases fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kiwidream
kiwidream added this pull request to stack #11 October 30, 2026 15:28

@qbit-reviewer qbit-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 findings posted inline.

Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/test/cancellation.test.ts Resolved
Comment thread packages/api/docs/testing.md Resolved
Comment thread packages/api/src/testing.ts Resolved
…s exact after mine

- Match a qbit bech32 address (qb, tq, qbrt) in lowercase or uppercase, in a
  request or in the fixture, and serve it lowercase, as qbit-electrs does: it
  looks an address up by the script it decodes it to with the bech32 crate,
  which takes either case and refuses mixed case. A mixed-case one is a 400
  reply, or a TypeError in the fixture; any other address, such as a base58
  one, is still matched exactly.
- Give a fault-taken transaction's inputs their prevouts, and its fee, once
  the transaction they spend arrives, as qbit-electrs looks prevouts up each
  time it serves a transaction. Its mempool index still leaves out a
  transaction that arrived without them, but once mine() confirms it every
  view counts its spends, so the address stats and /utxo agree. mine() puts
  a transaction after those it spends from, as a block qbit Core mines does.
- Make MockBroadcastFault a union on outcome: a rejected fault needs its
  code, and accepted and lost-reply take no code or message, as
  createMockEsplora already enforced at runtime.
- Hide the request signal from the mock in the two mid-pagination abort
  tests, so they no longer depend on when the client links the abort to
  fetch. With a client that links it at once and never checks the signal
  itself, the migrated tests passed; these now fail, as the pre-migration
  ones did.
- Fix the Quick start's request listing (two GETs come before the POST) and
  run the Quick start from docs/testing.md as a test that checks every value
  its comments give.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@qbit-reviewer qbit-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 findings posted inline.

Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/src/testing.ts Resolved
…submissions

An accepted or lost-reply fault can make the node take transactions qbit
Core never holds in its mempool. The mock now places them so that no block
spends an output of a mempool transaction, chain_stats never counts a spend
of an output mempool_stats counts, and an output a block spends is spent in
every view and in every verdict. docs/testing.md documents the rules.

- mine() leaves in the mempool a transaction that spends an output the mock
  does not know, and each one that spends from it, as no block Core's miner
  makes holds a transaction whose inputs are missing. Before, it confirmed a
  fault-taken child before its parent, and once the parent arrived in the
  mempool the child's confirmed spend could take the confirmed balance below
  zero.
- A coinbase taken through a fault is a coinbase, as a fixture's hex already
  decides: it is served with its coinbase input, counts as the next block's
  coinbase while in the mempool (a spend is premature at depth 0), goes
  first in the block mine() makes, and matures from there. The packed
  consumer check now asserts it for the genesis coinbase it submits.
- A transaction that a transaction in a block spends from, such as one
  outside the fixture that a fixture input in a block spends, is in the
  chain. Without a fault it is refused with -25
  bad-txns-inputs-missingorspent, as Core refuses a transaction in a block
  whose outputs are all spent: the mock holds none of its outputs, so
  BroadcastTransaction's UTXO check (-27) finds none. A fault puts it in that
  block, before its first spender there (first for a coinbase), with the
  mempool transactions it spends from. Before, it joined the mempool and its
  spent output became a coin that a second spend could take and /utxo
  listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kiwidream
kiwidream removed this pull request from stack #11 October 30, 2026 17:10
@kiwidream
kiwidream merged commit 20b3257 into release/0.1.1 Oct 30, 2026
3 checks passed
@kiwidream
kiwidream deleted the devex/testing-kit-pr branch October 30, 2026 17:10

@qbit-reviewer qbit-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete review: GitHub gave no textual diff for 1 file(s) not confirmed as non-text.

3 findings posted inline.

Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/src/testing.ts Resolved
Comment thread packages/api/README.md Resolved
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant