mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +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>
212 lines
7.9 KiB
TypeScript
212 lines
7.9 KiB
TypeScript
/**
|
|
* Onchain (NUT-30) mint quotes, against the REAL repo and a real database.
|
|
*
|
|
* The watch rule is what these tests are really about. Two facts make it subtle:
|
|
*
|
|
* - the mint returns `expiry: null`, so a deposit address never dies and nothing
|
|
* server-side bounds how long we poll a quote nobody paid;
|
|
* - a quote can be paid MORE than once, so "have we minted yet" is the wrong
|
|
* question — "is there money credited that we have not minted" is the right one.
|
|
*
|
|
* Getting this wrong loses money in one direction (abandon a quote holding funds)
|
|
* or polls forever in the other.
|
|
*
|
|
* Calls the production `Database.*` functions rather than mirroring their SQL: the
|
|
* op-sqlite jest mock backs the real driver seam with node:sqlite. Dates are
|
|
* anchored to REAL now, because the real watch query compares against `new Date()`.
|
|
*/
|
|
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 daysFromNow = (n: number) => new Date(Date.now() + n * 86400000).toISOString()
|
|
|
|
const addQuote = (q: {
|
|
quote: string
|
|
counterIndex: number
|
|
amountRequested?: number | null
|
|
amountPaid?: number
|
|
amountIssued?: number
|
|
watchUntil?: string
|
|
mintId?: string
|
|
}) =>
|
|
Database.addOnchainMintQuote({
|
|
quote: q.quote,
|
|
mintId: q.mintId ?? MINT_ID,
|
|
mintUrl: MINT,
|
|
unit: 'sat',
|
|
address: 'bc1q' + q.quote,
|
|
counterIndex: q.counterIndex,
|
|
pubkey: '02' + 'a'.repeat(64),
|
|
amountRequested: q.amountRequested ?? null,
|
|
amountPaid: q.amountPaid,
|
|
amountIssued: q.amountIssued,
|
|
expiry: null, // the mint returns null — the address never dies
|
|
watchUntil: q.watchUntil,
|
|
})
|
|
|
|
const watchedIds = () =>
|
|
Database.getWatchedOnchainMintQuotes()
|
|
.map(r => r.quote)
|
|
.sort()
|
|
|
|
beforeEach(() => {
|
|
Database.getInstance().executeBatch([['DELETE FROM onchain_mint_quotes']])
|
|
})
|
|
|
|
describe('onchain mint quotes', () => {
|
|
describe('the watch rule', () => {
|
|
it('watches a fresh unpaid quote (window still open)', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0})
|
|
expect(watchedIds()).toEqual(['q1'])
|
|
})
|
|
|
|
it('stops watching an unpaid quote once the window closes', () => {
|
|
// nothing ever arrived, and the mint will never expire the address for us —
|
|
// so the wallet has to draw the line itself
|
|
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
|
expect(watchedIds()).toEqual([])
|
|
})
|
|
|
|
it('watches a quote with credited-but-unminted funds', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 0})
|
|
expect(watchedIds()).toEqual(['q1'])
|
|
})
|
|
|
|
it('stops watching once the quote is fully drained (the "stop after first mint" case)', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
expect(watchedIds()).toEqual([])
|
|
})
|
|
|
|
it('KEEPS watching a PARTIALLY drained quote — money is still credited', () => {
|
|
// The reason the rule keys on the unminted BALANCE and not on "has ever
|
|
// minted". Keying on amountIssued > 0 would archive this and abandon 20k sat
|
|
// the mint is holding for us.
|
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 70000, amountIssued: 50000})
|
|
expect(watchedIds()).toEqual(['q1'])
|
|
})
|
|
|
|
it('watches credited funds even after the window has closed', () => {
|
|
// the deadline bounds quotes nobody paid; it must never override money that
|
|
// is actually sitting at the mint
|
|
addQuote({
|
|
quote: 'q1',
|
|
counterIndex: 0,
|
|
amountPaid: 50000,
|
|
amountIssued: 0,
|
|
watchUntil: daysFromNow(-30),
|
|
})
|
|
expect(watchedIds()).toEqual(['q1'])
|
|
})
|
|
|
|
it('separates watched from archived across a realistic mix', () => {
|
|
addQuote({quote: 'fresh', counterIndex: 0})
|
|
addQuote({quote: 'drained', counterIndex: 1, amountPaid: 10000, amountIssued: 10000})
|
|
addQuote({quote: 'unpaid-expired', counterIndex: 2, watchUntil: daysFromNow(-1)})
|
|
addQuote({quote: 'has-funds', counterIndex: 3, amountPaid: 30000, amountIssued: 0})
|
|
addQuote({quote: 'partly-drained', counterIndex: 4, amountPaid: 30000, amountIssued: 10000})
|
|
|
|
expect(watchedIds()).toEqual(['fresh', 'has-funds', 'partly-drained'])
|
|
})
|
|
})
|
|
|
|
describe('amount updates are monotonic', () => {
|
|
it('applies a normal forward update', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0})
|
|
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0)
|
|
|
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({amountPaid: 50000, amountIssued: 0})
|
|
})
|
|
|
|
it('ignores a stale response that would walk amounts BACKWARDS', () => {
|
|
// A slow reply landing after a fresh one must not resurrect an old view. If
|
|
// amountIssued regressed, the wallet would see unminted money that is not
|
|
// there — and mint it a second time.
|
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
|
|
Database.updateOnchainMintQuoteAmounts('q1', 50000, 0) // stale: "nothing issued yet"
|
|
|
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
|
amountPaid: 50000,
|
|
amountIssued: 50000,
|
|
})
|
|
expect(watchedIds()).toEqual([]) // still archived, not resurrected
|
|
})
|
|
|
|
it('records a second deposit arriving on the same address', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0, amountPaid: 50000, amountIssued: 50000})
|
|
expect(watchedIds()).toEqual([])
|
|
|
|
Database.updateOnchainMintQuoteAmounts('q1', 70000, 50000) // paid again
|
|
|
|
expect(watchedIds()).toEqual(['q1']) // unminted balance -> back in the watch set
|
|
})
|
|
})
|
|
|
|
describe('archived quotes stay recoverable', () => {
|
|
it('keeps the row, and with it the NUT-20 counterIndex', () => {
|
|
// counterIndex is the only way to re-derive the key that signs a mint request
|
|
// for this quote. Delete the row and a late deposit becomes permanently
|
|
// unspendable.
|
|
addQuote({quote: 'q1', counterIndex: 42, amountPaid: 10000, amountIssued: 10000})
|
|
|
|
expect(watchedIds()).toEqual([]) // archived
|
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
|
counterIndex: 42,
|
|
address: 'bc1qq1',
|
|
})
|
|
})
|
|
|
|
it('re-opens the watch window on user request', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0, watchUntil: daysFromNow(-1)})
|
|
expect(watchedIds()).toEqual([])
|
|
|
|
Database.extendOnchainMintQuoteWatch('q1', 7) // "check for deposits"
|
|
|
|
expect(watchedIds()).toEqual(['q1'])
|
|
})
|
|
})
|
|
|
|
describe('the requested amount is only a hint', () => {
|
|
it('keeps amountRequested separate from what was actually paid', () => {
|
|
// The BIP21 amount is a suggestion the sender can ignore. Under- and
|
|
// overpayment are normal, so the two must never be conflated.
|
|
addQuote({quote: 'q1', counterIndex: 0, amountRequested: 50000})
|
|
|
|
Database.updateOnchainMintQuoteAmounts('q1', 42000, 0) // sender underpaid
|
|
|
|
expect(Database.getOnchainMintQuote('q1')).toMatchObject({
|
|
amountRequested: 50000,
|
|
amountPaid: 42000,
|
|
})
|
|
})
|
|
|
|
it('allows a null requested amount', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0, amountRequested: null})
|
|
expect(Database.getOnchainMintQuote('q1')!.amountRequested).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('the mint reference', () => {
|
|
it('finds a mint\'s quotes by id, not by the url they were created at', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0})
|
|
addQuote({quote: 'q2', counterIndex: 1, mintId: 'other-mint'})
|
|
|
|
expect(Database.getOnchainMintQuotesByMintId(MINT_ID).map(r => r.quote)).toEqual(['q1'])
|
|
})
|
|
|
|
it('keeps mintUrl as the record of where the quote was created', () => {
|
|
addQuote({quote: 'q1', counterIndex: 0})
|
|
const row = Database.getOnchainMintQuote('q1')!
|
|
|
|
expect(row.mintId).toBe(MINT_ID)
|
|
expect(row.mintUrl).toBe(MINT)
|
|
})
|
|
})
|
|
})
|