diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 3e2deb809..f7ac2fa05 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -44,6 +44,7 @@ vi.mock("@/lib/logging/context", () => ({ const sendViaTelegramMock = vi.fn(); const sendViaNtfyMock = vi.fn(); const sendViaWebPushMock = vi.fn(); +const sendViaApnsMock = vi.fn(); vi.mock("@/lib/notifications/senders/telegram", () => ({ sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args), @@ -54,6 +55,9 @@ vi.mock("@/lib/notifications/senders/ntfy", () => ({ vi.mock("@/lib/notifications/senders/web-push", () => ({ sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args), })); +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), +})); import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { prisma } from "@/lib/db"; @@ -62,7 +66,7 @@ import { auditLog } from "@/lib/auth/audit"; type MockChannel = { id: string; userId: string; - type: "TELEGRAM" | "NTFY" | "WEB_PUSH"; + type: "TELEGRAM" | "NTFY" | "WEB_PUSH" | "APNS"; enabled: boolean; config: string; consecutiveFailures: number; @@ -203,6 +207,36 @@ describe("dispatchNotification — soft reject (web-push 429)", () => { }); }); +describe("dispatchNotification — bounded APNs fallback", () => { + it("continues to the next channel after an APNs timeout", async () => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + makeChannel({ id: "apns", type: "APNS" }), + makeChannel({ id: "ntfy", type: "NTFY" }), + ] as never); + sendViaApnsMock.mockResolvedValueOnce({ + ok: false, + hardReject: false, + reason: "apns_timeout", + }); + sendViaNtfyMock.mockResolvedValueOnce({ ok: true }); + + const result = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sendViaApnsMock).toHaveBeenCalledTimes(1); + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 1, + }); + }); +}); + describe("dispatchNotification — give-up after 5 transient failures", () => { it("auto-disables on the 5th in-a-row failure", async () => { const channel = makeChannel({ type: "NTFY", consecutiveFailures: 4 }); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index c0cdd32f2..2294c3790 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -128,6 +128,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); setEnv(); resetApnsForTesting(); }); @@ -367,6 +368,25 @@ describe("sendApnsPush — failure classification", () => { expect(r.reason).toBe("apns_network_error"); expect(r.shouldDisable).toBeFalsy(); }); + + it("bounds a provider send that never settles", async () => { + vi.useFakeTimers(); + sendMock.mockImplementationOnce(() => new Promise(() => undefined)); + + const result = sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(result).resolves.toMatchObject({ + ok: false, + reason: "apns_timeout", + shouldDisable: false, + }); + }); }); describe("sendApnsPush — config disabled paths", () => { diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index cfc6cccfb..83bbccc71 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -146,6 +146,53 @@ const PERMANENT_APNS_REASONS = new Set([ */ const APNS_COLLAPSE_ID_MAX_BYTES = 64; +/** + * node-apn owns an HTTP/2 connection pool but exposes no AbortSignal on + * Provider.send(). Bound the promise at this boundary so a stuck Apple + * connection cannot prevent the dispatcher's later fallback channels. + */ +const APNS_PROVIDER_SEND_TIMEOUT_MS = 5_000; + +class ApnsProviderTimeoutError extends Error { + constructor() { + super( + `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms`, + ); + this.name = "ApnsProviderTimeoutError"; + } +} + +/** + * Observe both branches of the provider promise even after the timeout wins. + * That prevents a late rejection from becoming an unhandled rejection while + * retaining a hard upper bound for the dispatcher. + */ +function awaitBoundedApnsProviderSend(operation: Promise): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + reject(new ApnsProviderTimeoutError()); + }, APNS_PROVIDER_SEND_TIMEOUT_MS); + + operation.then( + (result) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(result); + }, + (error: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + let cachedConfig: ApnsConfig | null | undefined; const providers = new Map<"sandbox" | "production", apn.Provider>(); @@ -467,16 +514,28 @@ export async function sendApnsPush( const start = performance.now(); let result: Awaited>; try { - result = await provider.send(note, input.deviceToken); + result = await awaitBoundedApnsProviderSend( + provider.send(note, input.deviceToken), + ); } catch (err) { - const message = err instanceof Error ? err.message : "send_failed"; + const timedOut = err instanceof ApnsProviderTimeoutError; + const message = timedOut + ? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "send_failed"; getEvent()?.addExternalCall({ service: "apns", method: "send", duration_ms: Math.round(performance.now() - start), - error: message, + error: timedOut ? "timeout" : message, }); - return { ok: false, reason: "apns_network_error", message }; + return { + ok: false, + reason: timedOut ? "apns_timeout" : "apns_network_error", + shouldDisable: false, + message, + }; } const duration = Math.round(performance.now() - start); @@ -864,16 +923,28 @@ export async function sendApnsRawPush( const start = performance.now(); let result: Awaited>; try { - result = await provider.send(note, input.deviceToken); + result = await awaitBoundedApnsProviderSend( + provider.send(note, input.deviceToken), + ); } catch (err) { - const message = err instanceof Error ? err.message : "send_failed"; + const timedOut = err instanceof ApnsProviderTimeoutError; + const message = timedOut + ? `APNs provider send timed out after ${APNS_PROVIDER_SEND_TIMEOUT_MS}ms` + : err instanceof Error + ? err.message + : "send_failed"; getEvent()?.addExternalCall({ service: "apns", method: `send_${input.pushType}`, duration_ms: Math.round(performance.now() - start), - error: message, + error: timedOut ? "timeout" : message, }); - return { ok: false, reason: "apns_network_error", message }; + return { + ok: false, + reason: timedOut ? "apns_timeout" : "apns_network_error", + shouldDisable: false, + message, + }; } const duration = Math.round(performance.now() - start); diff --git a/tests/integration/apns-dispatch.test.ts b/tests/integration/apns-dispatch.test.ts index c76cf5811..03b4fe0bb 100644 --- a/tests/integration/apns-dispatch.test.ts +++ b/tests/integration/apns-dispatch.test.ts @@ -235,6 +235,42 @@ describe("APNs dispatcher integration", () => { expect(audit).toBeTruthy(); }); + it("bounds a hung APNs provider and falls through to Telegram", async () => { + const { encrypt } = await import("@/lib/crypto"); + await seedApnsChannel(); + await seedDevice({ apnsToken: "hung-apns-token" }); + await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "TELEGRAM", + enabled: true, + config: encrypt( + JSON.stringify({ botToken: "telegram-token", chatId: "chat-id" }), + ), + }, + }); + + apnsSendMock.mockImplementationOnce(() => new Promise(() => undefined)); + telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 }); + + const { dispatchNotification } = + await import("@/lib/notifications/dispatcher"); + const result = await dispatchNotification({ + userId: TEST_USER_ID, + eventType: "MEDICATION_REMINDER", + title: "fallback", + message: "fallback", + }); + + expect(apnsSendMock).toHaveBeenCalledTimes(1); + expect(telegramSendMock).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 1, + }); + }, 7_000); + it("falls through to Telegram when APNS is disabled", async () => { const { encrypt } = await import("@/lib/crypto"); // Pre-existing disabled APNS channel from a previous failure run.