Files
MobilityOps/frontend/e2e/interactive-elements.spec.ts
T
NuklearRabbit 477b5e7ce9 feat(quality): add resolution UI for all five rule types and manual scan
DataQualityIssueDetail showed raw JSON as the primary interface for four of
five rule types, with no resolution surface beyond generic defer/reject.
Add a bounded panel per rule type (provide missing fields, retain/correct
an odometer reading, block one of two overlapping bookings, apply the
recommended vehicle status) wired to the new backend endpoints, and move
raw evidence behind a <details> disclosure. Add a "Run quality scan" action
to the workbench (confirmation, progress, per-rule result counts, auto
refresh) -- the endpoint already existed but had no UI trigger.
2026-08-02 06:16:14 +02:00

382 lines
16 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");
}
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Open as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("all seven nav 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$/],
];
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 page.getByLabel("Attention only").check();
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const attentionCells = await page.locator(".data-table tbody tr td:nth-child(6)").allTextContents();
expect(attentionCells.every((c) => c.includes("Needs attention"))).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: "Filter vehicles" }).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.
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
const statusRegion = page.locator(".impact-preview");
await expect(statusRegion.getByText("blocked", { exact: true })).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 page.goto("/data-quality");
await page
.getByRole("combobox", { name: "Rule type", exact: true })
.selectOption("missing_required_field");
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
const ref = await firstLink.textContent();
await firstLink.click();
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
await page.getByRole("button", { name: "Defer" }).click();
await expect(page.getByText("deferred", { exact: true })).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.getByRole("heading", { name: "DQ-DEMO-ATTENTION" })).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", { exact: true })).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.getByRole("heading", { name: "DQ-DEMO-OVERLAP" })).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", { exact: true })).toBeVisible();
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
expect((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.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
await page.getByRole("button", { name: "Calculate and apply recommended status" }).click();
await page.getByRole("button", { name: "Yes, apply" }).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.getByRole("heading", { name: target.public_ref })).toBeVisible();
await page.getByRole("radio", { name: /Retain canonical/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click();
await expect(page.getByText("resolved", { exact: true })).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");
await expect(page.locator(".data-table")).toBeVisible();
await page.getByLabel("Status").selectOption("failed");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const failedRowCountBefore = await page.locator(".data-table 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 page.locator(".data-table 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(".data-table")).toBeVisible();
await page.getByLabel("Action").fill("demo_login");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents();
expect(actions.every((a) => a.includes("demo login"))).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");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4);
await expect(changeCell).toContainText("status");
await expect(changeCell).toContainText("returned");
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3);
await expect(entityCell.locator("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();
await expect(input).toHaveValue("");
});
test("switch role button logs out and returns to login", async ({ page }) => {
await page.getByRole("button", { name: "Switch role" }).click();
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 page.getByRole("button", { name: "Switch role" }).click();
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 page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Open 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);
// 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 delivery status is visible to Operations Managers only."),
).toBeVisible();
await page.goto("/data-quality");
await expect(
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."),
).toBeVisible();
await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible();
});
test("rental employee direct API access to manager-only endpoints is rejected", async ({
page,
}) => {
await page.getByRole("button", { name: "Switch role" }).click();
await page.getByRole("button", { name: "Open 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);
}
});