Keep peer payment callbacks bound to their original modal operation
This commit is contained in:
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user