Refactor: Remove unused readJsonFile helper, improve server process handling in Playwright setup, and optimize superuser booking and draft count logic.

This commit is contained in:
Jeppe Bundgaard
2026-04-22 11:49:44 +02:00
parent 7bdfb43635
commit 6e3506ff28
31 changed files with 868 additions and 600 deletions
+5 -4
View File
@@ -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:
+2 -1
View File
@@ -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
+169 -26
View File
@@ -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();
}
}
+2 -5
View File
@@ -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,
@@ -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<Date>(null);
const pointerUpTime = ref<Date>(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<ReturnType<typeof setTimeout> | 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(() => {
<!-- Product -->
<PosDepartmentStepMobile2Product
v-on:pointerdown="pointerDown"
v-on:pointermove="pointerMove"
v-on:pointerup="pointerUp"
v-on:pointercancel="pointerCancel"
v-on:pointerleave="pointerCancel"
ref="primaryProduct"
:product="transactionItems.primaryItem.value"
:additional-items="transactionItems.additionalItems.value"
@@ -1,10 +1,32 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const FAST_DRAFT_COUNT_LIMIT = 1;
const FALLBACK_DRAFT_COUNT_LIMIT = 100;
const normalizePositiveInteger = (value) => {
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;
+11 -10
View File
@@ -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,
}),
+73 -44
View File
@@ -57,15 +57,12 @@ const buildOverviewPayload = () => ({
},
});
async function mockAdminDepartmentDependencies(
page,
options: {
departmentDelayMs?: number;
departments?: Array<Record<string, unknown>>;
permissions?: string[];
sessionData?: Record<string, unknown>;
} = {}
) {
async function mockAdminDepartmentDependencies(page, options: {
departmentDelayMs?: number;
departments?: Array<Record<string, unknown>>;
permissions?: string[];
sessionData?: Record<string, unknown>;
} = {}) {
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) => {
+25 -24
View File
@@ -529,27 +529,24 @@ function createTallActionMenuPosFixture() {
};
return orders;
}, {}),
attachmentsByOrderId: tallOrderIds.reduce<Record<number, Array<Record<string, unknown>>>>(
(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<Record<number, Array<Record<string, unknown>>>>((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, {
@@ -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
);
});
});
+1 -3
View File
@@ -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,
});
+3 -3
View File
@@ -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 }) => {
+9 -10
View File
@@ -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 ({
+12 -15
View File
@@ -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);
});
+73
View File
@@ -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,
}) => {
+8 -3
View File
@@ -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[] = [];
+84 -4
View File
@@ -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([]);
});
});
+1 -4
View File
@@ -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),
+124 -280
View File
@@ -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;
}
+2 -4
View File
@@ -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");
@@ -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"');
+7 -16
View File
@@ -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"');
-16
View File
@@ -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));
};
@@ -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) => {
+6 -6
View File
@@ -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 = "") => {
+2 -2
View File
@@ -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();
-20
View File
@@ -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 });
}
});
});
+33 -35
View File
@@ -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('');
});
});
+78 -1
View File
@@ -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({
@@ -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]);
+6 -6
View File
@@ -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 = "") => {