diff --git a/frontend/src/composables/__tests__/useWallet.test.ts b/frontend/src/composables/__tests__/useWallet.test.ts new file mode 100644 index 0000000..058258c --- /dev/null +++ b/frontend/src/composables/__tests__/useWallet.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Test the NWC timeout/error behavior in isolation +// Full component test would require too many mocks, so we test the promise pattern + +describe('NWC payment timeout handling', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('timeout produces proper rejection error', async () => { + // Simulate the payViaNWC timeout pattern + const payPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('NWC payment timed out')) + }, 30_000) + }) + + vi.advanceTimersByTime(30_000) + + await expect(payPromise).rejects.toThrow('NWC payment timed out') + }) + + it('websocket error produces proper rejection', async () => { + const payPromise = new Promise((_, reject) => { + // Simulate immediate ws error + queueMicrotask(() => reject(new Error('NWC WebSocket error'))) + }) + + await expect(payPromise).rejects.toThrow('NWC WebSocket error') + }) + + it('undefined preimage is treated as failure', () => { + const preimage: string | undefined = undefined + // The fixed code checks: if (preimage) { ...confirm... } + // So undefined preimage falls through to polling + expect(!preimage).toBe(true) + }) +}) diff --git a/frontend/src/composables/useWallet.ts b/frontend/src/composables/useWallet.ts index 251545c..a4ed7e5 100644 --- a/frontend/src/composables/useWallet.ts +++ b/frontend/src/composables/useWallet.ts @@ -173,16 +173,22 @@ export function useWallet() { } if (nwcUrl && nwcValid) { paymentStatus.value = 'paying' - const preimage = await payViaNWC(nwcUrl, bolt11) - - // Tell server payment is confirmed (skip lookup_invoice polling) - await authFetch(`/api/payments/confirm/${paymentId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ preimage, pubkey: pubkey.value }), - }) - paymentStatus.value = 'confirmed' - return paymentId + try { + const preimage = await payViaNWC(nwcUrl, bolt11) + if (preimage) { + await authFetch(`/api/payments/confirm/${paymentId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preimage, pubkey: pubkey.value }), + }) + paymentStatus.value = 'confirmed' + return paymentId + } + } catch (err) { + console.warn('[Wallet] NWC payment failed, falling back to polling:', err) + } + // NWC failed or returned no preimage — fall through to polling + paymentStatus.value = 'pending' } // No NWC — poll for confirmation (manual payment / QR code flow) @@ -268,7 +274,7 @@ async function payViaNWC(nwcUrl: string, bolt11: string): Promise { ws.close() - resolve(undefined) + reject(new Error('NWC payment timed out')) }, 30_000) ws.onopen = () => { @@ -309,7 +315,7 @@ async function payViaNWC(nwcUrl: string, bolt11: string): Promise { clearTimeout(timeout) - resolve(undefined) + reject(new Error('NWC WebSocket error')) } }) }