test(e2e): give the doctor-report journey its own account

Generating a report writes: the route remembers the scope and the
practice name on the account row, and the export panel a later visit
opens is therefore the collapsed "repeat run" state. The journey was
generating on the shared fixture account, which is the account
`settings-export.spec.ts` asserts the first-run state against in nine
places — an expanded picker, an empty summary, a disabled button. The
two files run in an unconstrained order under two workers, so the first
symptom was an order-dependent red in CI; against a persistent local
database the flip never cleared and the red was permanent.

The journey now seeds, generates and reads back on `e2e-report-owner`,
with its own cookie jar. The shared account and the whole-record
fixtures also get `report_selection_json` and `last_report_practice_name`
cleared on every seed, which repairs a database an earlier run already
dirtied and keeps a delegate's scope choice from being left on a record
owner's row.

The account's date order is pinned rather than left to the locale, and
its export bucket is purged beside the share-link and login buckets: the
10-per-hour ceiling is shared by every export route, keys on the actor
and outlives a run by an hour, so repeated local runs would otherwise
meet a 429 the spec reports as a missing artefact.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 03:48:37 +02:00
1 parent 7b06a235e1
commit 33f5bed802
3 files changed
+117 -16

No files matched your search

+16 -7
View File
@@ -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,
+100 -9
View File
@@ -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<void> {
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<void> {
[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<void> {
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<void> {
[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<void> {
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<void> {
* 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<void> {
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
+1
View File
@@ -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";