Files
MobilityOps/frontend/e2e/interactive-elements.spec.ts
T
NuklearRabbit 29325b6c27
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
M29: polish recruiter engineering story
2026-08-10 18:13:18 +02:00

443 lines
19 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 expect(page.getByRole("button", { name: "Reset demo data" })).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);
}
});