Add Playwright coverage for the fixes in this batch: vehicle search actually changes the rendered rows, booking pagination stays within 25 rows and page 2 differs from page 1, session survives a refresh, logout invalidates the server session, direct navigation without a session redirects to login, and Rental Employee is blocked from manager-only pages both in the UI (hidden nav, restricted message) and directly against the API (403).
268 lines
11 KiB
TypeScript
268 lines
11 KiB
TypeScript
import { expect, test, type APIRequestContext } from "@playwright/test";
|
|
|
|
async function resetDemoData(request: APIRequestContext) {
|
|
await request.post("http://localhost:8128/api/v1/demo/login", {
|
|
data: { role: "operations_manager" },
|
|
});
|
|
await request.post("http://localhost:8128/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,
|
|
request,
|
|
}) => {
|
|
await page.getByRole("button", { name: "Switch role" }).click();
|
|
await page.getByRole("button", { name: "Open as Rental Employee" }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
|
|
const cookies = await page.context().cookies();
|
|
const sessionCookie = cookies.find((c) => c.name === "mobilityops_session");
|
|
const cookieHeader = sessionCookie ? `${sessionCookie.name}=${sessionCookie.value}` : "";
|
|
|
|
for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) {
|
|
const response = await request.get(`http://localhost:8128${path}`, {
|
|
headers: { Cookie: cookieHeader },
|
|
});
|
|
expect(response.status(), path).toBe(403);
|
|
}
|
|
});
|