mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Directions such as "30 to 60 minutes before food" had nowhere to go: a medication carried no free text of its own, only the notes on a dose change or a course. The medication now has one note, edited on the last step of the wizard and shown under the name on the detail page, above the dose actions on both card variants, under the picked medication in the log-dose sheet, and per row in the take-all-due list. One MedicationNote component renders it everywhere, in foreground text. The note is sealed at rest in Medication.noteEncrypted (migration 0498_medication_note, lock-bounded), with no plaintext column. Create and update take an optional `note`: trimmed, at most 500 characters, an empty string or null clears it, absent keeps it. The wizard sends it on an edit only when it changed, so an unrelated save never clears it. The list and detail reads strip the ciphertext and answer `note`. Both ends of the data path carry it: the backup (decrypted in a portable file, ciphertext in a disaster-recovery file), the restore, the data export, the key-rotation registry and script, and the MCP medication schedule, resources and fetch, fenced as user text. The doctor report and the FHIR export leave it out: it is the person's own reminder, and the report has no selection for personal prose. The size baselines rise by the one-line wiring of the field in the medication page, detail tabs, cards, log-dose sheet, wizard step and list read; the wizard payload grows by the field's own encode and hydrate.
302 lines
9.3 KiB
TypeScript
302 lines
9.3 KiB
TypeScript
import { expect, type Page, type Route } from "@playwright/test";
|
||
|
||
/**
|
||
* v1.5.4 — shared helpers for the modal medication-wizard specs.
|
||
*
|
||
* The six specs (daily / weekdays / biweekly / monthly / rolling /
|
||
* oneshot) walk the same dialog with different cadence-specific
|
||
* inputs. The shared pieces — POST + GET stubs, list-page entry
|
||
* check, dialog open + step-indicator assertions — live here so
|
||
* the per-cadence specs only carry the cadence-specific selectors.
|
||
*
|
||
* The path counter follows the user's path:
|
||
* one-shot = 5 steps (1, 2, 3, 4, 8)
|
||
* daily = 7 steps (1, 2, 3, 4, 5, 7, 8)
|
||
* recurring = 8 steps (1, 2, 3, 4, 5, 6, 7, 8)
|
||
*
|
||
* `expectStep(page, displayIndex, totalSteps)` pins the visible
|
||
* "Schritt X von Y" string the dialog header renders.
|
||
*/
|
||
|
||
export const STUB_MEDICATION_ID = "med_e2e_wizard";
|
||
|
||
interface StubbedMedication {
|
||
id: string;
|
||
name: string;
|
||
dose: string;
|
||
rrule: string | null;
|
||
rollingIntervalDays: number | null;
|
||
oneShot: boolean;
|
||
/** #1371 — the medication's own note, echoed on the list. */
|
||
note?: string | null;
|
||
}
|
||
|
||
export function mockMedicationsApi(page: Page, stubbed: StubbedMedication) {
|
||
// A body that is not JSON must fail the spec where it reads `postBody`,
|
||
// with the parse error, not throw inside the un-awaited route callback.
|
||
const capture = {
|
||
rawPostBody: null as unknown,
|
||
parseError: null as string | null,
|
||
get postBody(): unknown {
|
||
if (this.parseError !== null) {
|
||
throw new Error(
|
||
`POST /api/medications sent a body that is not JSON: ${this.parseError}`,
|
||
);
|
||
}
|
||
return this.rawPostBody;
|
||
},
|
||
};
|
||
|
||
const listEntry = {
|
||
id: stubbed.id,
|
||
name: stubbed.name,
|
||
dose: stubbed.dose,
|
||
category: "OTHER",
|
||
active: true,
|
||
notificationsEnabled: true,
|
||
pausedAt: null,
|
||
lastTakenAt: null,
|
||
startsOn: null,
|
||
endsOn: null,
|
||
oneShot: stubbed.oneShot,
|
||
note: stubbed.note ?? null,
|
||
schedules: [
|
||
{
|
||
id: "sch_e2e_1",
|
||
windowStart: "08:00",
|
||
windowEnd: "09:00",
|
||
label: null,
|
||
dose: null,
|
||
daysOfWeek: null,
|
||
timesOfDay: ["08:00"],
|
||
rrule: stubbed.rrule,
|
||
rollingIntervalDays: stubbed.rollingIntervalDays,
|
||
reminderGraceMinutes: null,
|
||
},
|
||
],
|
||
};
|
||
|
||
void page.route("**/api/medications**", async (route: Route) => {
|
||
const req = route.request();
|
||
const url = new URL(req.url());
|
||
if (url.pathname !== "/api/medications") {
|
||
await route.fallback();
|
||
return;
|
||
}
|
||
if (req.method() === "POST") {
|
||
try {
|
||
capture.rawPostBody = JSON.parse(req.postData() ?? "null");
|
||
} catch (err) {
|
||
capture.parseError = err instanceof Error ? err.message : String(err);
|
||
}
|
||
await route.fulfill({
|
||
status: 201,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: { ...listEntry },
|
||
error: null,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
if (req.method() === "GET") {
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: [listEntry],
|
||
error: null,
|
||
}),
|
||
});
|
||
return;
|
||
}
|
||
await route.fallback();
|
||
});
|
||
|
||
return capture;
|
||
}
|
||
|
||
export async function stubDashboardAnalytics(page: Page): Promise<void> {
|
||
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
||
error: null,
|
||
}),
|
||
}),
|
||
);
|
||
await page.route("**/api/mood/analytics", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: { entries: [], summary: { count: 0 } },
|
||
error: null,
|
||
}),
|
||
}),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Open the wizard directly on `/medications?new=1`. The redirect from the
|
||
* legacy `/medications/new` route is a separate concern; these cadence specs
|
||
* exercise the wizard without racing that server navigation.
|
||
*/
|
||
export async function openCreateWizard(page: Page): Promise<void> {
|
||
// Pin the German locale for the wizard flow. The app default locale is
|
||
// English; these specs assert the "Schritt N von M" counter and other
|
||
// German label surfaces, which only render when the `healthlog-locale`
|
||
// cookie resolves to "de". Both the server layout and the client i18n
|
||
// provider read this exact cookie name, so setting it before the first
|
||
// navigation makes the dialog render German on first paint. Scoped here
|
||
// rather than in the shared auth state so English-asserting specs keep
|
||
// the default locale.
|
||
await page.context().addCookies([
|
||
{
|
||
name: "healthlog-locale",
|
||
value: "de",
|
||
url: new URL(process.env.E2E_BASE_URL ?? "http://localhost:3000").origin,
|
||
},
|
||
]);
|
||
await page.goto("/medications?new=1", { waitUntil: "load" });
|
||
await page.waitForURL(
|
||
(url) => url.pathname === "/medications" && !url.searchParams.has("new"),
|
||
{ timeout: 10_000 },
|
||
);
|
||
await expect(
|
||
page.locator('[data-slot="medication-wizard-dialog"]'),
|
||
).toBeVisible({ timeout: 10_000 });
|
||
}
|
||
|
||
/**
|
||
* Fill the Step 1 name input. The dialog's first step asks only for
|
||
* the medication name — the dose moved to Step 3.
|
||
*/
|
||
export async function fillStep1Name(
|
||
page: Page,
|
||
{ name }: { name: string },
|
||
): Promise<void> {
|
||
await expectStep(page, 1);
|
||
await page.locator("#wizard-name").fill(name);
|
||
}
|
||
|
||
/** Pick a Step 2 treatment-class row by its row identifier. */
|
||
export async function pickTreatmentRow(
|
||
page: Page,
|
||
row:
|
||
| "bloodPressure"
|
||
| "diabetes"
|
||
| "hormone"
|
||
| "glp1"
|
||
| "painRelief"
|
||
| "allergy"
|
||
| "vitamin"
|
||
| "supplement"
|
||
| "antibiotic"
|
||
| "other",
|
||
): Promise<void> {
|
||
await page
|
||
.locator(`[data-slot="wizard-class-row"][data-row="${row}"]`)
|
||
.click();
|
||
}
|
||
|
||
/** Fill the Step 3 dose amount input. */
|
||
export async function fillStep3Dose(
|
||
page: Page,
|
||
{ amount }: { amount: string },
|
||
): Promise<void> {
|
||
await page.locator("#wizard-dose-amount").fill(amount);
|
||
}
|
||
|
||
/** Pick a Step 5 cadence row by its row identifier. */
|
||
export async function pickCadenceRow(
|
||
page: Page,
|
||
row: "daily" | "weekdays" | "everyNWeeks" | "monthly" | "rolling" | "oneShot",
|
||
): Promise<void> {
|
||
await page
|
||
.locator(`[data-slot="wizard-cadence-row"][data-row="${row}"]`)
|
||
.click();
|
||
}
|
||
|
||
/** Click Next. Pinned by data-slot. */
|
||
export async function clickNext(page: Page): Promise<void> {
|
||
await page.locator('[data-slot="wizard-next"]').click();
|
||
}
|
||
|
||
/** Click Save (last step CTA on create + edit). */
|
||
export async function clickSave(page: Page): Promise<void> {
|
||
await page.locator('[data-slot="wizard-save"]').click();
|
||
}
|
||
|
||
/**
|
||
* Assert the wizard sits on a given visible step counter.
|
||
* `displayIndex` is the visible 1-based index; `totalSteps` is the
|
||
* path length for the active mode + cadence.
|
||
*/
|
||
export async function expectStep(
|
||
page: Page,
|
||
displayIndex: number,
|
||
totalSteps?: number,
|
||
): Promise<void> {
|
||
// The "Schritt X von Y" caption is mobile-only (`sm:hidden`) since the dot
|
||
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
|
||
// viewport-independent root data-attributes the dialog always carries.
|
||
const root = page.locator('[data-slot="medication-wizard-dialog"]');
|
||
// Each step advance waits on per-step form validation; on a loaded CI runner
|
||
// that settle can exceed the default 5s expect timeout (the dialog sits at
|
||
// the prior step when the assertion first samples). Give the step-transition
|
||
// assertion room so a slow runner doesn't read a mid-transition frame.
|
||
await expect(root).toHaveAttribute(
|
||
"data-display-step",
|
||
String(displayIndex),
|
||
{
|
||
timeout: 20_000,
|
||
},
|
||
);
|
||
if (totalSteps != null) {
|
||
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verify the post-submit URL settles on `/medications/<id>` (create
|
||
* path; the dialog navigates there on success).
|
||
*/
|
||
export async function expectRedirectToMedication(
|
||
page: Page,
|
||
id: string,
|
||
): Promise<void> {
|
||
await expect
|
||
.poll(() => new URL(page.url()).pathname, { timeout: 10_000 })
|
||
.toBe(`/medications/${id}`);
|
||
}
|
||
|
||
/**
|
||
* Navigate to the list and assert the just-created medication
|
||
* appears. The recurring variants surface a "Nächste Einnahme:" chip;
|
||
* the one-shot variant does not.
|
||
*/
|
||
export async function expectMedicationOnList(
|
||
page: Page,
|
||
{ name, withNextDueChip }: { name: string; withNextDueChip: boolean },
|
||
): Promise<void> {
|
||
// Pin the clock to a fixed afternoon before rendering the list. The
|
||
// recurring stub schedules an 08:00–09:00 window; the card derives its
|
||
// header from the real clock, so when CI happens to run inside that
|
||
// window the card shows the "take now" (in_window) pill and the
|
||
// "Nächste Einnahme:" next-due line is correctly suppressed — making the
|
||
// chip assertion below depend on the wall-clock. A fixed afternoon puts
|
||
// the morning window in the past so the next-due line always renders.
|
||
await page.clock.setFixedTime(new Date("2026-06-04T13:00:00Z"));
|
||
await page.goto("/medications", { waitUntil: "domcontentloaded" });
|
||
const main = page.locator("main");
|
||
await expect(main.getByText(name).first()).toBeVisible({ timeout: 10_000 });
|
||
if (withNextDueChip) {
|
||
await expect(main.getByText(/Nächste Einnahme:/i).first()).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
}
|
||
}
|