Keep peer payment callbacks bound to their original modal operation

This commit is contained in:
archipelago
2026-10-06 21:27:55 -04:00
parent 530c497277
commit 47cd915e40
4 changed files with 415 additions and 155 deletions
@@ -210,4 +210,197 @@ describe('Lightning file delivery recovery', () => {
wrapper.unmount()
})
// Append inside the existing PeerFilesLightning describe; uses real mounted component.
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
vm.closePayModal(); vm.openPayModal(item)
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vm.payItem.id).toBe(item.id)
expect(vm.lnPaying).toBe(false)
expect(localStorage.getItem(receiptKey)).toBeNull()
wrapper.unmount()
})
it('retains a late invoice for its original file without changing another file modal', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithInvoice()
await vm.payWithInvoice()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
expect(vm.payItem.id).toBe('second-file')
expect(vm.invoiceData).toBeNull()
expect(vm.lnReceipt).toBeNull()
expect(vm.invoiceWaiting).toBe(false)
wrapper.unmount()
})
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
const replies: ((value: unknown) => void)[] = []
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
? await new Promise(resolve => { replies.push(resolve) }) : { items: [] })
const { wrapper, vm } = await open()
const first = vm.prepareEcashPay()
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
const second = vm.prepareEcashPay()
replies[0]!({ cashu_sats: 100 })
await first
expect(vm.ecashPreparing).toBe(true)
expect(vm.ecashPlan).toBeNull()
replies[1]!({ cashu_sats: 20 })
await second
expect(vm.ecashPreparing).toBe(false)
expect(vm.ecashPlan.cashu).toBe(20)
wrapper.unmount()
})
it('cannot deliver a late paid invoice into another file modal', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.invoice-status') return { paid: true }
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
return { items: [] }
})
const { wrapper, vm } = await open()
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
vm.invoiceData = invoice
const pending = vm.pollInvoice({ item, onion: 'peer.onion', invoice, generation: vm.paymentGeneration })
await flushPromises()
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
reply({ owned: true, mime_type: 'video/mp4' })
await pending
expect(vm.viewerUrl).toBeNull()
expect(vm.payItem.id).toBe('second-file')
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
wrapper.unmount()
})
it('persists a dispatched Lightning result after closing without changing another file', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
download.mockResolvedValue({ error: 'Delivery remains recoverable' })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'pending' })
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
reply({ status: 'succeeded' })
await pending
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
expect(vm.payItem.id).toBe('second-file')
expect(vm.viewerUrl).toBeNull()
expect(vm.lnReceipt).toBeNull()
expect(vm.lnError).toBe('')
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
wrapper.unmount()
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(localStorage.getItem(receiptKey)).toBeNull()
})
it('retains already dispatched Lightning evidence after unmount without opening playback', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.payLightningInvoice).mockImplementation(async () => await new Promise<unknown>(resolve => { reply = resolve }) as never)
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4' })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
wrapper.unmount()
reply({ status: 'succeeded' })
await pending
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ state: 'succeeded' })
expect(vm.viewerUrl).toBeNull()
})
it('does not create an address, attempt or onchain send for a 545-sat file', async () => {
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 545, accepted: ['onchain', 'lightning', 'ecash'] } } })
await vm.payOnchain()
await vm.loadOnchainQr()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-onchain', 'lnd.sendcoins'].includes(call.method))).toBe(false)
expect(vm.paymentActionBusy).toBe(false)
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(vm.lnError).toContain('546')
wrapper.unmount()
})
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
return { items: [] }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
await vm.payOnchain()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-onchain')).toHaveLength(1)
expect(vi.mocked(rpcClient.call).mock.calls.some(([v]) => v.method === 'lnd.sendcoins')).toBe(false)
expect(vm.lnError).toContain('changed the payment amount')
expect(vm.paymentActionBusy).toBe(false)
wrapper.unmount()
})
it.each([
['copyInvoice', 'invoiceData', 'invoiceCopied', { bolt11: 'invoice-one', payment_hash: hash, price_sats: 5 }],
['copyOnchain', 'onchainData', 'onchainCopied', { address: 'address-one', amount_sats: 546 }],
] as const)('ignores late %s completion after reopening the same file', async (method, data, copied, value) => {
let resolve!: () => void
const writeText = vi.fn(() => new Promise<void>(done => { resolve = done }))
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const { wrapper, vm } = await open()
try {
vm[data] = value
const pending = vm[method]()
vm.closePayModal(); vm.openPayModal(item)
vm[data] = { ...value }
resolve(); await pending
expect(vm[copied]).toBe(false)
expect(writeText).toHaveBeenCalledTimes(1)
} finally {
wrapper.unmount()
if (previous) Object.defineProperty(navigator, 'clipboard', previous)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
it.each([
['copyInvoice', 'invoiceData', 'invoiceCopied', { bolt11: 'invoice-one', payment_hash: hash, price_sats: 5 }],
['copyOnchain', 'onchainData', 'onchainCopied', { address: 'address-one', amount_sats: 546 }],
] as const)('does not let the old %s timer reset a reopened modal', async (method, data, copied, value) => {
const previous = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
const { wrapper, vm } = await open()
vi.useFakeTimers()
try {
vm[data] = value
await vm[method]()
expect(vm[copied]).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
vm.closePayModal(); vm.openPayModal(item)
vm[data] = { ...value }
await vm[method]()
await vi.advanceTimersByTimeAsync(1000)
expect(vm[copied]).toBe(true)
await vi.advanceTimersByTimeAsync(1000)
expect(vm[copied]).toBe(false)
} finally {
wrapper.unmount(); vi.useRealTimers()
if (previous) Object.defineProperty(navigator, 'clipboard', previous)
else Reflect.deleteProperty(navigator, 'clipboard')
}
})
})