052751b2ba
* refactor(frontend): modularize styles and polish modal/ui interactions * feat: add report workflow and timeline/settings improvements * fix: resolve CI failures for backend typing, lint, and playwright config
443 lines
14 KiB
TypeScript
443 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> {
|
|
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") {
|
|
await packageTypeSelect.selectOption("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);
|
|
} 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);
|
|
await row.getByLabel(/(Usage \(pills\)|form\.blisters\.usage)/i).fill(intakes[i].usage);
|
|
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).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 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");
|
|
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);
|
|
});
|
|
});
|
|
});
|