mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user