import { expect, test, type APIRequestContext } from "@playwright/test"; async function resetDemoData(request: APIRequestContext) { await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } }); await request.post("/api/v1/demo/reset"); } async function switchRole(page: Page) { await page.locator(".operator-menu > summary").click(); await page.getByRole("button", { name: "Switch role" }).click(); } test.describe.configure({ mode: "serial" }); // This file's assertions were authored against the English UI copy; nl-BE is now the // app's default for a fresh session, so force English explicitly rather than rewriting // every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs). test.beforeEach(async ({ page, request }) => { // Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the // browser cookie jar here can race that deletion against the UI login below. await resetDemoData(request); await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB")); await page.goto("/login"); await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); test("all manager navigation items navigate correctly", async ({ page }) => { const items: [string, RegExp][] = [ ["Overview", /\/dashboard$/], ["Fleet", /\/vehicles$/], ["Bookings", /\/bookings$/], ["Data quality", /\/data-quality$/], ["Knowledge", /\/knowledge$/], ["Integrations", /\/automation$/], ["Audit trail", /\/audit$/], ["Users", /\/users$/], ["Privacy", /\/privacy$/], ]; const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" }); for (const [label, urlPattern] of items) { await primaryNavigation.getByRole("link", { name: label, exact: true }).click(); await expect(page).toHaveURL(urlPattern); } }); test("vehicles page: status filter and attention-only checkbox both work", async ({ page }) => { await page.goto("/vehicles"); await expect(page.locator(".data-table")).toBeVisible(); await page.getByLabel("Status").selectOption("maintenance"); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const statuses = await page.locator(".data-table tbody tr td:nth-child(4)").allTextContents(); expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy(); await page.getByLabel("Status").selectOption(""); await expect(page).not.toHaveURL(/status=maintenance/); await expect(page.getByLabel("Attention only")).not.toBeChecked(); // Updating the URL replaces this controlled input. Click it, then assert against the // newly-rendered control rather than asking Playwright to verify the detached node. await page.getByLabel("Attention only").click(); await expect(page).toHaveURL(/attention_only=true/); await expect(page.getByLabel("Attention only")).toBeChecked(); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const rows = page.locator(".data-table tbody tr"); await expect(rows.locator(".attention-flag")).toHaveCount(await rows.count()); const attentionReasons = await rows.locator(".attention-flag").allTextContents(); expect(attentionReasons.every((reason) => reason.trim().length > 0)).toBeTruthy(); }); test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => { await page.goto("/vehicles"); await expect(page.locator(".data-table")).toBeVisible(); const totalRows = await page.locator(".data-table tbody tr").count(); expect(totalRows).toBeGreaterThan(1); const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search"); await searchBox.fill("MO-001"); await expect(async () => { const rows = await page.locator(".data-table tbody tr").count(); expect(rows).toBe(1); }).toPass({ timeout: 5000 }); const refs = await page.locator(".data-table tbody tr th a").allTextContents(); expect(refs).toEqual(["MO-001"]); await searchBox.fill(""); await expect(async () => { const rows = await page.locator(".data-table tbody tr").count(); expect(rows).toBe(totalRows); }).toPass({ timeout: 5000 }); }); test("vehicle detail: all tabs render distinct content", async ({ page }) => { await page.goto("/vehicles/MO-016"); await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible(); for (const tab of ["Overview", "Bookings", "Inspections", "Maintenance", "Quality"]) { await page.getByRole("tab", { name: tab }).click(); await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true"); } }); test("bookings page: status filter works", async ({ page }) => { await page.goto("/bookings"); await expect(page.locator(".data-table")).toBeVisible(); await page.getByLabel("Status").selectOption("returned"); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const statuses = await page.locator(".data-table tbody tr td:nth-child(5)").allTextContents(); expect(statuses.every((s) => s.includes("returned"))).toBeTruthy(); }); test("bookings page: pagination renders at most 25 rows and page 2 differs from page 1", async ({ page, }) => { await page.goto("/bookings"); await expect(page.locator(".data-table")).toBeVisible(); const page1Count = await page.locator(".data-table tbody tr").count(); expect(page1Count).toBeLessThanOrEqual(25); const page1Refs = await page.locator(".data-table tbody tr th a").allTextContents(); const nextButton = page.getByRole("button", { name: "Next" }); await expect(nextButton).toBeEnabled(); await nextButton.click(); await expect(async () => { const page2Refs = await page.locator(".data-table tbody tr th a").allTextContents(); expect(page2Refs.length).toBeGreaterThan(0); expect(page2Refs).not.toEqual(page1Refs); }).toPass({ timeout: 5000 }); const page2Count = await page.locator(".data-table tbody tr").count(); expect(page2Count).toBeLessThanOrEqual(25); const prevButton = page.getByRole("button", { name: "Previous" }); await expect(prevButton).toBeEnabled(); }); test("return preview correctly reports blocked (not maintenance) for damage reported", async ({ page, request, }) => { await resetDemoData(request); await page.goto("/bookings/BK-DEMO-RETURN"); await page.getByLabel("End odometer (km)").fill("55000"); await page.getByLabel("Fuel level (%)").fill("40"); await page.getByRole("checkbox", { name: "Damage reported" }).check(); await page.getByRole("button", { name: "Review return" }).click(); // The preview is the server's authoritative evaluation: damage always routes to // "blocked", never "maintenance" -- this used to be guessed client-side and wrong. // The localized reason is the primary text; the raw code sits behind "Technical // details" so it isn't visible until expanded. await expect(page.getByText("Damage was reported on this return.")).toBeVisible(); const statusRegion = page.locator(".impact-preview"); await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible(); await page.getByText("Technical details").click(); await expect(page.getByText("Damage was reported on return.")).toBeVisible(); }); test("data quality page: status and rule-type filters work", async ({ page }) => { await page.goto("/data-quality"); await expect(page.locator(".data-table")).toBeVisible(); await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption( "possible_duplicate_customer", ); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents(); expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy(); await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(""); await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved"); await expect(page.locator(".data-table tbody tr").first()).toBeVisible(); }); test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality?status=open&rule_type=missing_required_field"); await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue( "missing_required_field", ); const firstLink = page.locator(".data-table tbody tr").first().locator("a"); await expect(firstLink).toBeVisible(); const ref = (await firstLink.textContent())?.trim() ?? ""; expect(ref).not.toBe(""); await firstLink.click(); await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`)); await expect(page.getByText(ref, { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Defer" }).click(); await expect(page.locator(".badge.status-deferred")).toBeVisible(); }); test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-ATTENTION"); await expect(page.getByText("DQ-DEMO-ATTENTION", { exact: true })).toBeVisible(); await page.getByLabel("Registration number").fill("TST-777"); await page.getByLabel("Make").fill("TestMake"); await page.getByLabel("Model").fill("TestModel"); await page.getByLabel("Location").fill("Depot"); await page.getByRole("button", { name: "Save and re-check" }).click(); await expect(page.getByText("Resolved").first()).toBeVisible(); }); test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-OVERLAP"); await expect(page.getByText("DQ-DEMO-OVERLAP", { exact: true })).toBeVisible(); await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check(); await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click(); await expect(page.getByText("Resolved").first()).toBeVisible(); await expect .poll(async () => { const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A"); return (await booking.json()).status; }) .toBe("blocked"); }); test("data quality: applying the recommended status resolves a vehicle conflict", async ({ page, request, }) => { await resetDemoData(request); await page.goto("/data-quality/DQ-DEMO-STATUS"); await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible(); await page.getByRole("button", { name: "Review recommendation" }).click(); await page.getByRole("button", { name: /^Change status to/ }).click(); await expect(page.getByText("Applied", { exact: false })).toBeVisible(); }); test("data quality: retaining canonical resolves an odometer regression issue", async ({ page, request, }) => { await resetDemoData(request); const issues = await ( await page.request.get("/api/v1/data-quality/issues", { params: { rule_type: "odometer_regression", status: "open" }, }) ).json(); const target = issues[0]; await page.goto(`/data-quality/${target.public_ref}`); await expect(page.getByText(target.public_ref, { exact: true })).toBeVisible(); await page.getByRole("radio", { name: /Retain canonical/ }).check(); await page.getByRole("button", { name: "Resolve issue" }).click(); await expect(page.getByText("Resolved").first()).toBeVisible(); }); test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => { await resetDemoData(request); await page.goto("/data-quality"); await page.getByRole("button", { name: "Run quality scan" }).click(); await page.getByRole("button", { name: "Yes, run scan" }).click(); await expect(page.getByText(/Scan complete/)).toBeVisible(); }); test("automation page: status filter and retry button work", async ({ page, request }) => { await resetDemoData(request); await page.goto("/automation"); const jobsTable = page.getByRole("table", { name: "Automation jobs" }); await expect(jobsTable).toBeVisible(); await page.getByLabel("Status").selectOption("failed"); await expect(jobsTable.locator("tbody tr").first()).toBeVisible(); const failedRowCountBefore = await jobsTable.locator("tbody tr").count(); const retryButton = page.getByRole("button", { name: "Retry" }).first(); await expect(retryButton).toBeVisible(); await retryButton.click(); // Retrying a failed delivery moves it out of the "failed" status, so — while still // filtered to "failed" — the row correctly disappears from this view rather than // showing "pending" in place. Confirm the filtered list shrank by one. await expect(async () => { const count = await jobsTable.locator("tbody tr").count(); expect(count).toBe(failedRowCountBefore - 1); }).toPass({ timeout: 5000 }); }); test("audit page: action filter works", async ({ page }) => { await page.goto("/audit"); await expect(page.locator(".audit-group-list")).toBeVisible(); await page.getByLabel("Action").fill("demo_login"); await expect(page.locator(".audit-group").first()).toBeVisible(); const headings = await page.locator(".audit-group-heading strong").allTextContents(); expect(headings.every((a) => a === "Logged in")).toBeTruthy(); }); test("audit page: shows human-readable before/after and a safe entity link", async ({ page, request, }) => { await resetDemoData(request); // demo/reset deletes the acting session's own cookie, so submit the return through // page.request instead -- it shares the browser context's still-valid OM session from // beforeEach rather than the now-logged-out standalone `request` fixture. const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", { data: { end_odometer_km: 60000, fuel_level_percent: 55, cleanliness_ok: true, damage_reported: false, technical_warning: false, }, headers: { "Idempotency-Key": "e2e-audit-before-after-check" }, }); expect(submitted.ok()).toBeTruthy(); await page.goto("/audit"); await page.getByLabel("Action").fill("return_registered"); const firstGroup = page.locator(".audit-group").first(); await expect(firstGroup).toBeVisible(); const changeDiff = firstGroup.locator(".change-diff"); await expect(changeDiff).toContainText(/status/i); await expect(changeDiff).toContainText("returned"); await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/); }); test("knowledge page: form submits and clears input", async ({ page }) => { await page.goto("/knowledge"); const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/); await input.fill("What must I do when a vehicle returns with damage?"); await page.getByRole("button", { name: "Ask" }).click(); await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 }); await expect(input).toHaveValue(""); }); test("switch role button logs out and returns to login", async ({ page }) => { await switchRole(page); await expect(page).toHaveURL(/\/login$/); }); test("session survives a page refresh and restores the correct role", async ({ page }) => { await page.goto("/vehicles"); await page.reload(); await expect(page).toHaveURL(/\/vehicles$/); await expect(page.getByText("Operations manager")).toBeVisible(); await expect(page.locator(".data-table")).toBeVisible(); }); test("logout invalidates the server session so a refresh returns to login", async ({ page }) => { await page.goto("/dashboard"); await switchRole(page); await expect(page).toHaveURL(/\/login$/); // Directly re-requesting a protected route after logout must not restore access from a // stale client cache; the server-side cookie is gone. await page.goto("/dashboard"); await expect(page).toHaveURL(/\/login$/); }); test("direct navigation to a protected route without a session redirects to login", async ({ page, context, }) => { await context.clearCookies(); await page.goto("/vehicles"); await expect(page).toHaveURL(/\/login$/); }); test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({ page, }) => { await switchRole(page); await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // Manager-only nav items are not shown at all, not merely disabled. await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0); // Direct URL navigation is still blocked server-side and shows the same restricted // message as a defense-in-depth measure, not just a hidden button. await page.goto("/automation"); await expect( page.getByText("Automation is visible to Operations Managers only.").first(), ).toBeVisible(); await page.goto("/data-quality"); await expect( page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(), ).toBeVisible(); await page.goto("/audit"); await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible(); await page.goto("/privacy"); await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible(); await page.goto("/users"); await expect(page.getByText("User administration is available to Operations Managers only.").first()).toBeVisible(); await expect(page.getByRole("button", { name: "Add user" })).toHaveCount(0); await page.goto("/vehicles/MO-024"); await expect(page.getByRole("tab", { name: "Quality" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0); }); test("rental employee return result exposes operational follow-up but no manager-only evidence links", async ({ page, }) => { await switchRole(page); await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/bookings/BK-DEMO-RETURN"); await page.getByLabel("End odometer (km)").fill("54000"); await page.getByLabel("Fuel level (%)").fill("75"); await page.getByRole("button", { name: "Review return" }).click(); await page.getByRole("button", { name: "Confirm return" }).click(); await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible(); await expect(page.getByRole("link", { name: /View vehicle MO-024/ })).toBeVisible(); await expect(page.getByRole("link", { name: "View automation status" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Trace the complete operation" })).toHaveCount(0); }); test("operations manager can reset demo data and is returned to login", async ({ page }) => { await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible(); await page.getByRole("button", { name: "Reset demo data" }).click(); await page.getByRole("button", { name: "Yes, reset" }).click(); await expect(page).toHaveURL(/\/login$/); // The reset must not have affected the ability to log back in against fresh data. await page.getByRole("button", { name: "Explore as Operations Manager" }).click(); await expect(page).toHaveURL(/\/dashboard$/); }); test("automation page shows the aggregate n8n integration status, not just the latest event", async ({ page, request, }) => { await resetDemoData(request); await page.goto("/automation"); await expect(page.locator(".integration-cards")).toContainText("succeeded"); await expect(page.locator(".integration-cards")).toContainText("failed"); }); test("rental employee direct API access to manager-only endpoints is rejected", async ({ page, }) => { await switchRole(page); await page.getByRole("button", { name: "Explore as Rental Employee" }).click(); await expect(page).toHaveURL(/\/dashboard$/); // page.request shares the browser context's cookies, and (via the web container's // nginx /api/ proxy) works identically against localhost and the deployed server -- // the backend API itself is never exposed directly on either. for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) { const response = await page.request.get(path); expect(response.status(), path).toBe(403); } });