Files
medassist-ng/frontend/e2e/medication-crud.spec.ts
T
Daniel Volz 98939877db feat: expand Playwright E2E coverage (#155)
* feat: comprehensive Playwright E2E test rewrite

Rewrite all E2E tests with correct CSS selectors, add new spec files,
and implement robust auth handling to work within backend rate limits.

Changes:
- Rewrite fixtures/index.ts with JWT-based /auth/me mock to avoid
  10 req/min rate limit on /auth/me during test runs
- Rewrite auth.setup.ts with offline JWT validity check to reuse
  existing auth state across runs (saves login rate-limit budget)
- Rewrite auth.spec.ts (6 tests) - login page, fields, submit,
  redirect guard, invalid credentials, login/register toggle
- Rewrite dashboard.spec.ts (8 tests) - header, nav tabs,
  navigation, overview/schedules sections, days selector, redirect
- Rewrite medications.spec.ts (8 tests) - form fields, stock
  inventory, package type toggle, intake schedule, save/cancel,
  unsaved changes guard
- Rewrite settings.spec.ts (12 tests) - language, notification
  matrix, thresholds, calculation mode, toggle switch, export/import,
  user menu navigation
- Create planner.spec.ts (9 tests) - form, date inputs, calculate,
  reset, checkbox, submit, tab state, eyebrow heading
- Create schedule.spec.ts (12 tests) - timeline, days selector,
  past/future toggles, day blocks, today highlight, collapse/expand,
  overview table, share button
- Update playwright.config.ts: remove mobile projects, enable
  webServer section for CI
- Add .github/workflows/e2e.yml CI workflow for Playwright tests

Total: 57 E2E tests across 6 spec files, all passing consistently
across 5+ consecutive runs without backend restart.

Closes #154

* feat: add comprehensive E2E data tests with medication CRUD, dashboard, planner, schedule

Add 48 new Playwright E2E tests covering real medication data scenarios:
- medication-crud: 14 tests for create/edit/delete/list via UI form
- dashboard-data: 13 tests for overview table, timeline, dose tracking
- planner-data: 9 tests for demand calculator with results/status chips
- schedule-data: 11 tests for timeline, collapse/expand, dose mark/undo

Infrastructure improvements:
- Add API helpers (createMedicationViaAPI, deleteMedicationViaAPI,
  deleteAllMedicationsViaAPI) with retry logic for rate-limit resilience
- Configure chromium-data project for serial execution with retry:1
- Add /auth/me mock to avoid rate-limit exhaustion on auth endpoint
- Increase navigateTo reliability with networkidle waits
- Increase auth token validity threshold from 2 to 10 minutes
- Make backend rate limit configurable via RATE_LIMIT_MAX env var
- Set RATE_LIMIT_MAX=300 in dev docker-compose for E2E test support

Total suite: 57 empty-state + 48 data tests = 105 tests (chromium)

* test: add E2E tests for medication editing, stock status, and share schedule

- medication-edit.spec.ts: 10 tests covering generic name, notes,
  taken-by add/remove, expiry date, refill, intake schedule editing,
  adding intake rows, reminder toggle, and package type changes
- stock-status.spec.ts: 12 tests verifying dashboard shows correct
  status chips (High/Normal/Warning/Danger) for different stock levels,
  overview table, reorder card, detail modal, and planner integration
- share-schedule.spec.ts: 10 tests for taken-by badges, share button,
  share dialog, link generation, shared schedule page navigation,
  dose tracking on shared page, and notes display
- fixtures/index.ts: add createShareTokenViaAPI, updateSettingsViaAPI
  helpers; expand createMedicationViaAPI with takenBy, notes, expiryDate
- playwright.config.ts: update testMatch/testIgnore for new test files
- docker-compose.dev.yml: increase RATE_LIMIT_MAX to 1000 for E2E tests

* docs: refine release-manager instructions for CLI safety and commit-linked release notes

* fix: resolve PR155 CI failures for frontend lint and e2e proxy

* fix: stabilize auth-related e2e checks in CI
2026-02-12 20:06:11 +01:00

422 lines
14 KiB
TypeScript

import type { Page } from "@playwright/test";
import {
authFile,
createMedicationViaAPI,
deleteAllMedicationsViaAPI,
deleteMedicationViaAPI,
expect,
navigateTo,
type TestMedication,
test,
} from "./fixtures";
/**
* Medication CRUD E2E Tests
*
* Tests creating, editing, and deleting medications via the UI form.
* Each test cleans up after itself to avoid side effects.
*/
/**
* Helper: fill the medication form and save. Waits for the successful
* API response and verifies the medication appears in the list.
*/
async function fillAndSaveMedication(
page: Page,
opts: {
name: string;
genericName?: string;
packageType?: "blister" | "bottle";
packs?: string;
blistersPerPack?: string;
pillsPerBlister?: string;
loosePills?: string;
totalCapacity?: string;
currentPills?: string;
expiryDate?: string;
notes?: string;
intakes?: { usage: string; every: string }[];
}
): Promise<void> {
await page.getByLabel(/Commercial Name/i).fill(opts.name);
if (opts.genericName) {
await page.getByLabel(/Generic Name/i).fill(opts.genericName);
}
if (opts.packageType === "bottle") {
await page.locator("select.package-type-select").selectOption("bottle");
if (opts.totalCapacity) await page.getByLabel(/Total Capacity/i).fill(opts.totalCapacity);
if (opts.currentPills) await page.getByLabel(/Current Pills/i).fill(opts.currentPills);
} else {
await page.locator("select.package-type-select").selectOption("blister");
if (opts.packs) await page.getByLabel(/^Packs$/i).fill(opts.packs);
if (opts.blistersPerPack) await page.getByLabel(/Blisters per pack/i).fill(opts.blistersPerPack);
if (opts.pillsPerBlister) await page.getByLabel(/Pills per blister/i).fill(opts.pillsPerBlister);
if (opts.loosePills) await page.getByLabel(/Loose pills/i).fill(opts.loosePills);
}
if (opts.expiryDate) await page.getByLabel(/Expiry Date/i).fill(opts.expiryDate);
if (opts.notes) await page.getByLabel(/Notes/i).fill(opts.notes);
// Fill intake schedules
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
for (let i = 0; i < intakes.length; i++) {
if (i > 0) {
await page.getByRole("button", { name: /Intake/i }).click();
}
const row = page.locator(".blister-row").nth(i);
await row.getByLabel(/Usage \(pills\)/i).fill(intakes[i].usage);
await row.getByLabel(/Every \(days\)/i).fill(intakes[i].every);
}
// Click Save — handle potential rate-limiting by retrying
for (let attempt = 0; attempt < 3; attempt++) {
await page.waitForLoadState("networkidle");
await page.locator("form.form-grid button[type='submit']").click();
// Wait for the form to reset: commercial name becomes empty after successful save
try {
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("", { timeout: 10000 });
break; // Save succeeded
} catch {
if (attempt === 2) throw new Error(`Failed to save medication "${opts.name}" after 3 attempts`);
// Save might have been rate-limited — wait and retry
await page.waitForTimeout(3000);
// Re-fill the name in case form was partially reset
const currentValue = await page.getByLabel(/Commercial Name/i).inputValue();
if (!currentValue) {
await page.getByLabel(/Commercial Name/i).fill(opts.name);
}
}
}
// Verify the medication appears in the list (may need reload if GET was rate-limited)
const medRow = page.locator(".med-row").filter({ hasText: opts.name });
try {
await expect(medRow).toBeVisible({ timeout: 5000 });
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(medRow).toBeVisible({ timeout: 10000 });
}
}
/**
* Helper: save after editing (PUT) and wait for success.
*/
async function saveEdit(page: Page, medName: string): Promise<void> {
await page.waitForLoadState("networkidle");
await page.locator("form.form-grid button[type='submit']").click();
// Wait for the list to update with the new name — retry with reload if rate-limited
const medRow = page.locator(".med-row").filter({ hasText: medName });
try {
await expect(medRow).toBeVisible({ timeout: 15000 });
} catch {
await page.reload();
await page.waitForLoadState("networkidle");
await expect(medRow).toBeVisible({ timeout: 10000 });
}
}
test.describe("Medication CRUD", () => {
test.use({ storageState: authFile });
// Clean up any leftover medications before and after all tests
test.beforeAll(async () => {
await deleteAllMedicationsViaAPI();
});
test.afterAll(async () => {
await deleteAllMedicationsViaAPI();
});
test.describe("Create medication", () => {
// Clean up after each create test to avoid state leakage to later test blocks
test.afterEach(async () => {
await deleteAllMedicationsViaAPI();
});
test("should create a blister-pack medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Ibuprofen",
genericName: "Ibuprofen",
packageType: "blister",
packs: "2",
blistersPerPack: "3",
pillsPerBlister: "10",
loosePills: "5",
});
// Verify medication details in the list
const medRow = page.locator(".med-row").filter({ hasText: "Test Ibuprofen" });
await expect(medRow.locator(".med-name")).toContainText("Test Ibuprofen");
});
test("should create a bottle medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Vitamin D Drops",
packageType: "bottle",
totalCapacity: "60",
currentPills: "45",
});
});
test("should create medication with multiple intake schedules", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Multi-Intake Med",
packs: "1",
blistersPerPack: "2",
pillsPerBlister: "14",
intakes: [
{ usage: "1", every: "1" },
{ usage: "0.5", every: "7" },
],
});
});
test("should create medication with notes and expiry date", async ({ page }) => {
await navigateTo(page, "/medications");
const expiryDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split("T")[0];
await fillAndSaveMedication(page, {
name: "Test Aspirin",
packs: "1",
blistersPerPack: "1",
pillsPerBlister: "20",
expiryDate,
notes: "Take with food. Do not exceed 3 per day.",
});
});
test("should not save with empty commercial name", async ({ page }) => {
await navigateTo(page, "/medications");
// Leave name empty — save button should be disabled
const saveBtn = page.locator("form.form-grid button[type='submit']");
await expect(saveBtn).toBeDisabled();
});
test("should reset form after saving a medication", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Reset Check",
packs: "1",
blistersPerPack: "1",
pillsPerBlister: "10",
});
// Form should reset — title should say "New medication"
await expect(page.locator("h2").filter({ hasText: /New medication/i })).toBeVisible({ timeout: 3000 });
// Commercial name should be empty
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("");
});
});
test.describe("Edit medication", () => {
test.describe.configure({ timeout: 60000 });
const createdMeds: TestMedication[] = [];
test.afterEach(async () => {
for (const med of createdMeds) {
await deleteMedicationViaAPI(med.id);
}
createdMeds.length = 0;
});
test("should edit an existing medication", async ({ page }) => {
// Create prerequisite via API (faster, no rate-limit issues)
createdMeds.push(await createMedicationViaAPI({ name: "Before Edit" }));
await navigateTo(page, "/medications");
// Click Edit
const medRow = page.locator(".med-row").filter({ hasText: "Before Edit" });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click();
// Form title should say "Edit medication"
await expect(page.locator("h2").filter({ hasText: /Edit medication/i })).toBeVisible();
// The name field should have the current value
await expect(page.getByLabel(/Commercial Name/i)).toHaveValue("Before Edit");
// Change the name
await page.getByLabel(/Commercial Name/i).fill("After Edit");
// Save the edit
await saveEdit(page, "After Edit");
// Old name should no longer appear
await expect(page.locator(".med-row").filter({ hasText: "Before Edit" })).not.toBeVisible();
// Update tracked ID for cleanup
createdMeds[0].name = "After Edit";
});
test("should cancel editing and discard changes", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Cancel Test Med" }));
await navigateTo(page, "/medications");
// Click Edit
const medRow = page.locator(".med-row").filter({ hasText: "Cancel Test Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click();
// Change the name
await page.getByLabel(/Commercial Name/i).fill("Modified Name");
// Click Cancel
await page.locator("form.form-grid button.ghost").click();
// Original name should still be in the list
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).toBeVisible();
});
test("should show refill section in edit mode", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Refill Test Med" }));
await navigateTo(page, "/medications");
// Click Edit
const medRow = page.locator(".med-row").filter({ hasText: "Refill Test Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.locator("button.info").click();
// Refill section should be visible
const refillSection = page.locator(".refill-section");
await expect(refillSection).toBeVisible();
await expect(refillSection.locator("button.success")).toBeVisible();
});
});
test.describe("Delete medication", () => {
test.describe.configure({ timeout: 60000 });
const createdMeds: TestMedication[] = [];
test.afterEach(async () => {
for (const med of createdMeds) {
await deleteMedicationViaAPI(med.id);
}
createdMeds.length = 0;
});
test("should delete a medication after confirming", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Delete Me Med" }));
await navigateTo(page, "/medications");
const medRow = page.locator(".med-row").filter({ hasText: "Delete Me Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
// Accept the native confirm() dialog
page.on("dialog", (dialog) => dialog.accept());
await medRow.locator("button.danger").click();
// Medication should be removed
await expect(medRow).not.toBeVisible({ timeout: 5000 });
// Already deleted via UI — clear tracked list
createdMeds.length = 0;
});
test("should not delete when confirm dialog is dismissed", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Keep Me Med" }));
await navigateTo(page, "/medications");
const medRow = page.locator(".med-row").filter({ hasText: "Keep Me Med" });
await expect(medRow).toBeVisible({ timeout: 10000 });
// Dismiss the native confirm()
page.on("dialog", (dialog) => dialog.dismiss());
await medRow.locator("button.danger").click();
// Medication should still be there
await expect(medRow).toBeVisible();
});
});
test.describe("Medication list", () => {
test.describe.configure({ timeout: 60000 });
const createdMeds: TestMedication[] = [];
test.afterEach(async () => {
for (const med of createdMeds) {
await deleteMedicationViaAPI(med.id);
}
createdMeds.length = 0;
});
test("should display multiple medications in the list", async ({ page }) => {
createdMeds.push(await createMedicationViaAPI({ name: "Med Alpha" }));
createdMeds.push(
await createMedicationViaAPI({
name: "Med Beta",
packCount: 2,
blistersPerPack: 2,
pillsPerBlister: 14,
intakes: [
{ usage: 2, every: 1, start: new Date().toISOString().slice(0, 16), intakeRemindersEnabled: false },
],
})
);
await navigateTo(page, "/medications");
// Both medications should be in the list
await expect(page.locator(".med-row").filter({ hasText: "Med Alpha" })).toBeVisible({ timeout: 10000 });
await expect(page.locator(".med-row").filter({ hasText: "Med Beta" })).toBeVisible();
expect(await page.locator(".med-row").count()).toBeGreaterThanOrEqual(2);
});
test("should show stock details on medication row", async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: "Stock Detail Med",
packCount: 3,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 3,
})
);
await navigateTo(page, "/medications");
const medRow = page.locator(".med-row").filter({ hasText: "Stock Detail Med" });
try {
await expect(medRow).toBeVisible({ timeout: 10000 });
} catch {
// Reload in case the list didn't include the newly created med
await page.reload();
await page.waitForLoadState("networkidle");
await expect(medRow).toBeVisible({ timeout: 10000 });
}
// Should display stock details
const medDetails = medRow.locator(".med-details, .med-total");
expect(await medDetails.count()).toBeGreaterThan(0);
});
});
test.describe("Intake schedule management", () => {
test("should add and remove intake schedule rows", async ({ page }) => {
await navigateTo(page, "/medications");
expect(await page.locator(".blister-row").count()).toBe(1);
await page.getByRole("button", { name: /Intake/i }).click();
expect(await page.locator(".blister-row").count()).toBe(2);
await page.getByRole("button", { name: /Intake/i }).click();
expect(await page.locator(".blister-row").count()).toBe(3);
const removeBtn = page
.locator(".blister-row")
.last()
.getByRole("button", { name: /Remove/i });
await removeBtn.click();
expect(await page.locator(".blister-row").count()).toBe(2);
});
});
});