diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index 1db17d453..85af6be0a 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -36,8 +36,12 @@ */ import type { Page } from "@playwright/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; -import { ageMedication, resetMedications } from "./setup/medication-fixture"; +import { MEDICATION_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ageMedication, + resetMedications, + type MedicationAgeStamps, +} from "./setup/medication-fixture"; import { expect, test } from "./setup/test"; import { clickNext, @@ -79,7 +83,7 @@ interface CompliancePayload { */ async function api( page: Page, - method: "GET" | "POST" | "PUT", + method: "GET" | "POST" | "PUT" | "DELETE", path: string, body?: unknown, ): Promise> { @@ -131,6 +135,28 @@ async function seedMedication( return id as string; } +/** + * The two stamps `ageMedication` writes, computed in the browser's clock. + * + * Deliberately not in SQL: `CURRENT_DATE` resolves in the database session's + * zone, the plan dates every flow sends are computed here, and between + * midnight and 02:00 Berlin those are different calendar days. + */ +async function ageStamps( + page: Page, + days: number, +): Promise { + return page.evaluate((days) => { + const pad = (n: number) => String(n).padStart(2, "0"); + const at = new Date(); + at.setDate(at.getDate() - days); + return { + createdAt: at.toISOString(), + startsOn: `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}`, + }; + }, days); +} + /** * The write `ageMedication` uses to flush the server's memoised compliance * cells after it has edited the row underneath them: an ordinary update that @@ -207,13 +233,12 @@ async function readTileLatestRate(page: Page): Promise { } /** - * Open a surface the seeding calls can run from. + * Open a surface the reset and the seeding calls can run from. * - * Deliberately NOT the medications list: every visit to it spends one call of - * the batched compliance route's per-actor minute allowance, and a spec that - * spends that allowance on its own setup has less of it left for the reads it - * actually asserts on — and none left at all for a CI retry, which re-runs the - * whole serial group inside the same minute. + * Both go out as the page's own `fetch`, so the page has to be on the app's + * origin first. Deliberately NOT the medications list: the reset that follows + * deletes rows, and a list mount would first fetch compliance for every one of + * them. */ async function openSeedingSurface(page: Page): Promise { await page.goto("/profile"); @@ -317,19 +342,24 @@ async function createTwiceDailyMedication( return { id: id as string, times }; } -test.beforeEach(async () => { - // Own every row the rates count. The dashboard tile sums the expected doses - // of every active medication on the account, so a leftover from a previous - // run would sit in its denominator. - await resetMedications(); -}); - -// Serial and single-account: the reset above would race a sibling worker, and -// two tests clearing the same cabinet would each undo the other's setup. +// Serial: the three tests share one cabinet, and two of them clearing it at +// once would each undo the other's setup. test.describe.configure({ mode: "serial" }); test.describe("medication adherence journey", () => { - test.use({ storageState: STORAGE_STATE_PATH }); + test.use({ storageState: MEDICATION_STORAGE_STATE_PATH }); + + test.beforeEach(async ({ page }) => { + // Own every row the rates count. The dashboard tile sums the expected + // doses of every active medication on the account, so a leftover from a + // previous run would sit in its denominator. The removal goes out as the + // app's own DELETE so the server's memoised medication cells go with it. + await openSeedingSurface(page); + await resetMedications(async (id) => { + const removed = await api(page, "DELETE", `/api/medications/${id}`); + expect(removed.status, "clearing a leftover medication").toBe(200); + }); + }); test("a taken and a skipped dose move the card, the history and the dashboard tile", async ({ page, @@ -346,7 +376,7 @@ test.describe("medication adherence journey", () => { // doses a day and nobody took any, so the starting rate is a real 0 %. await ageMedication( medicationId, - 4, + await ageStamps(page, 4), cacheFlusher(page, medicationId, "500 mg"), ); @@ -461,7 +491,7 @@ test.describe("medication adherence journey", () => { }); await ageMedication( medicationId, - 4, + stamps, cacheFlusher(page, medicationId, "10 mg"), ); @@ -549,8 +579,9 @@ test.describe("medication adherence journey", () => { }, }); + const stamps = await ageStamps(page, 8); for (const id of [weekdayId, dailyId]) { - await ageMedication(id, 8, cacheFlusher(page, id, "5 mg")); + await ageMedication(id, stamps, cacheFlusher(page, id, "5 mg")); for (const slot of plan.slots) { const written = await api( page, diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 14f0343ad..34062de61 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -93,6 +93,25 @@ export const E2E_GUARDIAN = { role: "USER", } as const; +/** + * The medication-adherence journey's own account. + * + * The batched compliance read is capped at thirty calls a minute per ACTOR, + * and the journey spends that allowance twice over: it writes doses and then + * reads the resulting rates back off the same cabinet. On the shared account + * the allowance is also being spent by whichever sibling happens to be + * mounting `/medications` in the other worker, and the cabinet itself is + * shared with them — so the journey's `beforeEach` clear would take rows + * somebody else seeded with it. One account of its own makes the rate-limit + * bucket, the cabinet and the dashboard tile's denominator private at once. + */ +export const E2E_MEDICATION = { + email: "e2e-medication@healthlog.test", + username: "e2e-medication", + password: "Hj6!Tw2pV9sQ4nDx", + role: "USER", +} as const; + /** A separate delegate session for the eight scoped-record browser journeys. */ export const E2E_SCOPE_DELEGATE = { email: "e2e-scope-delegate@healthlog.test", @@ -209,6 +228,12 @@ export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScopeCapabilities.json", ); +/** The adherence journey's jar — see `E2E_MEDICATION` for why it is its own. */ +export const MEDICATION_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateMedication.json", +); + /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as @@ -408,6 +433,32 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_GUARDIAN.username], ); + // The adherence journey's account. Modules are pinned rather than left to + // the defaults: the dashboard's medication tile is one of the surfaces the + // journey reads, and it only paints while the module is on. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_MEDICATION.username, + E2E_MEDICATION.email, + await hashPassword(E2E_MEDICATION.password), + now, + JSON.stringify({ medications: true }), + ], + ); + // The scoped-record journey uses a dedicated delegate so its preseeded // grants cannot affect the invitation lifecycle exercised by the sharing // journey above. Its own module preferences are explicitly off: a target @@ -647,7 +698,7 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in TEN times now + // The login bucket, for the same reason. This setup signs in TWELVE times now // (the shared jar, the owner, and one jar apiece for every spec that moves // a session's record selector), and the ceiling is five attempts per IP per // quarter-hour — so two local runs in a row would otherwise end with a 429 @@ -722,7 +773,7 @@ export default async function globalSetup(config: FullConfig): Promise { * Log one account in, clearing the login bucket first. * * The ceiling is FIVE attempts per IP per quarter-hour and this setup now - * signs in ten times, so clearing once before the batch is no longer + * signs in twelve times, so clearing once before the batch is no longer * enough — the sixth would be answered by the fixture's own 429 rather * than by the product. Only the auth surfaces' buckets are touched, and * only between logins this setup is itself performing. @@ -748,6 +799,10 @@ export default async function globalSetup(config: FullConfig): Promise { // for what happens when the journey switches the shared row instead. await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH); + // The adherence journey's jar. Its own account, so nothing it writes and + // nothing it clears is visible to the shared one. + await capture(E2E_MEDICATION, MEDICATION_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/e2e/setup/medication-fixture.ts b/e2e/setup/medication-fixture.ts index 101c60e09..86c85e3a0 100644 --- a/e2e/setup/medication-fixture.ts +++ b/e2e/setup/medication-fixture.ts @@ -2,13 +2,12 @@ * Account hygiene and row-ageing for the medication-adherence journey. * * `medication-compliance-journey.spec.ts` asserts RATES — a percentage over - * the seeded account's own doses — and the dashboard tile sums the expected - * doses of every active medication the account holds. Both are counts, so the - * spec has to own the rows it counts against: a medication left by an earlier - * run or a Playwright retry would sit in the denominator of every verdict. - * `resetMedications` is the delete-then-count guarantee, keyed by the seeded - * user's id; every other e2e spec that touches `/api/medications` stubs the - * route, so nothing else has live rows here to lose. + * one account's own doses — and the dashboard tile sums the expected doses of + * every active medication that account holds. Both are counts, so the spec has + * to own the rows it counts against: a medication left by an earlier run or a + * Playwright retry would sit in the denominator of every verdict. Everything + * here is keyed on `E2E_MEDICATION`, the journey's own account, so neither the + * clearing nor the ageing can be felt by a spec running in the other worker. * * The ageing is the other half. Compliance is reconstructed over * `[max(medication.createdAt, now − window), now]` — a medication created a @@ -19,7 +18,7 @@ */ import pg from "pg"; -import { E2E_USER } from "./global-setup"; +import { E2E_MEDICATION } from "./global-setup"; function pool(): pg.Pool { const url = process.env.DATABASE_URL; @@ -30,37 +29,72 @@ function pool(): pg.Pool { async function getUserId(db: pg.Pool): Promise { const res = await db.query<{ id: string }>( "SELECT id FROM users WHERE username = $1", - [E2E_USER.username], + [E2E_MEDICATION.username], ); const id = res.rows[0]?.id; if (!id) { throw new Error( - "[medication-fixture] e2e user not seeded — global-setup must run first", + "[medication-fixture] medication account not seeded — global-setup must run first", ); } return id; } /** - * Drop the seeded e2e user's medications. Schedules, schedule revisions, - * pause eras and intake events all cascade off `medications`, so the one - * delete clears the whole cabinet. Safe to call from every test's - * `beforeEach`. + * Clear the journey account's cabinet — through the app, not around it. + * + * The rows are enumerated in SQL because the list route serves a memoised + * shape and a row that cache has not caught up with would hide from it. Every + * removal then goes out as the caller's own `DELETE /api/medications/{id}`, + * which is the only path that evicts the three 15-minute per-user cells + * medication state lives in (`caches.medications`, + * `caches.medicationCompliance`, `caches.medicationsIntake`). A raw + * `DELETE FROM medications` reaches none of them, and the next read would + * answer out of a cache still naming rows that no longer exist — which is + * exactly the read this helper exists to make trustworthy. Schedules, schedule + * revisions, pause eras and intake events cascade off the row either way. + * + * Safe to call from every test's `beforeEach`. */ -export async function resetMedications(): Promise { +export async function resetMedications( + deleteMedication: (id: string) => Promise, +): Promise { const db = pool(); + let ids: string[]; try { const userId = await getUserId(db); - await db.query("DELETE FROM medications WHERE user_id = $1", [userId]); + const res = await db.query<{ id: string }>( + "SELECT id FROM medications WHERE user_id = $1", + [userId], + ); + ids = res.rows.map((row) => row.id); } finally { await db.end(); } + for (const id of ids) await deleteMedication(id); +} + +/** The two stamps {@link ageMedication} writes onto the row. */ +export interface MedicationAgeStamps { + /** The instant the row should claim as its creation, as an ISO string. */ + createdAt: string; + /** The `YYYY-MM-DD` the course should claim as its start. */ + startsOn: string; } /** - * Move a medication's creation stamp (and its course start) `days` into the - * past, so the compliance engine expands the schedule over those days and the - * doses nobody logged read as missed. + * Move a medication's creation stamp and its course start into the past, so + * the compliance engine expands the schedule over those days and the doses + * nobody logged read as missed. + * + * Both stamps come from the caller rather than from the database clock, and + * that is deliberate: `CURRENT_DATE` resolves in the database session's zone, + * which on CI is the Postgres container's UTC, while the plan dates the spec + * sends are computed in the browser's pinned `Europe/Berlin`. Between midnight + * and 02:00 Berlin the two name different calendar days, and the row would + * quietly start one day earlier than its caller asked for. `starts_on` is + * OVERWRITTEN, not adjusted — whatever the create call sent is replaced by + * what is passed here. * * The row is edited directly, which no cache invalidation can see: the * per-medication compliance payload and the dashboard's daily buckets are @@ -71,7 +105,7 @@ export async function resetMedications(): Promise { */ export async function ageMedication( medicationId: string, - days: number, + stamps: MedicationAgeStamps, flushCaches: () => Promise, ): Promise { const db = pool(); @@ -79,10 +113,10 @@ export async function ageMedication( const userId = await getUserId(db); const res = await db.query( `UPDATE medications - SET created_at = now() - make_interval(days => $1), - starts_on = (CURRENT_DATE - make_interval(days => $1))::date - WHERE id = $2 AND user_id = $3`, - [days, medicationId, userId], + SET created_at = $1::timestamptz, + starts_on = $2::date + WHERE id = $3 AND user_id = $4`, + [stamps.createdAt, stamps.startsOn, medicationId, userId], ); if (res.rowCount !== 1) { throw new Error( diff --git a/playwright.config.ts b/playwright.config.ts index 5989cc8b0..b04090933 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -152,12 +152,13 @@ export default defineConfig({ // through stable data-slots, not a mobile layout, so it runs in one // project. "vaccinations.spec.ts", - // The adherence journey walks the wizard, records doses on the one - // shared account and then reads RATES back off it — and the dashboard - // tile sums the expected doses of every active medication the account - // holds. Two projects writing that cabinet in parallel would each sit - // in the other's denominator, so the percentages would be answers to a - // question neither test asked. It proves flows through stable + // The adherence journey walks the wizard, records doses on its own + // account and then reads RATES back off it — and the dashboard tile + // sums the expected doses of every active medication that account + // holds. The account keeps siblings out; two PROJECTS running this + // same file in parallel would still write the one cabinet and each + // sit in the other's denominator, so the percentages would be answers + // to a question neither test asked. It proves flows through stable // data-slots, not a mobile layout, so it runs in one project. "medication-compliance-journey.spec.ts", // Runs only in the service-worker project.