From 1c33f8daef03103accd2b9310a96587cbd9aff25 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:24:12 +0100 Subject: [PATCH] fix: tell the user when a queued message gives up after unconfirmed attempts --- .../__tests__/outbox-send-queue.test.ts | 33 +++++++++++++++++++ src/stores/outbox-store.ts | 9 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/stores/__tests__/outbox-send-queue.test.ts b/src/stores/__tests__/outbox-send-queue.test.ts index 3985256d..46bc6715 100644 --- a/src/stores/__tests__/outbox-send-queue.test.ts +++ b/src/stores/__tests__/outbox-send-queue.test.ts @@ -154,6 +154,39 @@ describe('offline send queue', () => { expect(useOutboxStore.getState().sends).toEqual([]); }); + it('says so when a message fails after too many unconfirmed attempts, as for a refused one', async () => { + useNetworkStore.setState({ online: true, connected: true }); + runQueuedSend.mockRejectedValueOnce(new Error('Invalid envelope')); + useOutboxStore.getState().enqueueSend(payload('refused')); + await flushed(); + await flushed(); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.error.mock.calls[0][1]).toEqual({ message: 'refused: Invalid envelope' }); + + toast.error.mockClear(); + const id = useOutboxStore.getState().enqueueSend(payload('unconfirmed'))!; + useOutboxStore.setState({ sends: useOutboxStore.getState().sends.map((s) => (s.id === id ? { ...s, attempts: 2 } : s)) }); + runQueuedSend.mockRejectedValueOnce(named('SendUnconfirmedError', 'Send confirmation was not received')); + useOutboxStore.setState({ flushing: false }); + await useOutboxStore.getState().flush(); + await flushed(); + expect(useOutboxStore.getState().sends.find((s) => s.id === id)).toMatchObject({ status: 'failed', attempts: 3 }); + expect(toast.error).toHaveBeenCalledTimes(1); + expect(toast.error.mock.calls[0]).toEqual([ + 'A message in the Outbox could not be sent', { message: 'unconfirmed: Send confirmation was not received' }, + ]); + }); + + it('does not report an unconfirmed attempt that will be tried again', async () => { + useNetworkStore.setState({ online: true, connected: true }); + runQueuedSend.mockRejectedValueOnce(named('SendUnconfirmedError', 'Send confirmation was not received')); + useOutboxStore.getState().enqueueSend(payload()); + await flushed(); + await flushed(); + expect(useOutboxStore.getState().sends[0]).toMatchObject({ status: 'queued', attempts: 1 }); + expect(toast.error).not.toHaveBeenCalled(); + }); + it('stops after an expired session without failing the message', async () => { useNetworkStore.setState({ online: true, connected: true }); runQueuedSend.mockRejectedValueOnce(named('AuthenticationError', 'Session expired')); diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 3dd97d8f..d0ea2aef 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -690,14 +690,17 @@ async function flushSends(accountId: string): Promise<'done' | 'transient'> { } catch (err) { const kind = classifySendFailure(err, isTransientNetworkError); const message = err instanceof Error ? err.message : String(err); - updateSend(accountId, afterFailure(started, kind, message)); + const next = afterFailure(started, kind, message); + updateSend(accountId, next); + // Refused, or unconfirmed too often: it waits for the user now, and + // nothing retries it, so say so whatever the reason. + if (next.status === 'failed') void notifyQueuedSendFailed(started, message); if (kind === 'auth') { useOutboxStore.setState({ paused: true }); break; } if (kind === 'offline' || kind === 'unconfirmed') return 'transient'; - // Refused: this one waits for the user; the others go on. - void notifyQueuedSendFailed(started, message); + // Refused: the others go on. } } return 'done';