mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 11:18:24 +00:00
The db suites hand-copied both the schema and each repo's SQL, then asserted against the copy. That proves nothing about the code the app runs, and it drifted six times across this branch while staying green — counters.test.ts was still asserting the OLD (mintUrl, keysetId) key long after it was gone, and transactionsMintUrl.test.ts kept passing while testing a function that had been deleted. Rather than make the copies track production, this removes them. The op-sqlite jest mock now backs connection.ts's driver seam with node:sqlite. connection.ts documents itself as "the single seam between the rest of the app and the native SQLite library", so mocking exactly there means tests run the production path end to end: real connection.ts (param sanitizing, result adaptation, BEGIN/COMMIT batch emulation), real instance.ts (schema creation AND the real migration runner), real repos. Net -418 lines, and no production code changed. - counters, proofReservation, onchainQuotes, meltRecovery, inFlightRequests and nut20's counter half now call Database.* directly. No copied DDL, no copied SQL. - The migration suites import their SQL from the MIGRATIONS registry. Their PRE-migration shapes stay hand-written ON PURPOSE — those are frozen history and must never track today's schema, which is the whole reason v26/v28/v29/v31 froze their column lists. That distinction is now stated in each file so the next person does not "helpfully" point them at schema.ts and reintroduce the replay bug. It found a bug on contact: walletCountersRepo allocates an index with a single `INSERT … ON CONFLICT DO UPDATE … RETURNING`. The mock routed statements by leading keyword, sent it down .run(), and the allocation failed — exactly the point, since the mirror had RE-IMPLEMENTED that statement rather than running it and so could never exercise RETURNING. Two smaller gains from using the real path: counters' rollback test now trips the real sanitizeParams guard instead of a hand-rolled NOT NULL violation, and proofReservation locks real MST Proof nodes, because the repo calls isAlive() — which only answers for an actual node, so plain objects never took production's path. Limits, recorded in the mock: Node's SQLite is not op-sqlite's build (version and compile flags may differ), so this proves our SQL and our logic, not the exact native binary — device testing still owns that. And each test FILE shares one in-memory database (instance.ts caches its connection), so suites clear tables in beforeEach rather than rebuilding. Tests: 441 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
137 lines
5.3 KiB
TypeScript
137 lines
5.3 KiB
TypeScript
/**
|
|
* In-flight requests (inflight_requests), against the REAL repo and a real database.
|
|
*
|
|
* Per-transaction request params for an operation that has reached the mint but
|
|
* whose response may be lost. Written before the network call so the op can be
|
|
* retried against the mint's idempotent (NUT-19) endpoint.
|
|
*
|
|
* The table is a CHILD of the transaction — its primary key IS transactionId — so
|
|
* it carries no mint reference. It once duplicated mintUrl and keysetId; keysetId
|
|
* had no reader at all, and mintUrl served exactly one query ("every request of
|
|
* this mint"), which now JOINs through the parent's mintId. One owner of the fact,
|
|
* so nothing here can go stale when a mint moves.
|
|
*
|
|
* Calls the production `Database.*` functions rather than mirroring their SQL — the
|
|
* op-sqlite jest mock backs the real driver seam with node:sqlite. That matters most
|
|
* for the join: a hand-copied version proves nothing about the query the app runs.
|
|
*/
|
|
jest.mock('../src/services/logService', () => ({
|
|
log: {debug: jest.fn(), error: jest.fn(), info: jest.fn(), trace: jest.fn(), warn: jest.fn()},
|
|
}))
|
|
|
|
import {Database} from '../src/services/db'
|
|
|
|
const MINT = 'https://mint.test'
|
|
const MINT_ID = 'mint1111'
|
|
const OTHER_MINT_ID = 'mint9999'
|
|
|
|
/** The parent row the join reaches through. */
|
|
const addTransaction = (id: number, mintId: string | null) =>
|
|
Database.getInstance().execute(
|
|
`INSERT OR REPLACE INTO transactions (id, type, amount, unit, data, mint, mintId, status, createdAt)
|
|
VALUES (?, 'TOPUP', 100, 'sat', '{}', ?, ?, 'PENDING', '2026-01-01')`,
|
|
[id, MINT, mintId],
|
|
)
|
|
|
|
beforeEach(() => {
|
|
Database.getInstance().executeBatch([['DELETE FROM inflight_requests'], ['DELETE FROM transactions']])
|
|
})
|
|
|
|
describe('In-flight requests (inflight_requests)', () => {
|
|
test('stores and reads back a request (JSON round-trip)', () => {
|
|
const request = {token: 'cashuA...', options: {keysetId: 'k1'}}
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, request)
|
|
|
|
const rec = Database.getInFlightRequest(101)!
|
|
expect(rec.transactionId).toBe(101)
|
|
expect(rec.request).toEqual(request)
|
|
})
|
|
|
|
test('returns undefined when no entry exists', () => {
|
|
expect(Database.getInFlightRequest(999)).toBeUndefined()
|
|
})
|
|
|
|
test('add OVERWRITES an existing entry (set semantics)', () => {
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 'first'})
|
|
Database.addInFlightRequest(101, {v: 'second'})
|
|
|
|
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'second'})
|
|
})
|
|
|
|
test('remove deletes the entry', () => {
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 1})
|
|
Database.removeInFlightRequest(101)
|
|
|
|
expect(Database.getInFlightRequest(101)).toBeUndefined()
|
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
|
|
})
|
|
|
|
describe('getInFlightRequestsByMintId — the per-mint recovery sweep', () => {
|
|
test('returns all rows of a mint, and only that mint', () => {
|
|
addTransaction(101, MINT_ID)
|
|
addTransaction(102, MINT_ID)
|
|
addTransaction(103, OTHER_MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 1})
|
|
Database.addInFlightRequest(102, {v: 2})
|
|
Database.addInFlightRequest(103, {v: 3})
|
|
|
|
expect(
|
|
Database.getInFlightRequestsByMintId(MINT_ID)
|
|
.map(r => r.transactionId)
|
|
.sort(),
|
|
).toEqual([101, 102])
|
|
expect(Database.getInFlightRequestsByMintId(OTHER_MINT_ID)).toHaveLength(1)
|
|
})
|
|
|
|
// The point of joining on mintId rather than a url copy: a mint moving must
|
|
// never hide its own in-flight work. transactions.mint stays frozen as history;
|
|
// only the mint's live url moves, and the id is unaffected.
|
|
test("still finds a mint's requests regardless of the url on the transaction", () => {
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 1})
|
|
|
|
Database.getInstance().execute('UPDATE transactions SET mint = ? WHERE id = ?', [
|
|
'https://some-other.url',
|
|
101,
|
|
])
|
|
|
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(1)
|
|
})
|
|
|
|
// Correct, not a regression: the retry settles proofs onto its transaction and
|
|
// the handler branches on tx.type, so without the parent there is nothing to
|
|
// apply the result to.
|
|
test('a request whose transaction is gone is not returned', () => {
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 1})
|
|
Database.getInstance().execute('DELETE FROM transactions WHERE id = ?', [101])
|
|
|
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
|
|
})
|
|
|
|
test('a transaction with no mintId is not returned', () => {
|
|
addTransaction(101, null)
|
|
Database.addInFlightRequest(101, {v: 1})
|
|
|
|
expect(Database.getInFlightRequestsByMintId(MINT_ID)).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('seedInFlightRequests — one-time MST/MMKV copy', () => {
|
|
test('is idempotent — does not overwrite an existing entry', () => {
|
|
addTransaction(101, MINT_ID)
|
|
Database.addInFlightRequest(101, {v: 'live'})
|
|
Database.seedInFlightRequests([{transactionId: 101, request: {v: 'snapshot'}}])
|
|
|
|
expect(Database.getInFlightRequest(101)!.request).toEqual({v: 'live'})
|
|
})
|
|
|
|
test('an empty seed is a no-op', () => {
|
|
expect(Database.seedInFlightRequests([])).toEqual({seeded: 0})
|
|
})
|
|
})
|
|
})
|