mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The journey's isolation went one way only. `test.describe.configure` orders the tests inside the file; the suite is fullyParallel with two workers, so everything else runs beside it — and its own trigger, the admin reminder sweep, walked the whole instance and dispatched for every account that had an overdue dose. With the mail env pointed at the local responder, that would have started sending real messages for accounts this file has never heard of, and the exact-equality assertion on the accepted recipients would have been reading them. The sweep now names the account it then counts, and the comments that defended the opposite direction say what the exposure actually is. Its own server, too, and for two reasons. The `SMTP_*` vars decide whether the Email card renders at all, so on the shared server they put a card the axe scan and the card-anatomy sweep had never measured onto `/settings/integrations` for every spec that opens the route. And unset, `HEALTHLOG_PROCESS_TYPE` starts the pg-boss reminder scheduler inside the process serving requests, whose every-fifteen-minutes tick either mints the missed-dose row that makes the sweep skip its own dispatch or dispatches beside it. A third server carries both: mail configured, `web`, and only this spec talks to it. The shared server keeps `all` on measured grounds rather than taste. Worker status is per-process and in-memory, so a `web` process reports the worker stopped, `/admin` renders that row destructive-red, and axe fails it at 3.97:1 against the muted tile — a real contrast defect any operator running the documented web/worker split already sees, and not this branch's to fix. That limit is written where the server is defined. Also here: the stub draws its port per run instead of pinning one, and says so by name when the port is taken; teardown ends its sockets and survives a setup that never got as far as starting it; the ledger reset stops deleting rows whose subject is this account but whose recipient is somebody else, and clears intake events explicitly rather than through a cascade; the attempt poll refuses a window larger than the twenty rows the diagnostic returns; and the header names the precondition the file cannot enforce and the internal reasons it pins on purpose. Verified against a production build and a throwaway Postgres: the journey three times over at one worker, then the a11y and settings geometry specs at two. Both assertions broken once to see them fail.
702 lines
26 KiB
TypeScript
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([]);
|
|
});
|
|
});
|