diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/nwc-sw-test.js | 84 | ||||
| -rw-r--r-- | tools/sw-push-quota.test.js | 16 |
2 files changed, 96 insertions, 4 deletions
diff --git a/tools/nwc-sw-test.js b/tools/nwc-sw-test.js index 1ce9944..816b9ce 100644 --- a/tools/nwc-sw-test.js +++ b/tools/nwc-sw-test.js @@ -5,7 +5,8 @@ import { hex } from '@scure/base'; import * as nip04 from 'nostr-tools/nip04'; import * as nip44 from 'nostr-tools/nip44'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; -import { respondFromBg } from '../src/nwc-respond.js'; +import { respondFromBg, tendLnPays } from '../src/nwc-respond.js'; +import { maybeBolt11 } from '../src/ark/lightning.js'; let fails = 0; const check = (name, ok, detail = '') => { @@ -203,6 +204,87 @@ console.log('\n[pay outcome]'); } } +console.log('\n[sends begun on an earlier wake]'); +// The worker died mid-payment on an earlier wake: the action sits in the +// mirror at 'created' with its coin reserved (PullCap, 2026-10-23). +{ + const { ArkManager } = await import('../src/ark/manager.js'); + const HASH = maybeBolt11(INV21).paymentHash; + const PRE = 'cd'.repeat(32); + const names = ['init', 'payLnInvoice', 'awaitLnPay', 'lnAction', 'driveLn']; + const orig = Object.fromEntries(names.map((k) => [k, ArkManager.prototype[k]])); + ArkManager.prototype.init = async function () { this.state = this.storage.load(); return this; }; + // a fresh attempt at an invoice with an action on the books is refused… + ArkManager.prototype.payLnInvoice = async function (invoice) { + const hash = maybeBolt11(invoice).paymentHash; + if (this.state.actions.some((a) => a.type === 'ln-pay' && a.paymentHash === hash && a.step !== 'failed')) throw new Error('a payment for this invoice is already in progress'); + const a = { id: 'lnpay-new', type: 'ln-pay', step: 'done', invoice, paymentHash: hash, amountSat: 21, feeSat: 0, preimage: 'ab'.repeat(32) }; + this.state.actions.push(a); this.storage.save(this.state); + return a.id; + }; + // …and a drive takes the stuck one through to done, freeing its change + ArkManager.prototype.awaitLnPay = async function (id) { return this.driveLn(id); }; + ArkManager.prototype.driveLn = async function (id) { + const a = this.state.actions.find((x) => x.id === id); + if (a && a.step !== 'done') { + a.step = 'done'; a.preimage = PRE; + for (const v of this.state.vtxos) if (v.state === 'pending') v.state = 'spendable'; + this.storage.save(this.state); + } + return a; + }; + ArkManager.prototype.lnAction = function (id) { return this.state.actions.find((x) => x.id === id); }; + const stuckRec = (hash) => { + const rec = baseRec(); + rec.mgr.vtxos[0].state = 'pending'; + rec.mgr.actions.push({ id: 'lnpay-stuck', type: 'ln-pay', step: 'created', invoice: INV21, paymentHash: hash, amountSat: 21, feeSat: 0 }); + rec.pendingPays = [{ hash, connId: 'c1', ts: 1 }]; + return rec; + }; + try { + { + // the SAME invoice asked again: finish that send, answer with ITS preimage + const rec = stuckRec(HASH); + const log = []; + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, { ...deps, log: (m) => log.push(m) }); + check('same invoice → the stuck send is finished and answered with its preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE, JSON.stringify(handled)); + check('…recorded once on the spend log, under its connection', rec.spends.length === 1 && rec.spends[0].connId === 'c1' && rec.spends[0].preimage === PRE && rec.pendingPays.length === 0, JSON.stringify(rec.spends)); + check('…with a "Payment sent" heads-up', handled && handled.notify && handled.notify.title === 'Payment sent'); + check('…and the diary names the step it reached', log.some((m) => /already created/.test(m)) && log.some((m) => /→ done/.test(m)), log.join(' | ')); + } + { + // a send that settled earlier (in the app, say): its preimage again, + // nothing new on the spend log + const rec = baseRec(); + rec.mgr.actions.push({ id: 'lnpay-old', type: 'ln-pay', step: 'done', invoice: INV21, paymentHash: HASH, amountSat: 21, feeSat: 0, preimage: PRE }); + const { deps, published, decryptReply } = harness({ rec }); + await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('an invoice paid earlier answers with the same preimage', published.length === 1 && decryptReply(published[0]).result?.preimage === PRE); + check('…without a second spend', (rec.spends || []).length === 0); + } + { + // a stuck send for ANOTHER invoice holds the only coin: finish it first, + // then pay the new request from the freed coin + const rec = stuckRec('ee'.repeat(32)); + const { deps, published, decryptReply } = harness({ rec }); + const handled = await respondFromBg({ type: 'nwc', event: ev(makeReq('pay_invoice', { invoice: INV21 })) }, deps); + check('another invoice → the earlier send is finished, then this one paid', published.length === 1 && decryptReply(published[0]).result?.preimage === 'ab'.repeat(32), JSON.stringify(handled)); + check('…both on the spend log', rec.spends.length === 2 && rec.spends[0].preimage === PRE && rec.spends[1].preimage === 'ab'.repeat(32)); + check('…the earlier one gets its own "Payment sent" line', handled && handled.notices && handled.notices.length === 1 && handled.notify && handled.notify.title === 'Payment sent', JSON.stringify(handled)); + } + { + // any wake at all finishes it (a lookup, a push for something else) + const rec = stuckRec(HASH); + const notices = await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} }); + check('tendLnPays finishes a stuck send and reports it', notices.length === 1 && notices[0].title === 'Payment sent' && rec.spends.length === 1 && rec.spends[0].connId === 'c1', JSON.stringify(notices)); + check('…and a second wake has nothing left to do', (await tendLnPays({ recordsFn: async () => [{ walletKey: 'w1', rec }], saveFn: async () => {} })).length === 0); + } + } finally { + Object.assign(ArkManager.prototype, orig); + } +} + console.log('\n[network fit]'); { // a mutinynet-era mirror must not answer a mainnet invoice with a nonsense diff --git a/tools/sw-push-quota.test.js b/tools/sw-push-quota.test.js index e7d00f2..049dc1f 100644 --- a/tools/sw-push-quota.test.js +++ b/tools/sw-push-quota.test.js @@ -4,8 +4,9 @@ import { test, expect, mock } from 'bun:test'; let respond = async () => true; -mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {} })); -mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn() })); +mock.module('../src/nwc-respond.js', () => ({ respondFromBg: (...a) => respond(...a), tendRenewals: async () => {}, tendLnPays: async () => [] })); +mock.module('../src/nwc-lock.js', () => ({ withRequestLock: async (_id, fn) => fn(), withWorkLock: async (fn) => fn() })); +mock.module('../src/nwc-bg.js', () => ({ appendBgLog: async () => {} })); mock.module('../src/bg-autowithdraw.js', () => ({ bgAutoWithdraw: async () => {} })); mock.module('../src/dm-inbox.js', () => ({ allInboxes: async () => [{ muted: ['m'.repeat(64)] }], @@ -50,6 +51,13 @@ test('apple: every quiet path shows the stand-in', async () => { expect(closed.length).toBe(0); }); +test('apple: a send finished from an earlier wake shows its own line and drops the stand-in', async () => { + endpoint = 'https://web.push.apple.com/abc'; windows = []; + respond = async () => ({ notices: [{ title: 'Payment sent', body: '-21 sats via a connected app', tag: 'nwc-sent' }] }); + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); +}); + test('apple: an app on screen gets the stand-in closed at once', async () => { windows = [visible]; expect(await push({ type: 'notify', reason: 'payment', amountSat: 5 })).toEqual(['Coinos']); @@ -60,7 +68,9 @@ test('apple: a suspended background app does not swallow the request', async () windows = [hidden]; let asked = 0; respond = async () => { asked++; return { notify: { title: 'Payment sent', body: '' } }; }; - expect(await push(nwc)).toEqual(['Payment sent']); + // the stand-in goes up first and the outcome replaces it (91dbee4) + expect(await push(nwc)).toEqual(['Coinos', 'Payment sent']); + expect(closed.length).toBe(1); expect(asked).toBe(1); }); |
