Files
medassist-ng/frontend/e2e/medication-crud.spec.ts
T
Daniel Volz 2f5fc2d9e9 fix: stabilize medication Playwright gate
* fix: stabilize medication Playwright gate

* fix: satisfy medication Playwright frontend gate
2026-05-15 20:20:18 +02:00

516 lines
17 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" | "tube" | "liquid_container" | "inhaler" | "injection";
packs?: string;
blistersPerPack?: string;
pillsPerBlister?: string;
loosePills?: string;
totalCapacity?: string;
currentPills?: string;
expiryDate?: string;
notes?: string;
intakes?: { usage: string; every: string }[];
}
): Promise<void> {
const openCreateBtn = page.getByRole("button", { name: /New medication|New entry|form\.newEntry/i }).first();
if (await openCreateBtn.isVisible().catch(() => false)) {
await openCreateBtn.click();
}
const form = page.locator("form.form-grid:visible").first();
await expect(form.getByLabel(/(Commercial Name|form\.commercialName)/i)).toBeVisible({ timeout: 10000 });
await form.getByLabel(/(Commercial Name|form\.commercialName)/i).fill(opts.name);
if (opts.genericName) {
await form.getByLabel(/(Generic Name|form\.genericName)/i).fill(opts.genericName);
}
const packageTypeSelect = form.locator("select.package-type-select");
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\)|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();
if (opts.totalCapacity) {
await form.getByLabel(/(Amount per tube|form\.packageAmountPerTube)/i).fill(opts.totalCapacity);
}
} else if (opts.packageType === "liquid_container") {
await packageTypeSelect.selectOption("liquid_container");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.totalCapacity) {
await form.getByLabel(/(Package amount|form\.packageAmount)/i).fill(opts.totalCapacity);
}
} else {
await packageTypeSelect.selectOption("blister");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.packs) await form.getByLabel(/(^Packs$|form\.packs)/i).fill(opts.packs);
if (opts.blistersPerPack)
await form.getByLabel(/(Blisters per pack|form\.blistersPerPack)/i).fill(opts.blistersPerPack);
if (opts.pillsPerBlister)
await form.getByLabel(/(Pills per blister|form\.pillsPerBlister)/i).fill(opts.pillsPerBlister);
if (opts.loosePills) {
const looseField = form.getByLabel(/(Loose pills|form\.loosePills)/i);
if (await looseField.isVisible().catch(() => false)) {
await looseField.fill(opts.loosePills);
}
}
}
if (opts.expiryDate) await form.getByLabel(/(Expiry Date|form\.expiryDate)/i).fill(opts.expiryDate);
if (opts.notes) await form.getByLabel(/(Notes|form\.notes)/i).fill(opts.notes);
// Fill intake schedules
const intakes = opts.intakes ?? [{ usage: "1", every: "1" }];
await page.getByRole("tab", { name: /Schedule/i }).click();
for (let i = 0; i < intakes.length; i++) {
if (i > 0) {
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
}
const row = form.locator(".blister-row").nth(i);
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");
await form.locator("button[type='submit']").click();
// 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> {
const form = page.locator("form.form-grid:visible").first();
await page.waitForLoadState("networkidle");
const submitBtn = form.locator("button[type='submit']");
if (
(await submitBtn.count()) > 0 &&
(await submitBtn
.first()
.isVisible()
.catch(() => false))
) {
await submitBtn.first().click();
} else {
const closeBtn = form.getByRole("button", { name: /Close|Cancel/i }).first();
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.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 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");
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 a tube medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Tube Cream",
packageType: "tube",
totalCapacity: "50",
});
});
test("should create a liquid-container medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Liquid Syrup",
packageType: "liquid_container",
totalCapacity: "120",
});
});
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");
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.first()
.click();
// Saving without name should not create a medication row.
const saveBtn = page.locator("form.form-grid button[type='submit']");
await expect(saveBtn).toBeVisible();
await saveBtn.click();
await expect(page.locator(".med-row")).toHaveCount(0);
});
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",
});
// Opening a fresh form after save should start with an empty commercial name.
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.first()
.click();
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/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 entry" (or legacy "Edit medication").
await expect(
page.locator("h2").filter({ hasText: /(Edit(:| (entry|medication))|form\.editEntry)/i })
).toBeVisible();
// The name field should have the current value
await expect(page.getByLabel(/(Commercial Name|form\.commercialName)/i)).toHaveValue("Before Edit");
// Change the name
await page.getByLabel(/(Commercial Name|form\.commercialName)/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|form\.commercialName)/i).fill("Modified Name");
// Click Cancel
await page
.getByRole("button", { name: /Close|Cancel/i })
.first()
.click();
// Original name should still be in the list
await expect(page.locator(".med-row").filter({ hasText: "Cancel Test Med" })).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 });
await medRow.locator("button.danger").click();
await page
.locator(".confirm-modal-overlay, .modal-overlay")
.getByRole("button", { name: /Delete/i })
.click();
// Medication should be removed
await expect(medRow).toHaveCount(0, { timeout: 10000 });
// 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");
await page
.getByRole("button", { name: /New medication|New entry|form\.newEntry/i })
.first()
.click();
await page.getByRole("tab", { name: /Schedule/i }).click();
const form = page.locator("form.form-grid:visible").first();
expect(await form.locator(".blister-row").count()).toBe(1);
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await form.locator(".blister-row").count()).toBe(2);
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
expect(await form.locator(".blister-row").count()).toBe(3);
const removeBtn = page
.locator("form.form-grid:visible .blister-row")
.last()
.getByRole("button", { name: /Remove/i });
await removeBtn.click();
expect(await form.locator(".blister-row").count()).toBe(2);
});
});
});