Files
MobilityOps/frontend/e2e/interactive-elements.spec.ts
T
NuklearRabbit bdc58f396e fix(e2e): stop hardcoding localhost:8128 for API resets
demo.spec.ts, ui-redesign.spec.ts and interactive-elements.spec.ts all
hardcoded an absolute http://localhost:8128 base for their demo-reset
helpers, which silently pointed at the local dev API even when the suite
was pointed at a different target via MOBILITYOPS_PUBLIC_URL -- discovered
while running the suite against the actual Unraid deployment, where the
reset call kept hitting the local machine instead of the server and left
BK-DEMO-RETURN in whatever state a prior run had left it. Use relative
paths so the request fixture's configured baseURL is honoured everywhere.
2026-08-02 04:59:02 +02:00

262 lines
11 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("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("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("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);
}
});