diff --git a/src/lib/notifications/senders/__mocks__/apns.ts b/src/lib/notifications/senders/__mocks__/apns.ts new file mode 100644 index 000000000..c48dc216f --- /dev/null +++ b/src/lib/notifications/senders/__mocks__/apns.ts @@ -0,0 +1,116 @@ +/** + * Deterministic APNs mock for the test suite (v1.4.23 Wave 3 / F4). + * + * Test files swap the real `@/lib/notifications/senders/apns` module for + * this one via `vi.mock()`, then drive behaviour through three exported + * helpers: + * + * - `recordedApnsSends` — every `sendApnsPush()` call lands here in + * arrival order. Tests assert on the + * payload + topic + collapseId the + * dispatcher produced. + * - `recordedApnsBatches` — every `sendViaApns(userId, payload)` call + * records the userId + payload pair. + * - `setApnsResponse` — queues a per-token response. The next + * `sendApnsPush` for that token consumes the + * top of the queue. When the queue is empty + * the mock returns `{ ok: true, status: 200 }`. + * - `setApnsBatchOutcome` — controls what `sendViaApns()` returns. + * Defaults to `{ ok: true }`. + * - `resetApnsMock` — clears all recorded calls + queued + * responses; call from `beforeEach`. + * + * No real APNs library is loaded — the mock is a drop-in replacement for + * the sender module's exported surface so the dispatcher test path runs + * fully offline. + */ +import type { SendOutcome } from "@/lib/notifications/retry-policy"; +import type { + ApnsSendInput, + ApnsSendResult, +} from "@/lib/notifications/senders/apns"; + +export interface RecordedApnsSend extends ApnsSendInput { + /** Wall-clock time the dispatcher made the call. */ + at: Date; +} + +export interface RecordedApnsBatch { + userId: string; + payload: { + title: string; + message: string; + eventType: string; + metadata?: Record; + }; + at: Date; +} + +export const recordedApnsSends: RecordedApnsSend[] = []; +export const recordedApnsBatches: RecordedApnsBatch[] = []; + +const queuedResponses = new Map(); +let queuedBatchOutcome: SendOutcome | null = null; + +/** + * Queue a per-device-token response for the next `sendApnsPush` call. + * Calls without a queued response default to `{ ok: true, status: 200 }`. + */ +export function setApnsResponse( + deviceToken: string, + response: ApnsSendResult, +): void { + const list = queuedResponses.get(deviceToken) ?? []; + list.push(response); + queuedResponses.set(deviceToken, list); +} + +/** + * Force the next (and every subsequent) `sendViaApns()` call to return + * the supplied outcome. Pass `null` to restore the default of `{ ok: true }`. + */ +export function setApnsBatchOutcome(outcome: SendOutcome | null): void { + queuedBatchOutcome = outcome; +} + +/** Clear every recorded call + queued response. */ +export function resetApnsMock(): void { + recordedApnsSends.length = 0; + recordedApnsBatches.length = 0; + queuedResponses.clear(); + queuedBatchOutcome = null; +} + +/** Mock counterpart to the real `loadApnsConfig()`. */ +export function loadApnsConfig(): { bundleId: string } | null { + return { bundleId: "test.healthlog.ios" }; +} + +/** Mock counterpart to `resetApnsForTesting()`. No-op here. */ +export function resetApnsForTesting(): void { + resetApnsMock(); +} + +export async function sendApnsPush( + input: ApnsSendInput, +): Promise { + recordedApnsSends.push({ ...input, at: new Date() }); + const queued = queuedResponses.get(input.deviceToken); + if (queued && queued.length > 0) { + return queued.shift() as ApnsSendResult; + } + return { ok: true, status: 200 }; +} + +export async function sendViaApns( + userId: string, + payload: { + title: string; + message: string; + eventType: string; + metadata?: Record; + }, +): Promise { + recordedApnsBatches.push({ userId, payload, at: new Date() }); + return queuedBatchOutcome ?? { ok: true }; +} diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts new file mode 100644 index 000000000..b0dcd4dd5 --- /dev/null +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -0,0 +1,412 @@ +/** + * APNs sender unit tests (v1.4.23 Wave 3 / F4). + * + * Drives the real `apns.ts` against a stubbed `@parse/node-apn` Provider + * so we can exercise: + * 1. Env-var loader — all-or-none guard, inline-PEM `\n` unescape, + * file-fallback path. + * 2. Provider lazy-init + per-environment caching (sandbox vs production + * get distinct Provider instances; `forceProduction` overrides). + * 3. `sendApnsPush()` permanent-failure detection (`Unregistered`, + * `BadDeviceToken`, `DeviceTokenNotForTopic` → `shouldDisable: true`). + * 4. `sendViaApns()` dispatcher entry point — fan-out per device, + * dead-row cleanup, hard-vs-soft channel-reject classification. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sendMock = vi.fn(); +const shutdownMock = vi.fn(); +const providerCtorMock = vi.fn(); +const notificationCtorMock = vi.fn(); + +vi.mock("@parse/node-apn", () => { + class Provider { + constructor(opts: unknown) { + providerCtorMock(opts); + } + send(...args: unknown[]) { + return sendMock(...args); + } + shutdown() { + return shutdownMock(); + } + } + class Notification { + public topic = ""; + public alert: unknown; + public sound: string | undefined; + public badge: number | undefined; + public threadId: string | undefined; + public collapseId: string | undefined; + public payload: unknown; + constructor() { + notificationCtorMock(); + } + } + return { default: { Provider, Notification }, Provider, Notification }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + device: { + findMany: vi.fn(), + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addExternalCall: vi.fn(), + addWarning: vi.fn(), + }), +})); + +import { + loadApnsConfig, + resetApnsForTesting, + sendApnsPush, + sendViaApns, +} from "@/lib/notifications/senders/apns"; +import { prisma } from "@/lib/db"; + +const VALID_ENV = { + APNS_KEY_ID: "ABCDE12345", + APNS_TEAM_ID: "TEAM123456", + APNS_BUNDLE_ID: "test.healthlog.ios", + APNS_KEY: + "-----BEGIN PRIVATE KEY-----\\nMOCKKEY\\n-----END PRIVATE KEY-----", +}; + +function setEnv(over: Record = {}): void { + for (const k of [ + "APNS_KEY_ID", + "APNS_TEAM_ID", + "APNS_BUNDLE_ID", + "APNS_KEY", + "APNS_KEY_FILE", + "APNS_PRODUCTION", + ]) { + delete process.env[k]; + } + for (const [k, v] of Object.entries(over)) { + if (v !== undefined) process.env[k] = v; + } +} + +beforeEach(() => { + vi.clearAllMocks(); + resetApnsForTesting(); + setEnv(); +}); + +afterEach(() => { + setEnv(); + resetApnsForTesting(); +}); + +describe("loadApnsConfig — all-or-none env-var guard", () => { + it("returns null and emits NO warning when nothing is set", () => { + expect(loadApnsConfig()).toBeNull(); + }); + + it("returns null when only some APNS_* vars are set", () => { + setEnv({ APNS_KEY_ID: "only-this" }); + expect(loadApnsConfig()).toBeNull(); + }); + + it("returns config when all required vars are present (inline key)", () => { + setEnv(VALID_ENV); + const config = loadApnsConfig(); + expect(config).toBeTruthy(); + expect(config?.bundleId).toBe("test.healthlog.ios"); + // The escaped `\n` sequences in the env string become real newlines. + expect(config?.signingKey).toContain("\n"); + expect(config?.signingKey.split("\n").length).toBeGreaterThan(1); + }); + + it("falls back to APNS_KEY_FILE when APNS_KEY is absent", async () => { + // Write an actual temp PEM file because ESM doesn't let us spy on + // node:fs.readFileSync. The file content is meaningless to the test — + // we only verify loadApnsConfig propagates it into signingKey. + const fs = await import("node:fs"); + const path = await import("node:path"); + const os = await import("node:os"); + const tmpFile = path.join( + os.tmpdir(), + `apns-key-${Date.now()}-${Math.random().toString(36).slice(2)}.p8`, + ); + fs.writeFileSync(tmpFile, "FILE-PEM-CONTENT"); + try { + setEnv({ + APNS_KEY_ID: VALID_ENV.APNS_KEY_ID, + APNS_TEAM_ID: VALID_ENV.APNS_TEAM_ID, + APNS_BUNDLE_ID: VALID_ENV.APNS_BUNDLE_ID, + APNS_KEY_FILE: tmpFile, + }); + const config = loadApnsConfig(); + expect(config?.signingKey).toBe("FILE-PEM-CONTENT"); + } finally { + fs.unlinkSync(tmpFile); + } + }); + + it("caches the loaded config across calls", () => { + setEnv(VALID_ENV); + const a = loadApnsConfig(); + const b = loadApnsConfig(); + expect(a).toBe(b); + }); + + it("APNS_PRODUCTION=true sets forceProduction", () => { + setEnv({ ...VALID_ENV, APNS_PRODUCTION: "true" }); + expect(loadApnsConfig()?.forceProduction).toBe(true); + }); +}); + +describe("sendApnsPush — provider lazy-init + per-gateway cache", () => { + beforeEach(() => { + setEnv(VALID_ENV); + sendMock.mockResolvedValue({ + sent: [{ device: "abc" }], + failed: [], + }); + }); + + it("creates ONE Provider per environment on first send", async () => { + await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(providerCtorMock).toHaveBeenCalledTimes(1); + expect(providerCtorMock.mock.calls[0][0]).toMatchObject({ + production: false, + token: { key: expect.any(String), keyId: "ABCDE12345" }, + }); + }); + + it("sandbox + production environments get separate Providers", async () => { + await sendApnsPush({ + deviceToken: "a", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + await sendApnsPush({ + deviceToken: "a", + environment: "production", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(providerCtorMock).toHaveBeenCalledTimes(2); + expect(providerCtorMock.mock.calls[0][0].production).toBe(false); + expect(providerCtorMock.mock.calls[1][0].production).toBe(true); + }); + + it("APNS_PRODUCTION=true funnels every send through production", async () => { + setEnv({ ...VALID_ENV, APNS_PRODUCTION: "true" }); + await sendApnsPush({ + deviceToken: "a", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(providerCtorMock.mock.calls[0][0].production).toBe(true); + }); +}); + +describe("sendApnsPush — failure classification", () => { + beforeEach(() => { + setEnv(VALID_ENV); + }); + + it("returns ok on at-least-one delivery", async () => { + sendMock.mockResolvedValueOnce({ + sent: [{ device: "abc" }], + failed: [], + }); + const r = await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(r.ok).toBe(true); + expect(r.status).toBe(200); + }); + + it.each([ + ["Unregistered", true], + ["BadDeviceToken", true], + ["DeviceTokenNotForTopic", true], + ["TooManyRequests", false], + ["InternalServerError", false], + ])("reason %s → shouldDisable=%s", async (reason, shouldDisable) => { + sendMock.mockResolvedValueOnce({ + sent: [], + failed: [{ device: "abc", status: 410, response: { reason } }], + }); + const r = await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(r.ok).toBe(false); + expect(r.shouldDisable).toBe(shouldDisable); + expect(r.reason).toBe(reason); + }); + + it("network throw returns soft failure", async () => { + sendMock.mockRejectedValueOnce(new Error("ECONNRESET")); + const r = await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(r.ok).toBe(false); + expect(r.reason).toBe("apns_network_error"); + expect(r.shouldDisable).toBeFalsy(); + }); + +}); + +describe("sendApnsPush — config disabled paths", () => { + it("returns apns_not_configured when env is empty", async () => { + setEnv(); + const r = await sendApnsPush({ + deviceToken: "abc", + environment: "sandbox", + payload: { alert: { title: "t", body: "b" } }, + }); + expect(r.ok).toBe(false); + expect(r.reason).toBe("apns_not_configured"); + expect(sendMock).not.toHaveBeenCalled(); + }); +}); + +describe("sendViaApns — dispatcher fan-out", () => { + beforeEach(() => { + setEnv(VALID_ENV); + }); + + it("returns soft no-recipient when the user has no APNs devices", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([]); + const r = await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + }); + expect(r.ok).toBe(false); + expect(r.hardReject).toBe(false); + expect(r.reason).toBe("apns_no_devices"); + }); + + it("soft-fails when env is not configured (no DB lookup)", async () => { + setEnv(); + const r = await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + }); + expect(r.ok).toBe(false); + expect(r.reason).toBe("apns_not_configured"); + expect(prisma.device.findMany).not.toHaveBeenCalled(); + }); + + it("returns ok when at least one device delivered", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + { id: "d2", apnsToken: "tok-b", apnsEnvironment: "sandbox" }, + ] as never); + sendMock + .mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }) + .mockResolvedValueOnce({ + sent: [], + failed: [ + { device: "tok-b", status: 410, response: { reason: "Unregistered" } }, + ], + }); + const r = await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + }); + expect(r.ok).toBe(true); + // The Unregistered device row was deleted. + expect(prisma.device.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["d2"] } }, + }); + }); + + it("hard-rejects when EVERY device returns a permanent reason", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + { id: "d2", apnsToken: "tok-b", apnsEnvironment: "production" }, + ] as never); + sendMock + .mockResolvedValueOnce({ + sent: [], + failed: [ + { device: "tok-a", status: 410, response: { reason: "Unregistered" } }, + ], + }) + .mockResolvedValueOnce({ + sent: [], + failed: [ + { device: "tok-b", status: 400, response: { reason: "BadDeviceToken" } }, + ], + }); + const r = await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + }); + expect(r.ok).toBe(false); + expect(r.hardReject).toBe(true); + expect(prisma.device.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: ["d1", "d2"] } }, + }); + }); + + it("soft-fails when the failure is transient (e.g. 503)", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ + sent: [], + failed: [ + { + device: "tok-a", + status: 503, + response: { reason: "InternalServerError" }, + }, + ], + }); + const r = await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + }); + expect(r.ok).toBe(false); + expect(r.hardReject).toBe(false); + expect(r.reason).toBe("InternalServerError"); + // No device row deleted on transient failure. + expect(prisma.device.deleteMany).not.toHaveBeenCalled(); + }); + + it("strips HTML from the alert body before handing to APNs", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + await sendViaApns("u-1", { + title: "t", + message: "take meds", + eventType: "MEDICATION_REMINDER", + }); + const note = sendMock.mock.calls[0][0]; + expect(note.alert.body).toBe("take meds"); + }); +}); diff --git a/tests/integration/apns-dispatch.test.ts b/tests/integration/apns-dispatch.test.ts new file mode 100644 index 000000000..3a46be707 --- /dev/null +++ b/tests/integration/apns-dispatch.test.ts @@ -0,0 +1,266 @@ +/** + * APNs dispatch integration test (v1.4.23 Wave 3 / F4). + * + * Drives `dispatchNotification` against a real Postgres testcontainer + * with a stubbed `@parse/node-apn` Provider. Asserts: + * + * 1. Round-trip: a user with an APNS NotificationChannel + a Device + * row that carries `apnsToken` + `apnsEnvironment` receives an APNs + * push and the channel-state machine records success. + * 2. Failure path: when APNs returns `Unregistered` for every device, + * the Device row is deleted, the channel is auto-disabled with the + * captured reason, and an audit-log entry is written. + * 3. Cascade: when APNs is auto-disabled, the dispatcher falls through + * to Telegram on the next dispatch (different audit / different + * sender invoked). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Crypto module reads ENCRYPTION_KEY lazily on first encrypt(); seed a +// deterministic 32-byte test key before any test imports @/lib/crypto. +// Mirrors the pattern used by admin-backups-download.test.ts et al. +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const apnsSendMock = vi.fn(); +const apnsShutdownMock = vi.fn(); + +vi.mock("@parse/node-apn", () => { + class Provider { + constructor(_opts: unknown) { + void _opts; + } + send(...args: unknown[]) { + return apnsSendMock(...args); + } + shutdown() { + return apnsShutdownMock(); + } + } + class Notification { + public topic = ""; + public alert: unknown; + public sound: string | undefined; + public badge: number | undefined; + public threadId: string | undefined; + public collapseId: string | undefined; + public payload: unknown; + } + return { default: { Provider, Notification }, Provider, Notification }; +}); + +const telegramSendMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: (...args: unknown[]) => telegramSendMock(...args), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const TEST_USER_ID = "user-apns-dispatch-test"; + +const APNS_ENV = { + APNS_KEY_ID: "ABCDE12345", + APNS_TEAM_ID: "TEAM123456", + APNS_BUNDLE_ID: "test.healthlog.ios", + APNS_KEY: "-----BEGIN PRIVATE KEY-----\\nINTEG\\n-----END PRIVATE KEY-----", +}; + +beforeEach(async () => { + vi.clearAllMocks(); + for (const [k, v] of Object.entries(APNS_ENV)) { + process.env[k] = v; + } + delete process.env.APNS_PRODUCTION; + // Reset the apns-sender module so the env-var loader re-reads. + const apns = await import("@/lib/notifications/senders/apns"); + apns.resetApnsForTesting(); + + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: TEST_USER_ID, + username: "apns-dispatch", + email: "apns-dispatch@example.test", + }, + }); +}); + +afterEach(async () => { + for (const k of [ + "APNS_KEY_ID", + "APNS_TEAM_ID", + "APNS_BUNDLE_ID", + "APNS_KEY", + "APNS_KEY_FILE", + "APNS_PRODUCTION", + ]) { + delete process.env[k]; + } + const apns = await import("@/lib/notifications/senders/apns"); + apns.resetApnsForTesting(); +}); + +async function seedApnsChannel(): Promise { + const { encrypt } = await import("@/lib/crypto"); + const channel = await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "APNS", + enabled: true, + // APNS channel config is the empty record by design (per-device + // token + environment live on the Device row), but the dispatcher + // still decrypts it before the type switch — store an encrypted + // empty JSON object so the path stays symmetric. + config: encrypt("{}"), + }, + }); + return channel.id; +} + +async function seedDevice(opts: { + apnsToken: string; + apnsEnvironment?: "sandbox" | "production"; + token?: string; +}): Promise { + const device = await getPrismaClient().device.create({ + data: { + userId: TEST_USER_ID, + platform: "ios", + token: opts.token ?? `legacy-${opts.apnsToken}`, + bundleId: APNS_ENV.APNS_BUNDLE_ID, + apnsToken: opts.apnsToken, + apnsEnvironment: opts.apnsEnvironment ?? "sandbox", + }, + }); + return device.id; +} + +describe("APNs dispatcher integration", () => { + it("delivers a push end-to-end, records success, and clears cooldown", async () => { + await seedApnsChannel(); + await seedDevice({ apnsToken: "abc123" }); + + apnsSendMock.mockResolvedValueOnce({ + sent: [{ device: "abc123" }], + failed: [], + }); + + const { dispatchNotification } = await import( + "@/lib/notifications/dispatcher" + ); + await dispatchNotification({ + userId: TEST_USER_ID, + eventType: "MEDICATION_REMINDER", + title: "Time for your meds", + message: "Tylenol — 1 tablet", + }); + + expect(apnsSendMock).toHaveBeenCalledTimes(1); + const note = apnsSendMock.mock.calls[0][0]; + expect(note.topic).toBe(APNS_ENV.APNS_BUNDLE_ID); + expect(note.alert.title).toBe("Time for your meds"); + expect(note.alert.body).toBe("Tylenol — 1 tablet"); + expect(note.payload).toMatchObject({ eventType: "MEDICATION_REMINDER" }); + + const ch = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: TEST_USER_ID, type: "APNS" }, + }); + expect(ch?.enabled).toBe(true); + expect(ch?.consecutiveFailures).toBe(0); + expect(ch?.lastSuccessAt).toBeInstanceOf(Date); + expect(ch?.nextRetryAt).toBeNull(); + }); + + it("auto-disables the channel + deletes the dead Device on Unregistered", async () => { + await seedApnsChannel(); + await seedDevice({ apnsToken: "dead-tok" }); + + apnsSendMock.mockResolvedValueOnce({ + sent: [], + failed: [ + { + device: "dead-tok", + status: 410, + response: { reason: "Unregistered" }, + }, + ], + }); + + const { dispatchNotification } = await import( + "@/lib/notifications/dispatcher" + ); + await dispatchNotification({ + userId: TEST_USER_ID, + eventType: "SYSTEM_ALERT", + title: "test", + message: "test", + }); + + const ch = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: TEST_USER_ID, type: "APNS" }, + }); + expect(ch?.enabled).toBe(false); + expect(ch?.disabledReason).toBe("Unregistered"); + + const deadDevice = await getPrismaClient().device.findFirst({ + where: { apnsToken: "dead-tok" }, + }); + expect(deadDevice).toBeNull(); + + const audit = await getPrismaClient().auditLog.findFirst({ + where: { + userId: TEST_USER_ID, + action: "notification.channel.auto_disabled", + }, + }); + expect(audit).toBeTruthy(); + }); + + 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. + await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "APNS", + enabled: false, + disabledReason: "Unregistered", + config: encrypt("{}"), + }, + }); + // Healthy Telegram channel. + await getPrismaClient().notificationChannel.create({ + data: { + userId: TEST_USER_ID, + type: "TELEGRAM", + enabled: true, + config: encrypt(JSON.stringify({ botToken: "tok", chatId: "chat" })), + }, + }); + + telegramSendMock.mockResolvedValueOnce({ ok: true, messageId: 42 }); + + const { dispatchNotification } = await import( + "@/lib/notifications/dispatcher" + ); + await dispatchNotification({ + userId: TEST_USER_ID, + eventType: "MEDICATION_REMINDER", + title: "fallback", + message: "fallback", + }); + + expect(apnsSendMock).not.toHaveBeenCalled(); + expect(telegramSendMock).toHaveBeenCalledTimes(1); + + const tg = await getPrismaClient().notificationChannel.findFirst({ + where: { userId: TEST_USER_ID, type: "TELEGRAM" }, + }); + expect(tg?.lastSuccessAt).toBeInstanceOf(Date); + }); +});