- Improved error state management with `normalizeErrorState` and `clearErrorState` utilities. - Enhanced Stripe payment flow with clear status messaging and support for terminal setup requirements. - Updated POS component templates (`PosDepartmentStep3.vue`, `PayWithStripeButton.vue`) for better error messages and UX. - Added `pos-step-4` navigation and tests in e2e flows. - Introduced utility `toPositiveInteger` for robust numeric validation in bookings.
470 lines
16 KiB
TypeScript
470 lines
16 KiB
TypeScript
import { expect, test, type Page, type Route } from "@playwright/test";
|
|
|
|
type MockSubuser = {
|
|
id: number;
|
|
username: string | null;
|
|
name: string | null;
|
|
email: string | null;
|
|
phone_country_code: number | null;
|
|
phone: number | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
suspended_at: string | null;
|
|
two_factor_enabled: boolean;
|
|
setup_required: boolean;
|
|
grant_id: number;
|
|
grant_enabled: boolean;
|
|
grant_note: string | null;
|
|
grant_permissions: string[];
|
|
access_state: "active" | "pending_setup" | "disabled" | "inactive";
|
|
};
|
|
|
|
const API_PATTERN = /https:\/\/api\.truckwash\.io(?::4433)?\//;
|
|
|
|
const baseUserSession = {
|
|
id: 12,
|
|
customer_number: 12345678,
|
|
group_id: 3,
|
|
email: "demo@truckwash.test",
|
|
phone: {
|
|
number: "11111111",
|
|
country_code: "45",
|
|
},
|
|
notifications: {
|
|
wash_certificate_email: null,
|
|
email_notifications_enabled: true,
|
|
sms_notifications_enabled: false,
|
|
},
|
|
created_at: "2026-04-14 09:00:00",
|
|
updated_at: "2026-04-14 09:00:00",
|
|
display_name: "Demo Company",
|
|
economic_customer: [],
|
|
};
|
|
|
|
const baseSubuserSession = {
|
|
id: 91,
|
|
username: "manager",
|
|
name: "Subuser Manager",
|
|
email: "manager@example.com",
|
|
phone_country_code: 45,
|
|
phone: 12345678,
|
|
created_at: "2026-04-14 09:00:00",
|
|
updated_at: "2026-04-14 09:00:00",
|
|
suspended_at: null,
|
|
two_factor_enabled: false,
|
|
grants: [
|
|
{
|
|
name: "Demo Company",
|
|
billing_customer_number: 12345678,
|
|
permissions: ["SUBUSERS_LIST", "SUBUSERS_ADD", "SUBUSERS_EDIT", "SUBUSERS_DELETE"],
|
|
},
|
|
],
|
|
};
|
|
|
|
const initialSubusers = (): MockSubuser[] => [
|
|
{
|
|
id: 1,
|
|
username: "driver.pending",
|
|
name: "Pending Driver",
|
|
email: null,
|
|
phone_country_code: 45,
|
|
phone: 11111111,
|
|
created_at: "2026-04-14 08:00:00",
|
|
updated_at: "2026-04-14 08:00:00",
|
|
suspended_at: null,
|
|
two_factor_enabled: false,
|
|
setup_required: true,
|
|
grant_id: 11,
|
|
grant_enabled: true,
|
|
grant_note: null,
|
|
grant_permissions: ["BOOKINGS_LIST"],
|
|
access_state: "pending_setup",
|
|
},
|
|
{
|
|
id: 2,
|
|
username: "driver.active",
|
|
name: "Active Driver",
|
|
email: "active@example.com",
|
|
phone_country_code: 45,
|
|
phone: 22222222,
|
|
created_at: "2026-04-14 07:30:00",
|
|
updated_at: "2026-04-14 07:30:00",
|
|
suspended_at: null,
|
|
two_factor_enabled: false,
|
|
setup_required: false,
|
|
grant_id: 12,
|
|
grant_enabled: true,
|
|
grant_note: "Morgenhold",
|
|
grant_permissions: ["BOOKINGS_LIST", "VEHICLES_LIST"],
|
|
access_state: "active",
|
|
},
|
|
{
|
|
id: 3,
|
|
username: "driver.disabled",
|
|
name: "Disabled Driver",
|
|
email: "disabled@example.com",
|
|
phone_country_code: 45,
|
|
phone: 33333333,
|
|
created_at: "2026-04-14 07:00:00",
|
|
updated_at: "2026-04-14 07:00:00",
|
|
suspended_at: null,
|
|
two_factor_enabled: false,
|
|
setup_required: false,
|
|
grant_id: 13,
|
|
grant_enabled: false,
|
|
grant_note: "Sat på pause",
|
|
grant_permissions: ["BOOKINGS_LIST"],
|
|
access_state: "disabled",
|
|
},
|
|
];
|
|
|
|
const permissionNodePayload = [
|
|
{
|
|
group: "Subusers",
|
|
description: "Subuser management",
|
|
nodes: [
|
|
{
|
|
key: "SUBUSERS_LIST",
|
|
name: "List chauffeurs",
|
|
description: "Can view chauffeurs",
|
|
type: "LIST",
|
|
default: false,
|
|
},
|
|
{
|
|
key: "SUBUSERS_EDIT",
|
|
name: "Edit chauffeurs",
|
|
description: "Can edit chauffeurs",
|
|
type: "EDIT",
|
|
default: false,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const jsonResponse = (route: Route, data: unknown, meta: Record<string, unknown> = {}) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
success: true,
|
|
data,
|
|
meta,
|
|
includes: [],
|
|
}),
|
|
});
|
|
|
|
const recalculateAccessState = (subuser: MockSubuser) => {
|
|
if (subuser.grant_enabled) {
|
|
subuser.access_state = subuser.setup_required ? "pending_setup" : "active";
|
|
return;
|
|
}
|
|
|
|
subuser.access_state = "disabled";
|
|
};
|
|
|
|
async function primeSession(page: Page, options: { token: string; isSubuser?: boolean; selectedCustomerNumber?: number }) {
|
|
await page.addInitScript((session) => {
|
|
window.localStorage.setItem("token", session.token);
|
|
if (session.isSubuser) {
|
|
window.localStorage.setItem("is_subuser", "true");
|
|
} else {
|
|
window.localStorage.removeItem("is_subuser");
|
|
}
|
|
|
|
if (session.selectedCustomerNumber) {
|
|
window.localStorage.setItem("selected_customer_number", String(session.selectedCustomerNumber));
|
|
} else {
|
|
window.localStorage.removeItem("selected_customer_number");
|
|
}
|
|
}, options);
|
|
}
|
|
|
|
async function mockManagementApi(
|
|
page: Page,
|
|
{
|
|
userPermissions = ["user"],
|
|
subuserPermissions = ["SUBUSERS_LIST", "SUBUSERS_ADD", "SUBUSERS_EDIT", "SUBUSERS_DELETE"],
|
|
isSubuser = false,
|
|
}: {
|
|
userPermissions?: string[];
|
|
subuserPermissions?: string[];
|
|
isSubuser?: boolean;
|
|
}
|
|
) {
|
|
const subusers = initialSubusers();
|
|
let nextId = 100;
|
|
let nextGrantId = 1000;
|
|
|
|
await page.route(API_PATTERN, async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
|
|
if (url.pathname === "/auth/session" && request.method() === "GET") {
|
|
return jsonResponse(route, {
|
|
...baseUserSession,
|
|
permissions: userPermissions,
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers/me" && request.method() === "GET") {
|
|
return jsonResponse(route, {
|
|
...baseSubuserSession,
|
|
grants: baseSubuserSession.grants.map((grant) => ({
|
|
...grant,
|
|
permissions: subuserPermissions,
|
|
})),
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers" && request.method() === "GET") {
|
|
return jsonResponse(route, subusers, {
|
|
pagination: {
|
|
page: 1,
|
|
per_page: 100,
|
|
total: subusers.length,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers" && request.method() === "PUT") {
|
|
const payload = request.postDataJSON() as Record<string, string | number | null>;
|
|
const target = subusers.find((subuser) => subuser.id === Number(payload.id));
|
|
if (!target) {
|
|
return route.fulfill({ status: 404, body: JSON.stringify({ success: false }) });
|
|
}
|
|
|
|
target.name = (payload.name as string) ?? target.name;
|
|
target.username = (payload.username as string | null) ?? target.username;
|
|
target.email = (payload.email as string | null) ?? target.email;
|
|
target.phone_country_code = payload.phone_country_code ? Number(payload.phone_country_code) : target.phone_country_code;
|
|
target.phone = payload.phone ? Number(payload.phone) : target.phone;
|
|
target.updated_at = "2026-04-14 10:00:00";
|
|
return jsonResponse(route, { subuser: target, grant: { id: target.grant_id } });
|
|
}
|
|
|
|
if (url.pathname === "/subusers/invite" && request.method() === "POST") {
|
|
const payload = request.postDataJSON() as Record<string, string | number | null>;
|
|
const created: MockSubuser = {
|
|
id: nextId++,
|
|
username: (payload.username as string | null) || null,
|
|
name: payload.name as string,
|
|
email: (payload.email as string | null) || null,
|
|
phone_country_code: Number(payload.phone_country_code),
|
|
phone: Number(payload.phone),
|
|
created_at: "2026-04-14 10:00:00",
|
|
updated_at: "2026-04-14 10:00:00",
|
|
suspended_at: null,
|
|
two_factor_enabled: false,
|
|
setup_required: true,
|
|
grant_id: nextGrantId++,
|
|
grant_enabled: true,
|
|
grant_note: null,
|
|
grant_permissions: [],
|
|
access_state: "pending_setup",
|
|
};
|
|
subusers.unshift(created);
|
|
return jsonResponse(route, {
|
|
subuser: created,
|
|
grant: { id: created.grant_id },
|
|
invite: {
|
|
setup_link: `https://truckwash.io/complete-registration?token=subuser-${created.id}`,
|
|
delivery: {
|
|
status: "unavailable",
|
|
message: "SMS blev ikke sendt i testmiljøet.",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers/invite/resend" && request.method() === "POST") {
|
|
const payload = request.postDataJSON() as Record<string, number>;
|
|
const target = subusers.find((subuser) => subuser.id === Number(payload.id));
|
|
if (!target) {
|
|
return route.fulfill({ status: 404, body: JSON.stringify({ success: false }) });
|
|
}
|
|
|
|
return jsonResponse(route, {
|
|
subuser: target,
|
|
grant: { id: target.grant_id },
|
|
invite: {
|
|
setup_link: `https://truckwash.io/complete-registration?token=resend-${target.id}`,
|
|
delivery: {
|
|
status: "sent",
|
|
message: "Invitation sendt igen.",
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers/grants" && request.method() === "PUT") {
|
|
const payload = request.postDataJSON() as Record<string, unknown>;
|
|
const target = subusers.find((subuser) => subuser.grant_id === Number(payload.id));
|
|
if (!target) {
|
|
return route.fulfill({ status: 404, body: JSON.stringify({ success: false }) });
|
|
}
|
|
|
|
if (Array.isArray(payload.permissions)) {
|
|
target.grant_permissions = payload.permissions as string[];
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(payload, "note")) {
|
|
target.grant_note = (payload.note as string) || null;
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(payload, "enabled")) {
|
|
target.grant_enabled = Boolean(payload.enabled);
|
|
}
|
|
recalculateAccessState(target);
|
|
target.updated_at = "2026-04-14 10:05:00";
|
|
|
|
return jsonResponse(route, {
|
|
id: target.grant_id,
|
|
enabled: target.grant_enabled,
|
|
note: target.grant_note,
|
|
permissions: target.grant_permissions,
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/subusers/permission-nodes" && request.method() === "GET") {
|
|
return jsonResponse(route, permissionNodePayload);
|
|
}
|
|
|
|
if (url.pathname === "/subusers/me" && request.method() === "PUT") {
|
|
const payload = request.postDataJSON() as Record<string, unknown>;
|
|
return jsonResponse(route, {
|
|
...baseSubuserSession,
|
|
name: (payload.name as string) ?? baseSubuserSession.name,
|
|
email: (payload.email as string) ?? baseSubuserSession.email,
|
|
});
|
|
}
|
|
|
|
if (url.pathname === "/ping" && request.method() === "GET") {
|
|
return jsonResponse(route, { ok: true });
|
|
}
|
|
|
|
return route.fulfill({
|
|
status: 404,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
success: false,
|
|
data: {
|
|
message: `Unhandled API route in test: ${request.method()} ${url.pathname}`,
|
|
},
|
|
meta: {},
|
|
includes: [],
|
|
}),
|
|
});
|
|
});
|
|
}
|
|
|
|
test("customer user can invite and manage chauffører from /user/subusers", async ({ page }) => {
|
|
await primeSession(page, { token: "user-token" });
|
|
await mockManagementApi(page, { userPermissions: ["user"] });
|
|
|
|
await page.goto("/user/subusers");
|
|
|
|
await expect(page.getByRole("heading", { name: /underbrugere|chauffører/i })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /invitér chauffør/i })).toBeVisible();
|
|
await expect(page.getByText("Pending Driver")).toBeVisible();
|
|
await expect(page.getByText("Disabled Driver")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /invitér chauffør/i }).click();
|
|
await page.fill("#subuser-form-name", "Invited Driver");
|
|
await page.fill("#subuser-form-phone-country-code", "45");
|
|
await page.fill("#subuser-form-phone", "44444444");
|
|
await page.fill("#subuser-form-username", "driver.invited");
|
|
await page.fill("#subuser-form-email", "invited@example.com");
|
|
await page.getByRole("button", { name: "Send invitation" }).click();
|
|
await expect(page.getByRole("heading", { name: "Chauffør oprettet" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Luk" }).click();
|
|
await expect(page.getByText("Invited Driver")).toBeVisible();
|
|
|
|
await page.getByTestId("subuser-edit-100").click();
|
|
await page.fill("#subuser-form-name", "Updated Driver");
|
|
await page.fill("#subuser-form-phone-country-code", "45");
|
|
await page.fill("#subuser-form-phone", "45454545");
|
|
await page.fill("#subuser-form-email", "updated@example.com");
|
|
await page.getByRole("button", { name: "Gem ændringer" }).click();
|
|
await expect(page.getByRole("heading", { name: "Chauffør opdateret" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Luk" }).click();
|
|
await expect(page.getByText("Updated Driver")).toBeVisible();
|
|
|
|
await page.getByTestId("subuser-permissions-100").click();
|
|
await expect(page.getByText("Tilladelsesnoder for Updated Driver")).toBeVisible();
|
|
await page.getByTestId("permission-node-checkbox-SUBUSERS_LIST").click();
|
|
await page.getByRole("button", { name: "Gem" }).click();
|
|
await expect(page.getByText(/SUBUSERS_LIST/)).toBeVisible();
|
|
|
|
await page.getByTestId("subuser-toggle-100").click();
|
|
await page.getByRole("button", { name: "Deaktivér" }).click();
|
|
await expect(page.getByTestId("subuser-status-100")).toContainText("Deaktiveret");
|
|
|
|
await page.getByTestId("subuser-toggle-100").click();
|
|
await page.getByRole("button", { name: "Genaktivér" }).click();
|
|
await expect(page.getByTestId("subuser-status-100")).toContainText("Afventer opsætning");
|
|
|
|
await page.getByTestId("subuser-resend-100").click();
|
|
await expect(page.getByRole("heading", { name: "Chauffør oprettet" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Luk" }).click();
|
|
});
|
|
|
|
test("authorized subuser manager can access the chauffører page and legacy grants route redirects", async ({ page }) => {
|
|
await primeSession(page, {
|
|
token: "subuser-manager-token",
|
|
isSubuser: true,
|
|
selectedCustomerNumber: 12345678,
|
|
});
|
|
await mockManagementApi(page, {
|
|
isSubuser: true,
|
|
subuserPermissions: ["SUBUSERS_LIST", "SUBUSERS_ADD", "SUBUSERS_EDIT", "SUBUSERS_DELETE"],
|
|
});
|
|
|
|
await page.goto("/user/subusers/grants");
|
|
await expect(page).toHaveURL("/user/subusers");
|
|
await expect(page.locator("label.label", { hasText: "Vælg kunde" })).toBeVisible();
|
|
await expect(page.getByRole("button", { name: /invitér chauffør/i })).toBeVisible();
|
|
});
|
|
|
|
test("subuser self-service profile edits still save through /subusers/me", async ({ page }) => {
|
|
await primeSession(page, {
|
|
token: "subuser-profile-token",
|
|
isSubuser: true,
|
|
selectedCustomerNumber: 12345678,
|
|
});
|
|
await mockManagementApi(page, {
|
|
isSubuser: true,
|
|
});
|
|
|
|
await page.goto("/user/profile");
|
|
await expect(page.locator(".card-header").filter({ has: page.locator(".fa-user") }).first()).toBeVisible();
|
|
|
|
await page.locator(".card-header").filter({ has: page.locator(".fa-user") }).first().click();
|
|
await page.locator('button:has-text("Rediger navn")').click();
|
|
await page.fill(".swal2-input", "Profile Driver Updated");
|
|
await page.click(".swal2-confirm");
|
|
await expect(page.locator(".swal2-title")).toContainText("Navn gemt");
|
|
await page.click(".swal2-confirm");
|
|
|
|
await page.locator(".card-header").filter({ has: page.locator(".fa-address-card") }).first().click();
|
|
await page.locator('button:has-text("Rediger e-mail")').click();
|
|
await page.fill(".swal2-input", "profile.updated@example.com");
|
|
await page.click(".swal2-confirm");
|
|
await expect(page.locator(".swal2-title")).toContainText("E-mail gemt");
|
|
await page.click(".swal2-confirm");
|
|
});
|
|
|
|
test("subuser without SUBUSERS_LIST cannot access the chauffører page", async ({ page }) => {
|
|
await primeSession(page, {
|
|
token: "subuser-no-access-token",
|
|
isSubuser: true,
|
|
selectedCustomerNumber: 12345678,
|
|
});
|
|
await mockManagementApi(page, {
|
|
isSubuser: true,
|
|
subuserPermissions: ["BOOKINGS_LIST"],
|
|
});
|
|
|
|
await page.goto("/user/subusers");
|
|
|
|
await expect(page.getByRole("heading", { name: "403 Forbidden" }).first()).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "You do not have permission to view this page." })).toBeVisible();
|
|
});
|