From 2f5fc2d9e9837ef2cc0252728c8bd17fea430014 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Fri, 15 May 2026 20:20:18 +0200 Subject: [PATCH] fix: stabilize medication Playwright gate * fix: stabilize medication Playwright gate * fix: satisfy medication Playwright frontend gate --- frontend/e2e/auth.setup.ts | 77 +++++++++++--- frontend/e2e/fixtures/index.ts | 9 +- frontend/e2e/medication-crud.spec.ts | 59 +++++++++-- frontend/e2e/medication-edit.spec.ts | 151 ++++++++++++++++++++++++--- 4 files changed, 256 insertions(+), 40 deletions(-) diff --git a/frontend/e2e/auth.setup.ts b/frontend/e2e/auth.setup.ts index 5d65c33..33c0015 100644 --- a/frontend/e2e/auth.setup.ts +++ b/frontend/e2e/auth.setup.ts @@ -1,10 +1,35 @@ import * as fs from "node:fs"; import * as path from "node:path"; -import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test"; +import { type APIResponse, expect, type Page, test as setup } from "@playwright/test"; import { applyVideoSafetyMode, TEST_USER } from "./fixtures"; const authFile = path.join(import.meta.dirname, ".auth", "user.json"); +type StoredAuthCookie = { + name: string; + value: string; + domain: string; + path: string; + expires: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Strict" | "Lax" | "None"; +}; + +type BrowserCookie = { + name: string; + value: string; + url: string; + expires?: number; + httpOnly: boolean; + secure: boolean; + sameSite: "Strict" | "Lax" | "None"; +}; + +type StoredAuthState = { + cookies?: StoredAuthCookie[]; +}; + /** * Check if a JWT token is still valid (not expired) without making a * network request. Returns `true` when the token has at least 2 minutes @@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean { } } -function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null { +function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null { const segments = setCookieHeader .split(";") .map((segment) => segment.trim()) @@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul return null; } - const cookie: Cookie = { + const cookie: BrowserCookie = { name: nameValue.slice(0, separatorIndex), value: nameValue.slice(separatorIndex + 1), url: baseURL, @@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul return cookie; } -async function syncResponseCookiesToBrowserContext( - page: Parameters[0]>[0]["page"], - baseURL: string, - response: APIResponse -): Promise { +async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise { const cookies = response .headersArray() .filter((header) => header.name.toLowerCase() === "set-cookie") .map((header) => toBrowserCookie(header.value, baseURL)) - .filter((cookie): cookie is Cookie => cookie !== null); + .filter((cookie): cookie is BrowserCookie => cookie !== null); if (cookies.length > 0) { await page.context().addCookies(cookies); @@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext( setup("authenticate", async ({ page }) => { setup.setTimeout(120000); await applyVideoSafetyMode(page); + const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; // Create .auth directory if it doesn't exist const authDir = path.dirname(authFile); @@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => { // ---- 1. Try to reuse an existing auth file (offline check only) ---- if (fs.existsSync(authFile)) { try { - const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")); + const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState; const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token"); + const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token"); + + if (saved.cookies?.length) { + await page.context().addCookies(saved.cookies); + } + if (accessCookie?.value && isTokenValid(accessCookie.value)) { - // Keep going and verify the session online. A JWT can be time-valid but - // still rejected by backend token rotation/restart. + const hasSavedSession = await page.request + .get(`${baseURL}/api/auth/me`) + .then((response) => response.ok()) + .catch(() => false); + + if (hasSavedSession) { + await page.context().storageState({ path: authFile }); + return; + } + } + + if (refreshCookie?.value) { + const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null); + if (refreshResponse?.ok()) { + await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse); + + const refreshedSession = await page.request + .get(`${baseURL}/api/auth/me`) + .then((response) => response.ok()) + .catch(() => false); + + if (refreshedSession) { + await page.context().storageState({ path: authFile }); + return; + } + } } } catch { // Invalid file — fall through to regular login @@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => { // ---- 2. Fast path: already authenticated session ---- await page.goto("/"); - const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; let authEnabled = true; let formLoginEnabled = true; let oidcEnabled = false; diff --git a/frontend/e2e/fixtures/index.ts b/frontend/e2e/fixtures/index.ts index a776ded..c7d0926 100644 --- a/frontend/e2e/fixtures/index.ts +++ b/frontend/e2e/fixtures/index.ts @@ -303,7 +303,7 @@ export async function createMedicationViaAPI(data: { takenBy?: string[]; notes?: string; expiryDate?: string; - packageType?: "blister" | "bottle" | "tube" | "liquid_container"; + packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection"; medicationForm?: "capsule" | "tablet" | "liquid" | "topical"; packCount?: number; blistersPerPack?: number; @@ -323,7 +323,12 @@ export async function createMedicationViaAPI(data: { let token = await ensureAuthCookie(); const apiBase = await getRuntimeApiBase(); const packageType = data.packageType ?? "blister"; - const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"; + const isAmountBased = + packageType === "bottle" || + packageType === "tube" || + packageType === "liquid_container" || + packageType === "inhaler" || + packageType === "injection"; let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet"; if (packageType === "tube") { defaultMedicationForm = "topical"; diff --git a/frontend/e2e/medication-crud.spec.ts b/frontend/e2e/medication-crud.spec.ts index 4f04c0e..6f59de6 100644 --- a/frontend/e2e/medication-crud.spec.ts +++ b/frontend/e2e/medication-crud.spec.ts @@ -26,7 +26,7 @@ async function fillAndSaveMedication( opts: { name: string; genericName?: string; - packageType?: "blister" | "bottle" | "tube" | "liquid_container"; + packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection"; packs?: string; blistersPerPack?: string; pillsPerBlister?: string; @@ -50,12 +50,17 @@ async function fillAndSaveMedication( } const packageTypeSelect = form.locator("select.package-type-select"); - if (opts.packageType === "bottle") { - await packageTypeSelect.selectOption("bottle"); + if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") { + await packageTypeSelect.selectOption(opts.packageType ?? "bottle"); await page.getByRole("tab", { name: /Package/i }).click(); if (opts.totalCapacity) - await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity); - if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills); + await form + .getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i) + .fill(opts.totalCapacity); + if (opts.currentPills) + await form + .getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i) + .fill(opts.currentPills); } else if (opts.packageType === "tube") { await packageTypeSelect.selectOption("tube"); await page.getByRole("tab", { name: /Package/i }).click(); @@ -95,12 +100,12 @@ async function fillAndSaveMedication( await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click(); } const row = form.locator(".blister-row").nth(i); - await row - .getByLabel( - /(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i - ) - .fill(intakes[i].usage); - await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every); + const usageField = row.getByRole("textbox", { + name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i, + }); + const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i); + await usageField.fill(intakes[i].usage); + await everyField.fill(intakes[i].every); } await page.waitForLoadState("networkidle"); @@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => { }); }); + test("should create an inhaler medication via the form", async ({ page }) => { + await navigateTo(page, "/medications"); + + await fillAndSaveMedication(page, { + name: "Test Rescue Inhaler", + packageType: "inhaler", + totalCapacity: "200", + currentPills: "120", + intakes: [{ usage: "2", every: "1" }], + }); + + const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" }); + await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i); + await expect(medRow.locator(".med-total")).toContainText("120 / 200"); + }); + + test("should create an injection medication via the form", async ({ page }) => { + await navigateTo(page, "/medications"); + + await fillAndSaveMedication(page, { + name: "Test Weekly Injection", + packageType: "injection", + totalCapacity: "12", + currentPills: "4", + intakes: [{ usage: "1", every: "7" }], + }); + + const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" }); + await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i); + await expect(medRow.locator(".med-total")).toContainText("4 / 12"); + }); + test("should create medication with multiple intake schedules", async ({ page }) => { await navigateTo(page, "/medications"); diff --git a/frontend/e2e/medication-edit.spec.ts b/frontend/e2e/medication-edit.spec.ts index 786cadf..00b9d46 100644 --- a/frontend/e2e/medication-edit.spec.ts +++ b/frontend/e2e/medication-edit.spec.ts @@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise { }); } +async function openMedicationDetailFromDashboard(page: Page, medName: string) { + const overviewTable = page.locator(".dashboard-overview-section .table").first(); + for (let attempt = 0; attempt < 3; attempt++) { + try { + await expect(overviewTable).toBeVisible({ timeout: 10000 }); + const medRow = overviewTable.locator(".table-row").filter({ hasText: medName }); + await expect(medRow).toBeVisible({ timeout: 10000 }); + await medRow.click(); + const modal = page.locator(".modal-content.med-detail-modal"); + await expect(modal).toBeVisible({ timeout: 5000 }); + await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 }); + return modal; + } catch { + if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`); + await page.reload(); + await page.waitForLoadState("networkidle"); + } + } + + throw new Error(`Failed to open dashboard medication detail for ${medName}`); +} + /** Helper: save edit and verify success */ async function saveEditAndVerify(page: Page, medName: string): Promise { const form = page.locator("form.form-grid:visible").first(); @@ -310,24 +332,107 @@ test.describe("Medication Editing", () => { // Find the remind checkbox in the intake row const intakeRow = page.locator(".blister-row").first(); - const remindCheckbox = intakeRow.locator('input[type="checkbox"]'); + const remindToggle = intakeRow.locator(".toggle-switch"); + const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]'); - if (await remindCheckbox.isVisible().catch(() => false)) { - // Should be unchecked initially + await expect(remindCheckbox).not.toBeChecked(); + await remindToggle.click(); + await expect(remindCheckbox).toBeChecked(); + + await saveEditAndVerify(page, "Reminder Toggle Med"); + + // Verify reminder was saved + await clickEditMed(page, "Reminder Toggle Med"); + const savedCheckbox = page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]'); + await expect(savedCheckbox).toBeChecked(); + }); + + for (const scenario of [ + { + name: "Inhaler Reminder Refill Med", + packageType: "inhaler" as const, + totalCapacity: 200, + currentStock: 120, + refillAmount: 30, + expectedStock: 150, + unitLabel: /puffs?|common\.puffs?/i, + }, + { + name: "Injection Reminder Refill Med", + packageType: "injection" as const, + totalCapacity: 12, + currentStock: 4, + refillAmount: 3, + expectedStock: 7, + unitLabel: /injections?|common\.injections?/i, + }, + ]) { + test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => { + createdMeds.push( + await createMedicationViaAPI({ + name: scenario.name, + packageType: scenario.packageType, + totalPills: scenario.totalCapacity, + looseTablets: scenario.currentStock, + intakes: [ + { + usage: 1, + every: 1, + start: new Date().toISOString().slice(0, 16), + intakeRemindersEnabled: false, + }, + ], + }) + ); + + await navigateTo(page, "/medications"); + await clickEditMed(page, scenario.name); + await page.getByRole("tab", { name: /Schedule/i }).click(); + + const intakeRow = page.locator(".blister-row").first(); + const remindToggle = intakeRow.locator(".toggle-switch"); + const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]'); await expect(remindCheckbox).not.toBeChecked(); - - // Enable it - await remindCheckbox.check(); + await remindToggle.click(); await expect(remindCheckbox).toBeChecked(); - await saveEditAndVerify(page, "Reminder Toggle Med"); + await saveEditAndVerify(page, scenario.name); - // Verify reminder was saved - await clickEditMed(page, "Reminder Toggle Med"); - const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]'); - await expect(savedCheckbox).toBeChecked(); - } - }); + await clickEditMed(page, scenario.name); + await page.getByRole("tab", { name: /Schedule/i }).click(); + await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked(); + + await navigateTo(page, "/dashboard"); + const modal = await openMedicationDetailFromDashboard(page, scenario.name); + + await modal.getByRole("button", { name: /Refill|refill\.button/i }).click(); + const refillModal = page.locator(".modal-content.refill-modal"); + await expect(refillModal).toBeVisible({ timeout: 5000 }); + const refillInput = refillModal.locator('input[type="number"]').first(); + await refillInput.fill(String(scenario.refillAmount)); + await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`); + await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel); + + await refillModal.locator(".modal-footer .success").click(); + await expect(refillModal).not.toBeVisible({ timeout: 10000 }); + + const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({ + hasText: /Refill History|refill\.history/i, + }); + await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 }); + await refillHistoryHeader.click(); + const refillAmount = modal.locator(".refill-history-item .refill-amount").first(); + await expect(refillAmount).toContainText(`+${scenario.refillAmount}`); + await expect(refillAmount).toContainText(scenario.unitLabel); + + await page.locator("button.modal-close").click(); + await expect(modal).not.toBeVisible({ timeout: 5000 }); + + await navigateTo(page, "/medications"); + const medRow = page.locator(".med-row").filter({ hasText: scenario.name }); + await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`); + }); + } test("should change package type across all supported profiles", async ({ page }) => { createdMeds.push( @@ -369,12 +474,30 @@ test.describe("Medication Editing", () => { await packageSelect.selectOption("liquid_container"); await page.getByRole("tab", { name: /Package/i }).click(); await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible(); + await page.getByRole("tab", { name: /General/i }).click(); + + // Switch to inhaler + await packageSelect.selectOption("inhaler"); + await page.getByRole("tab", { name: /Package/i }).click(); + await expect( + form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i) + ).toBeVisible(); + await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible(); + await page.getByRole("tab", { name: /General/i }).click(); + + // Switch to injection and persist this final state + await packageSelect.selectOption("injection"); + await page.getByRole("tab", { name: /Package/i }).click(); + await expect( + form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i) + ).toBeVisible(); + await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible(); await saveEditAndVerify(page, "PackType Change Med"); // Verify final package type persisted await clickEditMed(page, "PackType Change Med"); - await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container"); + await expect(page.locator("select.package-type-select")).toHaveValue("injection"); }); test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {