mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
958f3bc50a
commit
a6a65719eb
4 files changed
+403
No files matched your search
@@ -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<string> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<ReminderDTO[]> {
|
||||
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<string[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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([]);
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user