Files
HealthLog/e2e/notification-dispatch-journey.spec.ts
Marc-André Bombeck 9cfc72fe1f fix(settings): channel switches act on tap again, beside their form
The Telegram, ntfy, webhook and email cards had folded the enable
switch into the form, so turning a channel off took a Save as well.
The switch saves on tap again, optimistically with a rollback, and sits
outside the form; the form's Save covers the channel's fields and sends
the stored enable state unchanged.

The save-model guard now splits a card into save units, each form and
the rest of the card, and refuses a unit that mixes both models: an
instant switch inside the form its Save submits, or a Save outside any
form next to one, still fails. A form shows its refusal inline only,
no longer as a toast on top.
2026-10-21 21:58:22 +02:00

702 lines
26 KiB
TypeScript

/**
* Notification preferences → dispatch decision, the web arm — one journey.
*
* The flow this file is checked against, in one script:
*
* 1. an account configures two delivery channels on the settings surface —
* email switched ON with a recipient, ntfy saved with a public relay and
* left OFF;
* 2. it pairs a phone, so the APNs arm exists in the cascade;
* 3. a medication whose dose window closed hours ago is created through the
* page's own fetch, and the reminder sweep the admin panel drives runs;
* 4. the dispatch decision is read back off the account's own delivery
* ledger: one email attempt, delivered; nothing for the channel that is
* switched off; the APNs arm attempted and skipped with its reason;
* 5. the account then opts into client-managed medication reminders — the
* iOS contract — and the same sweep skips the APNs arm for THAT reason
* while email still delivers, which is the whole claim of the opt-in.
*
* Refusing controls: a channel switched off is never attempted and its own
* test control is inert; and an ntfy relay URL inside a private range is
* refused by the save, leaves the stored config untouched, and writes no
* delivery attempt.
*
* Why email is the channel that gets exercised for real, and why nothing else
* could be: every account-supplied delivery target crosses the SSRF floor
* (`isPublicUrl` at save time, `safeFetch({ requirePublicHost: true })` with a
* connect-time address pin at send time), so ntfy, the generic webhook and Web
* Push all refuse a local stub by design; Telegram and APNs address hard-coded
* third parties. Email's transport is OPERATOR configuration, so it never
* crosses that floor — see `e2e/setup/smtp-stub.ts`. The APNs arm therefore
* cannot deliver here, and the journey does not pretend it can: what it proves
* about APNs is the DECISION, read from the reason the ledger carries.
*
* Every assertion addresses a stable attribute — `data-slot`, `data-state`,
* a form-control id — or the account's own API responses, never rendered copy:
* the labels are i18n-driven and this account renders them in English only by
* cookie.
*
* What isolates this file, and what does not. `test.describe.configure({ mode:
* "serial" })` orders the tests INSIDE it; the suite runs `fullyParallel` with
* two workers, so every other spec file is live against the same database
* throughout. Three things carry the isolation instead: the account is this
* file's own (`E2E_NOTIFY`), the server is this file's own (mail configured, no
* reminder scheduler — `e2e/setup/notification-server.ts`), and the reminder
* sweep is told which account to walk, so the one trigger here that could
* dispatch for the whole instance no longer does.
*
* What is left standing is a precondition rather than a mechanism: nothing else
* delivers email in this run, because no other e2e account configures an EMAIL
* channel and no other server is pointed at the responder. `smtp.accepted()` is
* asserted by exact equality on the strength of that, and a spec that seeds an
* email channel for another account has to say so here.
*
* `reason: "apns_not_configured"` and `reason: "client_managed"` are internal
* sender / dispatcher constants rather than published contract, and they are
* pinned on purpose: the ledger reason is the only place the DECISION is
* legible, and the whole claim of the client-managed opt-in is that the reason
* changes while everything else does not. A rename that reaches here should
* read as a decision to make, not as a mystery break.
*/
import type { Page } from "@playwright/test";
import { E2E_NOTIFY, NOTIFY_STORAGE_STATE_PATH } from "./setup/global-setup";
import { NOTIFICATION_BASE_URL } from "./setup/notification-server";
import {
notificationAccountId,
resetNotificationFixture,
} from "./setup/notification-fixture";
import { startSmtpStub, type SmtpStub } from "./setup/smtp-stub";
import { expect, test } from "./setup/test";
/** The panel has finished its read and the channel cards are mounted. */
const CHANNELS_PANEL = '[data-slot="notification-channels-panel"]';
/** Where the account's email notifications are addressed. */
const EMAIL_RECIPIENT = "notification-journey@healthlog.test";
/**
* A public relay hostname for the channel that stays switched OFF. It is
* never dialled — the dispatcher drops a disabled channel before any I/O, and
* that is precisely the verdict this journey wants — but it has to pass
* `isPublicUrl` for the save to succeed at all.
*/
const NTFY_SERVER = "https://ntfy.example.com";
const NTFY_TOPIC = "healthlog-e2e-notify";
/** RFC1918. The save refuses it; the refusal is the point. */
const PRIVATE_NTFY_SERVER = "http://192.168.31.7";
/**
* A 64-character hex APNs token. Hex only — the registration route refuses
* anything else — and this spec's alone, because a token belongs to exactly
* one account and re-registering it under another is a 409.
*/
const APNS_TOKEN = `e2e0d15c0de${"0".repeat(53)}`;
/** The medication whose window has closed, so the sweep has something to send. */
const MEDICATION_NAME = "E2E Notification Journey";
interface PushAttempt {
channel: string;
eventType: string;
result: string;
reason: string | null;
}
interface DiagnosticPayload {
notificationChannels: Array<{
type: string;
enabled: boolean;
configPresent: boolean;
}>;
recentPushAttempts: PushAttempt[];
}
/**
* Every fetch below rides the PAGE's own `fetch` rather than a shared
* Playwright request context: a pooled context can hand a POST to a keep-alive
* socket the server has already closed, and the retry lands as a second write.
* It also means the cookie, the origin and the CSRF posture are the browser's,
* which is the arm this journey is about.
*/
async function fromPage<T>(
page: Page,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<{ status: number; data: T | null }> {
return page.evaluate(
async (call: { path: string; method: string; body: string | null }) => {
const res = await fetch(call.path, {
method: call.method,
credentials: "include",
...(call.body === null
? {}
: {
headers: { "Content-Type": "application/json" },
body: call.body,
}),
});
const envelope = (await res.json()) as { data: unknown };
return { status: res.status, data: envelope.data };
},
{
path,
method: init.method ?? "GET",
body: init.body === undefined ? null : JSON.stringify(init.body),
},
) as Promise<{ status: number; data: T | null }>;
}
/** The admin notification diagnostic — channels and the trailing ledger. */
async function readDiagnostic(page: Page): Promise<DiagnosticPayload> {
const res = await fromPage<DiagnosticPayload>(
page,
"/api/admin/notifications/diagnostic",
);
expect(res.status, "the diagnostic answered the admin cookie").toBe(200);
if (!res.data) throw new Error("diagnostic returned no data");
return res.data;
}
/**
* The delivery attempts written since `before` rows existed.
*
* The ledger only ever grows and the diagnostic returns it newest-first, so
* the head of the list is exactly what this dispatch produced. The wait is a
* poll rather than a sleep because the ledger write is fire-and-forget: the
* trigger's response can return before the row lands.
*
* `expected` is asserted, not merely awaited — an EXTRA attempt is the failure
* this journey exists to catch (a channel that was switched off delivering
* anyway), and a poll that stops at "at least" would never see it.
*
* The diagnostic returns the trailing TWENTY rows and no more
* (`src/app/api/admin/notifications/diagnostic/route.ts`). The fixture resets
* the ledger to zero before every test and the largest cascade here is two, so
* the ceiling is far away — but a poll that ran past it could never converge
* and would report "the dispatch wrote N delivery attempt(s)" while the real
* answer was "the diagnostic stopped counting". The ceiling is asserted up
* front so that failure names itself.
*/
async function attemptsSince(
page: Page,
before: number,
expected: number,
): Promise<PushAttempt[]> {
expect(
before + expected,
"the diagnostic returns at most 20 attempts, so a larger window could never converge",
).toBeLessThanOrEqual(20);
await expect
.poll(async () => (await readDiagnostic(page)).recentPushAttempts.length, {
message: `the dispatch wrote ${expected} delivery attempt(s)`,
timeout: 20_000,
})
.toBe(before + expected);
const all = (await readDiagnostic(page)).recentPushAttempts;
expect(
all.length,
"no further attempt arrived after the ledger settled",
).toBe(before + expected);
return all.slice(0, expected);
}
/** How many attempts the ledger already holds. */
async function attemptCount(page: Page): Promise<number> {
return (await readDiagnostic(page)).recentPushAttempts.length;
}
/**
* Open the delivery-channels surface and wait until each card has taken its
* seed from the server.
*
* The wait is not cosmetic. Both cards mount BEFORE their query resolves and
* then copy the server's value into the input once it arrives — the React
* sync-from-server shape, no effect involved. A value typed into the input
* before that copy runs is silently replaced by the server's, so the save that
* follows persists an empty field and the next request is refused for a reason
* that points nowhere near the test. Waiting for the two reads is what makes
* the copy already done rather than pending.
*/
async function openChannels(page: Page): Promise<void> {
const seeded = ["/api/settings/email", "/api/settings/ntfy"].map((endpoint) =>
page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "GET",
),
);
await page.goto("/settings/integrations", { waitUntil: "domcontentloaded" });
await expect(page.locator(CHANNELS_PANEL)).toBeVisible({ timeout: 30_000 });
await Promise.all(seeded);
}
/**
* Type into a channel card's field and confirm the field kept it. The
* confirmation is the second half of the seed race described on
* `openChannels`: the read has landed, so nothing is left to overwrite it.
*/
async function fillField(
page: Page,
selector: string,
value: string,
): Promise<void> {
await page.locator(selector).fill(value);
await expect(page.locator(selector)).toHaveValue(value);
}
/**
* Save one channel card's form and hand back the status the settings route
* answered with. Gated on the card's own PUT rather than on a repaint, so a
* refusal is read from the wire instead of inferred from what did not change.
*/
async function saveCard(
page: Page,
anchor: string,
endpoint: string,
): Promise<number> {
const settled = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "PUT",
);
await page
.locator(
`${anchor} [data-slot="settings-card-actions"] button[type=submit]`,
)
.click();
return (await settled).status();
}
/** Flip a channel card's enable switch and wait for the route to answer. */
async function toggleCard(
page: Page,
switchId: string,
endpoint: string,
): Promise<number> {
const settled = page.waitForResponse(
(res) =>
new URL(res.url()).pathname === endpoint &&
res.request().method() === "PUT",
);
await page.locator(switchId).click();
return (await settled).status();
}
/**
* Configure both channels the way a person would: email on with a recipient,
* ntfy saved and left off. Everything here happens on the settings surface.
*/
async function configureChannels(page: Page): Promise<void> {
await openChannels(page);
await fillField(page, "#email-recipient", EMAIL_RECIPIENT);
expect(await saveCard(page, "#email", "/api/settings/email")).toBe(200);
expect(await toggleCard(page, "#email-toggle", "/api/settings/email")).toBe(
200,
);
await expect(page.locator("#email-toggle")).toHaveAttribute(
"data-state",
"checked",
);
await fillField(page, "#ntfy-server", NTFY_SERVER);
await fillField(page, "#ntfy-topic", NTFY_TOPIC);
expect(await saveCard(page, "#ntfy", "/api/settings/ntfy")).toBe(200);
await expect(page.locator("#ntfy-toggle")).toHaveAttribute(
"data-state",
"unchecked",
);
}
/**
* Pair a phone through the device-registration route the native client uses.
* That registration — and nothing else in the product — is what puts an APNS
* row in the cascade, so the arm the client-managed opt-in suppresses only
* exists once this has run.
*/
async function pairPhone(page: Page): Promise<void> {
const res = await fromPage(page, "/api/devices", {
method: "POST",
body: {
token: APNS_TOKEN,
bundleId: "dev.healthlog.e2e",
apnsToken: APNS_TOKEN,
apnsEnvironment: "sandbox",
},
});
expect(res.status, "the phone registered").toBe(201);
}
/**
* Pin the account's timezone so its local clock reads midday.
*
* The sweep classifies a dose window against the account's OWN wall clock, so
* a window written as a fixed pair of times is overdue for most of the day and
* not yet open around midnight. Rather than leave a spec that is honest 1439
* minutes out of 1440, the account sets the one preference that decides which
* clock is consulted — through its own profile route, the same PATCH the
* profile form sends — to a whole-hour zone where "now" is early afternoon.
* The morning window below is then unambiguously closed, every run.
*/
async function pinAccountClockToMidday(page: Page): Promise<void> {
const wrapped = (12 - new Date().getUTCHours() + 24) % 24;
const offsetHours = wrapped > 12 ? wrapped - 24 : wrapped;
// `Etc/GMT-5` is UTC+5 — the POSIX sign convention is inverted, which is
// why the zone name is built rather than looked up.
const zone =
offsetHours === 0
? "Etc/GMT"
: offsetHours > 0
? `Etc/GMT-${offsetHours}`
: `Etc/GMT+${-offsetHours}`;
const res = await fromPage<{ timezone: string }>(page, "/api/user/profile", {
method: "PATCH",
body: { timezone: zone },
});
expect(res.status, "the profile accepted the timezone").toBe(200);
expect(res.data?.timezone).toBe(zone);
}
/**
* A medication with a morning window and reminders on. With the account's
* clock pinned to early afternoon the window is hours closed, which is the
* state the sweep dispatches for.
*/
async function createOverdueMedication(page: Page): Promise<string> {
const res = await fromPage<{ id: string }>(page, "/api/medications", {
method: "POST",
body: {
name: MEDICATION_NAME,
dose: "1 tablet",
notificationsEnabled: true,
schedules: [{ windowStart: "08:00", windowEnd: "09:00" }],
},
});
expect(res.status, "the medication was created").toBe(201);
const id = res.data?.id;
expect(id, "the create returned an id").toBeTruthy();
return id as string;
}
interface SweepResult {
scoped: boolean;
medications: Array<{
name: string;
user: string;
schedules: Array<{ notificationSent: boolean }>;
}>;
}
/**
* Run the reminder sweep the admin notifications panel drives, NAMING this
* account. This is the product's own trigger for the medication-reminder
* dispatch, and it goes through `dispatchNotification` — the cascade, the
* per-channel preference read and the client-managed gate — rather than
* calling a sender directly.
*
* The selector is not a convenience. Without it the sweep walks every active
* medication on the instance and dispatches for each overdue slot, which makes
* this file a writer into every other account's delivery ledger — the exact
* inverse of the interference it is trying to avoid, and one that would start
* sending real mail the moment another account had a channel and an overdue
* dose. Scoped, the run reaches one account: the one it then counts.
*/
async function runReminderSweep(page: Page, userId: string): Promise<void> {
const res = await fromPage<SweepResult>(
page,
"/api/admin/notifications/reminder-check",
{ method: "POST", body: { userId } },
);
expect(res.status, "the sweep ran").toBe(200);
expect(
res.data?.scoped,
"the sweep walked one account, not the instance",
).toBe(true);
expect(
[...new Set(res.data?.medications.map((m) => m.user))],
"every medication the sweep touched belongs to this account",
).toEqual([E2E_NOTIFY.username]);
const mine = res.data?.medications.find((m) => m.name === MEDICATION_NAME);
expect(mine, "the sweep saw this account's overdue medication").toBeTruthy();
expect(
mine?.schedules.some((s) => s.notificationSent),
"the sweep dispatched for the closed window",
).toBe(true);
}
/** Look one channel's attempt out of a dispatch's rows. */
function attemptFor(
attempts: PushAttempt[],
channel: string,
): PushAttempt | undefined {
return attempts.find((a) => a.channel === channel);
}
test.describe("notification preferences drive the dispatch decision", () => {
/**
* Its own server as well as its own account. That process is the one carrying
* the mail configuration, and the one with no reminder scheduler inside it —
* `e2e/setup/notification-server.ts` says why each matters and what the
* shared server keeping the scheduler still leaves standing.
*/
test.use({
storageState: NOTIFY_STORAGE_STATE_PATH,
baseURL: NOTIFICATION_BASE_URL,
});
/**
* Serial, because every test configures the one account's channels and then
* counts rows in its ledger. Two of these in flight would be counting each
* other's deliveries.
*/
test.describe.configure({ mode: "serial" });
/**
* The ledger write that follows a dispatch is fire-and-forget and the tests
* poll for it, so the budget covers a loaded runner rather than the 30 s
* default.
*/
test.setTimeout(120_000);
let smtp: SmtpStub;
let accountId: string;
test.beforeAll(async ({ playwright }) => {
smtp = await startSmtpStub();
accountId = await notificationAccountId();
// The one precondition this file cannot arrange for itself: the server it
// talks to has to have been started with `SMTP_*`. `playwright.config.ts`
// hands them to the server it starts, but `reuseExistingServer` (the local
// default) and `E2E_SKIP_WEB_SERVER=1` both attach to a server started
// without them — and the symptom is a missing `#email-recipient` field
// thirty lines into the first test, which points nowhere near the cause.
const api = await playwright.request.newContext({
baseURL: NOTIFICATION_BASE_URL,
storageState: NOTIFY_STORAGE_STATE_PATH,
});
try {
const res = await api.get("/api/settings/email");
const body = (await res.json()) as { data: { smtpConfigured: boolean } };
expect(
body.data?.smtpConfigured,
`the server on ${NOTIFICATION_BASE_URL} was started without SMTP_* — a reused ` +
`dev server or E2E_SKIP_WEB_SERVER=1 does not carry them. Start it with ` +
`SMTP_HOST=127.0.0.1 SMTP_PORT=${smtp.port} SMTP_FROM=<addr>.`,
).toBe(true);
} finally {
await api.dispose();
}
});
test.afterAll(async () => {
// Optional-chained: a `beforeAll` that threw before the stub resolved
// leaves this undefined, and a teardown TypeError buries the real cause
// under its own.
await smtp?.close();
});
test.beforeEach(async () => {
await resetNotificationFixture();
smtp.reset();
});
test("the card's own test control delivers on the enabled channel only", async ({
page,
}) => {
await configureChannels(page);
// The diagnostic agrees with the surface about which channel is on. Both
// reads are the account's own; neither is the switch it just clicked.
const channels = (await readDiagnostic(page)).notificationChannels;
expect(
channels.find((c) => c.type === "EMAIL"),
"the email channel is configured and on",
).toMatchObject({ enabled: true, configPresent: true });
expect(
channels.find((c) => c.type === "NTFY"),
"the ntfy channel is configured and off",
).toMatchObject({ enabled: false, configPresent: true });
// A channel that is off offers no way to probe it — the refusal is on the
// surface, before any request is made.
await expect(
page.locator(
'#ntfy [data-slot="settings-card-actions"] button[type=button]',
),
).toBeDisabled();
const before = await attemptCount(page);
const probe = page.waitForResponse(
(res) => new URL(res.url()).pathname === "/api/settings/email/test",
);
await page
.locator('#email [data-slot="settings-card-actions"] button[type=button]')
.click();
expect((await probe).status(), "the probe reached the transport").toBe(200);
const attempts = await attemptsSince(page, before, 1);
expect(attempts[0]).toMatchObject({
channel: "EMAIL",
result: "ok",
reason: null,
});
// And the transport is a real one: the message left the process and was
// accepted for the address the settings surface saved.
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
});
test("a due reminder delivers on the enabled channel and skips the rest", async ({
page,
}) => {
await configureChannels(page);
await pairPhone(page);
await pinAccountClockToMidday(page);
await createOverdueMedication(page);
const before = await attemptCount(page);
await runReminderSweep(page, accountId);
// Two arms, and exactly two: the paired phone and the enabled email
// channel. A third row would mean the switched-off relay was dialled.
const attempts = await attemptsSince(page, before, 2);
expect(
attempts.map((a) => a.channel).sort(),
"only the channels in the cascade were attempted",
).toEqual(["APNS", "EMAIL"]);
expect(attemptFor(attempts, "EMAIL")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
// The APNs arm was reached and refused by the TRANSPORT, not by a
// preference — this instance has no APNs credentials. That distinction is
// the baseline the next test moves.
expect(attemptFor(attempts, "APNS")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "skipped",
reason: "apns_not_configured",
});
});
test("the client-managed opt-in suppresses the APNs arm and nothing else", async ({
page,
}) => {
await configureChannels(page);
await pairPhone(page);
await pinAccountClockToMidday(page);
await createOverdueMedication(page);
// The opt-in the native client sets when it owns the local reminder.
const patched = await fromPage<{ medication: { clientManaged: boolean } }>(
page,
"/api/auth/me/notification-prefs",
{ method: "PATCH", body: { medication: { clientManaged: true } } },
);
expect(patched.status, "the preference was accepted").toBe(200);
expect(patched.data?.medication.clientManaged).toBe(true);
// Read back through the account's own resolved preferences, not through
// the value just echoed: the deep-merge is what the dispatcher consults.
const resolved = await fromPage<{ medication: { clientManaged: boolean } }>(
page,
"/api/auth/me/notification-prefs",
);
expect(resolved.data?.medication.clientManaged).toBe(true);
const before = await attemptCount(page);
await runReminderSweep(page, accountId);
const attempts = await attemptsSince(page, before, 2);
// Suppressed BEFORE the sender: the reason is the gate's, not the
// transport's. Same instance, same missing credentials as the previous
// test — only the preference moved, and the ledger says so.
expect(attemptFor(attempts, "APNS")).toMatchObject({
result: "skipped",
reason: "client_managed",
});
// "The APNs leg only" is the published contract, so email still has to
// deliver. An opt-in that silenced the whole dispatch would pass every
// APNs assertion above and be the bug.
expect(attemptFor(attempts, "EMAIL")).toMatchObject({
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
expect(smtp.accepted()).toEqual([EMAIL_RECIPIENT]);
});
/**
* The web reflection of the opt-in, and the reason it is a test rather than a
* screenshot: the section reads `notificationPrefs.medication.clientManaged`
* off `/api/auth/me`, and that payload did not publish it until this branch.
* The flag was `undefined` for every account, so the chip never rendered and
* a person whose phone owned the reminders was shown a server-side switch
* that decided nothing. Both halves are asserted — the chip appears AND the
* switch row is gone — because either one alone passes on the wrong render.
*/
test("the client-managed opt-in is visible on the medication it silences", async ({
page,
}) => {
await configureChannels(page);
await pinAccountClockToMidday(page);
const medicationId = await createOverdueMedication(page);
const patched = await fromPage(page, "/api/auth/me/notification-prefs", {
method: "PATCH",
body: { medication: { clientManaged: true } },
});
expect(patched.status).toBe(200);
await page.goto(`/medications/${medicationId}`, {
waitUntil: "domcontentloaded",
});
await page.locator('[data-slot="medication-tab-zeitplan"]').click();
await expect(
page.locator('[data-slot="notifications-client-managed-chip"]'),
).toBeVisible({ timeout: 30_000 });
await expect(
page.locator('[data-slot="notifications-switch-row"]'),
).toHaveCount(0);
});
test("a relay URL inside a private range is refused and delivers nothing", async ({
page,
}) => {
await configureChannels(page);
const before = await attemptCount(page);
await fillField(page, "#ntfy-server", PRIVATE_NTFY_SERVER);
expect(
await saveCard(page, "#ntfy", "/api/settings/ntfy"),
"the save refused the private host",
).toBe(422);
// A refusal that still wrote something would be worse than no refusal:
// the stored relay is the one the account saved before.
const stored = await fromPage<{ serverUrl: string; enabled: boolean }>(
page,
"/api/settings/ntfy",
);
expect(stored.status).toBe(200);
expect(stored.data).toMatchObject({
serverUrl: NTFY_SERVER,
enabled: false,
});
// And nothing was dispatched on the way to being refused.
expect(await attemptCount(page)).toBe(before);
expect(smtp.accepted()).toEqual([]);
});
});