Repository navigation
feat(api): add the @qbit-oss/api/testing mock backend - #9
Merged
Merged
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…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>
…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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a
@qbit-oss/api/testingentry point, for tests only.createMockEsplorareturns an offline mock of the qbit-electrs or mempool-qbit backend, which an integrator passes toEsploraClientorMempoolQbitClientasfetchFn. 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 and404on mempool-qbit, which is whybroadcasting.mdsays to look a transaction up withgetTx.qbit Core's verdict on submissions. Without a fault, a submission gets qbit Core v1.0.0's
sendrawtransactionanswer, from the outputs that the fixture and the accepted submissions create and spend. It models these rules, each citing its source line;docs/testing.mdgives the order Core applies them in:maxburnamount;-27while the chain holds one of the transaction's own outputs unspent, or the txid when the transaction is already in the mempool;CheckTransactioncheck (bad-txns-vout-empty,bad-txns-vout-toolarge,bad-txns-inputs-duplicate, …);-25missing or spent inputs;CheckTxInputs' value checks, which the mock can decide because it knows the coins (bad-txns-in-belowout, a premature coinbase spend);So a payment rebuilt from the same input after
unknown_outcome, the mistakebroadcasting.mdwarns 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,
/utxoand the transaction routes, as they do on qbit-electrs: first in the mempool, oldest arrival first, then confirmed aftermock.mine(). Weight is the serialized size and version is unsigned, as qbit-electrs reports them. A fixture transaction can be markedcoinbase: 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 aRetry-Aftervalue thatHeaderswould refuse, is aTypeError. 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 aboveMAX_MONEY, or a spend of an unspendable output.Packaging
QbitApiError.testing/package.jsonshim points resolvers that ignoreexportsat it. Each was proven with a packed tarball: Jest 26 and 27, Metro without package exports, and TypeScript'smoduleResolution: "node10". Both entry points are built for ES2020, which webpack 4's parser does not support, so webpack 4 cannot bundle either.pack:checkrequires the shim.consumer:checktype-checks the subpath under node10, node16 and bundler resolution, and loads it from a copy of the package withoutexports. Its guard against a secondQbitApiErrormatches 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 verifypasses: api 1,478 tests, 199 of them in the kit's two test files; protocol 1,460; examples 104. So dopnpm smoke:consumerandpnpm docs:api:check(113 links).pack:checklists the shim.consumer:checkpasses under node10, node16 and bundler resolution, and loads the subpath withexportsignored.QbitApiErrormatches the forms esbuild emits, and checks itself. It must find the class in the client'slib/index.jsandlib/index.mjs, and must find neither the class nor its brand inlib/testing.*.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need.