Files
HealthLog/src/lib/notifications/__tests__/client-managed-apns.test.ts
T
Marc-André Bombeck 2ccd022410 test: update notification sender attempt mocks
- Exercise identity-aware delivery attempt recording\n- Keep self-delivery assertions explicit
2026-08-06 04:05:33 +02:00

208 lines
6.3 KiB
TypeScript

/**
* Client-managed suppression is APNs-only.
*
* `notificationPrefs.medication.clientManaged` (and its measurement-
* reminder sibling) means "my iPhone schedules this reminder locally, so
* don't push it". It never meant "stop telling me anywhere" — the OpenAPI
* description, the settings copy and the iOS contract all say APNs. The
* crons used to skip the whole dispatch, so a user who flipped the switch
* also lost the Telegram reminder they read on a desktop.
*
* Pinned here: the APNs channel is skipped, every other channel still
* sends, the skip is not counted as a delivery attempt, and the per-skip
* wide-event annotation survives so the suppression stays countable.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findMany: vi.fn(),
update: vi.fn(),
upsert: vi.fn(),
},
user: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => s,
decrypt: (s: string) => s,
}));
const addMetaMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addWarning: vi.fn(),
addMeta: (key: string, value: unknown) => addMetaMock(key, value),
addExternalCall: vi.fn(),
setError: vi.fn(),
}),
}));
const sendViaApnsMock = vi.fn();
const sendViaTelegramMock = vi.fn();
const recordPushAttemptMock = vi.fn();
vi.mock("@/lib/notifications/senders/apns", () => ({
sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args),
}));
vi.mock("@/lib/notifications/senders/telegram", () => ({
sendViaTelegram: (...args: unknown[]) => sendViaTelegramMock(...args),
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
}));
import { dispatchNotification } from "@/lib/notifications/dispatcher";
import { prisma } from "@/lib/db";
function channel(type: "APNS" | "TELEGRAM") {
return {
id: `ch-${type}`,
userId: "u-1",
type,
enabled: true,
config: JSON.stringify({ botToken: "b", chatId: "c" }),
consecutiveFailures: 0,
nextRetryAt: null,
preferences: [],
};
}
const MEDICATION_PAYLOAD = {
eventType: "MEDICATION_REMINDER" as const,
userId: "u-1",
title: "t",
message: "m",
metadata: {
medicationId: "med-1",
scheduledAt: "2026-06-15T08:00:00.000Z",
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
channel("APNS"),
channel("TELEGRAM"),
] as never);
sendViaApnsMock.mockResolvedValue({ ok: true });
sendViaTelegramMock.mockResolvedValue({ ok: true });
(
prisma.notificationChannel.update as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({ consecutiveFailures: 0 });
});
afterEach(() => {
vi.clearAllMocks();
});
describe("dispatchNotification — client-managed APNs suppression", () => {
it("skips APNs and still delivers over every other channel", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const outcome = await dispatchNotification(MEDICATION_PAYLOAD);
expect(sendViaApnsMock).not.toHaveBeenCalled();
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
// The skipped channel is not an attempt — `dispatched` has to keep
// meaning "something reached the user".
expect(outcome.channelsAttempted).toBe(1);
expect(outcome.channelsSucceeded).toBe(1);
expect(outcome.dispatched).toBe(true);
});
it("keeps the skip countable in the wide event and the ledger", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification(MEDICATION_PAYLOAD);
expect(addMetaMock).toHaveBeenCalledWith(
"medication_reminder_suppressed_client_managed",
"med-1:2026-06-15T08:00:00.000Z",
);
expect(addMetaMock).toHaveBeenCalledWith(
"medication_reminder_suppressed_meta",
{
user_id: "u-1",
medication_id: "med-1",
schedule_id: "",
phase: "",
dose_at: "2026-06-15T08:00:00.000Z",
},
);
expect(recordPushAttemptMock).toHaveBeenCalledWith({
recordUserId: "u-1",
recipientUserId: "u-1",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "skipped",
reason: "client_managed",
});
});
it("honours the roaming deliveryDefault mapping", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { deliveryDefault: "client" } },
} as never);
await dispatchNotification(MEDICATION_PAYLOAD);
expect(sendViaApnsMock).not.toHaveBeenCalled();
});
it("suppresses the measurement reminder's APNs leg on its own flag", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { measurementReminder: { clientManaged: true } },
} as never);
await dispatchNotification({
eventType: "MEASUREMENT_REMINDER",
userId: "u-1",
title: "t",
message: "m",
metadata: { reminderId: "rem-1" },
});
expect(sendViaApnsMock).not.toHaveBeenCalled();
expect(sendViaTelegramMock).toHaveBeenCalledTimes(1);
expect(addMetaMock).toHaveBeenCalledWith(
"measurement_reminder.suppressed_client_managed",
"rem-1",
);
});
it("sends over APNs when the flag is unset", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
await dispatchNotification(MEDICATION_PAYLOAD);
expect(sendViaApnsMock).toHaveBeenCalledTimes(1);
expect(recordPushAttemptMock).not.toHaveBeenCalled();
});
it("does not gate an event type that has no client-managed opt-out", async () => {
// The medication flag must not leak across event types: a user who
// owns their dose reminders locally still gets everything else.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
await dispatchNotification({
eventType: "SYSTEM_ALERT",
userId: "u-1",
title: "t",
message: "m",
});
expect(sendViaApnsMock).toHaveBeenCalledTimes(1);
});
});