mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
test(e2e): move the adherence journey onto its own account
The batched compliance read allows 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 two siblings spend it as well — `refetch-nav.spec.ts` mounts `/medications` unstubbed and drives a client-side round trip back to it, and `delegated-writes.spec.ts` opens `/medications?new=1` on a SECOND session row of the same account, which is a different session but the same actor and therefore the same bucket. Add a serial group's retries and thirty reads inside one minute is reachable rather than theoretical. The cabinet has the same shape of problem in the other direction: the clear before each test deletes every medication row of the account it runs on, and that is today the account every other authenticated spec holds. So the journey gets its own account and its own jar. The rate-limit bucket, the medication cabinet and the dashboard tile's denominator become private at once. Two fixture repairs ride along, because they are the same helper: - The clear now goes out as the app's own DELETE per row instead of one raw `DELETE FROM medications`. Three fifteen-minute per-user cells hold medication state and a raw statement evicts none of them; the file survived only because every test happened to write through the API afterwards, which is an accident of ordering rather than a property. The rows are still enumerated in SQL, because the list route serves a memoised shape and a row it has not caught up with would hide from it. - `ageMedication` takes both stamps from its caller instead of deriving them from `CURRENT_DATE`. That resolves in the database session's zone — UTC in CI — while the plan dates the spec sends are computed in the browser's pinned Europe/Berlin, so between midnight and 02:00 the row silently started a day earlier than the caller asked for. The docstring now says `starts_on` is overwritten, not adjusted.
This commit is contained in:
1 parent
617beb40dc
commit
d529d5976c
4 files changed
+175
-54
No files matched your search
@@ -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<T>(
|
||||
page: Page,
|
||||
method: "GET" | "POST" | "PUT",
|
||||
method: "GET" | "POST" | "PUT" | "DELETE",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<ApiResult<T>> {
|
||||
@@ -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<MedicationAgeStamps> {
|
||||
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<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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,
|
||||
|
||||
@@ -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<void> {
|
||||
[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<void> {
|
||||
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<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 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<void> {
|
||||
// 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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
export async function resetMedications(
|
||||
deleteMedication: (id: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
*/
|
||||
export async function ageMedication(
|
||||
medicationId: string,
|
||||
days: number,
|
||||
stamps: MedicationAgeStamps,
|
||||
flushCaches: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user