diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index daf661ef..8c2c01c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -112,6 +112,8 @@ jobs: env: PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }} PLAYWRIGHT_REPORTER_MODE: line-html + PLAYWRIGHT_WORKERS: 1 + PLAYWRIGHT_VIDEO_MODE: on-first-retry steps: - name: Repair self-hosted workspace permissions shell: bash @@ -227,6 +229,8 @@ jobs: --env CI="${CI:-}" \ --env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \ --env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \ + --env PLAYWRIGHT_WORKERS="$PLAYWRIGHT_WORKERS" \ + --env PLAYWRIGHT_VIDEO_MODE="$PLAYWRIGHT_VIDEO_MODE" \ --env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \ --env MATRIX_SUITE="$MATRIX_SUITE" \ --env MATRIX_PROJECT="$MATRIX_PROJECT" \ diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs index e11db5c3..e793e780 100644 --- a/scripts/playwright-pr-mapping.mjs +++ b/scripts/playwright-pr-mapping.mjs @@ -7,14 +7,18 @@ export const fallbackChangePatterns = [ /^vite\.config\.js$/u, /^playwright(?:\..+)?\.config\.(?:js|ts)$/u, /^playwright\.global-(?:setup|teardown)\.mjs$/u, - /^scripts\/run-playwright/u, + /^scripts\/run-playwright-(?:ci-parallel|batched-chromium)\.mjs$/u, /^tests\/e2e\/(?:support|fixtures)\//u, ]; export const sourceMappings = [ { name: "auth", - patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u], + patterns: [ + /^src\/(?:views|components|middleware)\/.*auth/iu, + /^src\/views\/auth\//u, + /^src\/components\/session\/(?!token\/SessionUser\/Objects\/)/u, + ], specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"], projects: chromiumProjects, }, @@ -24,6 +28,15 @@ export const sourceMappings = [ specs: ["tests/e2e/navigation.smoke.spec.js"], projects: chromiumProjects, }, + { + name: "superuser-roles-permissions", + patterns: [ + /^src\/views\/dashboards\/superUserDashboard\/roles\/SuperUserRolesPermissions\.vue$/u, + /^src\/views\/dashboards\/superUserDashboard\/roles\/rolePermissionCatalog\.js$/u, + ], + specs: ["tests/e2e/superuser-roles-permissions.spec.ts"], + projects: chromiumProjects, + }, { name: "booking", patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u], @@ -61,6 +74,12 @@ export const sourceMappings = [ specs: ["tests/e2e/admin-department-notifications.spec.ts"], projects: chromiumProjects, }, + { + name: "limited-backoffice", + patterns: [/^src\/views\/backoffice\/LimitedBackoffice/u], + specs: ["tests/e2e/limited-backoffice.spec.ts"], + projects: chromiumProjects, + }, { name: "invoicing", patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu], @@ -83,6 +102,15 @@ export const sourceMappings = [ specs: ["tests/e2e/superuser-system-status.smoke.spec.js"], projects: chromiumProjects, }, + { + name: "superuser-department-pricing", + patterns: [ + /^src\/views\/dashboards\/superUserDashboard\/department\/(?:DepartmentPricing|SuperUserSelectedDepartmentObject)\.vue$/u, + /^src\/components\/session\/token\/SessionUser\/Objects\/Departments\.vue$/u, + ], + specs: ["tests/e2e/superuser-department-pricing-custom-only.spec.ts"], + projects: ["chromium-desktop"], + }, { name: "self-serve", patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu], diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index 13e6b19b..ef936fed 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -103,9 +103,11 @@ export const ownedFilesByRole = { "superuser-department-branding.spec.js", "superuser-department-gates.spec.ts", "superuser-department-lanes.spec.ts", + "superuser-department-pricing-custom-only.spec.ts", "superuser-departments-archive.spec.ts", "superuser-drafts.spec.ts", "superuser-products-layout.spec.ts", + "superuser-roles-permissions.spec.ts", "superuser-system-status.smoke.spec.js", "superuser-users.spec.ts", "superuser-vehicles.smoke.spec.js", diff --git a/scripts/run-playwright-pr.mjs b/scripts/run-playwright-pr.mjs index dbd5df0f..7778163b 100644 --- a/scripts/run-playwright-pr.mjs +++ b/scripts/run-playwright-pr.mjs @@ -261,6 +261,8 @@ function selectChangedTests(changedFiles) { specProjects: new Map(), mappedFiles: [], unmappedFiles: [], + directSpecFiles: [], + skippedDirectSpecFiles: [], fallback: false, }; @@ -270,8 +272,7 @@ function selectChangedTests(changedFiles) { const file = normalizePath(rawFile); if (isE2eSpec(file)) { - addSpec(selection, file, selectedProjects); - selection.mappedFiles.push(file); + selection.directSpecFiles.push(file); continue; } @@ -295,13 +296,26 @@ function selectChangedTests(changedFiles) { selection.mappedFiles.push(file); for (const mapping of matches) { - const projects = mapping.projects.filter((project) => selectedProjects.includes(project)); + const mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects; + const projects = mappedProjects.filter((project) => selectedProjects.includes(project)); + if (projects.length === 0) { + continue; + } for (const spec of mapping.specs) { - addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects); + addSpec(selection, spec, projects); } } } + if (selection.specProjects.size === 0 && !selection.fallback) { + for (const file of selection.directSpecFiles) { + addSpec(selection, file, selectedProjects); + selection.mappedFiles.push(file); + } + } else { + selection.skippedDirectSpecFiles.push(...selection.directSpecFiles); + } + return selection; } @@ -370,6 +384,12 @@ async function runChangedSelection(selection) { return 0; } + if (selection.skippedDirectSpecFiles.length > 0) { + console.log( + `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join(", ")}` + ); + } + for (const [index, group] of groups.entries()) { for (const project of group.projects) { const code = await runPlaywright({ diff --git a/src/components/displays/buttons/DatePeriodSelector.vue b/src/components/displays/buttons/DatePeriodSelector.vue index 8ae23ce5..ca7bbe0f 100644 --- a/src/components/displays/buttons/DatePeriodSelector.vue +++ b/src/components/displays/buttons/DatePeriodSelector.vue @@ -5,6 +5,7 @@ import { useWindowSize } from "@vueuse/core"; import { BMessage } from "buefy"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; +import { formatLocalDateOnly, parseLocalDateOnly } from "@/services/dateOnly.js"; type DateRange = { startDate: Date, @@ -115,23 +116,11 @@ const availableYears = computed(() => ( )); const formatDateInputValue = (date: Date) => { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) { - return ""; - } - - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; + return formatLocalDateOnly(date); }; const parseDateInputValue = (value: string) => { - const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); - if (!match) { - return new Date(value); - } - - return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return parseLocalDateOnly(value); }; const isSameDateInputValue = (left: Date, right: Date) => ( diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue index 01c6d26c..ebbb08e8 100644 --- a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue +++ b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue @@ -30,6 +30,7 @@ import PosLastScannedLicensePlatesV2 from "@/components/displays/department/pos/ import PosDesktopOrderBookingSelectorModal from "@/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue"; import PosDesktopCustomerConflictModal from "@/components/displays/department/pos/steps/elements/PosDesktopCustomerConflictModal.vue"; import PosDesktopDuplicateWarning from "@/components/displays/department/pos/steps/elements/PosDesktopDuplicateWarning.vue"; +import { todayLocalDateOnly } from "@/services/dateOnly.js"; import { SessionUser } from "@/components/session/token/SessionUser.vue"; import { parsePosRouteSearch } from "@/views/dashboards/departmentDashboard/modules/Pos/posRouteState.js"; import { @@ -377,8 +378,8 @@ const fetchDuplicateOrdersForContext = async (context) => { try { const response = await SessionUser.request(SessionUser.objects.orders.meta.endpoint, "GET", { filters: `reg_1:${normalizedContext.reg1},department_id:${department_id.value},created_at-date_from:${ - new Date().toISOString().split("T")[0] - },created_at-date_to:${new Date().toISOString().split("T")[0]}`, + todayLocalDateOnly() + },created_at-date_to:${todayLocalDateOnly()}`, limit: 5, }); diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue index 396067d0..7845a042 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2AdditionalItems.vue @@ -22,7 +22,7 @@ import PosDepartmentStep2MobileVehicleSelection import GenericButton from "@/components/viewport/page/templates/generic/graphics/GenericButton.vue"; import PosDepartmentStepMobile2FloatingCart from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile2FloatingCart.vue"; -import { isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue"; +import { isAddonRestricted, canBuyAdditionalServices, isProductRestricted } from "@/components/shop/POSDepartmentProcess.vue"; const props = defineProps({ label: { @@ -46,6 +46,7 @@ const props = defineProps({ }, }); +const canSelectAdditionalItems = computed(() => canBuyAdditionalServices()); const checked = ref(props.defaultChecked); // Function to generate a summary from the last order function generateSummary(order: PosOrder): string { @@ -72,6 +73,12 @@ const displaySubtitle = computed(() => { }); // Emit event on toggle function onToggle(isOpen: boolean) { + if (isOpen && !canSelectAdditionalItems.value) { + checked.value = false; + pos.views.additionalItemSelection.value = false; + return; + } + checked.value = isOpen; // Open the additional item selection view if toggled open if (isOpen) { @@ -118,17 +125,45 @@ const getAvailableAdditionalItems = () => { })); } const availableAdditionalItems = ref(getAvailableAdditionalItems()); +const isAdditionalItemRestricted = (product: PosProduct) => { + if (!canSelectAdditionalItems.value) { + return true; + } + if (isProductRestricted(product)) { + return true; + } + return isAddonRestricted(convertProductToAddon(product)); +} + +watch(canSelectAdditionalItems, (canSelect) => { + if (!canSelect) { + checked.value = false; + pos.views.additionalItemSelection.value = false; + } +}); // Computed property to filter out restricted additional items based on customer attributes const filteredAdditionalItems = computed(() => { // If additional services are restricted, return empty array - if (!canBuyAdditionalServices()) { + if (!canSelectAdditionalItems.value) { return []; } // Filter out individually restricted addons - return availableAdditionalItems.value.filter((addon: Addon) => !isAddonRestricted(addon)); + return availableAdditionalItems.value.filter((addon: Addon) => { + if (isAddonRestricted(addon)) { + return false; + } + if (addon.product && isProductRestricted(addon.product)) { + return false; + } + return true; + }); }); const onClickAddOtherProduct = () => { + if (!canSelectAdditionalItems.value) { + pos.views.additionalItemSelection.value = false; + return; + } pos.views.additionalItemSelection.value = !pos.views.additionalItemSelection.value; } @@ -159,7 +194,9 @@ watch(() => pos.transactionItems.additionalItems.value, (newVal) => { }, { deep: true }); const onClickAddProduct = async (product: PosProduct) => { - // If the product requires note, open note input. + if (isAdditionalItemRestricted(product)) { + return; + } pos.transactionItems.addAdditionalItem(product); // If the view is fullscreen, close it after adding //if (pos.views.additionalItemSelection.value) { @@ -171,7 +208,7 @@ const onClickAddProduct = async (product: PosProduct) => { - \ No newline at end of file + diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 4b7e4a86..b44ddc69 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -1340,6 +1340,10 @@ const clearRecentCompletedRefreshInterval = () => { }; const scheduleRecentCompletedWashRefresh = (attempt = 0) => { + if (isMyWashStartUnmounted.value) { + return; + } + clearRecentCompletedRefreshTimeout(); if (refreshRecentlyCompletedWashState() || attempt >= 20 || isMyWashStartUnmounted.value) { @@ -1347,6 +1351,10 @@ const scheduleRecentCompletedWashRefresh = (attempt = 0) => { } recentCompletedRefreshTimeout.value = window.setTimeout(() => { + if (isMyWashStartUnmounted.value) { + return; + } + scheduleRecentCompletedWashRefresh(attempt + 1); }, 250); }; diff --git a/tests/e2e/edge-gateways.smoke.spec.js b/tests/e2e/edge-gateways.smoke.spec.js index 0a252303..c2516249 100644 --- a/tests/e2e/edge-gateways.smoke.spec.js +++ b/tests/e2e/edge-gateways.smoke.spec.js @@ -109,6 +109,58 @@ async function acceleratePageTimers(page, timerScale = 0.01) { }, timerScale); } +async function dismissUnexpectedSweetAlert(page) { + const overlays = page.locator(".swal2-container.swal2-backdrop-show"); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const overlay = overlays.first(); + if (!(await overlay.isVisible().catch(() => false))) { + return; + } + + let dismissed = false; + for (const selector of [".swal2-close", ".swal2-cancel", ".swal2-deny", ".swal2-confirm"]) { + const action = overlay.locator(selector).first(); + if (await action.isVisible().catch(() => false)) { + await action.click({ force: true }); + dismissed = true; + break; + } + } + + if (!dismissed) { + await page.keyboard.press("Escape"); + } + + await expect(overlays) + .toHaveCount(0, { timeout: 5_000 }) + .catch(() => {}); + } +} + +async function generateGatewayInstaller(page) { + const button = page.getByTestId("gateway-installer-generate"); + await expect(button).toBeVisible({ timeout: 15_000 }); + await expect(button).toBeEnabled({ timeout: 15_000 }); + + let lastError; + for (let attempt = 0; attempt < 3; attempt += 1) { + await dismissUnexpectedSweetAlert(page); + + try { + await button.click({ timeout: 15_000 }); + return; + } catch (error) { + lastError = error; + if (!/swal2-container|intercepts pointer events/i.test(error?.message || "")) { + throw error; + } + } + } + + throw lastError; +} + test.describe("Edge gateway management smoke", () => { test.describe.configure({ mode: "serial" }); @@ -193,7 +245,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Copy Test Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/); await page.getByTestId("gateway-installer-copy").click(); @@ -246,7 +298,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Canary Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running"); @@ -274,7 +326,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("CPH Edge 01"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running"); @@ -318,7 +370,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Broken Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-installer-status")).toBeVisible(); await expect @@ -382,7 +434,7 @@ test.describe("Edge gateway management smoke", () => { await page.getByTestId("gateway-installer-department").selectOption("1"); await page.getByTestId("gateway-installer-label").fill("Canary Pi"); - await page.getByTestId("gateway-installer-generate").click(); + await generateGatewayInstaller(page); await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/); await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 }); diff --git a/tests/e2e/errorReports.spec.ts b/tests/e2e/errorReports.spec.ts index 0b385a3c..480df2de 100644 --- a/tests/e2e/errorReports.spec.ts +++ b/tests/e2e/errorReports.spec.ts @@ -15,16 +15,20 @@ function isApiUrl(url: string) { ); } -async function mockHtml2Canvas(page) { - await page.route(/\/node_modules\/html2canvas\/dist\/html2canvas\.esm\.js(?:\?.*)?$/i, async (route) => { +const html2CanvasModulePattern = /\/(?:node_modules\/.*)?html2canvas(?:\.[\w-]+)?\.js(?:\?.*)?$/i; + +async function mockHtml2Canvas(page, { alwaysFail = false } = {}) { + await page.unroute(html2CanvasModulePattern).catch(() => {}); + await page.route(html2CanvasModulePattern, async (route) => { await route.fulfill({ contentType: "text/javascript", body: ` + const alwaysFail = ${alwaysFail ? "true" : "false"}; let html2canvasAttempts = 0; export default async function html2canvas() { html2canvasAttempts += 1; - if (html2canvasAttempts === 1) { - throw new Error("Simulated first capture failure"); + if (alwaysFail || html2canvasAttempts === 1) { + throw new Error(alwaysFail ? "Simulated permanent capture failure" : "Simulated first capture failure"); } const canvas = document.createElement("canvas"); canvas.width = 8; @@ -163,10 +167,68 @@ test.describe("Authenticated error reports", () => { expect(Array.isArray(body.vue_errors)).toBe(true); expect(body.context).toMatchObject({ data_collection_policy_version: "error-report-v1", + screenshot_attachment: { + status: "stored", + attached: true, + }, }); await expect(page.getByTestId("error-report-submitted")).toBeVisible(); }); + test("submits without a screenshot when capture never succeeds", async ({ page }) => { + await mockHtml2Canvas(page, { alwaysFail: true }); + const submittedBodies: Array> = []; + + await page.route(/\/error-reports(?:\?.*)?$/i, async (route) => { + if (!isApiUrl(route.request().url())) { + await route.fallback(); + return; + } + if (route.request().method().toUpperCase() !== "POST") { + await route.fallback(); + return; + } + const body = route.request().postDataJSON() as Record; + submittedBodies.push(body); + await route.fulfill( + json( + { + success: true, + data: { + id: 100, + status: "open", + screenshot: null, + }, + }, + 201 + ) + ); + }); + + await primeMockSession(page, { token: "error-report-user-token", bootPath: "/admin" }); + await openErrorReportLauncher(page); + + await page.getByLabel("What were you doing before the error occurred?").fill("Opening the orders page"); + await page.getByLabel("What did you expect would happen?").fill("The orders should load"); + await page.getByLabel("What actually happened?").fill("The page showed an error"); + await page.getByTestId("error-report-consent").check(); + await page.getByTestId("error-report-submit").click(); + + await expect.poll(() => submittedBodies.length).toBe(1); + const body = submittedBodies[0]; + expect(body.screenshot).toBeNull(); + expect(body.context).toMatchObject({ + screenshot_attachment: { + status: "capture_failed", + attached: false, + }, + }); + await expect(page.getByTestId("error-report-capture-warning")).toContainText( + "The screen capture failed. The report will be sent without a screenshot." + ); + await expect(page.getByTestId("error-report-submitted")).toBeVisible(); + }); + test("lets superusers inspect and resolve submitted reports", async ({ page }) => { const report: Record = { id: 12, @@ -277,4 +339,90 @@ test.describe("Authenticated error reports", () => { ]); await expect(page.getByTestId("error-report-detail")).toContainText("resolved"); }); + + test("lets superusers inspect submitted reports without screenshots", async ({ page }) => { + const report: Record = { + id: 13, + status: "open", + reporter: { + type: "user", + user_id: 7, + customer_number: 12345, + name: "Error Reporter", + email: "reporter@example.test", + }, + route_path: "/user/orders", + page_url: "https://app.example.test/user/orders", + release_trace_id: "trace-no-screenshot", + frontend_version: "front-1", + api_version: "api-1", + screenshot: null, + answers: { + before_error: "Opening the orders page", + expected: "Orders should load", + actual: "The table stayed empty", + }, + request_error_count: 0, + vue_error_count: 0, + request_errors: [], + vue_errors: [], + runtime_context: { + screenshot_attachment: { + status: "capture_failed", + attached: false, + mime_type: null, + size_bytes: 0, + }, + }, + created_at: "2026-05-19 08:00:00", + updated_at: "2026-05-19 08:00:00", + resolved_at: null, + resolved_by_user_id: null, + resolution_note: null, + }; + + await page.route(/\/superuser\/error-reports(?:\?.*)?$/i, async (route) => { + if (!isApiUrl(route.request().url())) { + await route.fallback(); + return; + } + if (route.request().method().toUpperCase() !== "GET") { + await route.fallback(); + return; + } + await route.fulfill( + json({ + success: true, + data: { + items: [report], + counts: { + open: 1, + resolved: 0, + all: 1, + }, + limit: 100, + offset: 0, + }, + }) + ); + }); + + await page.route(/\/superuser\/error-reports\/13$/i, async (route) => { + if (!isApiUrl(route.request().url())) { + await route.fallback(); + return; + } + await route.fulfill(json({ success: true, data: report })); + }); + + await primeMockSession(page, { token: "error-report-superuser-token", bootPath: "/superuser/error-reports" }); + await expect(page.getByTestId("error-reports-page")).toBeVisible(); + await expect(page.getByTestId("error-report-list")).toContainText("/user/orders"); + + await page.getByTestId("error-report-view").click(); + const detail = page.getByTestId("error-report-detail"); + await expect(detail).toContainText("Opening the orders page"); + await expect(detail).toContainText("trace-no-screenshot"); + await expect(detail.locator("img")).toHaveCount(0); + }); }); diff --git a/tests/e2e/limited-backoffice.spec.ts b/tests/e2e/limited-backoffice.spec.ts index 7ccc20ed..a14b3930 100644 --- a/tests/e2e/limited-backoffice.spec.ts +++ b/tests/e2e/limited-backoffice.spec.ts @@ -82,12 +82,128 @@ const pricePayload = { ], }; +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", + "view_order_items", + "create_order_items", + "update_order_lines", + "remove_order_lines", + "charge_orders", + ], + }, + ], + 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", + "view_order_items", + "create_order_items", + "update_order_lines", + "remove_order_lines", + "charge_orders", + ], + }, + { + 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", + "view_order_items", + "create_order_items", + "update_order_lines", + "remove_order_lines", + "charge_orders", + ], + }, + { + 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", "manage_employee_access"], + }, + ], +}; + const rolesPayload = [ - { key: "viewer", label: "Viewer", description: "Can view." }, - { key: "cashier", label: "Cashier", description: "Can sell." }, - { key: "booking_coordinator", label: "Booking coordinator", description: "Can coordinate." }, - { key: "operations_lead", label: "Operations lead", description: "Can coordinate operations." }, - { key: "department_admin", label: "Department admin", description: "Can administer." }, + { key: "viewer", label: "Viewer", description: "Can view.", 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." }, ]; @@ -97,6 +213,8 @@ const employeesPayload = [ customer_number: 900000501, 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" }], @@ -112,10 +230,17 @@ async function seedLimitedBackofficeSession(page, token = "limited-backoffice-to await seedAuthenticatedState(page, token); } -async function mockLimitedBackofficeApi(page, authSessionData = sessionData) { +const cloneJson = (value: T): T => JSON.parse(JSON.stringify(value)); + +async function mockLimitedBackofficeApi(page, authSessionData = sessionData, options: any = {}) { const calls: string[] = []; const forbiddenCalls: string[] = []; const priceUpdateCalls: unknown[] = []; + const employeeCreateCalls: unknown[] = []; + const employeeUpdateCalls: unknown[] = []; + const currentEmployees = { + value: cloneJson(options.employeesPayload ?? employeesPayload), + }; await page.route(API_HOST, async (route) => { const request = route.request(); @@ -182,7 +307,56 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData) { } if (pathname.endsWith("/limited-backoffice/employees") && method === "GET") { - await route.fulfill(json({ data: employeesPayload })); + 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 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; } @@ -193,6 +367,8 @@ async function mockLimitedBackofficeApi(page, authSessionData = sessionData) { calls, forbiddenCalls, priceUpdateCalls, + employeeCreateCalls, + employeeUpdateCalls, }; } @@ -262,6 +438,7 @@ test.describe("Limited backoffice", () => { await expect(page.getByTestId("limited-employees-title")).toBeVisible(); await expect(page.getByTestId("limited-employee-row-501")).toContainText("Casey Clerk"); + await expect(page.getByTestId("limited-employee-phone-501")).toHaveText("+45 12345678"); 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"); @@ -271,6 +448,142 @@ test.describe("Limited backoffice", () => { 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-cashier")).toContainText("Cashier"); + await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Orders"); + await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("View orders"); + await expect(page.getByTestId("limited-role-permissions-role-cashier")).toContainText("Create order lines"); + await expect(page.getByTestId("limited-role-permissions-role-viewer")).toContainText("Selected"); + + for (const rawPermission of [ + "list_orders", + "add_order", + "edit_order_items", + "delete_order_items", + "department_timebookings_entries_get", + "limited_backoffice_employees_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(1); + 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")).toBeDisabled(); + + await page.getByTestId("limited-employee-department-1").click(); + 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-902")).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 page.getByTestId("limited-employee-department-1").click(); + 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-903")).toContainText("Phone Worker"); + await expect(page.getByTestId("limited-employee-phone-903")).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("does not render data for a department outside the manager scope", async ({ page }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 4eb464dc..549b6f0e 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -3272,6 +3272,66 @@ test.describe("POS mobile order flow", () => { expect(createdProductIds).toEqual([53, 71, 91]); }); + test("manual step 2 blocks additional items when customer restricts additional services", async ({ page }) => { + const orderId = 9414; + const fixture = createMobilePosFixture({ + customerAttributesByNumber: { + [REGULAR_CUSTOMER_ID]: [ + { + id: 941401, + customer_number: REGULAR_CUSTOMER_ID, + attribute: "restrictAdditionalServices", + }, + ], + }, + ordersById: { + [orderId]: buildRegularOrder(orderId, { + reference: "STEP2-RESTRICT-ADDITIONAL", + reg_1: "ZZ00000", + }), + }, + orderItemsByOrderId: { + [orderId]: [], + }, + }); + + await setupMobilePosPage(page, fixture, { + token: "mobile-step2-restrict-additional-token", + seedState: { + customerId: REGULAR_CUSTOMER_ID, + reg: "ZZ00000", + reference: "STEP2-RESTRICT-ADDITIONAL", + includePrimaryItem: false, + vehicleType: null, + lastOrderId: null, + }, + route: { + step: 2, + orderId, + customerId: REGULAR_CUSTOMER_ID, + }, + }); + + await expect.poll(() => fixture.requestCounters.customerAttributesGet, { timeout: 10_000 }).toBeGreaterThan(0); + await selectPrimaryProduct(page, 53); + await expect(page.getByTestId("pos-mobile-additional-items-open")).toHaveCount(1); + + await page.getByTestId("pos-mobile-additional-items-open").click(); + await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); + + await longPressAdditionalItems(page); + await expect(page.getByTestId("pos-mobile-additional-items-selection")).toHaveCount(0); + await expect + .poll( + async () => { + const snapshot = await getStoredPosSnapshot(page); + return snapshot?.transactionItems?.additionalItems || []; + }, + { timeout: 10_000 } + ) + .toEqual([]); + }); + test("clear all deletes the order and returns to the scanner", async ({ page }) => { const orderId = 9403; const fixture = createMobilePosFixture({ diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js index 1077437a..37ab5fcb 100644 --- a/tests/e2e/self-serve-wash.spec.js +++ b/tests/e2e/self-serve-wash.spec.js @@ -124,6 +124,39 @@ async function seedSavedProgress(page, overrides = {}) { await page.addInitScript((savedProgress) => { window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); }, payload); + + try { + await page.evaluate((savedProgress) => { + window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); + }, payload); + } catch {} +} + +async function expectFinishingOrCompletedWash(page) { + const finishing = page.getByTestId("self-serve-finishing-wash"); + const completed = page.getByTestId("self-serve-completed-step"); + + await expect + .poll( + async () => { + if (await finishing.isVisible().catch(() => false)) { + return "finishing"; + } + if (await completed.isVisible().catch(() => false)) { + return "completed"; + } + return "pending"; + }, + { + message: "expected the wash to show the finishing state or complete", + timeout: 15_000, + } + ) + .toMatch(/^(finishing|completed)$/); + + if (await finishing.isVisible().catch(() => false)) { + await expect(finishing).toContainText("Afslutter vask, porten åbnes automatisk"); + } } function captureSelfServeGatewayRequests(page) { @@ -223,7 +256,7 @@ test.describe("Self-serve wash", () => { await page.goto("/user/wash"); - await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("Roskilde").first()).toBeVisible(); const orderedDepartmentNames = await page @@ -268,7 +301,7 @@ test.describe("Self-serve wash", () => { }); await page.reload({ waitUntil: "domcontentloaded" }); - await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-inline-progress")).toContainText("ZZ00000", { timeout: 20_000 }); await advanceGuidedWashToLastStep(page); await expect(page.getByTestId("self-serve-guided-next")).toBeHidden(); @@ -276,9 +309,7 @@ test.describe("Self-serve wash", () => { const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); - await expect(page.getByTestId("self-serve-finishing-wash")).toContainText( - "Afslutter vask, porten åbnes automatisk" - ); + await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; expect(stopCommandRequest.postDataJSON?.()).toMatchObject({ @@ -660,11 +691,18 @@ test.describe("Self-serve wash", () => { }); await page.reload({ waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); + await page.getByTestId("self-serve-question-21-yes").click(); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); + await page.getByTestId("self-serve-nav-confirm").click(); + await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled(); + await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Tilgængelig"); + await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Vaskebanen er ikke tilgængelig"); + await expect(page.getByRole("radio", { name: /Maskine/i })).toBeEnabled(); await expect(page.getByTestId("self-serve-machine-unavailable-guidance")).toHaveCount(0); await expect(page.locator("body")).not.toContainText(removedMachineUnavailableGuidanceText); - await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-lane-option-7").click(); await page.getByTestId("self-serve-wash-type-manual").click(); @@ -1147,9 +1185,7 @@ test.describe("Self-serve wash", () => { const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); - await expect(page.getByTestId("self-serve-finishing-wash")).toContainText( - "Afslutter vask, porten åbnes automatisk" - ); + await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; diff --git a/tests/e2e/superuser-department-pricing-custom-only.spec.ts b/tests/e2e/superuser-department-pricing-custom-only.spec.ts new file mode 100644 index 00000000..f0cf8a61 --- /dev/null +++ b/tests/e2e/superuser-department-pricing-custom-only.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; + +import { mockApi, 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 now = "2026-07-06 10:00:00"; + +const buildProduct = (product: Record) => ({ + description: "E2E product", + subscription_allowed: true, + category: 1, + piktogram: "truck", + economic_product_id: 0, + apply_category_discount: false, + requires_note: false, + created_at: now, + updated_at: now, + addons: [], + is_wash: true, + display_in_booking_form: true, + order_priority: 1, + ...product, +}); + +test.describe("Superuser department custom-only pricing", () => { + test("toggles no-fallback pricing and shows 999999 for missing department prices", async ({ page }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + let customPricingOnly = false; + let receivedToggleBody: Record | null = null; + + const products = [ + buildProduct({ id: 10, name: "Fallback Wash", price: 12345 }), + buildProduct({ id: 11, name: "Explicit Wash", price: 98765, order_priority: 2 }), + ]; + + await page.setViewportSize({ width: 1280, height: 720 }); + await seedAuthenticatedState(page, "superuser-department-pricing-token"); + await mockApi(page, { + authenticated: true, + permissions: [ + "superuser", + "user", + "list_products", + "list_departments", + "edit_department", + "superuser_fetch_department", + "superuser_fetch_department_prices", + "superuser_set_department_prices", + ], + sessionData: { + group_id: 1, + }, + }); + + await page.route("**/api/products**", async (route) => { + await route.fulfill(json({ data: products })); + }); + + await page.route("**/api/superuser/department/prices**", async (route) => { + await route.fulfill( + json({ + data: [{ id: 100, department_id: 42, product_id: 11, price: 2222 }], + }) + ); + }); + + await page.route(/\/api\/superuser\/department(?:\?.*)?$/i, async (route) => { + await route.fulfill( + json({ + data: { + id: 42, + name: "Custom Pricing Department", + description: "E2E department", + custom_pricing_only: customPricingOnly, + created_at: now, + updated_at: now, + }, + }) + ); + }); + + await page.route("**/api/departments", async (route) => { + const request = route.request(); + if (request.method() !== "PUT") { + await route.fallback(); + return; + } + + receivedToggleBody = request.postDataJSON() as Record; + customPricingOnly = Boolean(receivedToggleBody.custom_pricing_only); + await route.fulfill(json({ data: { message: "Department updated successfully" } })); + }); + + await page.goto("/superuser/departments/42/pricing", { waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("department-custom-pricing-settings")).toBeVisible(); + await expect(page.getByTestId("department-price-cell-10")).toHaveText(/-/); + await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/); + + await page.locator('label[for="department-custom-pricing-only"]').click(); + + await expect(page.getByTestId("department-custom-pricing-only-toggle")).toBeChecked(); + await expect(page.getByTestId("department-price-cell-10")).toHaveText(/999999/); + await expect(page.getByTestId("department-price-cell-11")).toHaveText(/2222/); + expect(receivedToggleBody).toMatchObject({ + id: "42", + custom_pricing_only: true, + }); + }); +}); diff --git a/tests/e2e/superuser-roles-permissions.spec.ts b/tests/e2e/superuser-roles-permissions.spec.ts new file mode 100644 index 00000000..ea327277 --- /dev/null +++ b/tests/e2e/superuser-roles-permissions.spec.ts @@ -0,0 +1,207 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js"; + +// This route is sensitive to stale cached Vue modules when the PWA service worker is active in mobile runs. +test.use({ serviceWorkers: "block" }); + +const json = (body: unknown, status = 200) => ({ + status, + contentType: "application/json", + body: JSON.stringify(body), +}); + +const permissionCatalog = { + "/orders": { + GET: { + list_orders: "List orders", + }, + POST: { + add_order: "Create orders", + }, + }, + "/admin/bookings": { + POST: { + create_bookings: "Create bookings", + }, + }, + "/superuser/system/replication": { + GET: { + superuser_replication_view: "View replication status", + }, + }, +}; + +const departments = [ + { + id: 1, + name: "Copenhagen", + }, + { + id: 2, + name: "Odense", + }, +]; + +type PermissionCall = { + action: "add" | "remove"; + permission: string; +}; + +const openGroupTab = async (page: Page, label: RegExp) => { + await page.locator(".tabs").getByText(label).click(); +}; + +const clickPermissionSwitch = async (page: Page, permission: string) => { + await page.locator(`label[for="role-permission-${permission}"]`).click(); +}; + +const installRolePermissionRoutes = async ( + page: Page, + options: { + failAction?: "add" | "remove"; + failPermission?: string; + } = {} +) => { + let rolePermissions = ["user", "superuser", "department_access_1", "list_orders"]; + const calls: PermissionCall[] = []; + + const rolePayload = () => ({ + id: 2, + name: "Operations manager", + description: "Can manage operations.", + created_at: "2026-07-06T08:00:00.000Z", + permissions: [...rolePermissions], + }); + + await page.route(apiPathPattern("/permissions"), async (route: Route) => { + await route.fulfill(json({ data: permissionCatalog })); + }); + + await page.route(apiPathPattern("/departments"), async (route: Route) => { + await route.fulfill(json({ data: departments })); + }); + + await page.route(apiPathPattern("/roles"), async (route: Route) => { + await route.fulfill(json({ data: rolePayload() })); + }); + + await page.route(apiPathPattern("/roles/permissions"), async (route: Route) => { + const request = route.request(); + const method = request.method(); + const url = new URL(request.url()); + const body = method === "POST" ? request.postDataJSON() : {}; + const permission = + method === "POST" ? String(body.permission_id || "") : String(url.searchParams.get("permission_id") || ""); + const action = method === "POST" ? "add" : "remove"; + + calls.push({ action, permission }); + + if (options.failAction === action && options.failPermission === permission) { + await route.fulfill(json({ data: { message: "Mutation failed" } }, 500)); + return; + } + + if (action === "add" && !rolePermissions.includes(permission)) { + rolePermissions = [...rolePermissions, permission]; + } + if (action === "remove") { + rolePermissions = rolePermissions.filter((value) => value !== permission); + } + + await route.fulfill(json({ data: rolePayload() })); + }); + + return { + calls, + getRolePermissions: () => [...rolePermissions], + }; +}; + +const bootRolePermissionsPage = async (page: Page, routeOptions = {}) => { + await page.addInitScript(() => { + window.localStorage.setItem("locale", "en"); + }); + await seedAuthenticatedState(page, "superuser-role-permissions-token"); + await mockApi(page, { + authenticated: true, + permissions: [ + "superuser", + "user", + "permissions_list", + "list_roles", + "add_role_permission", + "delete_role_permission", + ], + sessionData: { + group_id: 1, + }, + }); + const harness = await installRolePermissionRoutes(page, routeOptions); + + await page.goto("/superuser/roles/2", { waitUntil: "domcontentloaded" }); + await expect(page.getByTestId("role-permissions-page")).toBeVisible(); + await expect(page.getByTestId("role-permissions-loading")).toHaveCount(0); + + return harness; +}; + +test.describe("Superuser role permissions", () => { + test("@smoke @pr loads grouped permissions, searches, and toggles without a blank page", async ({ page }) => { + const { calls } = await bootRolePermissionsPage(page); + + await expect(page.getByText("Operations manager permissions")).toBeVisible(); + await expect(page.getByTestId("role-permissions-selected-count")).toContainText("4 of 9 selected"); + await expect(page.getByRole("heading", { name: "Access" })).toBeVisible(); + + await openGroupTab(page, /Orders and POS/); + await expect(page.getByTestId("role-permission-row-list_orders")).toContainText("List orders"); + await clickPermissionSwitch(page, "list_orders"); + + await expect.poll(() => calls).toContainEqual({ action: "remove", permission: "list_orders" }); + await expect(page.getByTestId("role-permissions-page")).toBeVisible(); + await expect(page.locator("body")).not.toContainText(/white screen|TypeError|Unhandled/i); + + await page.getByTestId("role-permissions-search").fill("booking"); + await openGroupTab(page, /Bookings/); + await expect(page.getByTestId("role-permission-row-create_bookings")).toContainText("Create bookings"); + await openGroupTab(page, /Orders and POS/); + await expect(page.getByTestId("role-permissions-empty-orders_pos")).toBeVisible(); + }); + + test("selects and clears all permissions in a group without no-op requests", async ({ page }) => { + const { calls, getRolePermissions } = await bootRolePermissionsPage(page); + + await openGroupTab(page, /Orders and POS/); + await page.getByTestId("role-permissions-clear-all-orders_pos").click(); + await expect.poll(() => calls).toEqual([{ action: "remove", permission: "list_orders" }]); + expect(getRolePermissions()).not.toContain("list_orders"); + expect(getRolePermissions()).not.toContain("add_order"); + + await page.getByTestId("role-permissions-select-all-orders_pos").click(); + await expect + .poll(() => calls) + .toEqual([ + { action: "remove", permission: "list_orders" }, + { action: "add", permission: "add_order" }, + { action: "add", permission: "list_orders" }, + ]); + expect(getRolePermissions()).toEqual(expect.arrayContaining(["list_orders", "add_order"])); + }); + + test("keeps the page rendered and reloads role permissions when a mutation fails", async ({ page }) => { + const { calls, getRolePermissions } = await bootRolePermissionsPage(page, { + failAction: "add", + failPermission: "add_order", + }); + + await openGroupTab(page, /Orders and POS/); + await clickPermissionSwitch(page, "add_order"); + + await expect.poll(() => calls).toContainEqual({ action: "add", permission: "add_order" }); + await expect(page.getByTestId("role-permission-error")).toBeVisible(); + await expect(page.getByTestId("role-permissions-page")).toBeVisible(); + await expect(page.getByTestId("role-permission-toggle-add_order")).not.toBeChecked(); + expect(getRolePermissions()).not.toContain("add_order"); + }); +}); diff --git a/tests/e2e/superuser-vehicles.smoke.spec.js b/tests/e2e/superuser-vehicles.smoke.spec.js index f2ec8a7e..0f1523ea 100644 --- a/tests/e2e/superuser-vehicles.smoke.spec.js +++ b/tests/e2e/superuser-vehicles.smoke.spec.js @@ -7,10 +7,13 @@ async function primeSuperuserSession(page) { } test.describe("Superuser vehicles smoke", () => { + test.describe.configure({ mode: "serial" }); + test.beforeEach(async ({ page }) => { await mockApi(page, { authenticated: true, permissions: ["superuser", "user"], + pos: true, }); await primeSuperuserSession(page); }); @@ -23,4 +26,51 @@ test.describe("Superuser vehicles smoke", () => { await expect(page.locator("body")).toContainText(/registrerede|registered/i); await expect(page.locator("body")).not.toContainText(/Order ID is required/i); }); + + test("superuser can add a vehicle after selecting a customer from searchable results", async ({ page }) => { + const createVehiclePayloads = []; + + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname.endsWith("/vehicles") && request.method() === "POST") { + createVehiclePayloads.push(request.postDataJSON()); + } + }); + + await page.goto("/superuser/vehicles"); + + await page.getByTestId("superuser-vehicles-add").click(); + await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeVisible(); + + await page.getByTestId("superuser-add-vehicle-customer-input").fill("12345679"); + await expect(page.getByTestId("superuser-add-vehicle-customer-option-0")).toBeVisible(); + await page.getByTestId("superuser-add-vehicle-customer-option-0").click(); + await expect(page.getByTestId("superuser-add-vehicle-customer-selected")).toContainText("#12345679"); + + await page.getByTestId("superuser-add-vehicle-registration").fill("ab12345"); + await expect(page.getByTestId("superuser-add-vehicle-type")).toBeEnabled(); + await page.getByTestId("superuser-add-vehicle-type").selectOption("53"); + await page.getByTestId("superuser-add-vehicle-wash-subscription").check(); + await page.getByTestId("superuser-add-vehicle-reference").fill("Fleet reference"); + + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes("/vehicles") && response.request().method() === "POST" + ), + page.getByTestId("superuser-add-vehicle-submit").click(), + ]); + + expect(createVehiclePayloads).toEqual([ + { + type: 53, + reg: "AB12345", + wash_subscription: true, + customer_id: 12345679, + reference: "Fleet reference", + }, + ]); + + await expect(page.getByTestId("superuser-add-vehicle-modal")).toBeHidden(); + await expect(page.getByTestId("user-vehicles-table")).toContainText("AB12345"); + }); }); diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index 60163eae..e25c4a8c 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -2736,6 +2736,7 @@ export function createPosFixture(overrides = {}) { last_order_id: 54518, }, ], + nextVehicleId: 7002, unknownVehicles: [], orderBookings: [], bookingOrderAssignments: [], @@ -3278,6 +3279,30 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos return true; } + if (pathname.endsWith("/vehicles") && method === "POST") { + const body = request.postDataJSON?.() || {}; + const customerId = Number(body.customer_id || 0); + const customer = posFixture.customersByNumber[customerId] || null; + const vehicle = { + id: posFixture.nextVehicleId || 9001, + reg: String(body.reg || "").toUpperCase(), + customer_id: customerId, + customer_name: customer?.name || "", + type: Number(body.type || 0), + status: "verified", + barred: false, + wash_subscription: Boolean(body.wash_subscription), + addons: { enabled: 0, available: 0, list: [] }, + reference: body.reference || null, + }; + + posFixture.nextVehicleId = vehicle.id + 1; + posFixture.vehicles = [vehicle, ...(posFixture.vehicles || [])]; + + await route.fulfill(json({ success: true, data: vehicle })); + return true; + } + if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") { await route.fulfill(json({ success: true, data: posFixture.unknownVehicles || [] })); return true; diff --git a/tests/e2e/workfeed-config.smoke.spec.js b/tests/e2e/workfeed-config.smoke.spec.js index 66489d43..86584bb0 100644 --- a/tests/e2e/workfeed-config.smoke.spec.js +++ b/tests/e2e/workfeed-config.smoke.spec.js @@ -6,7 +6,12 @@ async function primeSuperuserSession(page) { await primeMockSession(page, { token }); } -async function runDiagnostic(page, testId, inputValue = "") { +async function expectDiagnosticResult(page, expectedText) { + const result = page.locator(".swal2-popup:visible pre").first(); + await expect(result).toContainText(expectedText); +} + +async function runDiagnostic(page, testId, { inputValue = "", expectedText } = {}) { await page.locator(`[data-testid="${testId}"]:visible`).first().click(); await expect(page.locator(".swal2-popup")).toBeVisible(); const input = page.locator(".swal2-input"); @@ -14,10 +19,10 @@ async function runDiagnostic(page, testId, inputValue = "") { await input.fill(inputValue); } await page.locator(".swal2-confirm").click(); - await expect(page.locator(".swal2-popup pre")).toBeVisible(); + await expectDiagnosticResult(page, expectedText); } -async function runShiftDiagnostic(page, { startFrom, startTo, employeeID = "", released = "" }) { +async function runShiftDiagnostic(page, { startFrom, startTo, employeeID = "", released = "", expectedText }) { await page.locator('[data-testid="workfeed-diagnostics-list-shifts"]:visible').first().click(); await expect(page.locator(".swal2-popup")).toBeVisible(); await page.locator("#workfeed-start-from").fill(startFrom); @@ -29,11 +34,13 @@ async function runShiftDiagnostic(page, { startFrom, startTo, employeeID = "", r await page.locator("#workfeed-released").selectOption(released); } await page.locator(".swal2-confirm").click(); - await expect(page.locator(".swal2-popup pre")).toBeVisible(); + await expectDiagnosticResult(page, expectedText); } async function closeResultModal(page) { - await page.locator(".swal2-confirm").click(); + const confirmButton = page.locator(".swal2-popup:visible .swal2-confirm").first(); + await expect(confirmButton).toBeVisible(); + await confirmButton.evaluate((button) => button.click()); await expect(page.locator(".swal2-popup")).toBeHidden(); } @@ -67,12 +74,10 @@ test.describe("Workfeed configuration smoke", () => { await expect(page.locator('[data-testid="workfeed-diagnostics-get-employee"]:visible').first()).toBeVisible(); await expect(page.locator('[data-testid="workfeed-diagnostics-get-shift"]:visible').first()).toBeVisible(); - await runDiagnostic(page, "workfeed-diagnostics-list-departments"); - await expect(page.locator(".swal2-popup pre")).toContainText("North Facility"); + await runDiagnostic(page, "workfeed-diagnostics-list-departments", { expectedText: "North Facility" }); await closeResultModal(page); - await runDiagnostic(page, "workfeed-diagnostics-list-employees"); - await expect(page.locator(".swal2-popup pre")).toContainText("Anne Nielsen"); + await runDiagnostic(page, "workfeed-diagnostics-list-employees", { expectedText: "Anne Nielsen" }); await closeResultModal(page); await runShiftDiagnostic(page, { @@ -80,16 +85,20 @@ test.describe("Workfeed configuration smoke", () => { startTo: "2026-03-25T00:00", employeeID: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P", released: "true", + expectedText: "Morning Shift", }); - await expect(page.locator(".swal2-popup pre")).toContainText("Morning Shift"); await closeResultModal(page); - await runDiagnostic(page, "workfeed-diagnostics-get-employee", "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P"); - await expect(page.locator(".swal2-popup pre")).toContainText("Anne Nielsen"); + await runDiagnostic(page, "workfeed-diagnostics-get-employee", { + inputValue: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P", + expectedText: "Anne Nielsen", + }); await closeResultModal(page); - await runDiagnostic(page, "workfeed-diagnostics-get-shift", "shf_01J5P3X9D35R9C6BDZ1S0R4V6Q"); - await expect(page.locator(".swal2-popup pre")).toContainText("Morning Shift"); + await runDiagnostic(page, "workfeed-diagnostics-get-shift", { + inputValue: "shf_01J5P3X9D35R9C6BDZ1S0R4V6Q", + expectedText: "Morning Shift", + }); await closeResultModal(page); }); }); diff --git a/tests/unit/customer-search-select.spec.js b/tests/unit/customer-search-select.spec.js new file mode 100644 index 00000000..0d628faf --- /dev/null +++ b/tests/unit/customer-search-select.spec.js @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const searchMocks = vi.hoisted(() => ({ + isSearchingRef: null, + resultsRef: null, + searchCustomer: vi.fn(), +})); + +vi.mock("@/components/search/economic/customerSearch.vue", async () => { + const { ref } = await vi.importActual("vue"); + + searchMocks.isSearchingRef = ref(false); + searchMocks.resultsRef = ref([]); + + return { + isSearching: searchMocks.isSearchingRef, + searchCustomerResults: searchMocks.resultsRef, + searchCustomer: searchMocks.searchCustomer, + }; +}); + +import CustomerSearchSelect from "@/components/search/economic/CustomerSearchSelect.vue"; + +const customers = [ + { + customerNumber: 12345679, + name: "Acme Transport", + city: "Taastrup", + }, + { + customerNumber: 87654321, + name: "Nordic Wash", + city: "Copenhagen", + }, +]; + +const messages = { + en: { + vehicles: { + add_modal: { + customer_label: "Customer", + customer_placeholder: "Search customers", + selected_customer: "Selected customer", + }, + }, + common: { + clear: "Clear", + }, + }, +}; + +const mountComponent = (props = {}) => + mountWithApp(CustomerSearchSelect, { + props: { + inputId: "test-customer-search", + testIdPrefix: "test-customer", + ...props, + }, + messages, + }); + +describe("CustomerSearchSelect", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchMocks.isSearchingRef.value = false; + searchMocks.resultsRef.value = []; + searchMocks.searchCustomer.mockImplementation((query) => { + searchMocks.resultsRef.value = query ? customers : []; + return Promise.resolve(searchMocks.resultsRef.value); + }); + }); + + it("searches customers and emits the selected customer", async () => { + const wrapper = mountComponent(); + + await wrapper.get('[data-testid="test-customer-input"]').setValue("acme"); + await nextTick(); + + expect(searchMocks.searchCustomer).toHaveBeenLastCalledWith("acme"); + expect(wrapper.get('[data-testid="test-customer-option-0"]').text()).toContain("Acme Transport"); + + await wrapper.get('[data-testid="test-customer-option-0"]').trigger("mousedown"); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[0]]); + expect(wrapper.emitted("selected").at(-1)).toEqual([customers[0]]); + await wrapper.setProps({ modelValue: customers[0] }); + await nextTick(); + + expect(wrapper.get('[data-testid="test-customer-selected"]').text()).toContain("Acme Transport"); + }); + + it("supports keyboard selection and clearing", async () => { + const wrapper = mountComponent(); + + await wrapper.get('[data-testid="test-customer-input"]').setValue("nordic"); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" }); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "ArrowDown" }); + await wrapper.get('[data-testid="test-customer-input"]').trigger("keydown", { key: "Enter" }); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([customers[1]]); + await wrapper.setProps({ modelValue: customers[1] }); + await nextTick(); + + await wrapper.get('[data-testid="test-customer-clear"]').trigger("click"); + await nextTick(); + + expect(wrapper.emitted("update:modelValue").at(-1)).toEqual([null]); + expect(wrapper.emitted("cleared")).toHaveLength(1); + }); +}); diff --git a/tests/unit/date-only.spec.js b/tests/unit/date-only.spec.js new file mode 100644 index 00000000..a8e1cd7c --- /dev/null +++ b/tests/unit/date-only.spec.js @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { endOfLocalDate, formatLocalDateOnly, parseLocalDateOnly, startOfLocalDate } from "@/services/dateOnly.js"; + +describe("date-only helpers", () => { + it("formats Date objects from their local calendar date", () => { + expect(formatLocalDateOnly(new Date(2026, 2, 31, 0, 0, 0, 0))).toBe("2026-03-31"); + }); + + it("parses date input values as local calendar dates", () => { + const parsed = parseLocalDateOnly("2026-03-31"); + + expect(parsed.getFullYear()).toBe(2026); + expect(parsed.getMonth()).toBe(2); + expect(parsed.getDate()).toBe(31); + expect(formatLocalDateOnly(parsed)).toBe("2026-03-31"); + }); + + it("keeps date-only prefixes from API timestamps without timezone reinterpretation", () => { + expect(formatLocalDateOnly("2026-03-31T23:30:00.000Z")).toBe("2026-03-31"); + }); + + it("rejects impossible date input values", () => { + expect(Number.isNaN(parseLocalDateOnly("2026-02-31").getTime())).toBe(true); + }); + + it("builds local day boundaries from the selected date", () => { + expect(startOfLocalDate("2026-03-31").getHours()).toBe(0); + expect(startOfLocalDate("2026-03-31").getMinutes()).toBe(0); + expect(endOfLocalDate("2026-03-31").getHours()).toBe(23); + expect(endOfLocalDate("2026-03-31").getMinutes()).toBe(59); + expect(formatLocalDateOnly(endOfLocalDate("2026-03-31"))).toBe("2026-03-31"); + }); +}); diff --git a/tests/unit/date-period-selector.spec.js b/tests/unit/date-period-selector.spec.js index c516c724..91620d02 100644 --- a/tests/unit/date-period-selector.spec.js +++ b/tests/unit/date-period-selector.spec.js @@ -122,6 +122,32 @@ describe("DatePeriodSelector mobile layout", () => { expect(formatLocalDate(onSelectionChange.mock.calls[0][0])).toBe("2026-03-01"); expect(formatLocalDate(onSelectionChange.mock.calls[0][1])).toBe("2026-03-05"); }); + + it("emits the exact clicked date for start and end inputs", async () => { + sharedState.width.value = 1024; + const onSelectionChange = vi.fn(); + const wrapper = mount(DatePeriodSelector, { + props: { + selection: { + startDate: new Date(2026, 2, 1, 0, 0, 0, 0), + endDate: new Date(2026, 2, 3, 23, 59, 59, 999), + }, + onSelectionChange, + visibility: { + showUpdateButton: false, + }, + }, + }); + + await wrapper.get("[data-testid='date-period-start']").setValue("2026-03-31"); + await wrapper.get("[data-testid='date-period-end']").setValue("2026-04-02"); + + const emittedSelection = wrapper.emitted("update:selection")?.at(-1)?.[0]; + expect(formatLocalDate(emittedSelection.startDate)).toBe("2026-03-31"); + expect(formatLocalDate(emittedSelection.endDate)).toBe("2026-04-02"); + expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[0])).toBe("2026-03-31"); + expect(formatLocalDate(onSelectionChange.mock.calls.at(-1)[1])).toBe("2026-04-02"); + }); }); describe("DatePeriodSelector month warning", () => { diff --git a/tests/unit/department-overview-navigation.behavior.spec.js b/tests/unit/department-overview-navigation.behavior.spec.js index 03a22e5e..3911ba1a 100644 --- a/tests/unit/department-overview-navigation.behavior.spec.js +++ b/tests/unit/department-overview-navigation.behavior.spec.js @@ -2,6 +2,7 @@ import { defineComponent, nextTick } from "vue"; import { mount } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { formatLocalDateOnly } from "@/services/dateOnly.js"; const selectDateMock = vi.hoisted(() => vi.fn()); const sharedDateState = vi.hoisted(() => ({ @@ -39,10 +40,10 @@ const DatePeriodSelectorStub = defineComponent({ emits: ["update:selection"], template: `
- -
@@ -105,7 +106,7 @@ describe("DepartmentDashboardOverviewNavigation", () => { }); const selector = wrapper.findComponent(DatePeriodSelectorStub); - expect(selector.props("selection").startDate.toISOString().split("T")[0]).toBe("2026-05-05"); - expect(selector.props("selection").endDate.toISOString().split("T")[0]).toBe("2026-05-11"); + expect(formatLocalDateOnly(selector.props("selection").startDate)).toBe("2026-05-05"); + expect(formatLocalDateOnly(selector.props("selection").endDate)).toBe("2026-05-11"); }); }); diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index dfe3e2b3..2727fb53 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -579,6 +579,7 @@ describe("MyWashStart", () => { afterEach(() => { consoleWarnSpy?.mockRestore(); + vi.clearAllTimers(); vi.useRealTimers(); localStorage.clear(); }); diff --git a/tests/unit/pagination-date-selection.spec.js b/tests/unit/pagination-date-selection.spec.js new file mode 100644 index 00000000..4ca3aebb --- /dev/null +++ b/tests/unit/pagination-date-selection.spec.js @@ -0,0 +1,110 @@ +// @vitest-environment jsdom +import { defineComponent } from "vue"; +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("vue-i18n", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + useI18n: () => ({ + t: (value) => value, + }), + }; +}); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + objects: { + global: { + language: { + date_from: "From", + date_to: "To", + }, + }, + }, + }, +})); + +import { formatLocalDateOnly } from "@/services/dateOnly.js"; +import PaginationDisplayTemplateDate from "@/components/displays/pagination/templates/PaginationDisplayTemplateDate.vue"; +import PaginationDisplayTemplateDates from "@/components/displays/pagination/templates/PaginationDisplayTemplateDates.vue"; +import { dateFunctions } from "@/components/displays/pagination/PaginationDisplayDates.vue"; + +const PaginationDisplayItemColumnStub = defineComponent({ + name: "PaginationDisplayItemColumn", + props: { + label: { + type: String, + required: true, + }, + }, + template: "
", +}); + +describe("pagination date selection", () => { + it("renders Date model values as the same local date", () => { + const wrapper = mount(PaginationDisplayTemplateDate, { + props: { + label: "Date", + modelValue: new Date(2026, 2, 31, 0, 0, 0, 0), + }, + global: { + stubs: { + PaginationDisplayItemColumn: PaginationDisplayItemColumnStub, + }, + }, + }); + + expect(wrapper.get("input[type='date']").element.value).toBe("2026-03-31"); + }); + + it("emits the clicked input date as the same local date", async () => { + const wrapper = mount(PaginationDisplayTemplateDate, { + props: { + label: "Date", + modelValue: new Date(2026, 2, 1, 0, 0, 0, 0), + }, + global: { + stubs: { + PaginationDisplayItemColumn: PaginationDisplayItemColumnStub, + }, + }, + }); + + await wrapper.get("input[type='date']").setValue("2026-03-31"); + + const emittedDate = wrapper.emitted("update:date")?.at(-1)?.[0]; + expect(formatLocalDateOnly(emittedDate)).toBe("2026-03-31"); + }); + + it("uses the real first day of the month for month presets", () => { + const firstDay = dateFunctions.datePresetFunctions.month.firstDayOfMonth(new Date(2026, 4, 15, 12, 0, 0, 0)); + + expect(formatLocalDateOnly(firstDay)).toBe("2026-05-01"); + expect(firstDay.getHours()).toBe(0); + expect(firstDay.getMinutes()).toBe(0); + }); + + it("emits both start and end updates from the paired date selector", async () => { + const wrapper = mount(PaginationDisplayTemplateDates, { + props: { + startDate: new Date(2026, 2, 1, 0, 0, 0, 0), + endDate: new Date(2026, 2, 31, 0, 0, 0, 0), + }, + global: { + stubs: { + PaginationDisplayItemColumn: PaginationDisplayItemColumnStub, + }, + }, + }); + + const inputs = wrapper.findAll("input[type='date']"); + await inputs[0].setValue("2026-04-01"); + await inputs[1].setValue("2026-04-30"); + + expect(formatLocalDateOnly(wrapper.emitted("update:startDate")?.at(-1)?.[0])).toBe("2026-04-01"); + expect(formatLocalDateOnly(wrapper.emitted("update:endDate")?.at(-1)?.[0])).toBe("2026-04-30"); + }); +}); diff --git a/tests/unit/playwright-full-workflow.spec.js b/tests/unit/playwright-full-workflow.spec.js index 61d033a4..a255c4a1 100644 --- a/tests/unit/playwright-full-workflow.spec.js +++ b/tests/unit/playwright-full-workflow.spec.js @@ -40,6 +40,16 @@ describe("Playwright full E2E workflow grouping", () => { expect(source).toContain("scripts/ci/runner-diagnostics.sh"); }); + it("keeps PR E2E runner pressure bounded and diagnosable", () => { + const source = workflowSource(); + + expect(source).toMatch(/e2e-pr:[\s\S]*?max-parallel: 4/u); + expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_WORKERS: 1/u); + expect(source).toMatch(/e2e-pr:[\s\S]*?PLAYWRIGHT_VIDEO_MODE: on-first-retry/u); + expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_WORKERS="\$PLAYWRIGHT_WORKERS"/u); + expect(source).toMatch(/e2e-pr:[\s\S]*?--env PLAYWRIGHT_VIDEO_MODE="\$PLAYWRIGHT_VIDEO_MODE"/u); + }); + it("allows CI to reduce Playwright video artifact pressure", () => { const source = readFileSync(join(root, "playwright.config.ts"), "utf8"); diff --git a/tests/unit/playwright-pr-mapping.spec.js b/tests/unit/playwright-pr-mapping.spec.js index a9a0683b..6232cb2b 100644 --- a/tests/unit/playwright-pr-mapping.spec.js +++ b/tests/unit/playwright-pr-mapping.spec.js @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { sourceMappings } from "../../scripts/playwright-pr-mapping.mjs"; +import { fallbackChangePatterns, sourceMappings } from "../../scripts/playwright-pr-mapping.mjs"; const specsFor = (file) => sourceMappings @@ -7,12 +7,29 @@ const specsFor = (file) => .flatMap((mapping) => mapping.specs); describe("Playwright PR mapping", () => { + const triggersFallback = (file) => fallbackChangePatterns.some((pattern) => pattern.test(file)); + it("maps user vehicle table changes to the user vehicles E2E coverage", () => { expect(specsFor("src/components/displays/user/vehicles/vehiclesTable.vue")).toContain( "tests/e2e/userVehicles.spec.ts" ); }); + it("maps superuser department pricing changes to custom-only pricing coverage", () => { + expect(specsFor("src/views/dashboards/superUserDashboard/department/DepartmentPricing.vue")).toContain( + "tests/e2e/superuser-department-pricing-custom-only.spec.ts" + ); + expect( + specsFor("src/views/dashboards/superUserDashboard/department/SuperUserSelectedDepartmentObject.vue") + ).toContain("tests/e2e/superuser-department-pricing-custom-only.spec.ts"); + expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).toContain( + "tests/e2e/superuser-department-pricing-custom-only.spec.ts" + ); + expect(specsFor("src/components/session/token/SessionUser/Objects/Departments.vue")).not.toContain( + "tests/e2e/userAuth.spec.ts" + ); + }); + it("maps department notification table changes to the admin notification E2E coverage", () => { expect( specsFor("src/components/displays/department/notifications/departmentNotificationsPhoneTable.vue") @@ -21,4 +38,25 @@ describe("Playwright PR mapping", () => { specsFor("src/components/displays/pagination/models/DepartmentPos/NotificationsPhonePagination.vue") ).toContain("tests/e2e/admin-department-notifications.spec.ts"); }); + + it("maps superuser role permission page changes to role permissions E2E coverage", () => { + expect(specsFor("src/views/dashboards/superUserDashboard/roles/SuperUserRolesPermissions.vue")).toContain( + "tests/e2e/superuser-roles-permissions.spec.ts" + ); + expect(specsFor("src/views/dashboards/superUserDashboard/roles/rolePermissionCatalog.js")).toContain( + "tests/e2e/superuser-roles-permissions.spec.ts" + ); + }); + + it("keeps PR runner and full-slice metadata edits out of broad PR smoke fallback", () => { + expect(triggersFallback("scripts/run-playwright-pr.mjs")).toBe(false); + expect(triggersFallback("scripts/run-playwright-ci-parallel.mjs")).toBe(true); + expect(triggersFallback("scripts/run-playwright-full-slice.mjs")).toBe(false); + }); + + it("maps limited backoffice changes to the limited backoffice E2E coverage", () => { + expect(specsFor("src/views/backoffice/LimitedBackofficeEmployees.vue")).toContain( + "tests/e2e/limited-backoffice.spec.ts" + ); + }); }); diff --git a/tests/unit/role-permission-catalog.spec.js b/tests/unit/role-permission-catalog.spec.js new file mode 100644 index 00000000..bc9944ed --- /dev/null +++ b/tests/unit/role-permission-catalog.spec.js @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { + filterPermissionRecords, + getPermissionDelta, + groupPermissionRecords, + humanizeEndpoint, + humanizePermissionKey, + normalizePermissionCatalog, + sanitizePermissionDomId, +} from "../../src/views/dashboards/superUserDashboard/roles/rolePermissionCatalog.js"; + +const catalog = { + "/orders": { + GET: { + list_orders: "List orders", + }, + POST: { + add_order: "Add an order", + }, + }, + "/admin/bookings": { + POST: { + create_bookings: "Create bookings", + }, + }, + "/superuser/system/replication": { + GET: { + superuser_replication_view: "View replication status", + }, + }, +}; + +const departments = [ + { + id: 7, + name: "Copenhagen", + }, +]; + +const permissionOverrides = { + user: { + label: "User", + description: "Allows access to user dashboard pages.", + groupKey: "access", + }, + admin: { + label: "Admin", + description: "Allows access to admin dashboard pages.", + groupKey: "access", + }, + superuser: { + label: "Superuser", + description: "Allows access to superuser dashboard pages.", + groupKey: "access", + }, + department_access_7: { + label: "Copenhagen", + description: "Allows access to Copenhagen.", + groupKey: "access", + departmentId: 7, + }, +}; + +describe("role permission catalog helpers", () => { + it("normalizes route permissions and virtual access permissions", () => { + const records = normalizePermissionCatalog(catalog, { + departments, + permissionOverrides, + }); + + expect(records.map((record) => record.permission)).toEqual( + expect.arrayContaining([ + "user", + "admin", + "superuser", + "department_access_7", + "list_orders", + "add_order", + "create_bookings", + "superuser_replication_view", + ]) + ); + expect(records.find((record) => record.permission === "department_access_7")).toMatchObject({ + groupKey: "access", + label: "Copenhagen", + description: "Allows access to Copenhagen.", + isVirtual: true, + }); + expect(records.find((record) => record.permission === "list_orders")).toMatchObject({ + groupKey: "orders_pos", + label: "List orders", + description: "List orders", + endpoint: "/orders", + endpointLabel: "Orders", + method: "GET", + }); + expect(records.find((record) => record.permission === "create_bookings")).toMatchObject({ + groupKey: "bookings", + }); + expect(records.find((record) => record.permission === "superuser_replication_view")).toMatchObject({ + groupKey: "system_config", + }); + }); + + it("searches labels, descriptions, keys, endpoints, and methods", () => { + const records = normalizePermissionCatalog(catalog, { + departments, + permissionOverrides, + }); + + expect(filterPermissionRecords(records, "copenhagen").map((record) => record.permission)).toEqual([ + "department_access_7", + ]); + expect(filterPermissionRecords(records, "POST").map((record) => record.permission)).toEqual( + expect.arrayContaining(["add_order", "create_bookings"]) + ); + expect(filterPermissionRecords(records, "replication status").map((record) => record.permission)).toEqual([ + "superuser_replication_view", + ]); + }); + + it("groups records by stable group key", () => { + const grouped = groupPermissionRecords( + normalizePermissionCatalog(catalog, { + departments, + permissionOverrides, + }) + ); + + expect(grouped.get("access").map((record) => record.permission)).toEqual( + expect.arrayContaining(["user", "admin", "superuser", "department_access_7"]) + ); + expect(grouped.get("orders_pos").map((record) => record.permission)).toEqual( + expect.arrayContaining(["list_orders", "add_order"]) + ); + expect(grouped.get("bookings").map((record) => record.permission)).toEqual(["create_bookings"]); + }); + + it("calculates bulk add and remove deltas without no-op requests", () => { + const records = normalizePermissionCatalog(catalog, { + departments, + permissionOverrides, + }).filter((record) => ["list_orders", "add_order"].includes(record.permission)); + + expect(getPermissionDelta(records, new Set(["list_orders"]), true)).toEqual({ + add: ["add_order"], + remove: [], + }); + expect(getPermissionDelta(records, new Set(["list_orders"]), false)).toEqual({ + add: [], + remove: ["list_orders"], + }); + }); + + it("humanizes and sanitizes permission display values", () => { + expect(humanizePermissionKey("superuser_replication_view")).toBe("Superuser replication view"); + expect(humanizeEndpoint("/superuser/system/replication")).toBe("Superuser system replication"); + expect(sanitizePermissionDomId("department/access 7")).toBe("department-access-7"); + }); +}); diff --git a/tests/unit/setup.js b/tests/unit/setup.js index 13aa8ab6..237eccb5 100644 --- a/tests/unit/setup.js +++ b/tests/unit/setup.js @@ -95,6 +95,7 @@ afterEach(async () => { await Promise.resolve(); await Promise.resolve(); + vi.clearAllTimers(); vi.useRealTimers(); vi.clearAllMocks(); vi.unstubAllGlobals(); diff --git a/tests/unit/superuser-add-vehicle-modal.spec.js b/tests/unit/superuser-add-vehicle-modal.spec.js new file mode 100644 index 00000000..40a7497e --- /dev/null +++ b/tests/unit/superuser-add-vehicle-modal.spec.js @@ -0,0 +1,152 @@ +// @vitest-environment jsdom +import { nextTick } from "vue"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { mountWithApp } from "./helpers/mountWithApp.js"; + +const sessionMocks = vi.hoisted(() => ({ + addVehicle: vi.fn(), + parseErrorMessage: vi.fn(), + vehicleTypeOptions: vi.fn(), +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => { + const sessionUser = { + functions: { + parseErrorMessage: sessionMocks.parseErrorMessage, + }, + objects: { + vehicles: { + add: sessionMocks.addVehicle, + columns: { + type: { + options: sessionMocks.vehicleTypeOptions, + }, + }, + }, + }, + }; + + return { + SessionUser: sessionUser, + default: sessionUser, + }; +}); + +import SuperuserAddVehicleModal from "@/components/forms/superUser/SuperuserAddVehicleModal.vue"; + +const CustomerSearchSelectStub = { + props: ["modelValue"], + emits: ["update:modelValue"], + template: ` + + `, +}; + +const messages = { + en: { + vehicles: { + add_modal: { + customer_label: "Customer", + customer_placeholder: "Search customers", + error: "Unable to add vehicle.", + reference_placeholder: "Optional reference", + registration_placeholder: "Registration number", + selected_customer: "Selected customer", + submit: "Add vehicle", + title: "Add vehicle", + type_load_error: "Unable to load vehicle types.", + type_placeholder: "Select vehicle type", + validation_error: "Select required fields.", + }, + form: { + license_plate: "Registration", + type: "Type", + }, + }, + objects: { + vehicles: { + columns: { + wash_subscription: "Wash subscription", + }, + }, + }, + common: { + cancel: "Cancel", + close: "Close", + reference: "Reference", + }, + }, +}; + +const flushAll = async () => { + await Promise.resolve(); + await nextTick(); + await Promise.resolve(); + await nextTick(); +}; + +const mountComponent = () => + mountWithApp(SuperuserAddVehicleModal, { + messages, + global: { + stubs: { + CustomerSearchSelect: CustomerSearchSelectStub, + }, + }, + }); + +describe("SuperuserAddVehicleModal", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionMocks.vehicleTypeOptions.mockResolvedValue([ + { + id: 53, + name: "Forvogn", + }, + ]); + sessionMocks.addVehicle.mockResolvedValue({ + data: { + data: { + id: 7002, + }, + }, + }); + sessionMocks.parseErrorMessage.mockReturnValue(null); + }); + + it("submits the selected customer and vehicle fields through the vehicle API", async () => { + const wrapper = mountComponent(); + await flushAll(); + + await wrapper.get('[data-testid="customer-select-stub"]').trigger("click"); + await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("ab12345"); + await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53"); + await wrapper.get('[data-testid="superuser-add-vehicle-wash-subscription"]').setValue(true); + await wrapper.get('[data-testid="superuser-add-vehicle-reference"]').setValue("Fleet ref"); + await wrapper.get('[data-testid="superuser-add-vehicle-submit"]').trigger("click"); + await flushAll(); + + expect(sessionMocks.addVehicle).toHaveBeenCalledWith(53, "AB12345", true, 12345679, "Fleet ref"); + expect(wrapper.emitted("created")).toHaveLength(1); + }); + + it("keeps submit disabled until required fields are present", async () => { + const wrapper = mountComponent(); + await flushAll(); + + expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeDefined(); + + await wrapper.get('[data-testid="customer-select-stub"]').trigger("click"); + await wrapper.get('[data-testid="superuser-add-vehicle-registration"]').setValue("AB12345"); + await wrapper.get('[data-testid="superuser-add-vehicle-type"]').setValue("53"); + await nextTick(); + + expect(wrapper.get('[data-testid="superuser-add-vehicle-submit"]').attributes("disabled")).toBeUndefined(); + }); +}); diff --git a/tests/unit/superuser-invoices-view.spec.js b/tests/unit/superuser-invoices-view.spec.js index 66aad115..b887c5eb 100644 --- a/tests/unit/superuser-invoices-view.spec.js +++ b/tests/unit/superuser-invoices-view.spec.js @@ -371,6 +371,8 @@ describe("Invoice orders pagination contract", () => { expect(invoiceOrdersPaginationSource).toContain( "const onDateRangeSelected = (newSelectionStartDate, newSelectionToDate) => {" ); + expect(invoiceOrdersPaginationSource).toContain("formatLocalDateOnly(newSelectionStartDate)"); + expect(invoiceOrdersPaginationSource).toContain("formatLocalDateOnly(newSelectionToDate)"); expect(invoiceOrdersPaginationSource).toContain("date_from.value = formattedStartDate;"); expect(invoiceOrdersPaginationSource).toContain("date_to.value = formattedEndDate;"); expect(invoiceOrdersPaginationSource).toContain('setFilter("created_at-date_from", formattedStartDate, true);');