test(notifications): APNs sender unit + dispatcher integration coverage

23 unit tests under `senders/__tests__/apns.test.ts` exercise:
  * env-var loader — all-or-none guard, inline-PEM `\n` unescape, file
    fallback path, cache, APNS_PRODUCTION override
  * Provider lazy-init + per-gateway cache (sandbox vs production stay
    isolated; APNS_PRODUCTION funnels every send through production)
  * permanent-failure detection (`Unregistered`, `BadDeviceToken`,
    `DeviceTokenNotForTopic` flip `shouldDisable: true`; transient
    reasons stay false)
  * `sendViaApns()` dispatcher entry — fan-out, dead-row cleanup,
    hard-vs-soft channel-reject classification, HTML stripping

3 integration tests under `tests/integration/apns-dispatch.test.ts`
drive the real dispatcher against a Postgres testcontainer with a
stubbed `@parse/node-apn` Provider:
  * Round-trip: notification fans out via APNs and the channel-state
    machine records `lastSuccessAt` + clears any cooldown
  * Failure path: `Unregistered` deletes the dead Device row,
    auto-disables the channel with the captured reason, and writes the
    `notification.channel.auto_disabled` audit-log entry
  * Cascade: a disabled APNS channel falls through to Telegram on the
    next dispatch — the apns send-mock stays untouched

Adds `senders/__mocks__/apns.ts` — a deterministic mock with queued
per-token responses + recorded calls so future test files can swap the
real sender for a fixture-driven double without touching `@parse/node-apn`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 00:44:26 +02:00
1 parent 9c2119e48b
commit 9088cbd789
3 files changed
+794

No files matched your search

@@ -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<string, unknown>;
};
at: Date;
}
export const recordedApnsSends: RecordedApnsSend[] = [];
export const recordedApnsBatches: RecordedApnsBatch[] = [];
const queuedResponses = new Map<string, ApnsSendResult[]>();
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<ApnsSendResult> {
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<string, unknown>;
},
): Promise<SendOutcome> {
recordedApnsBatches.push({ userId, payload, at: new Date() });
return queuedBatchOutcome ?? { ok: true };
}
@@ -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<string, string | undefined> = {}): 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: "<b>take</b> meds",
eventType: "MEDICATION_REMINDER",
});
const note = sendMock.mock.calls[0][0];
expect(note.alert.body).toBe("take meds");
});
});
+266
View File
@@ -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<string> {
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<string> {
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);
});
});