From a6a65719eb57e204c5f836280109955fbcbf8e74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 8 Aug 2026 23:00:29 +0200 Subject: [PATCH] test(vaccinations): cover the immunization log end to end Five Playwright flows over the built app, each asserting stable data-slot attributes rather than viewport text: transcribe a catalogue pick with a lot into its antigen group, a combination dose rendering under each of its three component groups, the booster prompt minting a Vorsorge reminder that shows on checkups and moves its due date when the next dose is logged, the module-off redirect with the nav entry gone, and an axe pass over the list and the form. The catalogue option gains a data-catalog-slug hook so a flow can pick an exact antigen deterministically. The spec runs in a single project and serial, and clears its own rows before each test, so a grouped verdict counts only what the test wrote. --- e2e/setup/vaccination-fixture.ts | 89 +++++ e2e/vaccinations.spec.ts | 305 ++++++++++++++++++ playwright.config.ts | 8 + .../vaccinations/catalog-picker.tsx | 1 + 4 files changed, 403 insertions(+) create mode 100644 e2e/setup/vaccination-fixture.ts create mode 100644 e2e/vaccinations.spec.ts diff --git a/e2e/setup/vaccination-fixture.ts b/e2e/setup/vaccination-fixture.ts new file mode 100644 index 000000000..0078f2685 --- /dev/null +++ b/e2e/setup/vaccination-fixture.ts @@ -0,0 +1,89 @@ +/** + * The immunization-log spec's account hygiene. + * + * `vaccinations.spec.ts` asserts GROUPED verdicts — a combination dose appears + * in each of its three component groups, one antigen holds at most one minted + * booster reminder, a logged next dose moves the reminder's due date forward. + * Every one of those is a count over the seeded e2e user's live rows, so the + * spec has to own the rows it counts against: a dose left by an earlier run, a + * Playwright retry, or a second project sharing the account would leave a + * stale group on the page and a second reminder in the antigen bucket, and the + * grouped assertions would read the wrong count. + * + * The reset is delete-then-assert-clean, keyed by the seeded user's id. Links + * go first for their foreign key; the minted booster reminders go last and are + * scoped to the ones this feature writes — `vaccination_antigen IS NOT NULL` — + * so an unrelated Vorsorge reminder another spec relies on is never touched. + */ +import pg from "pg"; + +import { E2E_USER } from "./global-setup"; + +async function getUserId(pool: pg.Pool): Promise { + const res = await pool.query<{ id: string }>( + "SELECT id FROM users WHERE username = $1", + [E2E_USER.username], + ); + const id = res.rows[0]?.id; + if (!id) { + throw new Error( + "[vaccination-fixture] e2e user not seeded — global-setup must run first", + ); + } + return id; +} + +/** + * Remove the seeded e2e user's vaccination rows, their document links, and the + * booster reminders they minted. Safe to call from every test's `beforeEach`. + */ +export async function resetVaccinations(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + // Links first — they FK into the record rows. + await pool.query( + "DELETE FROM vaccination_document_links WHERE user_id = $1", + [userId], + ); + await pool.query("DELETE FROM vaccination_records WHERE user_id = $1", [ + userId, + ]); + // Only the reminders this feature mints. A booster reminder is the only + // kind that carries an antigen; a checkup another spec created has none and + // stays. + await pool.query( + "DELETE FROM measurement_reminders WHERE user_id = $1 AND vaccination_antigen IS NOT NULL", + [userId], + ); + } finally { + await pool.end(); + } +} + +/** + * Put the seeded e2e user's `vaccinations` module back to its default-on state. + * + * The module-off test flips the toggle through the real settings UI, and that + * write persists on the shared account past the test that made it. Every other + * flow needs the surface reachable, so this clears the `vaccinations` key from + * the preferences blob — default-on is absence — leaving any other module the + * account carries (`nutrients`) untouched. Called from `beforeEach` so no test + * order can strand the surface off. + */ +export async function ensureVaccinationsModuleOn(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vaccination-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + await pool.query( + "UPDATE users SET module_preferences_json = module_preferences_json - 'vaccinations' WHERE id = $1 AND module_preferences_json ? 'vaccinations'", + [userId], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/vaccinations.spec.ts b/e2e/vaccinations.spec.ts new file mode 100644 index 000000000..3cdf09356 --- /dev/null +++ b/e2e/vaccinations.spec.ts @@ -0,0 +1,305 @@ +/** + * The immunization log, proven end to end. + * + * Five flows, one script each, each assertion addressed to a stable + * `data-slot` / `data-*` attribute and never to viewport text — the copy is + * i18n-driven and the same slots paint at a different size on the mobile + * project, so a text assertion would break for the wrong reason. + * + * 1. Transcribe — a catalogue pick plus a lot number saves and reads back in + * its antigen group with a resolved series label. + * 2. Combination — one Tdap renders a row in each of its three component + * groups, the render-only duplication the series design exists for. + * 3. Mint + satisfy — confirming the booster prompt writes an ordinary + * Vorsorge reminder that shows on `/checkups`; logging the next dose moves + * its due date forward through the real satisfy matcher. + * 4. Module off — turning the module off in settings drops the nav entry and + * the direct visit lands on the module-off redirect, not a crash. + * 5. a11y — axe over the list and the capture form, serious/critical only. + * + * The flows mutate the one seeded account, so — like `visits.spec.ts` — this + * file runs in a single project (see `playwright.config.ts`) and serial, and + * clears its own rows before each test so a grouped verdict counts only what + * the test itself wrote. + */ +import AxeBuilder from "@axe-core/playwright"; +import type { APIRequestContext, Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; +import { + ensureVaccinationsModuleOn, + resetVaccinations, +} from "./setup/vaccination-fixture"; + +test.beforeEach(async () => { + // Own the counts every grouped assertion reads, and undo whatever the + // module-off flow left behind. + await resetVaccinations(); + await ensureVaccinationsModuleOn(); +}); + +// Serial and single-account: the reset above would race a sibling worker, and +// two tests clearing the same rows would each undo the other's setup. +test.describe.configure({ mode: "serial" }); + +/** One reminder as the list route publishes it. */ +interface ReminderDTO { + id: string; + origin: string; + nextDueAt: string | null; +} + +async function listReminders( + request: APIRequestContext, +): Promise { + const res = await request.get("/api/measurement-reminders"); + expect(res.status()).toBe(200); + return ((await res.json()) as { data: ReminderDTO[] }).data; +} + +async function listVaccinationIds( + request: APIRequestContext, +): Promise { + const res = await request.get("/api/vaccinations"); + expect(res.status()).toBe(200); + const body = (await res.json()) as { + data: { vaccinations: { id: string }[] }; + }; + return body.data.vaccinations.map((row) => row.id); +} + +/** + * Drive the capture sheet: open it, pick a catalogue antigen by its slug, set + * the date and an optional lot, and save. `booster` says what to do with the + * mint prompt a booster-bearing antigen raises after the save. + */ +async function captureDose( + page: Page, + opts: { + catalogSlug: string; + /** `YYYY-MM-DD`. Defaults to the form's own today. */ + date?: string; + lot?: string; + booster: "confirm" | "decline" | "none"; + }, +): Promise { + const add = page.locator('[data-slot="vaccination-add"]').first(); + const addEmpty = page.locator('[data-slot="vaccination-add-empty"]').first(); + // The list uses a header Add; the empty state uses its own. + if (await add.isVisible().catch(() => false)) { + await add.click(); + } else { + await addEmpty.click(); + } + + await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); + + if (opts.date) { + await page.getByTestId("vaccination-occurred-at").fill(opts.date); + } + + await page.locator('[data-slot="vaccination-catalog-trigger"]').click(); + await page + .locator('[data-slot="vaccination-catalog-search"]') + .fill(opts.catalogSlug); + await page + .locator( + `[data-slot="vaccination-catalog-option"][data-catalog-slug="${opts.catalogSlug}"]`, + ) + .click(); + + if (opts.lot) { + await page.locator("#vaccination-lot").fill(opts.lot); + } + + await page.locator('[data-slot="vaccination-save"]').click(); + + if (opts.booster === "confirm") { + await page.locator('[data-slot="vaccination-booster-confirm"]').click(); + } else if (opts.booster === "decline") { + await page.locator('[data-slot="vaccination-booster-decline"]').click(); + } + // The prompt (when raised) and the save button both leave the tree once the + // flow settles; waiting on the save button's detachment keeps the next step + // off a stale sheet. + await expect(page.locator('[data-slot="vaccination-save"]')).toBeHidden(); +} + +test.describe("vaccinations", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("a dose transcribed with a catalogue pick and a lot reads back in its antigen group", async ({ + page, + request, + }) => { + await page.goto("/vaccinations"); + + // Polio carries no booster interval, so the capture completes without a + // mint prompt — this flow is about the transcription, not the reminder. + await captureDose(page, { + catalogSlug: "polio", + lot: "LOT-E2E-001", + booster: "none", + }); + + const [id] = await listVaccinationIds(request); + expect(id, "the dose was written").toBeTruthy(); + + const group = page.locator( + '[data-slot="vaccination-group"][data-antigen="polio"]', + ); + await expect(group).toBeVisible({ timeout: 15_000 }); + + const row = group.locator(`[data-vaccination-id="${id}"]`); + await expect(row).toBeVisible(); + // The series label is resolved server-side and rendered as its own slot. + await expect(row.locator('[data-slot="vaccination-series"]')).toBeVisible(); + // The lot rides the row's meta line under its own slot. + await expect(row.locator('[data-slot="vaccination-lot"]')).toBeVisible(); + }); + + test("a combination dose appears in each of its three component groups", async ({ + page, + request, + }) => { + await page.goto("/vaccinations"); + + // Tdap's primary component (tetanus) carries a booster interval, so the + // mint prompt is raised — decline it; this flow is about the grouping. + await captureDose(page, { catalogSlug: "tdap", booster: "decline" }); + + const [id] = await listVaccinationIds(request); + expect(id, "the Tdap dose was written").toBeTruthy(); + + // One record, three appearances — once under each component antigen, each + // its own group, each carrying the same record id. + for (const antigen of ["tetanus", "diphtheria", "pertussis"] as const) { + const row = page.locator( + `[data-slot="vaccination-group"][data-antigen="${antigen}"] [data-vaccination-id="${id}"]`, + ); + await expect(row, `Tdap appears under ${antigen}`).toBeVisible({ + timeout: 15_000, + }); + } + }); + + test("confirming the booster prompt mints a reminder that a logged next dose re-anchors", async ({ + page, + request, + }) => { + const before = new Set((await listReminders(request)).map((r) => r.id)); + + await page.goto("/vaccinations"); + + // The first dose sits years back, so the minted reminder's first due date + // (dose + interval) is anchored in the past relative to a dose logged now — + // which is what lets the re-anchor below prove a forward move. + await captureDose(page, { + catalogSlug: "tetanus", + date: "2016-03-15", + booster: "confirm", + }); + + const afterMint = await listReminders(request); + const minted = afterMint.find((r) => !before.has(r.id)); + expect(minted, "the mint wrote exactly one new reminder").toBeDefined(); + expect(minted!.origin).toBe("VORSORGE"); + expect(minted!.nextDueAt).toBeTruthy(); + const dueBefore = new Date(minted!.nextDueAt!).getTime(); + + // It presents on /checkups like every other Vorsorge reminder — the file-a + // -visit affordance only paints on a rendered reminder card, so it standing + // in for "a reminder card is here" is a stable per-card slot. + await page.goto("/checkups"); + await expect( + page.locator('[data-slot="vorsorge-file-visit"]').first(), + ).toBeVisible({ timeout: 15_000 }); + + // Log the next dose today. The Phase 1 satisfy matcher re-anchors the + // minted reminder on this dose during the create, so its due date moves + // forward off the years-old anchor. + await page.goto("/vaccinations"); + await captureDose(page, { catalogSlug: "tetanus", booster: "decline" }); + + const reminderAfter = (await listReminders(request)).find( + (r) => r.id === minted!.id, + ); + expect( + reminderAfter, + "the reminder survives the second dose", + ).toBeDefined(); + expect(reminderAfter!.nextDueAt).toBeTruthy(); + expect(new Date(reminderAfter!.nextDueAt!).getTime()).toBeGreaterThan( + dueBefore, + ); + }); + + test("turning the module off drops the nav entry and the direct visit redirects", async ({ + page, + }) => { + await page.goto("/vaccinations"); + // The surface is present to begin with. + await expect( + page.locator('[data-tour-id="vaccinations-hero"]'), + ).toBeVisible({ timeout: 15_000 }); + + // Flip the real settings switch off and wait for the resolved module map to + // repaint the shell. + await page.goto("/settings/modules"); + const toggle = page.locator("#module-toggle-vaccinations"); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(toggle).not.toBeChecked(); + + // The nav entry is gone. + await expect(page.locator('[data-tour-id="nav-vaccinations"]')).toHaveCount( + 0, + ); + + // A direct visit no longer renders the surface: the page redirects home, + // so the hero never paints and the dashboard's own landmark does. + await page.goto("/vaccinations"); + await expect(page).toHaveURL(/\/$|\/dashboard/, { timeout: 15_000 }); + await expect( + page.locator('[data-tour-id="vaccinations-hero"]'), + ).toHaveCount(0); + }); + + test("the list and the capture form carry no serious accessibility violations", async ({ + page, + request, + }) => { + // Seed one dose so the list scans a populated surface rather than the empty + // state, then scan both the list and the open capture form. + const seeded = await request.post("/api/vaccinations", { + data: { occurredAt: "2019-05-01T00:00:00.000Z", antigenSlug: "polio" }, + }); + expect(seeded.status()).toBe(201); + + await page.goto("/vaccinations"); + await expect( + page.locator('[data-slot="vaccination-group"][data-antigen="polio"]'), + ).toBeVisible({ timeout: 15_000 }); + await expectNoSeriousAxe(page); + + await page.locator('[data-slot="vaccination-add"]').first().click(); + await expect(page.locator('[data-slot="vaccination-form"]')).toBeVisible(); + await expectNoSeriousAxe(page); + }); +}); + +/** Fail only on serious/critical WCAG violations, the suite's a11y floor. */ +async function expectNoSeriousAxe(page: Page): Promise { + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + const blocking = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + expect( + blocking.map((v) => v.id), + "serious/critical accessibility violations", + ).toEqual([]); +} diff --git a/playwright.config.ts b/playwright.config.ts index 421731c48..0fb132700 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -143,6 +143,14 @@ export default defineConfig({ // the single-candidate branch to the picker. It proves a flow through // stable data-slots, not a mobile layout, so it runs in one project. "visits.spec.ts", + // The immunization-log journey transcribes doses, mints a booster + // reminder and logs a next dose against the one shared account, then + // reads grouped counts back off it. Two projects mutating that account + // in parallel would leave a stale group on the page and a second row in + // an antigen bucket, flipping the grouped verdicts. It proves flows + // through stable data-slots, not a mobile layout, so it runs in one + // project. + "vaccinations.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", ], diff --git a/src/components/vaccinations/catalog-picker.tsx b/src/components/vaccinations/catalog-picker.tsx index c7725b30c..46243c4c2 100644 --- a/src/components/vaccinations/catalog-picker.tsx +++ b/src/components/vaccinations/catalog-picker.tsx @@ -116,6 +116,7 @@ export function CatalogPicker({ key={entry.slug} type="button" data-slot="vaccination-catalog-option" + data-catalog-slug={entry.slug} onClick={() => { onChange(entry.slug); setOpen(false);