Files
pleno-vue/tests/e2e/limited-backoffice.spec.ts
T

1251 lines
45 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { API_HOST, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const limitedManagerPermissions = [
"user",
"limited_backoffice_access",
"limited_backoffice_prices_manage",
"limited_backoffice_customer_pricing_view",
"limited_backoffice_customer_pricing_manage",
"limited_backoffice_employees_manage",
"department_access_1",
"department_access_2",
];
const sessionData = {
id: 50,
customer_number: 12345,
group_id: 77,
email: "manager@example.com",
phone: {
number: "12345678",
country_code: 45,
},
notifications: {
wash_certificate_email: null,
email_notifications_enabled: true,
sms_notifications_enabled: false,
},
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
display_name: "Limited Manager",
permissions: limitedManagerPermissions,
economic_customer: [],
runtime_config: {
economic: {
transaction_draft_customer_number: null,
default_distribution_department_id: null,
},
release: {},
},
};
const superuserSessionData = {
...sessionData,
id: 51,
customer_number: 12346,
email: "superuser@example.com",
display_name: "Superuser",
permissions: ["user", "superuser", "limited_backoffice_access", "department_access_1"],
};
const adminLimitedSessionData = {
...sessionData,
id: 52,
customer_number: 12347,
email: "admin-manager@example.com",
display_name: "Admin Manager",
permissions: ["user", "admin", "limited_backoffice_access", "department_access_1"],
};
const assignedDepartments = [
{ id: 1, name: "Assigned Depot", description: "", visible: true, archived: false, custom_pricing_only: true },
{ id: 2, name: "Remote Depot", description: "", visible: true, archived: false, custom_pricing_only: false },
];
const pricePayload = {
department: { id: 1, name: "Assigned Depot", description: "" },
categories: [
{
id: 10,
name: "Exterior",
description: "",
products: [
{
id: 101,
name: "Truck wash",
description: "Standard wash",
price: 125,
default_price: 8742,
},
{
id: 102,
name: "Trailer wash",
description: "",
price: 95,
default_price: 9842,
},
],
},
],
};
const duplicatePricePayload = {
...pricePayload,
categories: [
{
...pricePayload.categories[0],
products: [
pricePayload.categories[0].products[0],
{ ...pricePayload.categories[0].products[0], price: 999 },
pricePayload.categories[0].products[1],
],
},
],
};
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value));
const applyPriceRowsToPayload = (
payload: typeof pricePayload,
priceRows: Array<{ product_id: number; price: number | string }>
) => {
const updatedPayload = cloneJson(payload);
const priceLookup = new Map(priceRows.map((row) => [Number(row.product_id), Number(row.price)]));
for (const category of updatedPayload.categories) {
for (const product of category.products) {
if (priceLookup.has(Number(product.id))) {
product.price = priceLookup.get(Number(product.id)) ?? product.price;
}
}
}
return updatedPayload;
};
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
};
const rolePermissionGroups = {
viewer: [{ key: "account", capabilities: ["sign_in", "view_own_permissions"] }],
cashier: [
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
{
key: "orders",
capabilities: [
"view_orders",
"create_orders",
"edit_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
"remove_order_lines",
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
"view_department_bookings",
"view_own_bookings",
"update_bookings",
"create_bookings",
"mark_bookings_complete",
"send_booking_confirmations",
],
},
],
booking_coordinator: [
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
{ key: "orders", capabilities: ["view_orders"] },
{
key: "bookings",
capabilities: [
"view_department_bookings",
"view_own_bookings",
"update_bookings",
"create_bookings",
"mark_bookings_complete",
"send_booking_confirmations",
],
},
{
key: "time_bookings",
capabilities: ["view_time_booking_entries", "create_time_booking_entries", "edit_time_booking_entries"],
},
],
operations_lead: [
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
{
key: "orders",
capabilities: [
"view_orders",
"create_orders",
"edit_orders",
"delete_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
"remove_order_lines",
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
"view_department_bookings",
"view_own_bookings",
"update_bookings",
"create_bookings",
"mark_bookings_complete",
"send_booking_confirmations",
],
},
{ key: "reports", capabilities: ["view_order_statistics", "view_booking_statistics"] },
],
department_admin: [
{ key: "account", capabilities: ["sign_in", "view_own_permissions"] },
{
key: "orders",
capabilities: [
"view_orders",
"create_orders",
"edit_orders",
"delete_orders",
"complete_orders",
"view_order_items",
"create_order_items",
"update_order_lines",
"remove_order_lines",
"charge_orders",
],
},
{
key: "products",
capabilities: ["view_product_catalog", "view_product_recommendations"],
},
{
key: "customers",
capabilities: [
"search_customers",
"view_customer_details",
"view_customer_notes",
"add_customer_notes",
"view_customer_flags",
],
},
{
key: "vehicles",
capabilities: ["search_vehicles", "view_vehicle_matches", "view_vehicle_history"],
},
{
key: "attachments",
capabilities: ["view_order_attachments", "add_order_attachments", "download_order_attachments"],
},
{
key: "scanner",
capabilities: ["view_plate_scans"],
},
{
key: "bookings",
capabilities: [
"view_department_bookings",
"view_own_bookings",
"update_bookings",
"create_bookings",
"mark_bookings_complete",
"send_booking_confirmations",
],
},
{ key: "reports", capabilities: ["view_order_statistics", "view_booking_statistics"] },
{
key: "limited_backoffice",
capabilities: [
"open_limited_backoffice",
"manage_department_prices",
"view_customer_pricing",
"manage_customer_pricing",
"manage_employee_access",
],
},
],
};
const remotePricePayload = {
...pricePayload,
department: { id: 2, name: "Remote Depot", description: "" },
};
const customerPricingPayload = (overrides: Array<Record<string, unknown>> = []) => ({
department: { id: 1, name: "Assigned Depot", description: "", custom_pricing_only: true },
customer: { id: 601, customer_number: 6001, display_name: "Acme Haulage" },
overrides,
categories: [
{
id: 10,
name: "Exterior",
description: "",
products: [
{
id: 101,
name: "Truck wash",
description: "Standard wash",
category: 10,
apply_category_discount: true,
base_price: 8742,
department_price: 125,
effective_price: overrides.length ? 100 : 125,
missing_department_price: false,
},
],
},
],
});
const rolesPayload = [
{
key: "viewer",
label: "Deactivated",
description: "No order, booking, or management access.",
permission_groups: rolePermissionGroups.viewer,
},
{ key: "cashier", label: "Cashier", description: "Can sell.", permission_groups: rolePermissionGroups.cashier },
{
key: "booking_coordinator",
label: "Booking coordinator",
description: "Can coordinate.",
permission_groups: rolePermissionGroups.booking_coordinator,
},
{
key: "operations_lead",
label: "Operations lead",
description: "Can coordinate operations.",
permission_groups: rolePermissionGroups.operations_lead,
},
{
key: "department_admin",
label: "Department admin",
description: "Can administer.",
permission_groups: rolePermissionGroups.department_admin,
},
{ key: "superuser", label: "Superuser", description: "Must not render." },
];
const employeesPayload = [
{
id: 501,
user_id: 501,
customer_number: 0,
display_name: "Casey Clerk",
email: "casey@example.com",
phone_country_code: 45,
phone: 12345678,
active: true,
role: { key: "cashier", label: "Cashier", description: "Can sell." },
departments: [{ id: 1, name: "Assigned Depot" }],
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
{
id: 502,
customer_number: 0,
display_name: "Riley Remote",
email: "riley@example.com",
active: true,
role: { key: "viewer", label: "Deactivated", description: "No order, booking, or management access." },
departments: [{ id: 2, name: "Remote Depot" }],
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
},
];
async function seedLimitedBackofficeSession(page, token = "limited-backoffice-token") {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await seedAuthenticatedState(page, token);
}
async function mockLimitedBackofficeApi(page, authSessionData = sessionData, options: any = {}) {
const calls: string[] = [];
const forbiddenCalls: string[] = [];
const priceUpdateCalls: unknown[] = [];
const customerPricingUpdateCalls: unknown[] = [];
const employeeCreateCalls: unknown[] = [];
const employeeUpdateCalls: unknown[] = [];
const loginLinkCalls: string[] = [];
const currentPricePayload = {
value: cloneJson(options.pricePayload ?? pricePayload),
};
const currentEmployees = {
value: cloneJson(options.employeesPayload ?? employeesPayload),
};
await page.route(API_HOST, async (route) => {
const request = route.request();
const url = new URL(request.url());
const pathname = url.pathname;
const method = request.method();
calls.push(`${method} ${pathname}`);
if (pathname.endsWith("/products") || pathname.endsWith("/superuser/department/prices")) {
forbiddenCalls.push(`${method} ${pathname}`);
await route.fulfill(json({ message: "Forbidden endpoint called" }, 599));
return;
}
if (pathname.endsWith("/auth/session") && method === "GET") {
await route.fulfill(json({ data: authSessionData }));
return;
}
if (pathname.endsWith("/ping") && method === "GET") {
await route.fulfill(json({ data: { ok: true } }));
return;
}
if (pathname.endsWith("/worker/version") && method === "GET") {
await route.fulfill(json({ data: { version: "limited-backoffice-test" } }));
return;
}
if (
(pathname.endsWith("/auth/recaptcha/pre-check") || pathname.endsWith("/auth/reCAPTCHA/public")) &&
method === "GET"
) {
await route.fulfill(
json({
data: {
recaptcha: { enabled: false, site_key: "" },
rate_limit: { enabled: false, limit: 0, remaining: 0, reset: 0, warning: null },
},
})
);
return;
}
if (pathname.endsWith("/limited-backoffice/departments") && method === "GET") {
await route.fulfill(json({ data: assignedDepartments }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "GET") {
await route.fulfill(json({ data: currentPricePayload.value }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/2/prices") && method === "GET") {
await route.fulfill(json({ data: remotePricePayload }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/customer-pricing") && method === "GET") {
await route.fulfill(json({ data: customerPricingPayload() }));
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/customer-pricing") && method === "PUT") {
const body = request.postDataJSON?.() || null;
customerPricingUpdateCalls.push(body);
await route.fulfill(
json({
data: customerPricingPayload([
{
is_category: false,
product_or_category_id: 101,
percentage: 20,
fixed_price: null,
},
]),
})
);
return;
}
if (pathname.endsWith("/limited-backoffice/departments/1/prices") && method === "PUT") {
const body = request.postDataJSON?.() || null;
priceUpdateCalls.push(body);
if (typeof options.onPriceUpdate === "function") {
await options.onPriceUpdate({
route,
request,
body,
currentPricePayload,
});
return;
}
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
await route.fulfill(json({ data: currentPricePayload.value }));
return;
}
if (pathname.endsWith("/limited-backoffice/roles") && method === "GET") {
await route.fulfill(json({ data: rolesPayload }));
return;
}
if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") {
await route.fulfill(json({ data: currentEmployees.value }));
return;
}
if (pathname.endsWith("/limited-backoffice/employees") && method === "POST") {
const body = request.postDataJSON?.() || {};
employeeCreateCalls.push(body);
const role = rolesPayload.find((item) => item.key === body.role_key) || rolesPayload[0];
const created = {
id: 900 + currentEmployees.value.length + 1,
customer_number: 900001000 + currentEmployees.value.length + 1,
display_name: body.display_name,
email: body.email,
phone_country_code: body.phone_country_code ?? null,
phone: body.phone ?? null,
active: true,
role,
departments: assignedDepartments.filter((department) => body.department_ids?.includes(department.id)),
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-01 00:00:00",
};
currentEmployees.value.unshift(created);
await route.fulfill(json({ data: created }));
return;
}
const loginLinkMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)\/login-link$/);
if (loginLinkMatch && method === "POST") {
const employeeId = loginLinkMatch[1];
const status = options.loginLinkStatus ?? 200;
loginLinkCalls.push(employeeId);
await route.fulfill(
json(
status === 200
? {
data: {
employee_id: Number(employeeId),
login_path: `/login/qr?token=${"a".repeat(64)}`,
},
}
: { message: options.loginLinkMessage ?? "Could not create employee login link." },
status
)
);
return;
}
const employeeMatch = pathname.match(/\/limited-backoffice\/employees\/(\d+)$/);
if (employeeMatch && method === "PUT") {
const body = request.postDataJSON?.() || {};
employeeUpdateCalls.push(body);
const employeeId = Number(employeeMatch[1]);
const target = currentEmployees.value.find((employee) => Number(employee.id) === employeeId);
if (!target) {
await route.fulfill(json({ message: "Employee not found" }, 404));
return;
}
const role = rolesPayload.find((item) => item.key === body.role_key) || target.role;
Object.assign(target, {
display_name: body.display_name ?? target.display_name,
email: body.email ?? target.email,
phone_country_code: body.phone_country_code ?? null,
phone: body.phone ?? null,
role,
departments: Array.isArray(body.department_ids)
? assignedDepartments.filter((department) => body.department_ids.includes(department.id))
: target.departments,
updated_at: "2026-01-01 01:00:00",
});
await route.fulfill(json({ data: target }));
return;
}
await route.fulfill(json({ data: [] }));
});
return {
calls,
forbiddenCalls,
priceUpdateCalls,
customerPricingUpdateCalls,
employeeCreateCalls,
employeeUpdateCalls,
loginLinkCalls,
};
}
async function expectDepartmentSelectorInTitleRow(page: Page) {
const headingBox = await page.getByRole("heading", { name: "Backoffice" }).boundingBox();
const selectBox = await page.getByTestId("limited-backoffice-department-select").boundingBox();
expect(headingBox).not.toBeNull();
expect(selectBox).not.toBeNull();
if (!headingBox || !selectBox) {
return;
}
expect(selectBox.x).toBeGreaterThan(headingBox.x);
expect(Math.abs(selectBox.y + selectBox.height / 2 - (headingBox.y + headingBox.height / 2))).toBeLessThan(48);
}
test.describe("Limited backoffice", () => {
test("shows the limited backoffice header shortcut only on department-scoped admin pages", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
await mockLimitedBackofficeApi(page);
await page.goto("/admin/1");
const desktopNavigation = page.getByTestId("desktop-buefy-navigation");
const backofficeNavItem = desktopNavigation.locator('a[href="/backoffice"]:visible');
const headerShortcut = page.getByTestId("limited-backoffice-header-button");
await expect(backofficeNavItem).toHaveCount(0);
await expect(headerShortcut).toBeVisible();
await expect(headerShortcut).toHaveAttribute("href", "/backoffice/departments/1/prices");
await expect(headerShortcut).toContainText("Backoffice");
await expect(headerShortcut.locator(".icon i.fas.fa-tools")).toHaveCount(1);
await page.goto("/admin");
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
});
test("hides the limited backoffice header shortcut for superusers", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page, "limited-backoffice-superuser-token");
await mockLimitedBackofficeApi(page, superuserSessionData);
await page.goto("/admin/1");
await expect(page.getByTestId("desktop-buefy-navigation").locator('a[href="/backoffice"]:visible')).toHaveCount(0);
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
await expect(page.getByTestId("superuser-backoffice-header-button")).toBeVisible();
});
test("links backoffice department pages to the matching admin department for admin-capable users", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page, "admin-limited-backoffice-token");
await mockLimitedBackofficeApi(page, adminLimitedSessionData);
await page.goto("/backoffice/departments/1/prices");
const adminShortcut = page.getByTestId("admin-header-button");
await expect(adminShortcut).toBeVisible();
await expect(adminShortcut).toHaveAttribute("href", "/admin/1");
await expect(adminShortcut).toContainText("Departments");
await expect(page.getByTestId("limited-backoffice-header-button")).toHaveCount(0);
});
test("does not show the admin shortcut on backoffice department pages for limited-only users", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("admin-header-button")).toHaveCount(0);
});
test("limits price management to assigned departments and explicit prices", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("limited-prices-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Assigned Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).toContainText("Remote Depot");
await expect(page.getByTestId("limited-backoffice-department-select")).not.toContainText("Other Depot");
await expect(page.getByTestId("limited-price-row-101")).toContainText("Truck wash");
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
await expect(page.locator("body")).not.toContainText("8742");
await expect(page.locator("body")).not.toContainText(/default price/i);
await expect(page.locator("body")).not.toContainText("department_access_1");
await expect(page.locator("body")).not.toContainText("limited_backoffice_prices_manage");
await expect(page.getByTestId("limited-prices-save")).toHaveCount(0);
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
await page.getByTestId("limited-price-input-101").fill("");
await expect(page.getByTestId("limited-price-validation")).toBeVisible();
await page.waitForTimeout(700);
expect(api.priceUpdateCalls).toEqual([]);
expect(api.forbiddenCalls).toEqual([]);
});
test("shows customer pricing only for custom-only departments and saves scoped discounts", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/customer-pricing");
await expect(page.getByTestId("limited-backoffice-tab-customer-pricing")).toBeVisible();
await page.getByTestId("department-customer-pricing-customer-number").fill("6001");
await page.getByTestId("department-customer-pricing-load").click();
await expect(page.getByTestId("department-customer-pricing-customer")).toContainText("Acme Haulage");
await expect(page.getByTestId("department-customer-pricing-product-101")).toContainText("Truck wash");
await page.getByTestId("department-customer-pricing-item-discount-101").click();
await page.locator(".swal2-input").fill("20");
await page.locator(".swal2-confirm").click();
await expect(page.getByTestId("department-customer-pricing-item-discount-101")).toContainText("20%");
expect(api.customerPricingUpdateCalls).toEqual([
{
customer_number: 6001,
overrides: [
{
is_category: false,
product_or_category_id: 101,
percentage: 20,
fixed_price: null,
},
],
},
]);
await page.getByTestId("limited-backoffice-department-select").selectOption("2");
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/customer-pricing$/);
await expect(page.getByTestId("limited-backoffice-tab-customer-pricing")).toHaveCount(0);
await expect(page.getByTestId("department-customer-pricing-disabled")).toBeVisible();
});
test("autosaves valid price changes after a typing pause without blur", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/prices");
const priceInput = page.getByTestId("limited-price-input-101");
await expect(priceInput).toHaveValue("125");
await priceInput.fill("130");
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/pending/i);
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
await expect(priceInput).toBeFocused();
expect(api.priceUpdateCalls[0]).toEqual({
prices: [
{ product_id: 101, price: "130" },
{ product_id: 102, price: "95" },
],
});
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
});
test("flushes a valid pending price change on blur", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/prices");
const priceInput = page.getByTestId("limited-price-input-101");
await priceInput.fill("133");
await priceInput.blur();
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
expect(api.priceUpdateCalls[0]).toEqual({
prices: [
{ product_id: 101, price: "133" },
{ product_id: 102, price: "95" },
],
});
});
test("keeps newer edits when an older autosave response resolves late", async ({ page }) => {
const firstSave = deferred();
let saveCount = 0;
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page, sessionData, {
onPriceUpdate: async ({ route, body, currentPricePayload }) => {
saveCount += 1;
if (saveCount === 1) {
await firstSave.promise;
await route.fulfill(json({ data: pricePayload }));
return;
}
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
await route.fulfill(json({ data: currentPricePayload.value }));
},
});
await page.goto("/backoffice/departments/1/prices");
const priceInput = page.getByTestId("limited-price-input-101");
await priceInput.fill("130");
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
await priceInput.fill("140");
await page.waitForTimeout(700);
expect(api.priceUpdateCalls.length).toBe(1);
firstSave.resolve();
await expect.poll(() => api.priceUpdateCalls.length).toBe(2);
await expect(priceInput).toHaveValue("140");
expect(api.priceUpdateCalls[1]).toEqual({
prices: [
{ product_id: 101, price: "140" },
{ product_id: 102, price: "95" },
],
});
});
test("keeps failed autosaves dirty and retries on the next valid edit", async ({ page }) => {
let shouldFail = true;
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page, sessionData, {
onPriceUpdate: async ({ route, body, currentPricePayload }) => {
if (shouldFail) {
shouldFail = false;
await route.fulfill(json({ message: "Price save failed" }, 422));
return;
}
currentPricePayload.value = applyPriceRowsToPayload(currentPricePayload.value, body?.prices || []);
await route.fulfill(json({ data: currentPricePayload.value }));
},
});
await page.goto("/backoffice/departments/1/prices");
const priceInput = page.getByTestId("limited-price-input-101");
await priceInput.fill("130");
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
await expect(page.getByTestId("limited-prices-error")).toContainText("Price save failed");
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/pending/i);
await priceInput.fill("131");
await expect.poll(() => api.priceUpdateCalls.length).toBe(2);
await expect(page.getByTestId("limited-prices-error")).toHaveCount(0);
await expect(page.getByTestId("limited-prices-autosave-status")).toContainText(/saved/i);
expect(api.priceUpdateCalls[1]).toEqual({
prices: [
{ product_id: 101, price: "131" },
{ product_id: 102, price: "95" },
],
});
});
test("deduplicates repeated products in the price configuration", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page, sessionData, { pricePayload: duplicatePricePayload });
await page.goto("/backoffice/departments/1/prices");
await expect(page.getByTestId("limited-price-row-101")).toHaveCount(1);
await expect(page.getByTestId("limited-price-row-102")).toHaveCount(1);
await expect(page.getByTestId("limited-price-input-101")).toHaveValue("125");
await page.getByTestId("limited-price-input-101").fill("130");
await expect.poll(() => api.priceUpdateCalls.length).toBe(1);
expect(api.priceUpdateCalls[0]).toEqual({
prices: [
{ product_id: 101, price: "130" },
{ product_id: 102, price: "95" },
],
});
expect(api.priceUpdateCalls[0]).not.toEqual({
prices: [
{ product_id: 101, price: "130" },
{ product_id: 101, price: "130" },
{ product_id: 102, price: "95" },
],
});
expect(api.forbiddenCalls).toEqual([]);
});
test("shows only limited role presets in employee access", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/1/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expectDepartmentSelectorInTitleRow(page);
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-employee-user-id-501")).toHaveText("501");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678");
await expect(page.getByTestId("limited-employee-row-502")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.locator("#limited-employee-role option")).toHaveCount(5);
await expect(page.getByTestId("limited-employee-role")).not.toContainText("Superuser");
await expect(page.locator("body")).not.toContainText("department_access_1");
await expect(page.locator("body")).not.toContainText("limited_backoffice_employees_manage");
await expect(page.locator("body")).not.toContainText("raw_permissions");
expect(api.forbiddenCalls).toEqual([]);
});
test("generates an employee QR login link", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await page.getByTestId("limited-employee-login-link-501").click();
const modal = page.getByTestId("limited-login-link-modal");
await expect(modal).toBeVisible();
await expect(modal).toContainText("Employee login link");
await expect(modal).toContainText("Casey Clerk");
await expect(page.getByTestId("limited-login-link-qr")).toHaveAttribute("src", /^data:image\/png;base64,/);
await expect(page.getByTestId("limited-login-link-url")).toHaveValue(/\/login\/qr\?token=a{64}$/);
await page.getByTestId("limited-login-link-copy").click();
await expect(page.getByTestId("limited-login-link-copy-message")).toHaveText("Link copied.");
expect(api.loginLinkCalls).toEqual(["501"]);
expect(api.forbiddenCalls).toEqual([]);
});
test("shows an error when employee QR login link generation fails", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page, sessionData, {
loginLinkMessage: "Missing employee management permission.",
loginLinkStatus: 403,
});
await page.goto("/backoffice/employees");
await page.getByTestId("limited-employee-login-link-501").click();
await expect(page.getByTestId("limited-employees-error")).toHaveText("Missing employee management permission.");
await expect(page.getByTestId("limited-login-link-modal")).toHaveCount(0);
expect(api.loginLinkCalls).toEqual(["501"]);
expect(api.forbiddenCalls).toEqual([]);
});
test("shows grouped human-readable role permission help without raw permission keys", async ({ page }, testInfo) => {
await seedLimitedBackofficeSession(page);
await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
const helpButton = page.getByTestId("limited-employee-role-help");
await expect(helpButton).toBeVisible();
await expect(helpButton).toHaveAttribute("title", "View role permissions");
if (isDesktopProject(testInfo)) {
await helpButton.hover();
await expect(page.locator(".tooltip-content").filter({ hasText: "View role permissions" })).toBeVisible();
}
await helpButton.click();
const modal = page.getByTestId("limited-role-permissions-modal");
await expect(modal).toBeVisible();
await expect(modal).toContainText("Role permissions");
await expect(modal).toContainText("Every capability is limited to the departments selected for the employee.");
await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Deactivated");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Cashier");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Products");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Customers");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Vehicles");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Attachments");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Scanners");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Bookings");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Complete orders");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View product catalog");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Search customers");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Search vehicles");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Add order attachments");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View plate scans");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create bookings");
await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create order lines");
await expect(page.getByTestId("limited-role-permissions-role-department_admin")).toContainText(
"View customer pricing"
);
await expect(page.getByTestId("limited-role-permissions-role-department_admin")).toContainText(
"Manage customer pricing"
);
await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Selected");
for (const rawPermission of [
"list_orders",
"add_order",
"fetch_order",
"edit_order_items",
"delete_order_items",
"search_customers",
"list_order_attachments",
"list_products",
"add_bookings",
"department_timebookings_entries_get",
"limited_backoffice_employees_manage",
"limited_backoffice_customer_pricing_view",
"limited_backoffice_customer_pricing_manage",
"department_access_1",
"raw_permissions",
]) {
await expect(page.locator("body")).not.toContainText(rawPermission);
}
await page.getByTestId("limited-role-permissions-close-footer").click();
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
await helpButton.click();
await expect(page.getByTestId("limited-role-permissions-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("limited-role-permissions-modal")).toHaveCount(0);
});
test("validates employee fields and submits optional phone details", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page.getByTestId("limited-employees-title")).toBeVisible();
await expect(page.getByTestId("limited-employee-save")).toBeDisabled();
await expect(page.getByTestId("limited-employee-save")).toHaveClass(/is-fullwidth/);
await expect(page.getByTestId("limited-employee-departments").locator(".switch")).toHaveCount(2);
await expect(page.getByTestId("limited-employee-department-1")).toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-phone-country-code")).toContainText("+45");
await page.getByTestId("limited-employee-name").fill("No Phone Worker");
await page.getByTestId("limited-employee-email").fill("no-phone@example.com");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeCreateCalls.length).toBe(1);
expect(api.employeeCreateCalls[0]).toEqual({
display_name: "No Phone Worker",
email: "no-phone@example.com",
phone_country_code: null,
phone: null,
password: "Secret123!",
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-903")).toContainText("No Phone Worker");
await page.getByTestId("limited-employee-name").fill("Phone Worker");
await page.getByTestId("limited-employee-email").fill("phone@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-password").fill("Secret123!");
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeCreateCalls.length).toBe(2);
expect(api.employeeCreateCalls[1]).toEqual({
display_name: "Phone Worker",
email: "phone@example.com",
phone_country_code: 358,
phone: 87654321,
password: "Secret123!",
role_key: "viewer",
department_ids: [1],
});
await expect(page.getByTestId("limited-employee-row-904")).toContainText("Phone Worker");
await expect(page.getByTestId("limited-employee-phone-904")).toHaveText("+358 87654321");
});
test("edits employee contact details without requiring a new password", async ({ page }) => {
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page.getByTestId("limited-employee-row-501")).toBeVisible();
await page.getByTestId("limited-employee-edit-501").click();
await expect(page.getByTestId("limited-employee-password")).toHaveValue("");
await expect(page.getByTestId("limited-employee-save")).toBeEnabled();
await page.getByTestId("limited-employee-name").fill("Casey Lead");
await page.getByTestId("limited-employee-email").fill("casey.lead@example.com");
await page.getByTestId("limited-employee-phone-country-code").selectOption("358");
await page.getByTestId("limited-employee-phone").fill("87654321");
await page.getByTestId("limited-employee-save").click();
await expect.poll(() => api.employeeUpdateCalls.length).toBe(1);
expect(api.employeeUpdateCalls[0]).toEqual({
display_name: "Casey Lead",
email: "casey.lead@example.com",
phone_country_code: 358,
phone: 87654321,
role_key: "cashier",
department_ids: [1],
});
expect(api.employeeUpdateCalls[0]).not.toHaveProperty("password");
await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Lead");
await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+358 87654321");
});
test("keeps department context across employee access navigation", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/employees");
await expect(page).toHaveURL(/\/backoffice\/departments\/1\/employees$/);
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/1/prices"
);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/1/employees"
);
await page.getByTestId("limited-backoffice-department-select").selectOption("2");
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/employees$/);
await expect(page.getByTestId("limited-employee-row-501")).toHaveCount(0);
await expect(page.getByTestId("limited-employee-row-502")).toContainText("Riley Remote");
await expect(page.getByTestId("limited-employee-department-1")).not.toBeChecked();
await expect(page.getByTestId("limited-employee-department-2")).toBeChecked();
await expect(page.getByTestId("limited-backoffice-tab-prices")).toHaveAttribute(
"href",
"/backoffice/departments/2/prices"
);
await page.getByTestId("limited-backoffice-tab-prices").click();
await expect(page).toHaveURL(/\/backoffice\/departments\/2\/prices$/);
await expect(page.getByTestId("limited-backoffice-tab-employees")).toHaveAttribute(
"href",
"/backoffice/departments/2/employees"
);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/3/prices");
await expect(page.getByTestId("limited-prices-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-prices-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/departments/3/prices"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
test("does not render employee data for a department outside the manager scope", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedLimitedBackofficeSession(page);
const api = await mockLimitedBackofficeApi(page);
await page.goto("/backoffice/departments/3/employees");
await expect(page.getByTestId("limited-employees-forbidden")).toBeVisible();
await expect(page.locator("body")).not.toContainText("Secret Depot");
await expect(page.getByTestId("limited-employees-table")).toHaveCount(0);
expect(api.calls.some((call) => call.includes("/limited-backoffice/employees"))).toBe(false);
expect(api.forbiddenCalls).toEqual([]);
});
});