Files
HealthLog/tests/integration/notification-global-channel-switch.test.ts
T
Marc-André Bombeck 8d2753f942 fix(notifications): apply the global channel switches at dispatch
`AppSettings.telegramGlobal`, `.ntfyGlobal` and `.webPushGlobal` were read
in two places, and neither of them was the one that matters: the setup-card
visibility on Settings -> Integrations and an echo in the preferences
payload. The dispatcher never looked at them. An operator who switched a
channel off instance-wide lost the setup card and kept every existing
delivery on that channel, indefinitely.

Gate each of the three in the dispatcher, in the shape
`isApiGloballyEnabled()` already uses for the twelve API routes. The read
sits above the per-user channel row and below the per-event preference, it
happens at most once per dispatch, and only when a switchable channel is
actually in the cascade. A skip writes a `globally_disabled` row to the
push-attempt ledger so the admin diagnostics name the cause, and does not
count as an attempt, so `dispatched` keeps telling the truth. The account's
own channel row is left enabled: the switch is the operator's, and flipping
it back has to restore delivery without every user re-enabling anything.

APNs, the generic webhook and email carry no instance-wide switch and are
not gated here; they are turned off at their source instead.

The unit test drives the real dispatcher over a stubbed settings row; the
integration test writes the actual singleton and reads the ledger back out
of Postgres, which is the layer that proves the column the admin route
writes is the column the dispatcher reads.
2026-09-03 02:02:04 +02:00

139 lines
4.1 KiB
TypeScript

/**
* The operator's instance-wide channel switches, against a real settings row.
*
* The unit suite stubs `prisma.appSettings.findUnique`; this one writes the
* actual `app_settings` singleton, dispatches through the real cascade, and
* reads the `push_attempts` ledger back out of Postgres. It is the layer that
* proves the column the admin route writes is the column the dispatcher
* reads — a unit mock would agree with itself even if those two drifted apart.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const ntfySendMock = vi.fn();
vi.mock("@/lib/notifications/senders/ntfy", () => ({
sendViaNtfy: (...args: unknown[]) => ntfySendMock(...args),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { getPrismaClient, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-global-channel-switch";
async function seedNtfyChannel(): Promise<void> {
const { encrypt } = await import("@/lib/crypto");
await getPrismaClient().notificationChannel.create({
data: {
userId: TEST_USER_ID,
type: "NTFY",
enabled: true,
config: encrypt(
JSON.stringify({ serverUrl: "https://ntfy.example", topic: "health" }),
),
},
});
}
async function setNtfyGlobal(ntfyGlobal: boolean): Promise<void> {
await getPrismaClient().appSettings.upsert({
where: { id: "singleton" },
create: { id: "singleton", ntfyGlobal },
update: { ntfyGlobal },
});
}
async function dispatch() {
const { dispatchNotification } =
await import("@/lib/notifications/dispatcher");
return dispatchNotification({
userId: TEST_USER_ID,
eventType: "MEASUREMENT_REMINDER",
title: "Time to weigh in",
message: "Your morning reading is due",
});
}
/** The ledger write is fire-and-forget; let its microtask land. */
async function settleLedger(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 50));
}
beforeEach(async () => {
vi.clearAllMocks();
await truncateAllTables(getPrismaClient());
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "global-channel-switch",
email: "global-channel-switch@example.test",
},
});
});
describe("instance-wide notification channel switch", () => {
it("delivers nothing and records the skip when the operator switched ntfy off", async () => {
await seedNtfyChannel();
await setNtfyGlobal(false);
const outcome = await dispatch();
await settleLedger();
expect(ntfySendMock).not.toHaveBeenCalled();
expect(outcome).toEqual({
dispatched: false,
channelsAttempted: 0,
channelsSucceeded: 0,
});
const attempts = await getPrismaClient().pushAttempt.findMany({
where: { userId: TEST_USER_ID },
});
expect(attempts).toHaveLength(1);
expect(attempts[0]).toMatchObject({
channel: "NTFY",
eventType: "MEASUREMENT_REMINDER",
result: "skipped",
reason: "globally_disabled",
});
// The switch is the operator's, not the user's: the account's own
// channel row is left enabled so flipping the switch back restores
// delivery without every user having to re-enable anything.
const channel = await getPrismaClient().notificationChannel.findFirst({
where: { userId: TEST_USER_ID, type: "NTFY" },
});
expect(channel?.enabled).toBe(true);
});
it("delivers on the same channel once the switch is back on", async () => {
await seedNtfyChannel();
await setNtfyGlobal(true);
ntfySendMock.mockResolvedValueOnce({ ok: true });
const outcome = await dispatch();
expect(ntfySendMock).toHaveBeenCalledTimes(1);
expect(outcome).toEqual({
dispatched: true,
channelsAttempted: 1,
channelsSucceeded: 1,
});
});
it("delivers when no settings row exists at all", async () => {
await seedNtfyChannel();
ntfySendMock.mockResolvedValueOnce({ ok: true });
const outcome = await dispatch();
expect(ntfySendMock).toHaveBeenCalledTimes(1);
expect(outcome.dispatched).toBe(true);
});
});