diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfeacf05..04c4364e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,7 +16,8 @@ concurrency: jobs: format-tests: - runs-on: ubuntu-latest + # Match the labels exposed by the Coolify-managed GitHub runner. + runs-on: [self-hosted, Linux, X64, default] steps: - name: Checkout repository uses: actions/checkout@v5 @@ -38,7 +39,7 @@ jobs: build-and-unit: needs: format-tests - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, default] steps: - name: Checkout repository uses: actions/checkout@v5 @@ -63,7 +64,7 @@ jobs: e2e-smoke: if: github.event_name != 'schedule' needs: build-and-unit - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, default] strategy: fail-fast: false matrix: @@ -102,7 +103,7 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch needs: build-and-unit name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} - runs-on: ubuntu-latest + runs-on: [self-hosted, Linux, X64, default] strategy: fail-fast: false matrix: diff --git a/playwright.config.ts b/playwright.config.ts index 59523327..72b45707 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,7 +2,8 @@ import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173); -const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`; +const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`; const isCI = !!process.env.CI; const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim(); const artifactRoot = artifactNamespace diff --git a/playwright.global-setup.mjs b/playwright.global-setup.mjs index 614bee2c..b4e14800 100644 --- a/playwright.global-setup.mjs +++ b/playwright.global-setup.mjs @@ -1,17 +1,20 @@ import { execFile, spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; +import readline from "node:readline"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173); -const baseURL = `http://localhost:${devPort}`; +const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`; const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`) .trim() .replace(/[^a-zA-Z0-9._-]+/g, "-"); const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`); const posViewsDirectory = path.resolve(process.cwd(), "src/views/dashboards/departmentDashboard/modules/Pos"); const shopComponentsDirectory = path.resolve(process.cwd(), "src/components/shop"); +const serverOutputLimit = 80; async function getListeningProcessOnWindows(port) { const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { @@ -35,6 +38,59 @@ async function getListeningProcessOnWindows(port) { }; } +async function readUnixProcessDetails(pid, fallbackCommandLine = "") { + const [rawCommandLine, currentWorkingDirectory] = await Promise.all([ + fs.readFile(`/proc/${pid}/cmdline`).catch(() => Buffer.alloc(0)), + fs.readlink(`/proc/${pid}/cwd`).catch(() => ""), + ]); + const commandLine = rawCommandLine.toString("utf8").split("\0").filter(Boolean).join(" ").trim(); + const commandContext = [commandLine || fallbackCommandLine, currentWorkingDirectory].filter(Boolean).join(" | "); + + return { + Id: pid, + CommandLine: commandContext || fallbackCommandLine, + }; +} + +function parseSsListeningProcess(stdout, port) { + const line = stdout + .split(/\r?\n/) + .find((entry) => entry.includes("LISTEN") && entry.includes(`:${port}`) && entry.includes("pid=")); + if (!line) { + return null; + } + + const pidMatch = line.match(/pid=(\d+)/); + if (!pidMatch) { + return null; + } + + const commandMatch = line.match(/users:\(\("([^"]+)"/); + return { + pid: Number(pidMatch[1]), + commandLine: commandMatch ? commandMatch[1] : "", + }; +} + +function parseNetstatListeningProcess(stdout, port) { + const line = stdout + .split(/\r?\n/) + .find((entry) => entry.includes("LISTEN") && entry.match(new RegExp(`:${port}\\s`))); + if (!line) { + return null; + } + + const pidMatch = line.match(/\s(\d+)\/([^\s]+)\s*$/); + if (!pidMatch) { + return null; + } + + return { + pid: Number(pidMatch[1]), + commandLine: pidMatch[2], + }; +} + async function getListeningProcessOnUnix(port) { try { const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], { @@ -47,13 +103,36 @@ async function getListeningProcessOnUnix(port) { return null; } - return { - Id: Number(pidMatch[1]), - CommandLine: commandMatch ? commandMatch[1] : "", - }; + return readUnixProcessDetails(Number(pidMatch[1]), commandMatch ? commandMatch[1] : ""); } catch { - return null; + // Fall through to other tools that are commonly available on Linux runners. } + + try { + const { stdout } = await execFileAsync("ss", ["-ltnp", `sport = :${port}`], { + cwd: process.cwd(), + }); + const processMatch = parseSsListeningProcess(stdout, port); + if (processMatch) { + return readUnixProcessDetails(processMatch.pid, processMatch.commandLine); + } + } catch { + // Fall through to netstat. + } + + try { + const { stdout } = await execFileAsync("netstat", ["-ltnp"], { + cwd: process.cwd(), + }); + const processMatch = parseNetstatListeningProcess(stdout, port); + if (processMatch) { + return readUnixProcessDetails(processMatch.pid, processMatch.commandLine); + } + } catch { + // No more fallbacks available. + } + + return null; } async function getListeningProcess(port) { @@ -85,23 +164,82 @@ async function killProcessTree(pid) { } } -async function waitForServerReady(url, timeoutMs = 120_000) { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - try { - const response = await fetch(url, { redirect: "manual" }); - if (response.status < 500) { - return; - } - } catch { - // keep polling until ready +function captureProcessOutput(stream, lines) { + const reader = readline.createInterface({ input: stream }); + reader.on("line", (line) => { + lines.push(line); + if (lines.length > serverOutputLimit) { + lines.splice(0, lines.length - serverOutputLimit); } + }); + return reader; +} - await new Promise((resolve) => setTimeout(resolve, 1000)); +function formatProcessOutputSection(label, lines) { + if (lines.length === 0) { + return ""; } - throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`); + return `\n${label}:\n${lines.join("\n")}`; +} + +function formatServerExitError(url, exitInfo, stdoutLines, stderrLines) { + const exitLabel = exitInfo.signal ? `signal ${exitInfo.signal}` : `code ${exitInfo.code ?? 0}`; + return new Error( + `The local Playwright dev server exited before ${url} became ready (${exitLabel}).${formatProcessOutputSection( + "stdout", + stdoutLines + )}${formatProcessOutputSection("stderr", stderrLines)}` + ); +} + +async function waitForServerReady(url, serverProcess, stdoutLines, stderrLines, timeoutMs = 120_000) { + const deadline = Date.now() + timeoutMs; + let exitInfo = null; + const handleExit = (code, signal) => { + exitInfo = { + code, + signal, + }; + }; + + serverProcess.on("exit", handleExit); + + try { + while (Date.now() < deadline) { + if (exitInfo || serverProcess.exitCode !== null || serverProcess.signalCode !== null) { + throw formatServerExitError( + url, + exitInfo || { + code: serverProcess.exitCode, + signal: serverProcess.signalCode, + }, + stdoutLines, + stderrLines + ); + } + + try { + const response = await fetch(url, { redirect: "manual" }); + if (response.status < 500) { + return; + } + } catch { + // keep polling until ready + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + throw new Error( + `Timed out waiting for the local Playwright dev server at ${url}.${formatProcessOutputSection( + "stdout", + stdoutLines + )}${formatProcessOutputSection("stderr", stderrLines)}` + ); + } finally { + serverProcess.off("exit", handleExit); + } } async function buildWarmupTargets() { @@ -180,35 +318,40 @@ export default async function globalSetup() { const serverProcess = process.platform === "win32" - ? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], { + ? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host ${devHost} --port ${devPort} --strictPort`], { cwd: process.cwd(), detached: true, env: { ...process.env, PLAYWRIGHT: "1", }, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }) - : spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], { + : spawn("npm", ["run", "dev", "--", "--host", devHost, "--port", String(devPort), "--strictPort"], { cwd: process.cwd(), detached: true, env: { ...process.env, PLAYWRIGHT: "1", }, - stdio: "ignore", + stdio: ["ignore", "pipe", "pipe"], }); - - serverProcess.unref(); + const stdoutLines = []; + const stderrLines = []; + const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines); + const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines); await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8"); try { - await waitForServerReady(baseURL); + await waitForServerReady(baseURL, serverProcess, stdoutLines, stderrLines); await warmUpDevServer(baseURL); } catch (error) { await killProcessTree(serverProcess.pid); throw error; + } finally { + stdoutReader.close(); + stderrReader.close(); } } diff --git a/playwright.prod.config.ts b/playwright.prod.config.ts index 9c795325..cd6d136b 100644 --- a/playwright.prod.config.ts +++ b/playwright.prod.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; -const baseURL = "http://localhost:4173"; +const baseURL = "http://127.0.0.1:4173"; const isCI = !!process.env.CI; process.env.PLAYWRIGHT_BASE_URL = baseURL; @@ -13,10 +13,7 @@ export default defineConfig({ forbidOnly: isCI, retries: isCI ? 2 : 0, workers: isCI ? 2 : 3, - reporter: [ - ["list"], - ["html", { open: "never", outputFolder: "output/playwright/prod/report" }], - ], + reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]], outputDir: "output/playwright/prod/test-results", use: { baseURL, diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue index 809dfe48..e1755685 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue @@ -487,47 +487,82 @@ const openCustomerSelection = () => { popups.select("select_customer"); }; -// When the primary product is long pressed, log the primary item details +const LONG_PRESS_DELAY_MS = 500; +const LONG_PRESS_MOVE_THRESHOLD_PX = 12; +const LONG_PRESS_SCROLL_THRESHOLD_PX = 10; + +// When the primary product is long pressed, open the vehicle selection. const primaryProduct = ref(null); -const pointerDownTime = ref(null); -const pointerUpTime = ref(null); -const pointerDownScrollPosition = ref(0); // Track the scroll position when the pointer is pressed down (To prevent triggering when scrolling with touch) -const pointerDown = () => { - // Record the time when the pointer is pressed down - const instanceTime = new Date(); - pointerDownScrollPosition.value = window.scrollY || 0; - pointerDownTime.value = instanceTime; - pointerUpTime.value = null; - // Wait 500 milliseconds to check if it's a long press - setTimeout(() => { - if (instanceTime !== pointerDownTime.value) { - return; // If the time has changed, it's not a long press - } - if (pointerDownTime.value && !pointerUpTime.value) { - // If the pointer is still down after 500ms, it's a long press - /** Open the vehicle selection view */ - vehicleSelection.value = true; - } - // Reset the pointer down time after the check - pointerDownTime.value = null; - pointerUpTime.value = null; - }, 500); +const longPressTimeoutId = ref | null>(null); +const longPressStartPoint = ref<{ x: number; y: number } | null>(null); +const longPressStartScrollPosition = ref(0); + +const clearLongPress = () => { + if (longPressTimeoutId.value !== null) { + clearTimeout(longPressTimeoutId.value); + } + + longPressTimeoutId.value = null; + longPressStartPoint.value = null; + longPressStartScrollPosition.value = 0; }; + +const getScrollPosition = (target: EventTarget | null | undefined) => { + if (target instanceof HTMLElement) { + return target.scrollTop; + } + + if (target === document) { + return document.documentElement?.scrollTop || 0; + } + + return window.scrollY || document.documentElement?.scrollTop || 0; +}; + +const pointerDown = (event: PointerEvent) => { + clearLongPress(); + + longPressStartPoint.value = { + x: event.clientX ?? 0, + y: event.clientY ?? 0, + }; + longPressStartScrollPosition.value = getScrollPosition(event.target); + longPressTimeoutId.value = setTimeout(() => { + vehicleSelection.value = true; + clearLongPress(); + }, LONG_PRESS_DELAY_MS); +}; + +const pointerMove = (event: PointerEvent) => { + if (!longPressStartPoint.value) { + return; + } + + const deltaX = Math.abs((event.clientX ?? 0) - longPressStartPoint.value.x); + const deltaY = Math.abs((event.clientY ?? 0) - longPressStartPoint.value.y); + + if (Math.max(deltaX, deltaY) > LONG_PRESS_MOVE_THRESHOLD_PX) { + clearLongPress(); + } +}; + const pointerUp = () => { - // Record the time when the pointer is released - pointerUpTime.value = new Date(); + clearLongPress(); +}; + +const pointerCancel = () => { + clearLongPress(); }; const onScroll = (event: Event) => { - // If the user scrolls while the pointer is down, cancel the long press detection - if (pointerDownTime.value) { - const target = event.target as HTMLElement | Document | Window; - const currentScrollPosition = "scrollY" in window ? window.scrollY : (target as HTMLElement)?.scrollTop || 0; - // If the scroll position has changed significantly, cancel the long press - if (Math.abs(currentScrollPosition - pointerDownScrollPosition.value) > 10) { - pointerDownTime.value = null; - pointerUpTime.value = null; - } + if (longPressTimeoutId.value === null) { + return; + } + + const currentScrollPosition = getScrollPosition(event.target); + + if (Math.abs(currentScrollPosition - longPressStartScrollPosition.value) > LONG_PRESS_SCROLL_THRESHOLD_PX) { + clearLongPress(); } }; @@ -1145,7 +1180,10 @@ const filteredAddons = computed(() => { { const parsedValue = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null; }; +const fetchDraftOrdersPage = async ({ customerNumber, limit }) => { + return authenticatedRequest("/orders", "GET", { + filters: `customer_id:${customerNumber}`, + page: 1, + limit, + search: "", + order: "id:DESC", + }); +}; + +const getDraftOrders = (response) => { + return Array.isArray(response?.data?.data) ? response.data.data : []; +}; + +const getDraftOrdersPaginationTotal = (response) => { + const total = Number.parseInt(String(response?.data?.meta?.pagination?.total ?? 0), 10); + return Number.isInteger(total) && total > 0 ? total : 0; +}; + export const fetchSuperUserDraftCount = async ({ customerNumber }) => { const normalizedCustomerNumber = normalizePositiveInteger(customerNumber); @@ -13,16 +35,33 @@ export const fetchSuperUserDraftCount = async ({ customerNumber }) => { } try { - const response = await authenticatedRequest("/orders", "GET", { - filters: `customer_id:${normalizedCustomerNumber}`, - page: 1, - limit: 1, - search: "", - order: "id:DESC", + const fastResponse = await fetchDraftOrdersPage({ + customerNumber: normalizedCustomerNumber, + limit: FAST_DRAFT_COUNT_LIMIT, }); + const fastOrders = getDraftOrders(fastResponse); + const fastTotal = getDraftOrdersPaginationTotal(fastResponse); - const total = Number.parseInt(String(response?.data?.meta?.pagination?.total ?? 0), 10); - return Number.isInteger(total) && total > 0 ? total : 0; + if (fastOrders.length > 0) { + return Math.max(fastTotal, fastOrders.length); + } + + if (fastTotal === 0) { + return 0; + } + + const fallbackResponse = await fetchDraftOrdersPage({ + customerNumber: normalizedCustomerNumber, + limit: FALLBACK_DRAFT_COUNT_LIMIT, + }); + const fallbackOrders = getDraftOrders(fallbackResponse); + const fallbackTotal = getDraftOrdersPaginationTotal(fallbackResponse); + + if (fallbackOrders.length === 0) { + return 0; + } + + return Math.max(fallbackTotal, fallbackOrders.length); } catch (error) { console.error("Error fetching superuser draft count:", error); return 0; diff --git a/src/config.js b/src/config.js index c2f85395..280a58de 100644 --- a/src/config.js +++ b/src/config.js @@ -2,19 +2,20 @@ export const IS_DEV = import.meta.env.VITE_IS_DEV; // Set to false in production // Development mode export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1); -export const API_URL = (IS_DEV ? 'https://api.truckwash.io:4433' : 'https://api.truckwash.io'); +export const API_URL = IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io"; // Allowed origins export const ALLOWED_ORIGINS = [ - 'https://truckwash.io', - 'https://www.truckwash.io', - 'http://localhost:5173', - 'http://localhost:4173', - 'http://localhost:4174', - 'http://127.0.0.1:4173', - 'http://127.0.0.1:4174', + "https://truckwash.io", + "https://www.truckwash.io", + "http://localhost:5173", + "http://localhost:4173", + "http://localhost:4174", + "http://127.0.0.1:5173", + "http://127.0.0.1:4173", + "http://127.0.0.1:4174", ]; -export const MIGRATION_ORIGIN = 'https://truckwash.io'; +export const MIGRATION_ORIGIN = "https://truckwash.io"; // Global request queue / retry / progress configuration export const REQUEST_QUEUE_CONFIG = Object.freeze({ @@ -43,7 +44,7 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({ jitterMs: 50, }), ping: Object.freeze({ - endpoint: '/ping', + endpoint: "/ping", intervalMs: 30000, timeoutMs: 5000, }), diff --git a/tests/e2e/admin-department-visibility.spec.ts b/tests/e2e/admin-department-visibility.spec.ts index d63c8834..f45a7316 100644 --- a/tests/e2e/admin-department-visibility.spec.ts +++ b/tests/e2e/admin-department-visibility.spec.ts @@ -57,15 +57,12 @@ const buildOverviewPayload = () => ({ }, }); -async function mockAdminDepartmentDependencies( - page, - options: { - departmentDelayMs?: number; - departments?: Array>; - permissions?: string[]; - sessionData?: Record; - } = {} -) { +async function mockAdminDepartmentDependencies(page, options: { + departmentDelayMs?: number; + departments?: Array>; + permissions?: string[]; + sessionData?: Record; +} = {}) { await seedAuthenticatedState(page); await mockApi(page, { authenticated: true, @@ -133,7 +130,10 @@ test.describe("Admin department visibility", () => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "department_access_5"], + permissions: [ + ...adminPermissions, + "department_access_5", + ], departments: [ { id: 1, name: "Visible North", visible: true, priority_order: 20 }, { id: 2, name: "Ingen data", visible: true, priority_order: 1 }, @@ -208,7 +208,11 @@ test.describe("Admin department visibility", () => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "add_order", "list_orders"], + permissions: [ + ...adminPermissions, + "add_order", + "list_orders", + ], departments: [ { id: 1, name: "Visible North", visible: true }, { id: 2, name: "Visible South", visible: true }, @@ -232,11 +236,17 @@ test.describe("Admin department visibility", () => { await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible(); }); - test("shows a department-scoped draft count badge in the desktop buefy menu", async ({ page }, testInfo) => { + test("shows a department-scoped draft count badge in the desktop buefy menu", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "add_order", "list_orders"], + permissions: [ + ...adminPermissions, + "add_order", + "list_orders", + ], sessionData: { runtime_config: { economic: { @@ -305,7 +315,11 @@ test.describe("Admin department visibility", () => { const requestedFilters: string[] = []; await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "add_order", "list_orders"], + permissions: [ + ...adminPermissions, + "add_order", + "list_orders", + ], sessionData: { runtime_config: { economic: { @@ -371,7 +385,10 @@ test.describe("Admin department visibility", () => { const requestedDepartments: string[] = []; await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "list_bookings"], + permissions: [ + ...adminPermissions, + "list_bookings", + ], }); await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => { @@ -410,7 +427,10 @@ test.describe("Admin department visibility", () => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "list_bookings"], + permissions: [ + ...adminPermissions, + "list_bookings", + ], }); await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => { @@ -451,7 +471,10 @@ test.describe("Admin department visibility", () => { const requestedDepartments: string[] = []; await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "list_bookings"], + permissions: [ + ...adminPermissions, + "list_bookings", + ], }); await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => { @@ -461,18 +484,17 @@ test.describe("Admin department visibility", () => { await route.fulfill( json({ - data: - department === "1" - ? { - past: 2, - current: 3, - future: 1, - } - : { - past: 0, - current: 0, - future: 0, - }, + data: department === "1" + ? { + past: 2, + current: 3, + future: 1, + } + : { + past: 0, + current: 0, + future: 0, + }, }) ); }); @@ -513,7 +535,10 @@ test.describe("Admin department visibility", () => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "list_bookings"], + permissions: [ + ...adminPermissions, + "list_bookings", + ], }); await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => { @@ -522,18 +547,17 @@ test.describe("Admin department visibility", () => { await route.fulfill( json({ - data: - department === "1" - ? { - past: 2, - current: 0, - future: 1, - } - : { - past: 0, - current: 0, - future: 0, - }, + data: department === "1" + ? { + past: 2, + current: 0, + future: 1, + } + : { + past: 0, + current: 0, + future: 0, + }, }) ); }); @@ -551,14 +575,19 @@ test.describe("Admin department visibility", () => { await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1"); }); - test("uses the cached bookings counts endpoint for desktop buefy menu badges", async ({ page }, testInfo) => { + test("uses the cached bookings counts endpoint for desktop buefy menu badges", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); const countRequests: string[] = []; const listRequests: string[] = []; await mockAdminDepartmentDependencies(page, { - permissions: [...adminPermissions, "list_bookings"], + permissions: [ + ...adminPermissions, + "list_bookings", + ], }); await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => { diff --git a/tests/e2e/admin-pos-orders.spec.ts b/tests/e2e/admin-pos-orders.spec.ts index 5b8f027c..0a90e229 100644 --- a/tests/e2e/admin-pos-orders.spec.ts +++ b/tests/e2e/admin-pos-orders.spec.ts @@ -529,27 +529,24 @@ function createTallActionMenuPosFixture() { }; return orders; }, {}), - attachmentsByOrderId: tallOrderIds.reduce>>>( - (attachments, orderId) => { - attachments[orderId] = Array.from({ length: 3 }, (_, index) => ({ - id: 7000 + orderId * 10 + index, - object_type: "orders", - object_id: orderId, - content: { - image: null, - document: `action-menu-${orderId}-${index + 1}.pdf`, - relation: null, - other: `action-menu-${orderId}-${index + 1}.pdf`, - src: null, - }, - created_at: "2026-04-21 08:44:07", - updated_at: "2026-04-21 08:44:07", - deleted_at: null, - })); - return attachments; - }, - {} - ), + attachmentsByOrderId: tallOrderIds.reduce>>>((attachments, orderId) => { + attachments[orderId] = Array.from({ length: 3 }, (_, index) => ({ + id: 7000 + orderId * 10 + index, + object_type: "orders", + object_id: orderId, + content: { + image: null, + document: `action-menu-${orderId}-${index + 1}.pdf`, + relation: null, + other: `action-menu-${orderId}-${index + 1}.pdf`, + src: null, + }, + created_at: "2026-04-21 08:44:07", + updated_at: "2026-04-21 08:44:07", + deleted_at: null, + })); + return attachments; + }, {}), }); } @@ -1540,10 +1537,12 @@ test.describe("Admin POS Orders - desktop settings", () => { await expect(dropdownContent).toHaveCSS("border-top-style", "solid"); await expect(attachmentItems.nth(0)).toContainText(/vaskecertifikat|wash certificate/i); await expect(attachmentItems.nth(1)).toContainText(/upload/i); + }); }); -}); -test("hides the draft export action in the order rail for non-superusers", async ({ page }, testInfo) => { +test("hides the draft export action in the order rail for non-superusers", async ({ + page, +}, testInfo) => { test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order rail coverage"); await mockApi(page, { @@ -1577,7 +1576,9 @@ test("hides the draft export action in the order rail for non-superusers", async await expect(receiptPopup.locator("body")).not.toContainText("Download Excel"); }); -test("shows the draft export and receipt actions in the order rail for superusers", async ({ page }, testInfo) => { +test("shows the draft export and receipt actions in the order rail for superusers", async ({ + page, +}, testInfo) => { test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order rail coverage"); await mockApi(page, { diff --git a/tests/e2e/assign-draft-order-modal-create.spec.ts b/tests/e2e/assign-draft-order-modal-create.spec.ts index fd548992..79c277e2 100644 --- a/tests/e2e/assign-draft-order-modal-create.spec.ts +++ b/tests/e2e/assign-draft-order-modal-create.spec.ts @@ -136,6 +136,8 @@ test.describe("Assign draft order modal create invoice collection", () => { } expect(createdOptionBox.y).toBeGreaterThanOrEqual(modalBodyBox.y - 1); - expect(createdOptionBox.y + createdOptionBox.height).toBeLessThanOrEqual(modalBodyBox.y + modalBodyBox.height + 1); + expect(createdOptionBox.y + createdOptionBox.height).toBeLessThanOrEqual( + modalBodyBox.y + modalBodyBox.height + 1 + ); }); }); diff --git a/tests/e2e/change-customer.spec.ts b/tests/e2e/change-customer.spec.ts index 4d2f3737..cf5e42c6 100644 --- a/tests/e2e/change-customer.spec.ts +++ b/tests/e2e/change-customer.spec.ts @@ -151,9 +151,7 @@ test.describe("Orders change customer", () => { await expect(modal).toHaveCount(0); await expect(page.getByTestId("change-customer-harness-status")).toHaveText("assigned"); - const assignment = await page.evaluate( - () => (window as typeof window & { __changeCustomerAssignment?: unknown }).__changeCustomerAssignment - ); + const assignment = await page.evaluate(() => (window as typeof window & { __changeCustomerAssignment?: unknown }).__changeCustomerAssignment); expect(assignment).toMatchObject({ customer_id: 12345679, }); diff --git a/tests/e2e/edge-gateways.smoke.spec.js b/tests/e2e/edge-gateways.smoke.spec.js index b5ff0b46..256f73fc 100644 --- a/tests/e2e/edge-gateways.smoke.spec.js +++ b/tests/e2e/edge-gateways.smoke.spec.js @@ -75,9 +75,9 @@ test.describe("Edge gateway management smoke", () => { await expect(page.locator("body")).toContainText("Installer command copied."); await expect(page.getByTestId("gateway-installer-copy")).toContainText("Copied"); - await expect - .poll(() => page.evaluate(() => window.__copiedInstallerCommand)) - .toContain("install.sh?token=edge-install-token"); + await expect.poll(() => page.evaluate(() => window.__copiedInstallerCommand)).toContain( + "install.sh?token=edge-install-token" + ); }); test("@smoke shows fleet usage statistics on the landing page", async ({ page }) => { diff --git a/tests/e2e/pos-flow.spec.js b/tests/e2e/pos-flow.spec.js index bb905dae..3a84d09e 100644 --- a/tests/e2e/pos-flow.spec.js +++ b/tests/e2e/pos-flow.spec.js @@ -1158,7 +1158,9 @@ test.describe("POS flow", () => { const cardButton = page.getByTestId("pos-card-payment-inline-action"); await expect(cardButton).toBeVisible(); - await expect.poll(() => fixture.readersGet, { timeout: 10_000 }).toBeGreaterThan(0); + await expect + .poll(() => fixture.readersGet, { timeout: 10_000 }) + .toBeGreaterThan(0); await expect(cardButton).toBeDisabled(); fixture.readers = [ @@ -1171,12 +1173,9 @@ test.describe("POS flow", () => { ]; await expect - .poll( - async () => { - return await cardButton.isEnabled(); - }, - { timeout: 12_000 } - ) + .poll(async () => { + return await cardButton.isEnabled(); + }, { timeout: 12_000 }) .toBe(true); }); @@ -1201,9 +1200,9 @@ test.describe("POS flow", () => { await expect.poll(() => fixture.ordersById[9300]?.id ?? null, { timeout: 10_000 }).toBe(9300); await expect.poll(() => fixture.ordersById[9300]?.reg_2 ?? null, { timeout: 10_000 }).toBe("TRAILER9"); - await expect - .poll(() => fixture.ordersById[9300]?.reference ?? null, { timeout: 10_000 }) - .toBe("MANUAL-DESKTOP-SAVE"); + await expect.poll(() => fixture.ordersById[9300]?.reference ?? null, { timeout: 10_000 }).toBe( + "MANUAL-DESKTOP-SAVE" + ); }); test("desktop auto-applies the only previous customer suggestion while selection source is none", async ({ diff --git a/tests/e2e/pos-mobile-card-payments.spec.js b/tests/e2e/pos-mobile-card-payments.spec.js index a805dc5f..8fad7767 100644 --- a/tests/e2e/pos-mobile-card-payments.spec.js +++ b/tests/e2e/pos-mobile-card-payments.spec.js @@ -54,9 +54,7 @@ test.describe("POS mobile card payments", () => { expect(snapshot?.metadata?.reference).toBe("CARD-CTX-REF"); }); - test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({ - page, - }) => { + test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({ page }) => { const fixture = createMobilePosFixture({ readers: [], }); @@ -79,7 +77,9 @@ test.describe("POS mobile card payments", () => { const cardButton = page.getByTestId("pos-mobile-direct-card-payment"); await expect(cardButton).toBeVisible({ timeout: 10_000 }); - await expect.poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 }).toBeGreaterThan(0); + await expect + .poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 }) + .toBeGreaterThan(0); await expect(cardButton).toBeDisabled(); fixture.readers = [ @@ -92,12 +92,9 @@ test.describe("POS mobile card payments", () => { ]; await expect - .poll( - async () => { - return await cardButton.isEnabled(); - }, - { timeout: 12_000 } - ) + .poll(async () => { + return await cardButton.isEnabled(); + }, { timeout: 12_000 }) .toBe(true); await cardButton.click(); @@ -231,10 +228,7 @@ test.describe("POS mobile card payments", () => { await expect(page.getByTestId("pos-stripe-terminal-group-in_use")).toBeVisible(); await expect(page.getByTestId("pos-stripe-terminal-group-offline")).toBeVisible(); await expect(page.getByTestId("pos-stripe-terminal-option-reader_in_use")).toHaveAttribute("aria-disabled", "true"); - await expect(page.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute( - "aria-disabled", - "true" - ); + await expect(page.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute("aria-disabled", "true"); await expect(page.getByTestId("pos-stripe-terminal-option-dot-reader_offline")).toHaveCSS( "background-color", "rgb(220, 38, 38)" @@ -266,7 +260,10 @@ test.describe("POS mobile card payments", () => { await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled({ timeout: 10_000 }); await expect(page.getByTestId("pos-stripe-selected-reader")).toContainText("Recovered Reader"); await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveAttribute("data-status-key", "ready"); - await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS("background-color", "rgb(22, 163, 74)"); + await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS( + "background-color", + "rgb(22, 163, 74)" + ); expect(fixture.requestCounters.paymentIntentCreate).toBe(0); }); diff --git a/tests/e2e/pos-mobile-order-flow.spec.js b/tests/e2e/pos-mobile-order-flow.spec.js index 9fe6acc5..6d2cd3c9 100644 --- a/tests/e2e/pos-mobile-order-flow.spec.js +++ b/tests/e2e/pos-mobile-order-flow.spec.js @@ -285,6 +285,36 @@ async function openVehicleSelectionFromPrimaryProduct(page, productName = "Tank await expect(page.getByTestId("pos-mobile-vehicle-selection")).toBeVisible({ timeout: 10_000 }); } +async function dragAcrossPrimaryProduct(page, offsetY = 48) { + const trigger = page.getByTestId("pos-mobile-primary-product-card"); + await expect(trigger).toBeVisible({ timeout: 10_000 }); + + const box = await trigger.boundingBox(); + if (!box) { + throw new Error("Primary product card bounding box is unavailable."); + } + + const clientX = Math.round(box.x + box.width / 2); + const clientY = Math.round(box.y + Math.min(box.height - 24, 40)); + + await trigger.dispatchEvent("pointerdown", { + pointerType: "touch", + clientX, + clientY, + }); + await trigger.dispatchEvent("pointermove", { + pointerType: "touch", + clientX, + clientY: clientY - offsetY, + }); + await page.waitForTimeout(650); + await page.locator("body").dispatchEvent("pointerup", { + pointerType: "touch", + clientX, + clientY: clientY - offsetY, + }); +} + async function waitForBookingHydration(page, { primaryId, addonProductIds = [] } = {}) { await expect .poll( @@ -2125,6 +2155,49 @@ test.describe("POS mobile order flow", () => { .toBe(63); }); + test("dragging on the primary product card does not open vehicle selection", async ({ page }) => { + const orderId = 9403; + const fixture = createMobilePosFixture({ + ordersById: { + [orderId]: buildRegularOrder(orderId, { + reference: "SCROLL-GUARD", + reg_1: "AB12345", + }), + }, + orderItemsByOrderId: { + [orderId]: [], + }, + }); + + await setupMobilePosPage(page, fixture, { + token: "mobile-step2-scroll-guard-token", + seedState: { + customerId: REGULAR_CUSTOMER_ID, + reg: "AB12345", + reference: "SCROLL-GUARD", + includePrimaryItem: true, + primaryItemId: 53, + vehicleType: 53, + lastOrderId: null, + }, + route: { + step: 2, + orderId, + customerId: REGULAR_CUSTOMER_ID, + }, + }); + + await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); + + await dragAcrossPrimaryProduct(page); + + await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("pos-mobile-vehicle-selection")).toHaveCount(0); + + await openVehicleSelectionFromPrimaryProduct(page); + }); + test("copy previous wash restores the last order primary service, addons, and standalone additional items", async ({ page, }) => { diff --git a/tests/e2e/superuser-bookings.spec.ts b/tests/e2e/superuser-bookings.spec.ts index a661d664..ef189bba 100644 --- a/tests/e2e/superuser-bookings.spec.ts +++ b/tests/e2e/superuser-bookings.spec.ts @@ -9,7 +9,9 @@ const json = (body: unknown, status = 200) => ({ }); test.describe("Superuser bookings", () => { - test("shows a light grey loading indicator while the bookings count is fetching", async ({ page }, testInfo) => { + test("shows a light grey loading indicator while the bookings count is fetching", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); await seedAuthenticatedState(page); @@ -157,7 +159,8 @@ test.describe("Superuser bookings", () => { const filters = url.searchParams.get("filters") || ""; const limit = Number(url.searchParams.get("limit") || "100"); - const isPendingTodayRequest = filters.includes("datetime-date_from:") && filters.includes("datetime-date_to:"); + const isPendingTodayRequest = + filters.includes("datetime-date_from:") && filters.includes("datetime-date_to:"); const isDepartmentScoped = filters.includes("department:"); await route.fulfill( @@ -323,7 +326,9 @@ test.describe("Superuser bookings", () => { await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1"); }); - test("uses the cached bookings counts endpoint for superuser menu badges", async ({ page }, testInfo) => { + test("uses the cached bookings counts endpoint for superuser menu badges", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); const countRequests: string[] = []; diff --git a/tests/e2e/superuser-drafts.spec.ts b/tests/e2e/superuser-drafts.spec.ts index 4c158f08..16bfd9b7 100644 --- a/tests/e2e/superuser-drafts.spec.ts +++ b/tests/e2e/superuser-drafts.spec.ts @@ -9,7 +9,9 @@ const json = (body: unknown, status = 200) => ({ }); test.describe("Superuser drafts", () => { - test("shows a light grey loading indicator while the draft count is fetching", async ({ page }, testInfo) => { + test("shows a light grey loading indicator while the draft count is fetching", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); const orderFilters: string[] = []; @@ -92,12 +94,88 @@ test.describe("Superuser drafts", () => { await expect(loadingBadge).toHaveCount(0); await expect(draftsBadge).toHaveText("1"); + expect(orderFilters.some((filters) => filters.includes("customer_id:6001") && !filters.includes("department_id:"))).toBe( + true + ); + }); + + test("keeps the drafts badge hidden when the fast count request reports a stale total", async ({ + page, + }, testInfo) => { + test.skip(!isDesktopProject(testInfo), "Desktop only"); + + const orderRequests: Array<{ filters: string; limit: number }> = []; + + await seedAuthenticatedState(page); + await mockApi(page, { + authenticated: true, + permissions: ["superuser", "user"], + sessionData: { + runtime_config: { + economic: { + transaction_draft_customer_number: 6001, + }, + }, + }, + }); + + await page.route("https://api.truckwash.io:4433/departments**", async (route) => { + await route.fulfill( + json({ + data: [ + { id: 1, name: "Hvidovre", visible: true }, + { id: 2, name: "Glostrup", visible: true }, + ], + }) + ); + }); + + await page.route("https://api.truckwash.io:4433/orders**", async (route) => { + const url = new URL(route.request().url()); + const filters = url.searchParams.get("filters") || ""; + const limit = Number(url.searchParams.get("limit") || "100"); + orderRequests.push({ filters, limit }); + const isDraftRequest = filters.includes("customer_id:6001") && !filters.includes("department_id:"); + + await route.fulfill( + json({ + data: [], + meta: { + pagination: { + page: 1, + per_page: limit, + total: isDraftRequest && limit === 1 ? 1 : 0, + }, + }, + }) + ); + }); + + await page.goto("/superuser/orders"); + + const draftsLabel = page.getByTestId("desktop-buefy-nav-drafts-label"); + const draftsBadge = page.getByTestId("desktop-buefy-nav-drafts-badge"); + + await expect(draftsLabel).toContainText("Kladder"); + await expect(draftsBadge).toHaveCount(0); + + await draftsLabel.click(); + + await expect(page).toHaveURL(/\/superuser\/orders\/drafts$/); + await expect(page.getByTestId("superuser-drafts-page")).toBeVisible(); + await expect(page.getByText("Viser 0 vaske")).toBeVisible(); + expect( - orderFilters.some((filters) => filters.includes("customer_id:6001") && !filters.includes("department_id:")) + orderRequests.some((request) => request.filters.includes("customer_id:6001") && request.limit === 1) + ).toBe(true); + expect( + orderRequests.some((request) => request.filters.includes("customer_id:6001") && request.limit > 1) ).toBe(true); }); - test("shows a drafts menu item with a count and lists drafts across departments", async ({ page }, testInfo) => { + test("shows a drafts menu item with a count and lists drafts across departments", async ({ + page, + }, testInfo) => { test.skip(!isDesktopProject(testInfo), "Desktop only"); const orderFilters: string[] = []; @@ -231,7 +309,9 @@ test.describe("Superuser drafts", () => { await expect(page.locator("body")).toContainText("DN59443"); expect( - orderFilters.filter((filters) => filters.includes("customer_id:6001") && filters.includes("department_id:")) + orderFilters.filter( + (filters) => filters.includes("customer_id:6001") && filters.includes("department_id:") + ) ).toEqual([]); }); }); diff --git a/tests/e2e/support/mobilePos.js b/tests/e2e/support/mobilePos.js index 17107bb8..d9f8c4b9 100644 --- a/tests/e2e/support/mobilePos.js +++ b/tests/e2e/support/mobilePos.js @@ -753,10 +753,7 @@ export function createMobilePosFixture(overrides = {}) { ordersById: mergeObjectMaps(base.ordersById, overrides.ordersById), orderItemsByOrderId: mergeObjectMaps(base.orderItemsByOrderId, overrides.orderItemsByOrderId), attachmentsByOrderId: mergeObjectMaps(base.attachmentsByOrderId, overrides.attachmentsByOrderId), - stripeModuleOrdersByOrderId: mergeObjectMaps( - base.stripeModuleOrdersByOrderId, - overrides.stripeModuleOrdersByOrderId - ), + stripeModuleOrdersByOrderId: mergeObjectMaps(base.stripeModuleOrdersByOrderId, overrides.stripeModuleOrdersByOrderId), paymentIntentsByOrderId: mergeObjectMaps(base.paymentIntentsByOrderId, overrides.paymentIntentsByOrderId), bookingsById: mergeObjectMaps(base.bookingsById, overrides.bookingsById), cvrSearchResponses: mergeObjectMaps(base.cvrSearchResponses, overrides.cvrSearchResponses), diff --git a/tests/e2e/support/network.js b/tests/e2e/support/network.js index d36794d0..8428b2a0 100644 --- a/tests/e2e/support/network.js +++ b/tests/e2e/support/network.js @@ -730,7 +730,12 @@ function createFixtureOperation(type, request = {}, overrides = {}) { status: overrides.status || "PENDING", request, summary: overrides.summary || { - label: overrides.status === "COMPLETED" ? "Completed" : overrides.status === "FAILED" ? "Failed" : "Queued", + label: + overrides.status === "COMPLETED" + ? "Completed" + : overrides.status === "FAILED" + ? "Failed" + : "Queued", progress: overrides.status === "COMPLETED" ? 100 : overrides.status === "FAILED" ? 100 : 0, retryable: overrides.status !== "FAILED", }, @@ -805,8 +810,7 @@ function buildEdgeGatewayRuntimeFixture(gateway) { const cloudRelays = relayHealth.filter((relay) => relay.execution_path === "cloud"); const operations = Array.isArray(gateway.operations) ? gateway.operations : []; - const activeOperation = - operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null; + const activeOperation = operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null; const versionDrift = gateway.installed_version && gateway.target_version && gateway.installed_version !== gateway.target_version; const credentialFreshnessState = gateway.metadata?.credentials_rotated_at ? "FRESH" : "UNKNOWN"; @@ -863,26 +867,23 @@ function buildEdgeGatewayRuntimeFixture(gateway) { last_successful_discovery_at: gateway.last_successful_discovery_at || gateway.last_heartbeat_at, active_operation: gateway.active_operation || activeOperation, recent_operations_summary: gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations), - version_drift: gateway.version_drift || { - installed_version: gateway.installed_version || null, - target_version: gateway.target_version || null, - release_channel: gateway.release_channel || "stable", - is_drifted: Boolean(versionDrift), - status: versionDrift ? "UPDATE_AVAILABLE" : "IN_SYNC", - }, - credential_freshness: gateway.credential_freshness || { - rotated_at: gateway.metadata?.credentials_rotated_at || null, - age_days: gateway.metadata?.credentials_rotated_at ? 1 : null, - state: credentialFreshnessState, - }, + version_drift: + gateway.version_drift || { + installed_version: gateway.installed_version || null, + target_version: gateway.target_version || null, + release_channel: gateway.release_channel || "stable", + is_drifted: Boolean(versionDrift), + status: versionDrift ? "UPDATE_AVAILABLE" : "IN_SYNC", + }, + credential_freshness: + gateway.credential_freshness || { + rotated_at: gateway.metadata?.credentials_rotated_at || null, + age_days: gateway.metadata?.credentials_rotated_at ? 1 : null, + state: credentialFreshnessState, + }, diagnostics: gateway.diagnostics || diagnostics, error_state: - gateway.error_state || - (diagnostics[0] - ? { ...diagnostics[0] } - : activeOperation?.error_code - ? { code: activeOperation.error_code, message: activeOperation.error_message } - : null), + gateway.error_state || (diagnostics[0] ? { ...diagnostics[0] } : activeOperation?.error_code ? { code: activeOperation.error_code, message: activeOperation.error_message } : null), }; } @@ -966,32 +967,16 @@ function createEdgeGatewayFixture(options = {}) { ], recent_commands: [], operations: [ - createFixtureOperation( - "DISCOVERY", - {}, - { - id: 8801, - status: "COMPLETED", - started_at: "2026-04-08 08:14:20", - completed_at: "2026-04-08 08:14:38", - events: [ - { - id: 1, - level: "INFO", - code: "OPERATION_STARTED", - message: "Gateway started processing the operation", - created_at: "2026-04-08 08:14:20", - }, - { - id: 2, - level: "INFO", - code: "OPERATION_COMPLETED", - message: "Operation completed successfully", - created_at: "2026-04-08 08:14:38", - }, - ], - } - ), + createFixtureOperation("DISCOVERY", {}, { + id: 8801, + status: "COMPLETED", + started_at: "2026-04-08 08:14:20", + completed_at: "2026-04-08 08:14:38", + events: [ + { id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" }, + { id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" }, + ], + }), ], audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }], }, @@ -1115,9 +1100,7 @@ function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail }, binding_summary: { total: bindings.length, - fallback_overrides: bindings.filter( - (binding) => String(binding.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL" - ).length, + fallback_overrides: bindings.filter((binding) => String(binding.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL").length, }, agent_runtime: { hostname: gateway.hostname, @@ -1129,9 +1112,7 @@ function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail }, operations, active_operation: cloneJson(gateway.active_operation || null), - recent_operations_summary: cloneJson( - gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations) - ), + recent_operations_summary: cloneJson(gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations)), version_drift: cloneJson(gateway.version_drift || null), credential_freshness: cloneJson(gateway.credential_freshness || null), diagnostics: cloneJson(gateway.diagnostics || []), @@ -1285,10 +1266,7 @@ function processPendingEdgeGatewayClaims(edgeGatewayFixture) { function createHttpEdgeGatewayFixture(options = {}) { const gatewayOverridesById = new Map( - (Array.isArray(options.gatewayOverrides) ? options.gatewayOverrides : []).map((gateway) => [ - Number(gateway.id), - gateway, - ]) + (Array.isArray(options.gatewayOverrides) ? options.gatewayOverrides : []).map((gateway) => [Number(gateway.id), gateway]) ); const extraGateways = Array.isArray(options.gateways) ? options.gateways : []; @@ -1319,69 +1297,25 @@ function createHttpEdgeGatewayFixture(options = {}) { }, }, inventory: [ - { - id: 1, - device_id: "shelly-plus-01", - local_ip: "10.1.0.31", - model: "Shelly Plus 2PM", - channel_count: 2, - online: true, - }, - { - id: 2, - device_id: "shelly-mini-offline", - local_ip: "10.1.0.34", - model: "Shelly Mini 1", - channel_count: 1, - online: false, - }, + { id: 1, device_id: "shelly-plus-01", local_ip: "10.1.0.31", model: "Shelly Plus 2PM", channel_count: 2, online: true }, + { id: 2, device_id: "shelly-mini-offline", local_ip: "10.1.0.34", model: "Shelly Mini 1", channel_count: 1, online: false }, ], bindings: [ - { - id: 1, - relay_id: "M-7", - device_id: "shelly-plus-01", - local_ip: "10.1.0.31", - channel: 0, - fallback_mode: "PREFER_LOCAL", - }, - { - id: 2, - relay_id: "M-7-LEGACY", - device_id: "shelly-missing-legacy", - local_ip: "10.1.0.99", - channel: 1, - fallback_mode: "CLOUD_ONLY", - }, + { id: 1, relay_id: "M-7", device_id: "shelly-plus-01", local_ip: "10.1.0.31", channel: 0, fallback_mode: "PREFER_LOCAL" }, + { id: 2, relay_id: "M-7-LEGACY", device_id: "shelly-missing-legacy", local_ip: "10.1.0.99", channel: 1, fallback_mode: "CLOUD_ONLY" }, ], recent_commands: [], operations: [ - createFixtureOperation( - "DISCOVERY", - {}, - { - id: 8801, - status: "COMPLETED", - started_at: "2026-04-08 08:14:20", - completed_at: "2026-04-08 08:14:38", - events: [ - { - id: 1, - level: "INFO", - code: "OPERATION_STARTED", - message: "Gateway started processing the operation", - created_at: "2026-04-08 08:14:20", - }, - { - id: 2, - level: "INFO", - code: "OPERATION_COMPLETED", - message: "Operation completed successfully", - created_at: "2026-04-08 08:14:38", - }, - ], - } - ), + createFixtureOperation("DISCOVERY", {}, { + id: 8801, + status: "COMPLETED", + started_at: "2026-04-08 08:14:20", + completed_at: "2026-04-08 08:14:38", + events: [ + { id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" }, + { id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" }, + ], + }), ], audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }], }, @@ -1412,9 +1346,7 @@ function createHttpEdgeGatewayFixture(options = {}) { bindings: [], recent_commands: [], operations: [], - audit_logs: [ - { id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }, - ], + audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }], }, ] .map((gateway) => mergeEdgeGatewayFixtureRecord(gateway, gatewayOverridesById.get(Number(gateway.id)) || {})) @@ -2683,19 +2615,14 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos const orderId = Number(body.order_id || 0); const existingInvoice = posFixture.stripeModuleOrdersByOrderId[orderId] || {}; if (existingInvoice?.invoice_id && !isTerminalStripeInvoiceStatus(existingInvoice.status)) { - await route.fulfill( - json( - { - success: false, - data: { - message: "A Stripe payment link is already active for this order.", - code: "stripe_invoice_exists", - stripeModuleOrders: existingInvoice, - }, - }, - 409 - ) - ); + await route.fulfill(json({ + success: false, + data: { + message: "A Stripe payment link is already active for this order.", + code: "stripe_invoice_exists", + stripeModuleOrders: existingInvoice, + }, + }, 409)); return true; } @@ -2704,20 +2631,18 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos }); posFixture.stripeModuleOrdersByOrderId[orderId] = nextInvoice; - await route.fulfill( - json({ - success: true, - data: { - id: nextInvoice.invoice_id, - customer: nextInvoice.customer_id, - hosted_invoice_url: nextInvoice.url, - paid: nextInvoice.paid, - status: nextInvoice.status, - amount_due: nextInvoice.amount_due, - amount_paid: nextInvoice.amount_paid, - }, - }) - ); + await route.fulfill(json({ + success: true, + data: { + id: nextInvoice.invoice_id, + customer: nextInvoice.customer_id, + hosted_invoice_url: nextInvoice.url, + paid: nextInvoice.paid, + status: nextInvoice.status, + amount_due: nextInvoice.amount_due, + amount_paid: nextInvoice.amount_paid, + }, + })); return true; } @@ -2728,50 +2653,48 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos if (!existingInvoice?.invoice_id) { posFixture.stripeModuleOrdersByOrderId[orderId] = {}; - await route.fulfill( - json({ - success: true, - data: { - stripeModuleOrders: [], - }, - }) - ); - return true; - } - - if (Boolean(existingInvoice.paid) || String(existingInvoice.status || "") === "paid") { - await route.fulfill( - json( - { - success: false, - data: { - message: "A paid Stripe payment link cannot be cancelled.", - code: "stripe_invoice_paid", - stripeModuleOrders: existingInvoice, - }, - }, - 409 - ) - ); - return true; - } - - posFixture.stripeModuleOrdersByOrderId[orderId] = {}; - await route.fulfill( - json({ + await route.fulfill(json({ success: true, data: { stripeModuleOrders: [], }, - }) - ); + })); + return true; + } + + if (Boolean(existingInvoice.paid) || String(existingInvoice.status || "") === "paid") { + await route.fulfill(json({ + success: false, + data: { + message: "A paid Stripe payment link cannot be cancelled.", + code: "stripe_invoice_paid", + stripeModuleOrders: existingInvoice, + }, + }, 409)); + return true; + } + + posFixture.stripeModuleOrdersByOrderId[orderId] = {}; + await route.fulfill(json({ + success: true, + data: { + stripeModuleOrders: [], + }, + })); return true; } return false; } -async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) { +async function handleEdgeGatewayRoute({ + route, + request, + parsedUrl, + pathname, + method, + edgeGatewayFixture, +}) { if (!edgeGatewayFixture) { return false; } @@ -2784,9 +2707,7 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met const findGateway = (gatewayId) => edgeGatewayFixture.gateways.find((entry) => Number(entry.id) === Number(gatewayId)) || null; const gatewayResponse = (gateway, includeDetail = true) => - gateway - ? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail) }) - : json({ message: "Gateway not found" }, 404); + gateway ? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail) }) : json({ message: "Gateway not found" }, 404); const createOperation = (gateway, type, request = {}, overrides = {}) => { const operation = createFixtureOperation(type, request, { id: edgeGatewayFixture.nextOperationId++, @@ -2867,20 +2788,8 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met updated_at: now, summary: { label: "Gateway is processing the operation", progress: 20, retryable: true }, events: [ - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_QUEUED", - message: "Operation queued for gateway execution", - created_at: now, - }, - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_STARTED", - message: "Gateway started processing the operation", - created_at: now, - }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now }, ], }); edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = { @@ -2905,26 +2814,10 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met completed_at: now, updated_at: now, summary: { label: "Completed", progress: 100, retryable: true }, - result: { - installed_version: gateway.installed_version, - target_version: gateway.target_version, - restart_required: true, - }, + result: { installed_version: gateway.installed_version, target_version: gateway.target_version, restart_required: true }, events: [ - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_QUEUED", - message: "Operation queued for gateway execution", - created_at: now, - }, - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_COMPLETED", - message: "Operation completed successfully", - created_at: now, - }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now }, ], }); } else if (operationType === "UNINSTALL") { @@ -2941,29 +2834,12 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met summary: { label: "Completed", progress: 100, retryable: false }, result: { uninstalled: true, manual_cleanup_required: true }, events: [ - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_QUEUED", - message: "Operation queued for gateway execution", - created_at: now, - }, - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_COMPLETED", - message: "Operation completed successfully", - created_at: now, - }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now }, ], }); } else { - await route.fulfill( - json( - { data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } }, - 422 - ) - ); + await route.fulfill(json({ data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } }, 422)); return true; } @@ -2972,17 +2848,7 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met ...(gateway.audit_logs || []), ]; Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway)); - await route.fulfill( - json( - { - data: { - operation: cloneJson(operation), - gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true), - }, - }, - 201 - ) - ); + await route.fulfill(json({ data: { operation: cloneJson(operation), gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) } }, 201)); return true; } @@ -3039,33 +2905,16 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met if (gateway) { const now = toSqlDateTime(); - const operation = createOperation( - gateway, - "DISCOVERY", - {}, - { - status: "IN_PROGRESS", - started_at: now, - updated_at: now, - summary: { label: "Gateway is processing the operation", progress: 20, retryable: true }, - events: [ - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_QUEUED", - message: "Operation queued for gateway execution", - created_at: now, - }, - { - id: edgeGatewayFixture.nextOperationEventId++, - level: "INFO", - code: "OPERATION_STARTED", - message: "Gateway started processing the operation", - created_at: now, - }, - ], - } - ); + const operation = createOperation(gateway, "DISCOVERY", {}, { + status: "IN_PROGRESS", + started_at: now, + updated_at: now, + summary: { label: "Gateway is processing the operation", progress: 20, retryable: true }, + events: [ + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now }, + { id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now }, + ], + }); gateway.discovery_status = "PENDING"; edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = { operationId: operation.id, @@ -3164,16 +3013,11 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met })); Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway)); } - await route.fulfill( - json({ data: gateway ? buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) : null }) - ); + await route.fulfill(json({ data: gateway ? buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) : null })); return true; } - if ( - /\/edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/.test(pathname) || - /\/edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/.test(pathname) - ) { + if (/\/edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/.test(pathname) || /\/edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/.test(pathname)) { await route.fulfill(json({ message: "Route not found" }, 404)); return true; } diff --git a/tests/unit/edge-gateway-service.spec.js b/tests/unit/edge-gateway-service.spec.js index 03014412..a6742415 100644 --- a/tests/unit/edge-gateway-service.spec.js +++ b/tests/unit/edge-gateway-service.spec.js @@ -8,12 +8,10 @@ describe("edge gateway service contract", () => { it("wraps the v2 backend edge gateway REST endpoints", () => { expect(source).toContain('"/departments"'); expect(source).toContain('"/edge-gateways"'); - expect(source).toContain('view = "summary"'); + expect(source).toContain("view = \"summary\""); expect(source).toContain('"/edge-gateways/install-token"'); expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations"); - expect(source).toContain( - "/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events" - ); + expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events"); expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials"); expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/discovery"); expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/bindings"); diff --git a/tests/unit/edge-gateway-workflow-helpers.spec.js b/tests/unit/edge-gateway-workflow-helpers.spec.js index 4b2b9753..a590e312 100644 --- a/tests/unit/edge-gateway-workflow-helpers.spec.js +++ b/tests/unit/edge-gateway-workflow-helpers.spec.js @@ -5,18 +5,15 @@ import { describe, expect, it } from "vitest"; import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js"; const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8"); -const operationsSource = readFileSync( - join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), - "utf8" -); +const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8"); const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8"); describe("edge gateway workflow helpers", () => { it("exposes v2 tabs and management actions without shell controls", () => { - expect(managerSource).toContain('id: "overview"'); - expect(managerSource).toContain('id: "inventory"'); - expect(managerSource).toContain('id: "operations"'); - expect(managerSource).toContain('id: "manage"'); + expect(managerSource).toContain("id: \"overview\""); + expect(managerSource).toContain("id: \"inventory\""); + expect(managerSource).toContain("id: \"operations\""); + expect(managerSource).toContain("id: \"manage\""); expect(managerSource).toContain("`gateway-tab-${tab.id}`"); expect(operationsSource).toContain('data-testid="gateway-operation-update"'); expect(operationsSource).toContain('data-testid="gateway-operation-uninstall"'); diff --git a/tests/unit/edge-gateway-workspace.spec.js b/tests/unit/edge-gateway-workspace.spec.js index 92f6c7d3..6deb4972 100644 --- a/tests/unit/edge-gateway-workspace.spec.js +++ b/tests/unit/edge-gateway-workspace.spec.js @@ -3,18 +3,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8"); -const overviewSource = readFileSync( - join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), - "utf8" -); -const inventorySource = readFileSync( - join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), - "utf8" -); -const operationsSource = readFileSync( - join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), - "utf8" -); +const overviewSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8"); +const inventorySource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8"); +const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8"); const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8"); const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8"); const edgeGatewaysPageSource = readFileSync( @@ -32,10 +23,10 @@ describe("edge gateway workspace contract", () => { expect(managerSource).toContain('data-testid="gateway-error-details"'); expect(managerSource).toContain('data-testid="gateway-fleet-usage"'); expect(managerSource).toContain("response?.data?.meta?.fleet_usage"); - expect(managerSource).toContain("import EdgeGatewayOverviewPage"); - expect(managerSource).toContain("import EdgeGatewayInventoryPage"); - expect(managerSource).toContain("import EdgeGatewayOperationsPage"); - expect(managerSource).toContain("import EdgeGatewayManagePage"); + expect(managerSource).toContain('import EdgeGatewayOverviewPage'); + expect(managerSource).toContain('import EdgeGatewayInventoryPage'); + expect(managerSource).toContain('import EdgeGatewayOperationsPage'); + expect(managerSource).toContain('import EdgeGatewayManagePage'); expect(overviewSource).toContain('data-testid="gateway-overview-page"'); expect(inventorySource).toContain('data-testid="gateway-inventory-page"'); expect(operationsSource).toContain('data-testid="gateway-operations-page"'); diff --git a/tests/unit/helpers/readJsonFile.js b/tests/unit/helpers/readJsonFile.js deleted file mode 100644 index f248c315..00000000 --- a/tests/unit/helpers/readJsonFile.js +++ /dev/null @@ -1,16 +0,0 @@ -import { readFileSync } from "node:fs"; - -const UTF8_BOM = "\uFEFF"; - -export const stripUtf8Bom = (value) => { - if (typeof value !== "string") { - return value; - } - - return value.startsWith(UTF8_BOM) ? value.slice(1) : value; -}; - -export const readJsonFile = (path) => { - const raw = readFileSync(path, "utf8"); - return JSON.parse(stripUtf8Bom(raw)); -}; diff --git a/tests/unit/i18n-action-settings-wheel.spec.js b/tests/unit/i18n-action-settings-wheel.spec.js index b3112387..4fa88710 100644 --- a/tests/unit/i18n-action-settings-wheel.spec.js +++ b/tests/unit/i18n-action-settings-wheel.spec.js @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readJsonFile } from "./helpers/readJsonFile"; const root = process.cwd(); const componentPath = join(root, "src/components/displays/buttons/ActionSettingsWheelButton.vue"); @@ -9,7 +8,7 @@ const activeLocales = ["da", "en", "sv", "de", "no"]; const translationCallPattern = /(?:\$t|\bt)\(\s*(["'])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g; const readLocale = (locale) => { - return readJsonFile(join(root, `src/i18n/locales/${locale}.json`)); + return JSON.parse(readFileSync(join(root, `src/i18n/locales/${locale}.json`), "utf8")); }; const extractLiteralTranslationKeys = (source) => { diff --git a/tests/unit/invoice-distribution-i18n.spec.js b/tests/unit/invoice-distribution-i18n.spec.js index b44fd66c..f8d1501c 100644 --- a/tests/unit/invoice-distribution-i18n.spec.js +++ b/tests/unit/invoice-distribution-i18n.spec.js @@ -1,15 +1,15 @@ +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readJsonFile } from "./helpers/readJsonFile"; const root = process.cwd(); const locales = { - da: readJsonFile(join(root, "src/i18n/locales/da.json")), - en: readJsonFile(join(root, "src/i18n/locales/en.json")), - sv: readJsonFile(join(root, "src/i18n/locales/sv.json")), - de: readJsonFile(join(root, "src/i18n/locales/de.json")), - no: readJsonFile(join(root, "src/i18n/locales/no.json")), + da: JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")), + en: JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")), + sv: JSON.parse(readFileSync(join(root, "src/i18n/locales/sv.json"), "utf8")), + de: JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")), + no: JSON.parse(readFileSync(join(root, "src/i18n/locales/no.json"), "utf8")), }; const flattenKeys = (value, prefix = "") => { diff --git a/tests/unit/orders-change-customer.spec.js b/tests/unit/orders-change-customer.spec.js index 1f9ebb06..1d87ec0d 100644 --- a/tests/unit/orders-change-customer.spec.js +++ b/tests/unit/orders-change-customer.spec.js @@ -9,7 +9,7 @@ const createAppMock = vi.hoisted(() => use: createAppUseMock, mount: createAppMountMock, unmount: createAppUnmountMock, - })) + })), ); vi.mock("sweetalert2", () => ({ @@ -89,7 +89,7 @@ describe("Orders.showChangeCustomerForm", () => { }, onAssigned: expect.any(Function), onClose: expect.any(Function), - }) + }), ); expect(Swal.fire).not.toHaveBeenCalled(); diff --git a/tests/unit/read-json-file.helper.spec.js b/tests/unit/read-json-file.helper.spec.js deleted file mode 100644 index 8fa31576..00000000 --- a/tests/unit/read-json-file.helper.spec.js +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { readJsonFile, stripUtf8Bom } from "./helpers/readJsonFile"; - -describe("readJsonFile helper", () => { - it("strips UTF-8 BOM prefixes before parsing JSON", () => { - const baseDir = mkdtempSync(join(tmpdir(), "read-json-file-")); - const fixturePath = join(baseDir, "locale.json"); - - try { - writeFileSync(fixturePath, '\uFEFF{"label":"Hej"}', "utf8"); - expect(readJsonFile(fixturePath)).toEqual({ label: "Hej" }); - expect(stripUtf8Bom("\uFEFFsample")).toBe("sample"); - } finally { - rmSync(baseDir, { recursive: true, force: true }); - } - }); -}); diff --git a/tests/unit/stripe-terminal-readers.spec.js b/tests/unit/stripe-terminal-readers.spec.js index b5209211..c27f2ebf 100644 --- a/tests/unit/stripe-terminal-readers.spec.js +++ b/tests/unit/stripe-terminal-readers.spec.js @@ -1,67 +1,65 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from 'vitest'; import { getStripeTerminalStatusKey, getStripeTerminalReaderById, normalizeStripeTerminalReaders, selectPreferredStripeTerminalReaderId, STRIPE_TERMINAL_STATUS, -} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js"; +} from '@/components/displays/department/pos/displays/stripeTerminalReaders.js'; -describe("stripeTerminalReaders", () => { - it("maps raw Stripe reader status into ready, in-use, and offline states", () => { - expect(getStripeTerminalStatusKey({ status: "online", action: null })).toBe(STRIPE_TERMINAL_STATUS.ready); - expect(getStripeTerminalStatusKey({ status: "busy", action: null })).toBe(STRIPE_TERMINAL_STATUS.inUse); - expect(getStripeTerminalStatusKey({ status: "online", action: { type: "process_payment_intent" } })).toBe( - STRIPE_TERMINAL_STATUS.inUse - ); - expect(getStripeTerminalStatusKey({ status: "offline", action: null })).toBe(STRIPE_TERMINAL_STATUS.offline); - expect(getStripeTerminalStatusKey({ status: "unknown_state", action: null })).toBe(STRIPE_TERMINAL_STATUS.offline); +describe('stripeTerminalReaders', () => { + it('maps raw Stripe reader status into ready, in-use, and offline states', () => { + expect(getStripeTerminalStatusKey({ status: 'online', action: null })).toBe(STRIPE_TERMINAL_STATUS.ready); + expect(getStripeTerminalStatusKey({ status: 'busy', action: null })).toBe(STRIPE_TERMINAL_STATUS.inUse); + expect( + getStripeTerminalStatusKey({ status: 'online', action: { type: 'process_payment_intent' } }), + ).toBe(STRIPE_TERMINAL_STATUS.inUse); + expect(getStripeTerminalStatusKey({ status: 'offline', action: null })).toBe(STRIPE_TERMINAL_STATUS.offline); + expect(getStripeTerminalStatusKey({ status: 'unknown_state', action: null })).toBe(STRIPE_TERMINAL_STATUS.offline); }); - it("normalizes readers, keeps blocked readers visible, and sorts them by status priority", () => { + it('normalizes readers, keeps blocked readers visible, and sorts them by status priority', () => { const normalizedReaders = normalizeStripeTerminalReaders([ - { id: "offline_terminal", label: "Offline terminal", status: "offline", action: null }, - { id: "busy_terminal", label: "Busy terminal", status: "online", action: { type: "payment_intent" } }, - { id: "ready_terminal", label: "Ready terminal", status: "online", action: null }, + { id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null }, + { id: 'busy_terminal', label: 'Busy terminal', status: 'online', action: { type: 'payment_intent' } }, + { id: 'ready_terminal', label: 'Ready terminal', status: 'online', action: null }, ]); expect(normalizedReaders.map((reader) => reader.id)).toEqual([ - "ready_terminal", - "busy_terminal", - "offline_terminal", + 'ready_terminal', + 'busy_terminal', + 'offline_terminal', ]); expect(normalizedReaders.map((reader) => reader.selectable)).toEqual([true, false, false]); - expect(normalizedReaders.map((reader) => reader.colorToken)).toEqual(["green", "yellow", "red"]); + expect(normalizedReaders.map((reader) => reader.colorToken)).toEqual(['green', 'yellow', 'red']); }); - it("keeps the current ready reader selected when it remains ready", () => { + it('keeps the current ready reader selected when it remains ready', () => { const normalizedReaders = normalizeStripeTerminalReaders([ - { id: "ready_terminal_a", label: "Ready terminal A", status: "online", action: null }, - { id: "ready_terminal_b", label: "Ready terminal B", status: "online", action: null }, + { id: 'ready_terminal_a', label: 'Ready terminal A', status: 'online', action: null }, + { id: 'ready_terminal_b', label: 'Ready terminal B', status: 'online', action: null }, ]); - expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "ready_terminal_b")).toBe("ready_terminal_b"); + expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'ready_terminal_b')).toBe('ready_terminal_b'); }); - it("falls back to the first ready reader when the current reader is no longer selectable", () => { + it('falls back to the first ready reader when the current reader is no longer selectable', () => { const normalizedReaders = normalizeStripeTerminalReaders([ - { id: "busy_terminal", label: "Busy terminal", status: "busy", action: null }, - { id: "ready_terminal", label: "Ready terminal", status: "online", action: null }, - { id: "offline_terminal", label: "Offline terminal", status: "offline", action: null }, + { id: 'busy_terminal', label: 'Busy terminal', status: 'busy', action: null }, + { id: 'ready_terminal', label: 'Ready terminal', status: 'online', action: null }, + { id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null }, ]); - expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "busy_terminal")).toBe("ready_terminal"); - expect(getStripeTerminalReaderById(normalizedReaders, "offline_terminal")?.statusKey).toBe( - STRIPE_TERMINAL_STATUS.offline - ); + expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'busy_terminal')).toBe('ready_terminal'); + expect(getStripeTerminalReaderById(normalizedReaders, 'offline_terminal')?.statusKey).toBe(STRIPE_TERMINAL_STATUS.offline); }); - it("clears the selection when no ready readers remain", () => { + it('clears the selection when no ready readers remain', () => { const normalizedReaders = normalizeStripeTerminalReaders([ - { id: "busy_terminal", label: "Busy terminal", status: "online", action: { type: "payment_intent" } }, - { id: "offline_terminal", label: "Offline terminal", status: "offline", action: null }, + { id: 'busy_terminal', label: 'Busy terminal', status: 'online', action: { type: 'payment_intent' } }, + { id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null }, ]); - expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "busy_terminal")).toBe(""); + expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'busy_terminal')).toBe(''); }); }); diff --git a/tests/unit/superuser-draft-count.spec.js b/tests/unit/superuser-draft-count.spec.js index b9906508..4f4552d4 100644 --- a/tests/unit/superuser-draft-count.spec.js +++ b/tests/unit/superuser-draft-count.spec.js @@ -16,9 +16,10 @@ describe("fetchSuperUserDraftCount", () => { consoleErrorSpy.mockClear(); }); - it("returns pagination total when the orders request succeeds", async () => { + it("returns pagination total when the fast draft-count request returns a row", async () => { authenticatedRequestMock.mockResolvedValue({ data: { + data: [{ id: 1 }], meta: { pagination: { total: 5, @@ -33,6 +34,7 @@ describe("fetchSuperUserDraftCount", () => { }) ).resolves.toBe(5); + expect(authenticatedRequestMock).toHaveBeenCalledTimes(1); expect(authenticatedRequestMock).toHaveBeenCalledWith("/orders", "GET", { filters: "customer_id:6001", page: 1, @@ -42,6 +44,81 @@ describe("fetchSuperUserDraftCount", () => { }); }); + it("falls back to a full page request when the fast request reports a stale total without rows", async () => { + authenticatedRequestMock + .mockResolvedValueOnce({ + data: { + data: [], + meta: { + pagination: { + total: 5, + }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [{ id: 11 }, { id: 12 }], + meta: { + pagination: { + total: 2, + }, + }, + }, + }); + + await expect( + fetchSuperUserDraftCount({ + customerNumber: 6001, + }) + ).resolves.toBe(2); + + expect(authenticatedRequestMock).toHaveBeenNthCalledWith(1, "/orders", "GET", { + filters: "customer_id:6001", + page: 1, + limit: 1, + search: "", + order: "id:DESC", + }); + expect(authenticatedRequestMock).toHaveBeenNthCalledWith(2, "/orders", "GET", { + filters: "customer_id:6001", + page: 1, + limit: 100, + search: "", + order: "id:DESC", + }); + }); + + it("returns 0 when the fallback request also has no rows", async () => { + authenticatedRequestMock + .mockResolvedValueOnce({ + data: { + data: [], + meta: { + pagination: { + total: 1, + }, + }, + }, + }) + .mockResolvedValueOnce({ + data: { + data: [], + meta: { + pagination: { + total: 0, + }, + }, + }, + }); + + await expect( + fetchSuperUserDraftCount({ + customerNumber: 6001, + }) + ).resolves.toBe(0); + }); + it("returns 0 when the customer number is invalid", async () => { await expect( fetchSuperUserDraftCount({ diff --git a/tests/unit/system-search-modal-contract.spec.js b/tests/unit/system-search-modal-contract.spec.js index 7c1cf1de..e0df7de4 100644 --- a/tests/unit/system-search-modal-contract.spec.js +++ b/tests/unit/system-search-modal-contract.spec.js @@ -1,7 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readJsonFile } from "./helpers/readJsonFile"; const root = process.cwd(); const modalSource = readFileSync( @@ -16,9 +15,9 @@ const departmentGoalSupportSource = readFileSync( join(root, "src/components/viewport/page/headers/menu/systemSearchDepartmentGoalSupport.ts"), "utf8" ); -const enLocale = readJsonFile(join(root, "src/i18n/locales/en.json")); -const daLocale = readJsonFile(join(root, "src/i18n/locales/da.json")); -const deLocale = readJsonFile(join(root, "src/i18n/locales/de.json")); +const enLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")); +const daLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")); +const deLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")); const entityTypesMatch = supportSource.match(/export const SYSTEM_SEARCH_ENTITY_TYPES = \[(.*?)\] as const;/s); const systemSearchEntityTypes = Array.from(entityTypesMatch?.[1]?.matchAll(/'([^']+)'/g) ?? [], (match) => match[1]); diff --git a/tests/unit/workfeed-i18n.spec.js b/tests/unit/workfeed-i18n.spec.js index 8cca1ea8..4855d825 100644 --- a/tests/unit/workfeed-i18n.spec.js +++ b/tests/unit/workfeed-i18n.spec.js @@ -1,15 +1,15 @@ +import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { readJsonFile } from "./helpers/readJsonFile"; const root = process.cwd(); const locales = { - da: readJsonFile(join(root, "src/i18n/locales/da.json")), - en: readJsonFile(join(root, "src/i18n/locales/en.json")), - sv: readJsonFile(join(root, "src/i18n/locales/sv.json")), - de: readJsonFile(join(root, "src/i18n/locales/de.json")), - no: readJsonFile(join(root, "src/i18n/locales/no.json")), + da: JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")), + en: JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")), + sv: JSON.parse(readFileSync(join(root, "src/i18n/locales/sv.json"), "utf8")), + de: JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")), + no: JSON.parse(readFileSync(join(root, "src/i18n/locales/no.json"), "utf8")), }; const flattenKeys = (value, prefix = "") => {