469 lines
20 KiB
TypeScript
469 lines
20 KiB
TypeScript
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);
|
|
}
|
|
});
|