diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index 38e2c76c2..ea999e41d 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -26,12 +26,21 @@ * period and simply carries no blood-pressure section. An empty report is a * true statement about an empty window; a truncated or unopenable file is not. * + * Its own account, and not the shared one: generating a report WRITES. The + * route remembers the scope and the practice name on the account row, so the + * panel a later visit opens is the collapsed "repeat run" state — which is + * exactly the state `settings-export.spec.ts` asserts the opposite of, against + * the shared account, in nine places. See `E2E_REPORT_OWNER`. + * * The fixture is seeded through the PAGE's own `fetch` under the session the * spec is already authenticated as — not through a second Playwright request * context — so the rows are written by exactly the credential that later reads - * them back. Seeding is idempotent: a re-post of the same reading collides on - * the measurement's natural key and answers 409, which is why `--repeat-each` - * and a second local run both stay green. + * them back. The ROWS are idempotent: a re-post of the same reading collides on + * the measurement's natural key and answers 409, so `--repeat-each` and a + * second local run write nothing twice. The export QUOTA is not idempotent — + * every export route shares one 10-per-hour bucket keyed on the actor, and it + * outlives the run — so `global-setup` clears this account's bucket before each + * run rather than the file pretending the press is free. * * The delegate half of the story — a READ-level delegate cannot generate the * owner's report — lives in `doctor-report-delegate.spec.ts`, which needs its @@ -42,7 +51,7 @@ import { readFile } from "node:fs/promises"; import type { Page, Response } from "@playwright/test"; import { PDFParse } from "pdf-parse"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { REPORT_OWNER_STORAGE_STATE_PATH } from "./setup/global-setup"; import { expect, test } from "./setup/test"; /** The allergy and the drug the report has to name. Both plain ASCII: the @@ -360,9 +369,9 @@ function periodSpanDays(text: string): number { } test.describe.serial("the doctor report", () => { - // The seeded account owns the readings, the allergy and the drug, and the - // page's own `fetch` writes them under its session. - test.use({ storageState: STORAGE_STATE_PATH }); + // This journey's own account owns the readings, the allergy and the drug, and + // the page's own `fetch` writes them under its session. + test.use({ storageState: REPORT_OWNER_STORAGE_STATE_PATH }); test("generates a PDF that names the period, the vitals and the medication", async ({ page, diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 969ebdbde..b5e7866a6 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -101,6 +101,31 @@ export const E2E_SCOPE_DELEGATE = { role: "USER", } as const; +/** + * The doctor-report journey's own account. + * + * Generating a report WRITES: the route remembers the scope and the practice + * name on the account row, and the panel a later visit opens is therefore the + * "repeat run" state with the picker collapsed. `settings-export.spec.ts` + * asserts the opposite — an expanded picker, an empty summary, a disabled + * button — against the shared account, so a journey that generated there would + * turn nine of its assertions red the moment the two files happened to run in + * the wrong order, and keep them red on every later run against the same + * database. + * + * Its own account also keeps the seeded readings, the emergency profile and + * the hourly export bucket off the account sixty other specs read. + * + * `date_format` is pinned rather than left on AUTO: the spec reads the period + * line out of the rendered PDF and has to know which field is the month. + */ +export const E2E_REPORT_OWNER = { + email: "e2e-report-owner@healthlog.test", + username: "e2e-report-owner", + password: "Hd6!Tw3pV9nQ2xLb", + role: "USER", +} as const; + /** One non-personal target record per closed sharing domain. */ export const E2E_SCOPE_RECORDS = [ { @@ -261,6 +286,15 @@ export const REPORT_DELEGATE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateReportDelegate.json", ); +/** + * The doctor-report journey's jar. Its own account, so what generating a report + * stamps on the account row cannot reach the shared one — see `E2E_REPORT_OWNER`. + */ +export const REPORT_OWNER_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateReportOwner.json", +); + /** * The banner-geometry spec's jar, and the reason it exists is a release-shaped * one rather than a load-shaped one. @@ -368,7 +402,9 @@ export default async function globalSetup(config: FullConfig): Promise { insights_cached_text = NULL, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, - module_preferences_json = EXCLUDED.module_preferences_json`, + module_preferences_json = EXCLUDED.module_preferences_json, + report_selection_json = NULL, + last_report_practice_name = NULL`, [ cuid(), E2E_USER.username, @@ -419,6 +455,40 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_GUARDIAN.username], ); + // The doctor-report journey's account. Seeded like the shared one — a + // record with a height, a date of birth and a gender, so the report has a + // patient header to print — and with the two report columns cleared, which + // is what puts the panel back into its first-run state on every run. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + height_cm, date_of_birth, gender, date_format, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, + 180, $6, 'MALE', 'MDY', + $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + height_cm = EXCLUDED.height_cm, + date_of_birth = EXCLUDED.date_of_birth, + gender = EXCLUDED.gender, + date_format = EXCLUDED.date_format, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + report_selection_json = NULL, + last_report_practice_name = NULL`, + [ + cuid(), + E2E_REPORT_OWNER.username, + E2E_REPORT_OWNER.email, + await hashPassword(E2E_REPORT_OWNER.password), + now, + dob, + ], + ); + // 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 @@ -529,7 +599,9 @@ export default async function globalSetup(config: FullConfig): Promise { managed_profile_at = EXCLUDED.managed_profile_at, onboarding_completed_at = EXCLUDED.onboarding_completed_at, onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, - module_preferences_json = EXCLUDED.module_preferences_json`, + module_preferences_json = EXCLUDED.module_preferences_json, + report_selection_json = NULL, + last_report_practice_name = NULL`, [ cuid(), record.username, @@ -589,6 +661,20 @@ export default async function globalSetup(config: FullConfig): Promise { [E2E_USER.username], ); + // The export bucket, for the same reason and with the same shape. Every + // export route shares one 10-per-hour bucket keyed on the ACTOR, the + // counter outlives a run by a full hour, and the doctor-report journey + // spends two of it per pass — so a fourth run inside the hour would meet a + // 429 and report a missing artefact rather than a spent quota. Only the two + // accounts that generate a report are cleared. + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + SELECT 'export:' || id::text FROM users WHERE username = ANY($1) + )`, + [[E2E_REPORT_OWNER.username, E2E_SCOPE_DELEGATE.username]], + ); + // Opt the seed account into cycle tracking. The user is seeded as // `MALE`, and v1.18.0 resolves the `cycle` module through // `isCycleEnabled(gender, CycleProfile)` — NULL `cycleTrackingEnabled` @@ -658,12 +744,13 @@ 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 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 - // from the fixture rather than a failure from the product. Only the auth - // surfaces' own buckets are cleared; nothing else in the table is touched. + // The login bucket, for the same reason. This setup signs in THIRTEEN + // times now (the shared jar, the owner, the report 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 from the fixture rather than a failure + // from the product. Only the auth surfaces' own buckets are cleared; + // nothing else in the table is touched. await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); // One marker weight per record. Re-seeded by delete-then-insert so a @@ -733,7 +820,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 thirteen 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. @@ -773,6 +860,10 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, REPORT_DELEGATE_STORAGE_STATE_PATH); + // The doctor-report journey's own account, for the reason written on + // E2E_REPORT_OWNER: generating a report writes to the account row. + await capture(E2E_REPORT_OWNER, REPORT_OWNER_STORAGE_STATE_PATH); + // v1.37.0 — the guardian's jar, and only then its second factor. // // Order is the whole of it. `requireFreshMfa` refuses an account with no diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index ac69785ff..6d9f4c65d 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -26,5 +26,6 @@ export { FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, REPORT_DELEGATE_STORAGE_STATE_PATH, + REPORT_OWNER_STORAGE_STATE_PATH, GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup";