From db49d09fb63667021ef4b30f0e55ee90d6cccbed Mon Sep 17 00:00:00 2001 From: do0ori Date: Fri, 4 Sep 2026 09:12:24 +0900 Subject: [PATCH] fix: Ignore network failures when cancelling background alerts Co-Authored-By: Claude Opus 5 (1M context) --- src/services/timerNotificationService.test.ts | 32 +++++++++++++++++++ src/services/timerNotificationService.ts | 17 +++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/services/timerNotificationService.test.ts b/src/services/timerNotificationService.test.ts index 7a6c2d7..8d3a854 100644 --- a/src/services/timerNotificationService.test.ts +++ b/src/services/timerNotificationService.test.ts @@ -1,5 +1,6 @@ import { base64UrlToUint8Array, + cancelTimerNotification, createScheduleCredentials, configureTimerNotificationApiUrl, getBackgroundAlertStatus, @@ -9,9 +10,12 @@ import { } from './timerNotificationService'; describe('timer notification service', () => { + const originalFetch = global.fetch; + afterEach(() => { localStorage.clear(); configureTimerNotificationApiUrl(undefined); + global.fetch = originalFetch; }); it('converts a URL-safe VAPID key into subscription bytes', () => { @@ -75,4 +79,32 @@ describe('timer notification service', () => { expect(requestAlerts).not.toHaveBeenCalled(); }); + + it('cancels a background schedule and forgets its credentials', async () => { + configureTimerNotificationApiUrl('https://worker.example'); + createScheduleCredentials('timer-1', () => 'generated-token'); + const fetchMock = jest.fn().mockResolvedValue({ ok: true } as Response); + global.fetch = fetchMock as unknown as typeof fetch; + + await cancelTimerNotification('timer-1'); + + expect(fetchMock).toHaveBeenCalledWith('https://worker.example/v1/schedules/timer-1', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: 'generated-token' }), + }); + expect(localStorage.getItem('timer-notification:timer-1')).toBeNull(); + }); + + it('keeps credentials and stays quiet when cancelling fails offline', async () => { + configureTimerNotificationApiUrl('https://worker.example'); + createScheduleCredentials('timer-1', () => 'generated-token'); + const fetchMock = jest.fn().mockRejectedValue(new TypeError('Failed to fetch')); + global.fetch = fetchMock as unknown as typeof fetch; + + await expect(cancelTimerNotification('timer-1')).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(localStorage.getItem('timer-notification:timer-1')).toContain('generated-token'); + }); }); diff --git a/src/services/timerNotificationService.ts b/src/services/timerNotificationService.ts index b946662..c3b855e 100644 --- a/src/services/timerNotificationService.ts +++ b/src/services/timerNotificationService.ts @@ -90,11 +90,18 @@ export const cancelTimerNotification = async (timerId: string) => { if (!apiBaseUrl || !storedCredentials) return; const credentials = JSON.parse(storedCredentials) as ScheduleCredentials; - await fetch(`${apiBaseUrl}/v1/schedules/${encodeURIComponent(credentials.scheduleId)}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ capability: credentials.capability }), - }); + try { + await fetch(`${apiBaseUrl}/v1/schedules/${encodeURIComponent(credentials.scheduleId)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ capability: credentials.capability }), + }); + } catch (error) { + // Callers cancel as fire-and-forget, so an offline or backgrounded page must not + // reject. Keep the credentials so a later cancel can still delete the schedule. + console.debug('Unable to cancel background timer alert:', error); + return; + } localStorage.removeItem(scheduleCredentialsKey(timerId)); };