7336 lines
244 KiB
JavaScript
7336 lines
244 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const API_BASE_URL_SOURCE =
|
|
"https?:\\/\\/(?:api\\.truckwash\\.io(?::\\d+)?|api-v2\\.truckwash\\.io\\/[^/]+\\/api|localhost(?::\\d+)?\\/api|127\\.0\\.0\\.1(?::\\d+)?\\/api)";
|
|
export const API_HOST = new RegExp(`${API_BASE_URL_SOURCE}\\/.*`, "i");
|
|
const escapeRegex = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
export const apiPathPattern = (pathname) => {
|
|
const normalizedPath = `/${String(pathname || "").replace(/^\/+/, "")}`;
|
|
return new RegExp(`${API_BASE_URL_SOURCE}${escapeRegex(normalizedPath)}(?:[?#].*)?$`, "i");
|
|
};
|
|
const TINY_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAukB9pY9ZxQAAAAASUVORK5CYII=",
|
|
"base64"
|
|
);
|
|
const TINY_PDF = Buffer.from(
|
|
"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n2 0 obj<</Type/Pages/Count 1/Kids[3 0 R]>>endobj\n3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF",
|
|
"utf8"
|
|
);
|
|
const SUPPORT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
const FONT_AWESOME_FIXTURE_DIR = resolve(SUPPORT_DIR, "../fixtures/fontawesome/6.7.1");
|
|
const FONT_AWESOME_CSS = readFileSync(resolve(FONT_AWESOME_FIXTURE_DIR, "css/all.min.css"), "utf8");
|
|
const FONT_AWESOME_WEBFONTS = new Map(
|
|
["fa-brands-400.woff2", "fa-regular-400.woff2", "fa-solid-900.woff2", "fa-v4compatibility.woff2"].map((filename) => [
|
|
filename,
|
|
readFileSync(resolve(FONT_AWESOME_FIXTURE_DIR, "webfonts", filename)),
|
|
])
|
|
);
|
|
|
|
function json(body, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
headers: {
|
|
"access-control-allow-origin": "*",
|
|
"access-control-allow-headers":
|
|
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
|
|
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
},
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function binary(body, contentType = "image/png", status = 200) {
|
|
return {
|
|
status,
|
|
contentType,
|
|
body,
|
|
};
|
|
}
|
|
|
|
function isSafeReadMethod(method = "GET") {
|
|
return ["GET", "HEAD", "OPTIONS"].includes(String(method || "GET").toUpperCase());
|
|
}
|
|
|
|
function isUnsafeSelfServePassthrough(pathname, method) {
|
|
if (isSafeReadMethod(method)) {
|
|
return false;
|
|
}
|
|
|
|
return [
|
|
/\/modules\/self-serve(?:\/|$)/,
|
|
/\/department\/selfserve(?:\/|$)/,
|
|
/\/department\/lanes(?:\/|$)/,
|
|
/\/department\/relays(?:\/|$)/,
|
|
/\/(?:modules\/)?edge-gateways(?:\/|$)/,
|
|
/\/departments\/\d+\/gateway-cutover$/,
|
|
/\/edgegateway\/config(?:\/|$)/,
|
|
].some((pattern) => pattern.test(pathname));
|
|
}
|
|
|
|
function getAttachmentPreviewContentType(attachment = null) {
|
|
const filename = String(
|
|
attachment?.content?.image || attachment?.content?.document || attachment?.content?.other || ""
|
|
).toLowerCase();
|
|
|
|
if (filename.endsWith(".pdf")) {
|
|
return {
|
|
body: TINY_PDF,
|
|
contentType: "application/pdf",
|
|
};
|
|
}
|
|
|
|
return {
|
|
body: TINY_PNG,
|
|
contentType: "image/png",
|
|
};
|
|
}
|
|
|
|
function mergeFixture(base, overrides = {}) {
|
|
return {
|
|
...base,
|
|
...overrides,
|
|
previewByKey: {
|
|
...(base.previewByKey || {}),
|
|
...(overrides.previewByKey || {}),
|
|
},
|
|
summaryBySessionId: {
|
|
...(base.summaryBySessionId || {}),
|
|
...(overrides.summaryBySessionId || {}),
|
|
},
|
|
sessionDetailsById: {
|
|
...(base.sessionDetailsById || {}),
|
|
...(overrides.sessionDetailsById || {}),
|
|
},
|
|
summaryByKey: {
|
|
...(base.summaryByKey || {}),
|
|
...(overrides.summaryByKey || {}),
|
|
},
|
|
answerResponseByKey: {
|
|
...(base.answerResponseByKey || {}),
|
|
...(overrides.answerResponseByKey || {}),
|
|
},
|
|
attachmentsByTaskId: {
|
|
...(base.attachmentsByTaskId || {}),
|
|
...(overrides.attachmentsByTaskId || {}),
|
|
},
|
|
attachmentDownloadByKey: {
|
|
...(base.attachmentDownloadByKey || {}),
|
|
...(overrides.attachmentDownloadByKey || {}),
|
|
},
|
|
laneAllowedServicesByLane: {
|
|
...(base.laneAllowedServicesByLane || {}),
|
|
...(overrides.laneAllowedServicesByLane || {}),
|
|
},
|
|
inProgressByLaneId: {
|
|
...(base.inProgressByLaneId || {}),
|
|
...(overrides.inProgressByLaneId || {}),
|
|
},
|
|
relayStatuses: {
|
|
...(base.relayStatuses || {}),
|
|
...(overrides.relayStatuses || {}),
|
|
},
|
|
dynamicImagesByLaneId: {
|
|
...(base.dynamicImagesByLaneId || {}),
|
|
...(overrides.dynamicImagesByLaneId || {}),
|
|
},
|
|
edgeGatewayHealthByDepartment: {
|
|
...(base.edgeGatewayHealthByDepartment || {}),
|
|
...(overrides.edgeGatewayHealthByDepartment || {}),
|
|
},
|
|
laneAllowedServiceResponses: overrides.laneAllowedServiceResponses ?? base.laneAllowedServiceResponses,
|
|
laneAllowedServiceResponse: overrides.laneAllowedServiceResponse ?? base.laneAllowedServiceResponse,
|
|
};
|
|
}
|
|
|
|
function toSqlDateTime(value = new Date()) {
|
|
return new Date(value).toISOString().slice(0, 19).replace("T", " ");
|
|
}
|
|
|
|
function normalizeCreatedAtValue(value) {
|
|
if (value === null || value === undefined || value === "") {
|
|
return value;
|
|
}
|
|
|
|
const normalized = String(value).trim();
|
|
const dateTimeMatch = normalized.match(/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::(\d{2}))?$/);
|
|
if (dateTimeMatch) {
|
|
return `${dateTimeMatch[1]} ${dateTimeMatch[2]}:${dateTimeMatch[3] || "00"}`;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeRegistrationValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return String(value)
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/[^A-Z0-9]/g, "");
|
|
}
|
|
|
|
function normalizeIncludeInInvoiceValue(value) {
|
|
if (value === null || value === undefined || value === "" || value === "use_department" || value === "null") {
|
|
return null;
|
|
}
|
|
|
|
if (value === true || value === 1 || value === "1" || value === "true" || value === "include") {
|
|
return true;
|
|
}
|
|
|
|
if (value === false || value === 0 || value === "0" || value === "false" || value === "exclude") {
|
|
return false;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function normalizeSafetySealValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
return String(value).trim();
|
|
}
|
|
|
|
function normalizePositiveIntegerValue(value) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|
|
|
|
function normalizeOrderPoValue(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function findPosOrderBookingById(posFixture, bookingId) {
|
|
const normalizedBookingId = normalizePositiveIntegerValue(bookingId);
|
|
if (!normalizedBookingId) {
|
|
return null;
|
|
}
|
|
|
|
return (posFixture.orderBookings || []).find((booking) => Number(booking?.id || 0) === normalizedBookingId) || null;
|
|
}
|
|
|
|
function getPosOrderBookingPo(posFixture, bookingId) {
|
|
const booking = findPosOrderBookingById(posFixture, bookingId);
|
|
const bookingPo = normalizeOrderPoValue(booking?.po);
|
|
return bookingPo || "";
|
|
}
|
|
|
|
function applyBookingPoDefaultToMockOrder(posFixture, order) {
|
|
if (!order || normalizeOrderPoValue(order.po) !== "") {
|
|
return order;
|
|
}
|
|
|
|
const bookingPo = getPosOrderBookingPo(posFixture, order.booking_id);
|
|
if (!bookingPo) {
|
|
return order;
|
|
}
|
|
|
|
return {
|
|
...order,
|
|
po: bookingPo,
|
|
};
|
|
}
|
|
|
|
function normalizeReferenceKey(value) {
|
|
return String(value ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function normalizeReferencePlate(value) {
|
|
return String(value ?? "")
|
|
.trim()
|
|
.toUpperCase()
|
|
.replace(/\s+/g, "");
|
|
}
|
|
|
|
function referenceMatchScore(reference, search) {
|
|
const referenceKey = normalizeReferenceKey(reference);
|
|
const searchKey = normalizeReferenceKey(search);
|
|
|
|
if (!searchKey) {
|
|
return 0;
|
|
}
|
|
if (referenceKey === searchKey) {
|
|
return 1000;
|
|
}
|
|
if (referenceKey.startsWith(searchKey)) {
|
|
return 600;
|
|
}
|
|
if (referenceKey.includes(searchKey)) {
|
|
return 300;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function referenceSourceScore(source) {
|
|
if (source === "booking") {
|
|
return 30;
|
|
}
|
|
if (source === "order") {
|
|
return 20;
|
|
}
|
|
if (source === "vehicle") {
|
|
return 10;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function referenceContextBoost(row, customerId, plates) {
|
|
let score = 0;
|
|
if (customerId && Number(row.customer_id || 0) === Number(customerId)) {
|
|
score += 80;
|
|
}
|
|
|
|
if (referenceMatchesAnyPlate(row, plates)) {
|
|
score += 90;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
function referenceMatchesAnyPlate(row, plates) {
|
|
const rowPlates = [row.reg_1, row.reg_2, row.reg_3].map(normalizeReferencePlate).filter(Boolean);
|
|
return plates.some((plate) => rowPlates.includes(plate));
|
|
}
|
|
|
|
function referenceSection(row, customerId, plates) {
|
|
if (referenceMatchesAnyPlate(row, plates)) {
|
|
return "this_vehicle";
|
|
}
|
|
if (customerId && Number(row.customer_id || 0) === Number(customerId)) {
|
|
return "other_customer_vehicle";
|
|
}
|
|
return "other";
|
|
}
|
|
|
|
function referenceSectionScore(section) {
|
|
if (section === "this_vehicle") {
|
|
return 40;
|
|
}
|
|
if (section === "other_customer_vehicle") {
|
|
return 20;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function buildReferenceSuggestions(
|
|
posFixture,
|
|
{ search = "", departmentId = null, customerId = null, plates = [], limit = 10 }
|
|
) {
|
|
const normalizedSearch = String(search || "").trim();
|
|
const normalizedDepartmentId = Number(departmentId || 0);
|
|
const normalizedCustomerId = Number(customerId || 0);
|
|
const normalizedPlates = plates.map(normalizeReferencePlate).filter(Boolean);
|
|
const rows = [];
|
|
|
|
for (const booking of posFixture.orderBookings || []) {
|
|
const reference = String(booking.reference ?? booking.reference_number ?? "").trim();
|
|
if (
|
|
!reference ||
|
|
booking.deleted_at ||
|
|
Number(booking.department ?? booking.department_id ?? 0) !== normalizedDepartmentId
|
|
) {
|
|
continue;
|
|
}
|
|
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
|
|
continue;
|
|
}
|
|
rows.push({
|
|
source: "booking",
|
|
origin_id: Number(booking.id || 0),
|
|
reference,
|
|
source_created_at: booking.datetime || booking.created_at || "",
|
|
used_at: booking.datetime || booking.created_at || "",
|
|
customer_id: Number(booking.customer_number || booking.customer_id || 0),
|
|
reg_1: booking.reg_1 || booking.regNr || "",
|
|
reg_2: booking.reg_2 || booking.regNrTrailer || "",
|
|
reg_3: booking.reg_3 || "",
|
|
});
|
|
}
|
|
|
|
for (const order of Object.values(posFixture.ordersById || {})) {
|
|
const reference = String(order.reference || "").trim();
|
|
if (!reference || order.deleted_at || Number(order.department_id || 0) !== normalizedDepartmentId) {
|
|
continue;
|
|
}
|
|
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
|
|
continue;
|
|
}
|
|
rows.push({
|
|
source: "order",
|
|
origin_id: Number(order.id || 0),
|
|
reference,
|
|
source_created_at: order.created_at || "",
|
|
used_at: order.created_at || "",
|
|
customer_id: Number(order.customer_id || 0),
|
|
reg_1: order.reg_1 || "",
|
|
reg_2: order.reg_2 || "",
|
|
reg_3: order.reg_3 || "",
|
|
});
|
|
}
|
|
|
|
for (const vehicle of posFixture.vehicles || []) {
|
|
const reference = String(vehicle.reference || "").trim();
|
|
const vehiclePlate = normalizeReferencePlate(vehicle.reg);
|
|
const matchesContext =
|
|
(normalizedCustomerId && Number(vehicle.customer_id || 0) === normalizedCustomerId) ||
|
|
(vehiclePlate && normalizedPlates.includes(vehiclePlate));
|
|
if (!reference || vehicle.deleted_at || !matchesContext) {
|
|
continue;
|
|
}
|
|
if (normalizedSearch && !normalizeReferenceKey(reference).includes(normalizeReferenceKey(normalizedSearch))) {
|
|
continue;
|
|
}
|
|
rows.push({
|
|
source: "vehicle",
|
|
origin_id: Number(vehicle.id || 0),
|
|
reference,
|
|
source_created_at: vehicle.created_at || "",
|
|
used_at: vehicle.created_at || "",
|
|
customer_id: Number(vehicle.customer_id || 0),
|
|
reg_1: vehicle.reg || "",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
});
|
|
}
|
|
|
|
const groups = new Map();
|
|
for (const row of rows) {
|
|
const key = normalizeReferenceKey(row.reference);
|
|
const existing = groups.get(key) || {
|
|
reference: row.reference,
|
|
rows: [],
|
|
usage_count: 0,
|
|
last_used_at: "",
|
|
context_boost: 0,
|
|
section: "other",
|
|
};
|
|
const rowSection = referenceSection(row, normalizedCustomerId, normalizedPlates);
|
|
existing.rows.push(row);
|
|
existing.usage_count += 1;
|
|
existing.last_used_at =
|
|
String(row.used_at || "") > String(existing.last_used_at || "") ? row.used_at || "" : existing.last_used_at;
|
|
existing.context_boost = Math.max(
|
|
existing.context_boost,
|
|
referenceContextBoost(row, normalizedCustomerId, normalizedPlates)
|
|
);
|
|
existing.section =
|
|
referenceSectionScore(rowSection) > referenceSectionScore(existing.section) ? rowSection : existing.section;
|
|
groups.set(key, existing);
|
|
}
|
|
|
|
return Array.from(groups.values())
|
|
.map((group) => {
|
|
const bestRow = [...group.rows].sort((left, right) => {
|
|
return (
|
|
referenceSourceScore(right.source) - referenceSourceScore(left.source) ||
|
|
String(right.source_created_at || "").localeCompare(String(left.source_created_at || "")) ||
|
|
Number(right.origin_id || 0) - Number(left.origin_id || 0)
|
|
);
|
|
})[0];
|
|
const usageCount = Number(group.usage_count || 1);
|
|
return {
|
|
source: bestRow.source,
|
|
section: group.section,
|
|
reference: group.reference,
|
|
source_created_at: bestRow.source_created_at || "",
|
|
last_used_at: group.last_used_at || bestRow.used_at || "",
|
|
usage_count: usageCount,
|
|
origin_id: Number(bestRow.origin_id || 0),
|
|
score:
|
|
referenceMatchScore(group.reference, normalizedSearch) +
|
|
Number(group.context_boost || 0) +
|
|
referenceSectionScore(group.section) +
|
|
referenceSourceScore(bestRow.source) +
|
|
Math.min(usageCount, 20) * 5,
|
|
};
|
|
})
|
|
.sort((left, right) => {
|
|
return (
|
|
Number(right.score || 0) - Number(left.score || 0) ||
|
|
Number(right.usage_count || 0) - Number(left.usage_count || 0) ||
|
|
referenceSectionScore(right.section) - referenceSectionScore(left.section) ||
|
|
String(right.last_used_at || "").localeCompare(String(left.last_used_at || "")) ||
|
|
String(left.reference || "").localeCompare(String(right.reference || ""))
|
|
);
|
|
})
|
|
.slice(0, Math.max(1, Math.min(Number(limit || 10), 25)));
|
|
}
|
|
|
|
function resolveCustomerNumberFromAttributeTarget(posFixture, target = {}) {
|
|
const customerNumber = normalizePositiveIntegerValue(target.customer_number ?? target.customerNumber);
|
|
if (customerNumber) {
|
|
return customerNumber;
|
|
}
|
|
|
|
const userId = normalizePositiveIntegerValue(target.user_id ?? target.userId);
|
|
if (!userId) {
|
|
return null;
|
|
}
|
|
|
|
const matchedCustomer = Object.values(posFixture.customersByNumber || {}).find((customer) => {
|
|
return Number(customer?.id ?? customer?.user_id ?? 0) === userId;
|
|
});
|
|
|
|
return normalizePositiveIntegerValue(
|
|
matchedCustomer?.customerNumber ?? matchedCustomer?.customer_number ?? matchedCustomer?.economic_customer
|
|
);
|
|
}
|
|
|
|
function ensureCustomerAttributeBucket(posFixture, customerNumber) {
|
|
const normalizedCustomerNumber = normalizePositiveIntegerValue(customerNumber);
|
|
if (!normalizedCustomerNumber) {
|
|
return [];
|
|
}
|
|
|
|
if (!posFixture.customerAttributesByNumber) {
|
|
posFixture.customerAttributesByNumber = {};
|
|
}
|
|
|
|
if (!Array.isArray(posFixture.customerAttributesByNumber[normalizedCustomerNumber])) {
|
|
posFixture.customerAttributesByNumber[normalizedCustomerNumber] = [];
|
|
}
|
|
|
|
return posFixture.customerAttributesByNumber[normalizedCustomerNumber];
|
|
}
|
|
|
|
function isWashCertificateProduct(product) {
|
|
const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0);
|
|
if (productId === 41) {
|
|
return true;
|
|
}
|
|
|
|
return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? ""));
|
|
}
|
|
|
|
function orderContainsWashCertificate(posFixture, orderId) {
|
|
return (posFixture.orderItemsByOrderId[orderId] || []).some((item) =>
|
|
isWashCertificateProduct(item?.product || item)
|
|
);
|
|
}
|
|
|
|
function ensureWashCertificateAttachment(posFixture, orderId) {
|
|
if (!Array.isArray(posFixture.attachmentsByOrderId[orderId])) {
|
|
posFixture.attachmentsByOrderId[orderId] = [];
|
|
}
|
|
|
|
const existingAttachment =
|
|
(posFixture.attachmentsByOrderId[orderId] || []).find((attachment) => {
|
|
return String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE";
|
|
}) || null;
|
|
|
|
if (existingAttachment) {
|
|
return existingAttachment;
|
|
}
|
|
|
|
const attachmentId = posFixture.nextAttachmentId++;
|
|
const attachment = {
|
|
id: attachmentId,
|
|
object_type: "orders",
|
|
object_id: orderId,
|
|
content: {
|
|
image: null,
|
|
document: `wash_certificate_${orderId}.pdf`,
|
|
relation: null,
|
|
other: "WASH_CERTIFICATE",
|
|
src: null,
|
|
},
|
|
created_at: toSqlDateTime(),
|
|
updated_at: toSqlDateTime(),
|
|
deleted_at: null,
|
|
};
|
|
posFixture.attachmentsByOrderId[orderId].push(attachment);
|
|
syncOrderAttachments(posFixture, orderId);
|
|
return attachment;
|
|
}
|
|
|
|
function syncOrderAttachments(posFixture, orderId) {
|
|
if (!posFixture.ordersById?.[orderId]) {
|
|
return;
|
|
}
|
|
|
|
posFixture.ordersById[orderId].attachments = [...(posFixture.attachmentsByOrderId[orderId] || [])];
|
|
}
|
|
|
|
function replaceWashCertificateAttachment(posFixture, orderId) {
|
|
if (!Array.isArray(posFixture.attachmentsByOrderId[orderId])) {
|
|
posFixture.attachmentsByOrderId[orderId] = [];
|
|
}
|
|
|
|
const existingAttachmentIndex = (posFixture.attachmentsByOrderId[orderId] || []).findIndex((attachment) => {
|
|
return String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE";
|
|
});
|
|
|
|
if (existingAttachmentIndex === -1) {
|
|
return null;
|
|
}
|
|
|
|
const existingAttachment = posFixture.attachmentsByOrderId[orderId][existingAttachmentIndex];
|
|
const attachmentId = posFixture.nextAttachmentId++;
|
|
const replacementAttachment = {
|
|
...existingAttachment,
|
|
id: attachmentId,
|
|
content: {
|
|
...existingAttachment.content,
|
|
document: `wash_certificate_${orderId}_${attachmentId}.pdf`,
|
|
other: "WASH_CERTIFICATE",
|
|
src: null,
|
|
},
|
|
created_at: toSqlDateTime(),
|
|
updated_at: toSqlDateTime(),
|
|
deleted_at: null,
|
|
};
|
|
|
|
posFixture.attachmentsByOrderId[orderId].splice(existingAttachmentIndex, 1, replacementAttachment);
|
|
syncOrderAttachments(posFixture, orderId);
|
|
|
|
return replacementAttachment;
|
|
}
|
|
|
|
function normalizeRelevantOrderUpdateValue(field, value) {
|
|
if (field === "customer_id") {
|
|
return normalizePositiveIntegerValue(value);
|
|
}
|
|
|
|
if (field === "reg_1" || field === "reg_2" || field === "reg_3") {
|
|
return normalizeRegistrationValue(value);
|
|
}
|
|
|
|
if (field === "safety_seal") {
|
|
return normalizeSafetySealValue(value);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function shouldRegenerateWashCertificateForOrderUpdate(order, body) {
|
|
if (!order || !body || typeof body !== "object") {
|
|
return false;
|
|
}
|
|
|
|
const relevantFields = ["customer_id", "reg_1", "reg_2", "reg_3", "safety_seal"];
|
|
|
|
return relevantFields.some((field) => {
|
|
const hasDirectField = Object.prototype.hasOwnProperty.call(body, field);
|
|
const hasLegacyField = body.field === field;
|
|
|
|
if (!hasDirectField && !hasLegacyField) {
|
|
return false;
|
|
}
|
|
|
|
const nextValue = normalizeRelevantOrderUpdateValue(field, hasDirectField ? body[field] : body.value);
|
|
const currentValue = normalizeRelevantOrderUpdateValue(field, order[field]);
|
|
|
|
return nextValue !== currentValue;
|
|
});
|
|
}
|
|
|
|
function resolveDepartmentIncludeInInvoice(posFixture, departmentId) {
|
|
const department = (posFixture.departments || []).find((entry) => Number(entry.id) === Number(departmentId));
|
|
if (!department) {
|
|
return true;
|
|
}
|
|
|
|
if (typeof department.exclude_from_invoicing === "boolean") {
|
|
return !department.exclude_from_invoicing;
|
|
}
|
|
|
|
if (typeof department.include_in_invoice === "boolean") {
|
|
return department.include_in_invoice;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function toPublicTimeBookingDepartments(departments = []) {
|
|
return (departments || [])
|
|
.filter(
|
|
(department) =>
|
|
department.bookingsystem_time_based_enabled === true ||
|
|
department.bookingsystem_time_based_enabled === "true" ||
|
|
department.time_booking_enabled === true
|
|
)
|
|
.map((department) => ({
|
|
...department,
|
|
description: department.description ?? department.address ?? "",
|
|
address: department.address ?? department.description ?? "",
|
|
time_booking_enabled: true,
|
|
}));
|
|
}
|
|
|
|
function withEffectiveOrderState(posFixture, order) {
|
|
if (!order) {
|
|
return order;
|
|
}
|
|
|
|
const includeInInvoice = normalizeIncludeInInvoiceValue(order.include_in_invoice);
|
|
const orderId = Number(order.id || 0);
|
|
const attachments = Array.isArray(order.attachments)
|
|
? order.attachments
|
|
: Array.isArray(posFixture.attachmentsByOrderId?.[orderId])
|
|
? posFixture.attachmentsByOrderId[orderId]
|
|
: [];
|
|
|
|
return {
|
|
...order,
|
|
attachments,
|
|
include_in_invoice: includeInInvoice,
|
|
include_in_invoice_effective:
|
|
includeInInvoice === null ? resolveDepartmentIncludeInInvoice(posFixture, order.department_id) : includeInInvoice,
|
|
};
|
|
}
|
|
|
|
function parseFilterExpressions(filters) {
|
|
return String(filters || "")
|
|
.split(",")
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.reduce((result, entry) => {
|
|
const separatorIndex = entry.indexOf(":");
|
|
if (separatorIndex === -1) {
|
|
return result;
|
|
}
|
|
|
|
const key = entry.slice(0, separatorIndex);
|
|
const value = entry.slice(separatorIndex + 1);
|
|
if (key) {
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}, {});
|
|
}
|
|
|
|
function toOrderBookingListEntry(booking, stripDetails = false, options = {}) {
|
|
if (!stripDetails || !booking || typeof booking !== "object") {
|
|
return booking;
|
|
}
|
|
|
|
const summaryBooking = { ...booking };
|
|
delete summaryBooking.items;
|
|
delete summaryBooking.parsed_services;
|
|
if (options.stripMetadata === true) {
|
|
delete summaryBooking.reference;
|
|
delete summaryBooking.reference_number;
|
|
delete summaryBooking.notes;
|
|
delete summaryBooking.note;
|
|
delete summaryBooking.po;
|
|
}
|
|
return summaryBooking;
|
|
}
|
|
|
|
function filterPosOrders(posFixture, filters) {
|
|
const filterMap = parseFilterExpressions(filters);
|
|
return Object.values(posFixture.ordersById || {}).filter((order) => {
|
|
if (filterMap.id && Number(order.id) !== Number(filterMap.id)) {
|
|
return false;
|
|
}
|
|
|
|
if (filterMap.customer_id && Number(order.customer_id) !== Number(filterMap.customer_id)) {
|
|
return false;
|
|
}
|
|
|
|
if (filterMap.department_id && Number(order.department_id) !== Number(filterMap.department_id)) {
|
|
return false;
|
|
}
|
|
|
|
if (filterMap.reg_1 && String(order.reg_1 || "").toUpperCase() !== String(filterMap.reg_1).toUpperCase()) {
|
|
return false;
|
|
}
|
|
|
|
const createdAtDate = String(order.created_at || "").slice(0, 10);
|
|
if (filterMap["created_at-date_from"] && (!createdAtDate || createdAtDate < filterMap["created_at-date_from"])) {
|
|
return false;
|
|
}
|
|
|
|
if (filterMap["created_at-date_to"] && (!createdAtDate || createdAtDate > filterMap["created_at-date_to"])) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function filterNumberPlateScans(posFixture, { filters = "", search = "", order = "created_at:desc" } = {}) {
|
|
const filterMap = parseFilterExpressions(filters);
|
|
const searchValue = String(search || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
const [orderBy = "created_at", orderDirection = "desc"] = String(order || "created_at:desc").split(":");
|
|
|
|
const filteredRows = (posFixture.numberPlateScans || []).filter((scan) => {
|
|
if (filterMap.department_id && Number(scan.department_id) !== Number(filterMap.department_id)) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
searchValue &&
|
|
!String(scan.plate || "")
|
|
.toUpperCase()
|
|
.includes(searchValue)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
return filteredRows.sort((left, right) => {
|
|
const leftValue = left?.[orderBy];
|
|
const rightValue = right?.[orderBy];
|
|
const leftComparable = typeof leftValue === "string" ? leftValue.toUpperCase() : leftValue;
|
|
const rightComparable = typeof rightValue === "string" ? rightValue.toUpperCase() : rightValue;
|
|
|
|
if (leftComparable === rightComparable) {
|
|
return 0;
|
|
}
|
|
|
|
if (leftComparable > rightComparable) {
|
|
return orderDirection === "desc" ? -1 : 1;
|
|
}
|
|
|
|
return orderDirection === "desc" ? 1 : -1;
|
|
});
|
|
}
|
|
|
|
function paginateRows(rows, page = 1, limit = 10) {
|
|
const currentPage = Math.max(1, Number.parseInt(page, 10) || 1);
|
|
const perPage = Math.max(1, Number.parseInt(limit, 10) || 10);
|
|
const startIndex = (currentPage - 1) * perPage;
|
|
const pagedRows = rows.slice(startIndex, startIndex + perPage);
|
|
|
|
return {
|
|
rows: pagedRows,
|
|
meta: {
|
|
pagination: {
|
|
current_page: currentPage,
|
|
per_page: perPage,
|
|
total: rows.length,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSelfServeFixture(overrides = {}) {
|
|
const departments = [
|
|
{
|
|
id: 6,
|
|
name: "Roskilde",
|
|
address: "Industrivej 45, 4000 Roskilde",
|
|
latitude: 55.6415,
|
|
longitude: 12.0803,
|
|
self_serve_enabled: true,
|
|
bookingsystem_time_based_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 7,
|
|
department: 6,
|
|
name: "7",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: true,
|
|
products: [2, 3],
|
|
relay_in_id: "IN-7",
|
|
relay_out_id: "OUT-7",
|
|
relay_machine_id: "M-7",
|
|
relay_machine_program_picker_id: "MPP-7",
|
|
relay_machine_cleaner_id: "MC-7",
|
|
dynamic_image_id: 77,
|
|
machine_type_id: 1001,
|
|
},
|
|
{
|
|
id: 8,
|
|
department: 6,
|
|
name: "8",
|
|
status: "FAULT",
|
|
selfserve_enabled: true,
|
|
machine_available: false,
|
|
products: [2, 3],
|
|
relay_in_id: "IN-8",
|
|
relay_out_id: "OUT-8",
|
|
relay_machine_id: "M-8",
|
|
relay_machine_program_picker_id: "MPP-8",
|
|
relay_machine_cleaner_id: "MC-8",
|
|
dynamic_image_id: 78,
|
|
machine_type_id: 1001,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Odense",
|
|
address: "Beta 2",
|
|
latitude: 55.4038,
|
|
longitude: 10.4024,
|
|
self_serve_enabled: true,
|
|
bookingsystem_time_based_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 9,
|
|
department: 2,
|
|
name: "9",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: true,
|
|
products: [2, 4],
|
|
relay_in_id: "IN-9",
|
|
relay_out_id: "OUT-9",
|
|
relay_machine_id: "M-9",
|
|
relay_machine_program_picker_id: "MPP-9",
|
|
relay_machine_cleaner_id: "MC-9",
|
|
dynamic_image_id: 79,
|
|
machine_type_id: 1002,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Aarhus",
|
|
address: "Gamma 3",
|
|
latitude: 56.1629,
|
|
longitude: 10.2039,
|
|
self_serve_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 10,
|
|
department: 3,
|
|
name: "10",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_available: false,
|
|
products: [3, 4],
|
|
relay_in_id: "IN-10",
|
|
relay_out_id: "OUT-10",
|
|
relay_machine_id: "M-10",
|
|
relay_machine_program_picker_id: "MPP-10",
|
|
relay_machine_cleaner_id: "MC-10",
|
|
dynamic_image_id: 80,
|
|
machine_type_id: 1001,
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
const products = [
|
|
{
|
|
id: 2,
|
|
name: "Truck",
|
|
price: 100,
|
|
description: "Large truck",
|
|
piktogram: "truck",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "Van",
|
|
price: 80,
|
|
description: "Medium van",
|
|
piktogram: "van",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
{
|
|
id: 4,
|
|
name: "Car",
|
|
price: 60,
|
|
description: "Small car",
|
|
piktogram: "car",
|
|
is_wash: true,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
},
|
|
];
|
|
|
|
const machineTypes = [
|
|
{ id: 1001, name: "Portal machine", description: "Machine with relay-controlled start and cleaner stages." },
|
|
{ id: 1002, name: "Express machine", description: "Compact lane machine with program picker relay." },
|
|
];
|
|
|
|
const vehicleTypes = products.map((product) => ({
|
|
id: product.id,
|
|
vehicleTypeId: product.id,
|
|
name: product.name,
|
|
price: product.price,
|
|
product,
|
|
}));
|
|
|
|
const questions = [
|
|
{
|
|
id: 11,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
condition_id: null,
|
|
question: "Is the tarp removed?",
|
|
description: "Required before machine wash.",
|
|
order_priority: 1,
|
|
},
|
|
{
|
|
id: 21,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
condition_id: null,
|
|
question: "Ready for direct wash?",
|
|
description: "Fallback direct-wash confirmation.",
|
|
order_priority: 2,
|
|
},
|
|
];
|
|
|
|
const conditions = [
|
|
{
|
|
id: 5011,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
machine_type_id: 1001,
|
|
condition_id: null,
|
|
name: "Pre-wash checks complete",
|
|
description: "Driver has confirmed the required preparation questions.",
|
|
},
|
|
{
|
|
id: 5012,
|
|
department: 6,
|
|
lane: 7,
|
|
product: 2,
|
|
machine_type_id: 1001,
|
|
condition_id: 5011,
|
|
name: "Machine service visible",
|
|
description: "The machine service remains visible only while the relay is online.",
|
|
},
|
|
];
|
|
|
|
const rules = [
|
|
{
|
|
id: 6101,
|
|
condition_id: 5011,
|
|
type: "IS_TRUE",
|
|
object_type: "question",
|
|
object_id: 11,
|
|
name: "Tarp removed",
|
|
description: "Question 11 must be answered yes.",
|
|
},
|
|
{
|
|
id: 6102,
|
|
condition_id: 5012,
|
|
type: "IS_TRUE_OR_NOT_SET",
|
|
object_type: "condition",
|
|
object_id: 5011,
|
|
name: "Keep machine visible until checks fail",
|
|
description: "Allows the normal path and covers unset preview state.",
|
|
},
|
|
];
|
|
|
|
const tasks = [
|
|
{
|
|
id: 9001,
|
|
task: "Prepare the truck",
|
|
description: "Complete the pre-wash checks.",
|
|
order_priority: 1,
|
|
services: ["MACHINE"],
|
|
buttons: [1],
|
|
dynamic_images_vehicle_type: 2,
|
|
gate_type: "QUESTION",
|
|
gate_ref_id: 11,
|
|
},
|
|
{
|
|
id: 9002,
|
|
task: "Machine access",
|
|
description: "Enable the wash machine relay.",
|
|
order_priority: 2,
|
|
services: ["MACHINE"],
|
|
buttons: [2, 3],
|
|
dynamic_images_vehicle_type: 3,
|
|
gate_type: "CONDITION",
|
|
gate_ref_id: 5011,
|
|
},
|
|
{
|
|
id: 9003,
|
|
task: "Hidden machine service",
|
|
description: "Used by failure tests when the machine relay is offline.",
|
|
order_priority: 3,
|
|
services: [],
|
|
buttons: [],
|
|
dynamic_images_vehicle_type: null,
|
|
gate_type: "CONDITION",
|
|
gate_ref_id: 5012,
|
|
},
|
|
];
|
|
|
|
const sessions = [
|
|
{
|
|
id: 701,
|
|
lane_id: 7,
|
|
department_id: 6,
|
|
machine_type_id: 1001,
|
|
customer_number: 12345679,
|
|
vehicle_id: 1,
|
|
vehicle_type_id: 2,
|
|
reg: "AB12345",
|
|
status: "MACHINE_STARTED",
|
|
allowed: true,
|
|
machine_relay_enabled: true,
|
|
machine_relay_enabled_at: "2026-04-28 08:15:00",
|
|
machine_start_triggered: true,
|
|
machine_start_triggered_at: "2026-04-28 08:16:00",
|
|
wash_started_at: "2026-04-28 08:16:00",
|
|
order_id: null,
|
|
completed_at: null,
|
|
metadata: {
|
|
evaluation_trace: [
|
|
{ task_id: 9001, satisfied: true },
|
|
{ task_id: 9002, satisfied: true },
|
|
],
|
|
relay_status: { relay_id: "M-7", online: true, on: true, transport: "local" },
|
|
edge_gateway: { gateway_id: 1, fallback_mode: "PREFER_LOCAL", execution_path: "local" },
|
|
},
|
|
elapsed_minutes: 18,
|
|
open: true,
|
|
created_at: "2026-04-28 08:10:00",
|
|
updated_at: "2026-04-28 08:16:00",
|
|
},
|
|
{
|
|
id: 702,
|
|
lane_id: 9,
|
|
department_id: 2,
|
|
machine_type_id: 1002,
|
|
customer_number: 12345680,
|
|
vehicle_id: 2,
|
|
vehicle_type_id: 3,
|
|
reg: "CD67890",
|
|
status: "MACHINE_RELAY_ENABLED",
|
|
allowed: true,
|
|
machine_relay_enabled: true,
|
|
machine_relay_enabled_at: "2026-04-28 09:05:00",
|
|
machine_start_triggered: false,
|
|
machine_start_triggered_at: null,
|
|
wash_started_at: "2026-04-28 09:05:00",
|
|
order_id: null,
|
|
completed_at: null,
|
|
metadata: {
|
|
relay_status: { relay_id: "M-9", online: true, on: false, transport: "cloud" },
|
|
edge_gateway: { gateway_id: 2, fallback_mode: "CLOUD_ONLY", execution_path: "cloud" },
|
|
},
|
|
elapsed_minutes: 8,
|
|
open: true,
|
|
created_at: "2026-04-28 09:00:00",
|
|
updated_at: "2026-04-28 09:05:00",
|
|
},
|
|
{
|
|
id: 703,
|
|
lane_id: 7,
|
|
department_id: 6,
|
|
machine_type_id: 1001,
|
|
customer_number: 12345681,
|
|
vehicle_id: null,
|
|
vehicle_type_id: null,
|
|
reg: "ZZ00000",
|
|
status: "COMPLETED",
|
|
allowed: true,
|
|
machine_relay_enabled: false,
|
|
machine_relay_enabled_at: null,
|
|
machine_start_triggered: true,
|
|
machine_start_triggered_at: "2026-04-27 15:30:00",
|
|
wash_started_at: "2026-04-27 15:30:00",
|
|
order_id: 8800,
|
|
completed_at: "2026-04-27 15:55:00",
|
|
metadata: {
|
|
force_stop: { bill: true, order_id: 8800, reason: "Completed by fixture" },
|
|
},
|
|
elapsed_minutes: 25,
|
|
open: false,
|
|
created_at: "2026-04-27 15:25:00",
|
|
updated_at: "2026-04-27 15:55:00",
|
|
},
|
|
];
|
|
|
|
const baseFixture = {
|
|
departments,
|
|
departmentLanes: departments.flatMap((department) => department.lanes),
|
|
products,
|
|
vehicleTypes,
|
|
machineTypes,
|
|
questions,
|
|
conditions,
|
|
rules,
|
|
tasks,
|
|
sessions,
|
|
sessionDetailsById: {
|
|
701: {
|
|
session: sessions[0],
|
|
lane: { id: 7, name: "7", department: 6, machine_type_id: 1001 },
|
|
machine_type: machineTypes[0],
|
|
questions: [
|
|
{ question_id: 11, question: "Is the tarp removed?", answer: true, answered_at: "2026-04-28 08:12:00" },
|
|
],
|
|
tasks: [
|
|
{ task_id: 9001, task: "Prepare the truck", services: ["MACHINE"], description: "Complete checks." },
|
|
{ task_id: 9002, task: "Machine access", services: ["MACHINE"], description: "Enable the machine relay." },
|
|
],
|
|
events: [
|
|
{ id: 1, type: "SESSION_SYNCED", payload: { allowed: true }, created_at: "2026-04-28 08:10:00" },
|
|
{ id: 2, type: "MACHINE_START_TRIGGERED", payload: { lane_id: 7 }, created_at: "2026-04-28 08:16:00" },
|
|
],
|
|
config_version_id: 12,
|
|
evaluation_trace: [
|
|
{ task_id: 9001, satisfied: true },
|
|
{ task_id: 9002, satisfied: true },
|
|
],
|
|
},
|
|
702: {
|
|
session: sessions[1],
|
|
lane: { id: 9, name: "9", department: 2, machine_type_id: 1002 },
|
|
machine_type: machineTypes[1],
|
|
questions: [],
|
|
tasks: [{ task_id: 9002, task: "Machine access", services: ["MACHINE"], description: "" }],
|
|
events: [{ id: 3, type: "SESSION_SYNCED", payload: { allowed: true }, created_at: "2026-04-28 09:00:00" }],
|
|
config_version_id: 12,
|
|
evaluation_trace: [],
|
|
},
|
|
703: {
|
|
session: sessions[2],
|
|
lane: { id: 7, name: "7", department: 6, machine_type_id: 1001 },
|
|
machine_type: machineTypes[0],
|
|
questions: [],
|
|
tasks: [],
|
|
events: [{ id: 4, type: "SESSION_COMPLETED", payload: { order_id: 8800 }, created_at: "2026-04-27 15:55:00" }],
|
|
config_version_id: 11,
|
|
evaluation_trace: [],
|
|
},
|
|
},
|
|
customerVehicles: [
|
|
{ id: 1, reg: "AB12345", type: 2 },
|
|
{ id: 2, reg: "CD67890", type: 3 },
|
|
],
|
|
previewByKey: {
|
|
"7:AB12345": {
|
|
allowed: true,
|
|
machine_available: true,
|
|
lane: { id: 7, name: "7" },
|
|
session: { id: 501, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Is the tarp removed?",
|
|
description: "Required before machine wash.",
|
|
answer: null,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions,
|
|
rules,
|
|
tasks: tasks.slice(0, 1),
|
|
allowed_services: ["MACHINE"],
|
|
},
|
|
"7:ZZ00000": {
|
|
allowed: true,
|
|
machine_available: true,
|
|
lane: { id: 7, name: "7" },
|
|
session: { id: 601, status: "IN_PROGRESS", allowed: true },
|
|
questions: [{ id: 21, question: "Ready for direct wash?", answer: null, order_priority: 1 }],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
allowed_services: ["MACHINE"],
|
|
},
|
|
},
|
|
summaryBySessionId: {
|
|
501: {
|
|
session: { id: 501, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [],
|
|
conditions,
|
|
rules,
|
|
tasks: [],
|
|
events: [{ id: 1, type: "STARTED", created_at: "2026-01-01T10:00:00.000Z" }],
|
|
},
|
|
601: {
|
|
session: { id: 601, status: "IN_PROGRESS", allowed: true },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [{ id: 21, question: "Ready for direct wash?", answer: null, order_priority: 1 }],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
events: [{ id: 2, type: "STARTED", created_at: "2026-01-01T11:00:00.000Z" }],
|
|
},
|
|
},
|
|
summaryByKey: {
|
|
"7:AB12345": {
|
|
session: { id: 501, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Is the tarp removed?",
|
|
description: "Required before machine wash.",
|
|
answer: null,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions,
|
|
rules,
|
|
tasks: tasks.slice(0, 1),
|
|
events: [{ id: 2, type: "SESSION_SYNCED", created_at: "2026-04-28T10:01:00.000Z" }],
|
|
},
|
|
},
|
|
answerResponseByKey: {
|
|
"7:AB12345:11:true": {
|
|
session: { id: 501, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [
|
|
{
|
|
id: 11,
|
|
question: "Is the tarp removed?",
|
|
description: "Required before machine wash.",
|
|
answer: true,
|
|
order_priority: 1,
|
|
},
|
|
],
|
|
conditions,
|
|
rules,
|
|
tasks: tasks.slice(0, 1),
|
|
events: [{ id: 3, type: "QUESTION_ANSWERED", created_at: "2026-01-01T10:01:00.000Z" }],
|
|
},
|
|
"7:ZZ00000:21:true": {
|
|
session: { id: 601, status: "IN_PROGRESS", allowed: true },
|
|
lane: { id: 7, name: "7" },
|
|
questions: [{ id: 21, question: "Ready for direct wash?", answer: true, order_priority: 1 }],
|
|
conditions: [],
|
|
rules: [],
|
|
tasks: [],
|
|
events: [{ id: 4, type: "QUESTION_ANSWERED", created_at: "2026-01-01T11:01:00.000Z" }],
|
|
},
|
|
},
|
|
attachmentsByTaskId: {
|
|
9001: [
|
|
{ id: 301, content: { other: "prep.pdf" } },
|
|
{ id: 302, content: { other: "prep.jpg" } },
|
|
],
|
|
},
|
|
attachmentDownloadByKey: {
|
|
"9001:301": { download_link: "https://cdn.example.test/prep.pdf" },
|
|
"9001:302": { download_link: "https://cdn.example.test/prep.jpg" },
|
|
},
|
|
laneAllowedServicesByLane: {
|
|
7: ["MACHINE"],
|
|
8: [],
|
|
9: ["MACHINE"],
|
|
10: [],
|
|
},
|
|
laneAllowedServices: ["MACHINE"],
|
|
laneAllowedServiceResponse: null,
|
|
laneAllowedServiceResponses: null,
|
|
inProgressByLaneId: {},
|
|
allowedServiceRequests: [],
|
|
answerRequests: [],
|
|
commandResponse: { success: true },
|
|
commandResponses: null,
|
|
commandResponseDelayMs: 0,
|
|
commandRequests: [],
|
|
forceStopResponse: null,
|
|
forceStopResponses: null,
|
|
forceStopRequests: [],
|
|
relayResponse: { success: true },
|
|
relayResponses: null,
|
|
relayRequests: [],
|
|
gateRequests: [],
|
|
previewResponseDelayMs: 0,
|
|
summaryResponseDelayMs: 0,
|
|
relayStatuses: {
|
|
"M-7": { relay_id: "M-7", online: true, on: true, transport: "local", source: "edge_gateway" },
|
|
"M-8": { relay_id: "M-8", online: false, on: false, transport: "local", source: "edge_gateway" },
|
|
"M-9": { relay_id: "M-9", online: true, on: false, transport: "cloud", source: "cloud_fallback" },
|
|
"M-10": { relay_id: "M-10", online: false, on: false, transport: "local", source: "offline_gateway" },
|
|
},
|
|
edgeGatewayHealthByDepartment: {
|
|
6: {
|
|
department_id: 6,
|
|
gateway_id: 1,
|
|
transport_mode: "gateway",
|
|
fallback_mode: "PREFER_LOCAL",
|
|
healthy: true,
|
|
relays: ["M-7", "M-8"],
|
|
},
|
|
2: {
|
|
department_id: 2,
|
|
gateway_id: 2,
|
|
transport_mode: "cloud",
|
|
fallback_mode: "CLOUD_ONLY",
|
|
healthy: false,
|
|
relays: ["M-9"],
|
|
},
|
|
},
|
|
dynamicImageDelayMs: 0,
|
|
dynamicImage: TINY_PNG,
|
|
dynamicImagesByLaneId: {
|
|
7: TINY_PNG,
|
|
8: TINY_PNG,
|
|
9: TINY_PNG,
|
|
10: TINY_PNG,
|
|
},
|
|
};
|
|
|
|
const fixture = mergeFixture(baseFixture, overrides);
|
|
fixture.departmentLanes = fixture.departments.flatMap((department) => department.lanes || []);
|
|
fixture.laneById = fixture.departmentLanes.reduce((accumulator, lane) => {
|
|
accumulator[String(lane.id)] = lane;
|
|
return accumulator;
|
|
}, {});
|
|
fixture.sessionDetailsById = {
|
|
...(fixture.sessions || []).reduce((accumulator, session) => {
|
|
accumulator[String(session.id)] = fixture.sessionDetailsById?.[session.id] || {
|
|
session,
|
|
lane: fixture.laneById[String(session.lane_id)] || null,
|
|
machine_type: null,
|
|
questions: [],
|
|
tasks: [],
|
|
events: [],
|
|
config_version_id: null,
|
|
evaluation_trace: session.metadata?.evaluation_trace || null,
|
|
};
|
|
return accumulator;
|
|
}, {}),
|
|
...(fixture.sessionDetailsById || {}),
|
|
};
|
|
return fixture;
|
|
}
|
|
|
|
function mergeEdgeGatewayFixtureRecord(baseGateway, overrides = {}) {
|
|
return {
|
|
...baseGateway,
|
|
...overrides,
|
|
metadata: {
|
|
...(baseGateway.metadata || {}),
|
|
...(overrides.metadata || {}),
|
|
system_metrics: {
|
|
...(baseGateway.metadata?.system_metrics || {}),
|
|
...(overrides.metadata?.system_metrics || {}),
|
|
},
|
|
},
|
|
inventory: Array.isArray(overrides.inventory) ? overrides.inventory : baseGateway.inventory,
|
|
bindings: Array.isArray(overrides.bindings) ? overrides.bindings : baseGateway.bindings,
|
|
recent_commands: Array.isArray(overrides.recent_commands) ? overrides.recent_commands : baseGateway.recent_commands,
|
|
audit_logs: Array.isArray(overrides.audit_logs) ? overrides.audit_logs : baseGateway.audit_logs,
|
|
operations: Array.isArray(overrides.operations) ? overrides.operations : baseGateway.operations || [],
|
|
};
|
|
}
|
|
|
|
function createFixtureOperation(type, request = {}, overrides = {}) {
|
|
const startedAt = overrides.started_at || null;
|
|
const completedAt = overrides.completed_at || null;
|
|
const status = overrides.status || "PENDING";
|
|
return {
|
|
id: overrides.id || Date.now(),
|
|
type,
|
|
status,
|
|
request,
|
|
summary: overrides.summary || {
|
|
label:
|
|
status === "COMPLETED"
|
|
? "Completed"
|
|
: status === "FAILED"
|
|
? "Failed"
|
|
: status === "CANCELLED"
|
|
? "Cancelled"
|
|
: status === "CANCEL_REQUESTED"
|
|
? "Cancellation requested"
|
|
: "Queued",
|
|
progress: ["COMPLETED", "FAILED"].includes(status) ? 100 : 0,
|
|
retryable: status !== "FAILED",
|
|
},
|
|
result: overrides.result || {},
|
|
error_code: overrides.error_code || null,
|
|
error_message: overrides.error_message || null,
|
|
requested_at: overrides.requested_at || toSqlDateTime(),
|
|
started_at: startedAt,
|
|
completed_at: completedAt,
|
|
created_at: overrides.created_at || toSqlDateTime(),
|
|
updated_at: overrides.updated_at || completedAt || startedAt || toSqlDateTime(),
|
|
events: Array.isArray(overrides.events) ? overrides.events : [],
|
|
};
|
|
}
|
|
|
|
function buildEdgeGatewayOperationSummary(operations = []) {
|
|
return operations.reduce(
|
|
(summary, operation, index) => {
|
|
const status = String(operation.status || "PENDING");
|
|
if (status === "PENDING") summary.pending += 1;
|
|
if (status === "IN_PROGRESS") summary.in_progress += 1;
|
|
if (status === "CANCEL_REQUESTED") summary.cancel_requested += 1;
|
|
if (status === "CANCELLED") {
|
|
summary.cancelled += 1;
|
|
summary.latest_cancelled_at = summary.latest_cancelled_at || operation.completed_at || null;
|
|
}
|
|
if (status === "COMPLETED") {
|
|
summary.completed += 1;
|
|
summary.latest_completed_at = summary.latest_completed_at || operation.completed_at || null;
|
|
}
|
|
if (status === "FAILED") {
|
|
summary.failed += 1;
|
|
summary.latest_failed_at = summary.latest_failed_at || operation.completed_at || null;
|
|
}
|
|
if (index === 0) {
|
|
summary.latest_type = operation.type || null;
|
|
summary.latest_status = operation.status || null;
|
|
}
|
|
return summary;
|
|
},
|
|
{
|
|
total: operations.length,
|
|
pending: 0,
|
|
in_progress: 0,
|
|
cancel_requested: 0,
|
|
cancelled: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
latest_cancelled_at: null,
|
|
latest_completed_at: null,
|
|
latest_failed_at: null,
|
|
latest_type: null,
|
|
latest_status: null,
|
|
}
|
|
);
|
|
}
|
|
|
|
function invalidateEdgeGatewayRuntimeSnapshot(gateway) {
|
|
delete gateway.active_operation;
|
|
delete gateway.recent_operations_summary;
|
|
delete gateway.version_drift;
|
|
delete gateway.diagnostics;
|
|
delete gateway.error_state;
|
|
}
|
|
|
|
function buildEdgeGatewayRuntimeFixture(gateway) {
|
|
const relayHealth = (gateway.bindings || []).map((binding) => {
|
|
const fallbackMode = binding.fallback_mode || "PREFER_LOCAL";
|
|
const executionPath =
|
|
gateway.department_transport_mode === "cloud" || fallbackMode === "CLOUD_ONLY" ? "cloud" : "local";
|
|
const reason =
|
|
gateway.department_transport_mode === "cloud"
|
|
? "department_cutover"
|
|
: fallbackMode === "CLOUD_ONLY"
|
|
? "binding_cloud_only"
|
|
: null;
|
|
|
|
return {
|
|
binding_id: binding.id,
|
|
relay_id: binding.relay_id,
|
|
fallback_mode: fallbackMode,
|
|
execution_path: executionPath,
|
|
reason,
|
|
recommended_action: reason ? "review_binding_override" : null,
|
|
device_freshness_state: "READY",
|
|
};
|
|
});
|
|
|
|
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", "CANCEL_REQUESTED"].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";
|
|
const diagnostics = [];
|
|
|
|
if (gateway.status === "OFFLINE") {
|
|
diagnostics.push({
|
|
code: "EDGE_GATEWAY_OFFLINE",
|
|
message: "Gateway heartbeat has expired and the gateway is offline.",
|
|
recommended_action: "restart_agent",
|
|
});
|
|
}
|
|
if (versionDrift) {
|
|
diagnostics.push({
|
|
code: "EDGE_GATEWAY_VERSION_DRIFT",
|
|
message: "Installed gateway version differs from the target version.",
|
|
recommended_action: "queue_update",
|
|
});
|
|
}
|
|
const containerHealth = gateway.container_health ||
|
|
gateway.metadata?.container_health || {
|
|
state: gateway.status === "OFFLINE" ? "OFFLINE" : "ONLINE",
|
|
summary: gateway.status === "OFFLINE" ? "0/6 containers healthy" : "6/6 containers healthy",
|
|
services: [
|
|
{ name: "edge-agent", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
{ name: "lan-worker", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
{ name: "redis", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
{ name: "mariadb", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
{ name: "minio", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
{ name: "auto-updater", status: gateway.status === "OFFLINE" ? "offline" : "healthy" },
|
|
],
|
|
};
|
|
const outboxStatus = gateway.outbox_status ||
|
|
gateway.metadata?.outbox_status || {
|
|
state: "IN_SYNC",
|
|
queued: 0,
|
|
summary: "Outbox is empty",
|
|
oldest_queued_at: null,
|
|
last_replayed_at: gateway.last_heartbeat_at,
|
|
};
|
|
const updateWindow = gateway.update_window || {
|
|
window: gateway.metadata?.update_window || "02:00-04:00",
|
|
strategy: "nightly",
|
|
timezone: gateway.metadata?.timezone || null,
|
|
};
|
|
const stagedVersion = gateway.staged_version || gateway.metadata?.staged_version || null;
|
|
const rollbackStatus = gateway.rollback_status ||
|
|
gateway.metadata?.rollback_status || {
|
|
state: "IDLE",
|
|
reason: null,
|
|
rolled_back_to: null,
|
|
at: null,
|
|
};
|
|
|
|
if (Number(outboxStatus.queued || 0) > 0) {
|
|
diagnostics.push({
|
|
code: "EDGE_GATEWAY_OUTBOX_BACKLOG",
|
|
message: "The gateway has queued outbound control-plane items waiting for replay.",
|
|
recommended_action: "inspect_connectivity",
|
|
});
|
|
}
|
|
if (String(rollbackStatus.state || "").toUpperCase() === "ROLLED_BACK") {
|
|
diagnostics.push({
|
|
code: "EDGE_GATEWAY_UPDATE_ROLLED_BACK",
|
|
message: "The last container rollout was rolled back automatically.",
|
|
recommended_action: "review_diagnostics",
|
|
});
|
|
}
|
|
|
|
return {
|
|
...gateway,
|
|
channel_status: gateway.channel_status || {
|
|
command: {
|
|
preferred: gateway.metadata?.broker_connected ? "BROKER_FAST_PATH" : "API_POLLING",
|
|
active: gateway.metadata?.broker_connected ? "BROKER_FAST_PATH" : "API_POLLING",
|
|
state: gateway.status === "OFFLINE" ? "OFFLINE" : gateway.metadata?.broker_connected ? "ONLINE" : "DEGRADED",
|
|
backlog_depth: 0,
|
|
last_success_at: gateway.last_heartbeat_at,
|
|
},
|
|
broker: {
|
|
connected: Boolean(gateway.metadata?.broker_connected),
|
|
state: gateway.metadata?.broker_connected ? "ONLINE" : "OFFLINE",
|
|
last_error: gateway.metadata?.broker_connected ? null : "Broker unavailable",
|
|
},
|
|
},
|
|
transport_health: gateway.transport_health || {
|
|
status: cloudRelays.length ? "DEGRADED" : gateway.status,
|
|
summary: cloudRelays.length
|
|
? `${cloudRelays.length} relæ(er) kører via cloud fallback`
|
|
: "Broker fast path er aktiv med API polling som fallback",
|
|
recommended_action: cloudRelays.length ? "review_binding_override" : null,
|
|
last_successful_sync_at:
|
|
gateway.last_sync_at ||
|
|
gateway.metadata?.control_plane_status?.last_successful_sync_at ||
|
|
gateway.metadata?.last_sync_at ||
|
|
gateway.last_heartbeat_at ||
|
|
null,
|
|
last_transport_failure_at: gateway.metadata?.control_plane_status?.last_transport_failure_at || null,
|
|
last_transport_error: gateway.metadata?.control_plane_status?.last_transport_error || null,
|
|
},
|
|
fallback_summary: gateway.fallback_summary || {
|
|
local_relays: relayHealth.filter((relay) => relay.execution_path === "local").length,
|
|
cloud_relays: cloudRelays.length,
|
|
local_only_relays: relayHealth.filter((relay) => relay.fallback_mode === "LOCAL_ONLY").length,
|
|
cloud_only_relays: relayHealth.filter((relay) => relay.fallback_mode === "CLOUD_ONLY").length,
|
|
affected_relays: cloudRelays.map((relay) => relay.relay_id),
|
|
recommended_action: cloudRelays.length ? "review_binding_override" : null,
|
|
},
|
|
relay_health: gateway.relay_health || relayHealth,
|
|
last_successful_command_at: gateway.last_successful_command_at || gateway.last_heartbeat_at,
|
|
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,
|
|
},
|
|
container_health: containerHealth,
|
|
outbox_status: outboxStatus,
|
|
last_sync_at: gateway.last_sync_at || gateway.metadata?.last_sync_at || outboxStatus.last_replayed_at || null,
|
|
update_window: updateWindow,
|
|
staged_version: stagedVersion,
|
|
rollback_status: rollbackStatus,
|
|
diagnostics: gateway.diagnostics || diagnostics,
|
|
error_state:
|
|
gateway.error_state ||
|
|
(diagnostics[0]
|
|
? { ...diagnostics[0] }
|
|
: activeOperation?.error_code &&
|
|
!["CANCEL_REQUESTED", "CANCELLED"].includes(String(activeOperation?.status || ""))
|
|
? { code: activeOperation.error_code, message: activeOperation.error_message }
|
|
: null),
|
|
};
|
|
}
|
|
|
|
function settleEdgeGatewayWork(edgeGatewayFixture, gatewayId) {
|
|
const gateway = edgeGatewayFixture.gateways.find((entry) => entry.id === gatewayId) || null;
|
|
if (!gateway) {
|
|
return null;
|
|
}
|
|
|
|
const pendingDiscovery = edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
|
|
if (pendingDiscovery) {
|
|
pendingDiscovery.fetchCount = (pendingDiscovery.fetchCount || 0) + 1;
|
|
if (pendingDiscovery.fetchCount >= edgeGatewayFixture.discoveryAutoCompleteFetches) {
|
|
gateway.discovery_status = "READY";
|
|
gateway.last_successful_discovery_at = toSqlDateTime();
|
|
if (!gateway.inventory.some((device) => device.device_id === pendingDiscovery.device.device_id)) {
|
|
gateway.inventory = [...gateway.inventory, pendingDiscovery.device];
|
|
}
|
|
gateway.operations = (gateway.operations || []).map((operation) =>
|
|
Number(operation.id) === Number(pendingDiscovery.operationId)
|
|
? {
|
|
...operation,
|
|
status: "COMPLETED",
|
|
completed_at: gateway.last_successful_discovery_at,
|
|
updated_at: gateway.last_successful_discovery_at,
|
|
summary: { ...(operation.summary || {}), label: "Completed", progress: 100, retryable: true },
|
|
result: { inventory: cloneJson(gateway.inventory) },
|
|
events: [
|
|
...(operation.events || []),
|
|
{
|
|
id: edgeGatewayFixture.nextOperationEventId++,
|
|
level: "INFO",
|
|
code: "OPERATION_COMPLETED",
|
|
message: "Operation completed successfully",
|
|
created_at: gateway.last_successful_discovery_at,
|
|
},
|
|
],
|
|
}
|
|
: operation
|
|
);
|
|
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
|
|
invalidateEdgeGatewayRuntimeSnapshot(gateway);
|
|
}
|
|
}
|
|
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
|
|
return gateway;
|
|
}
|
|
|
|
function cloneJson(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail = false) {
|
|
const inventory = cloneJson(gateway.inventory || []);
|
|
const bindings = cloneJson(gateway.bindings || []);
|
|
const operations = cloneJson(gateway.operations || []);
|
|
|
|
return {
|
|
...cloneJson(gateway),
|
|
readiness: {
|
|
status: gateway.status,
|
|
last_heartbeat_at: gateway.last_heartbeat_at,
|
|
heartbeat_age_seconds: null,
|
|
discovery_status: gateway.discovery_status,
|
|
},
|
|
inventory_summary: {
|
|
total: inventory.length,
|
|
online: inventory.filter((device) => device.online !== false).length,
|
|
offline: inventory.filter((device) => device.online === false).length,
|
|
last_discovery_at: gateway.last_successful_discovery_at || null,
|
|
},
|
|
binding_summary: {
|
|
total: bindings.length,
|
|
fallback_overrides: bindings.filter(
|
|
(binding) => String(binding.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL"
|
|
).length,
|
|
},
|
|
agent_runtime: {
|
|
hostname: gateway.hostname,
|
|
installed_version: gateway.installed_version,
|
|
target_version: gateway.target_version,
|
|
last_seen_ip: gateway.last_seen_ip,
|
|
last_heartbeat_at: gateway.last_heartbeat_at,
|
|
system_metrics: cloneJson(gateway.metadata?.system_metrics || {}),
|
|
},
|
|
operations,
|
|
active_operation: cloneJson(gateway.active_operation || null),
|
|
recent_operations_summary: cloneJson(
|
|
gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations)
|
|
),
|
|
version_drift: cloneJson(gateway.version_drift || null),
|
|
credential_freshness: cloneJson(gateway.credential_freshness || null),
|
|
container_health: cloneJson(gateway.container_health || null),
|
|
outbox_status: cloneJson(gateway.outbox_status || null),
|
|
last_sync_at: gateway.last_sync_at || null,
|
|
update_window: cloneJson(gateway.update_window || null),
|
|
staged_version: cloneJson(gateway.staged_version || null),
|
|
rollback_status: cloneJson(gateway.rollback_status || null),
|
|
diagnostics: cloneJson(gateway.diagnostics || []),
|
|
error_state: cloneJson(gateway.error_state || null),
|
|
inventory: includeDetail ? inventory : undefined,
|
|
bindings: includeDetail ? bindings : undefined,
|
|
audit_logs: includeDetail ? cloneJson(gateway.audit_logs || []) : undefined,
|
|
};
|
|
}
|
|
|
|
function buildHttpEdgeGatewayTasksPage(edgeGatewayFixture, gateway) {
|
|
const gatewayPayload = buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true);
|
|
return {
|
|
gateway: gatewayPayload,
|
|
active_operation: cloneJson(gateway.active_operation || null),
|
|
operations: cloneJson(gateway.operations || []),
|
|
recent_commands: cloneJson(gateway.recent_commands || []),
|
|
recent_operations_summary: cloneJson(
|
|
gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(gateway.operations || [])
|
|
),
|
|
};
|
|
}
|
|
|
|
function buildHttpEdgeGatewayLogsPage(edgeGatewayFixture, gateway) {
|
|
const gatewayPayload = buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true);
|
|
const auditLogs = cloneJson(gateway.audit_logs || []);
|
|
const logEntries = cloneJson(gateway.log_entries || []);
|
|
const relayLogs = logEntries.filter((entry) => String(entry.stream || "").toLowerCase() === "relay");
|
|
const shellSessions = cloneJson(edgeGatewayFixture.shellSessionsByGatewayId?.[gateway.id] || []);
|
|
const timeline = [
|
|
...auditLogs.map((entry) => ({
|
|
type: "audit",
|
|
level: entry.severity || "INFO",
|
|
message: entry.action || "AUDIT_EVENT",
|
|
created_at: entry.created_at || null,
|
|
entry,
|
|
})),
|
|
...logEntries.map((entry) => ({
|
|
type: String(entry.stream || "").toLowerCase() === "relay" ? "relay" : "log",
|
|
level: entry.level || "INFO",
|
|
message: entry.message || "",
|
|
created_at: entry.created_at || null,
|
|
entry,
|
|
})),
|
|
...(gateway.operations || []).flatMap((operation) =>
|
|
(operation.events || []).map((entry) => ({
|
|
type: "operation_event",
|
|
level: entry.level || "INFO",
|
|
message: entry.message || entry.code || "",
|
|
created_at: entry.created_at || null,
|
|
entry: {
|
|
...entry,
|
|
operation_id: operation.id,
|
|
operation_type: operation.type,
|
|
},
|
|
}))
|
|
),
|
|
].sort((left, right) => String(right.created_at || "").localeCompare(String(left.created_at || "")));
|
|
|
|
return {
|
|
gateway: gatewayPayload,
|
|
timeline,
|
|
audit_logs: auditLogs,
|
|
log_entries: logEntries,
|
|
relay_logs: relayLogs,
|
|
shell_sessions: shellSessions,
|
|
};
|
|
}
|
|
|
|
function buildHttpEdgeGatewayStatisticsPage(edgeGatewayFixture, gateway) {
|
|
const gatewayPayload = buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true);
|
|
return {
|
|
gateway: gatewayPayload,
|
|
fleet_usage: buildHttpEdgeGatewayFleetUsage([gatewayPayload]),
|
|
channel_status: cloneJson(gatewayPayload.channel_status || {}),
|
|
transport_health: cloneJson(gatewayPayload.transport_health || {}),
|
|
backlog_depth: cloneJson(gatewayPayload.backlog_depth || {}),
|
|
container_health: cloneJson(gatewayPayload.container_health || {}),
|
|
system_metrics: cloneJson(
|
|
gatewayPayload.metadata?.system_metrics || gatewayPayload.agent_runtime?.system_metrics || {}
|
|
),
|
|
version_drift: cloneJson(gatewayPayload.version_drift || {}),
|
|
};
|
|
}
|
|
|
|
function averageEdgeGatewayMetric(rows = [], metricKey) {
|
|
const values = rows
|
|
.map((gateway) => gateway?.agent_runtime?.system_metrics?.[metricKey])
|
|
.filter((value) => Number.isFinite(Number(value)))
|
|
.map((value) => Number(value));
|
|
|
|
if (!values.length) {
|
|
return null;
|
|
}
|
|
|
|
return Math.round(values.reduce((sum, value) => sum + value, 0) / values.length);
|
|
}
|
|
|
|
function buildHttpEdgeGatewayFleetUsage(rows = []) {
|
|
const departments = new Set();
|
|
|
|
for (const gateway of rows) {
|
|
if (Number(gateway?.department_id || 0) > 0) {
|
|
departments.add(Number(gateway.department_id));
|
|
}
|
|
}
|
|
|
|
return {
|
|
gateways: {
|
|
total: rows.length,
|
|
departments: departments.size,
|
|
online: rows.filter((gateway) => gateway?.status === "ONLINE").length,
|
|
offline: rows.filter((gateway) => gateway?.status === "OFFLINE").length,
|
|
degraded: rows.filter((gateway) => gateway?.status === "DEGRADED").length,
|
|
drifted: rows.filter((gateway) => Boolean(gateway?.version_drift?.is_drifted)).length,
|
|
broker_connected: rows.filter((gateway) => Boolean(gateway?.channel_status?.broker?.connected)).length,
|
|
},
|
|
inventory: {
|
|
total: rows.reduce((sum, gateway) => sum + Number(gateway?.inventory_summary?.total || 0), 0),
|
|
online: rows.reduce((sum, gateway) => sum + Number(gateway?.inventory_summary?.online || 0), 0),
|
|
offline: rows.reduce((sum, gateway) => sum + Number(gateway?.inventory_summary?.offline || 0), 0),
|
|
},
|
|
bindings: {
|
|
total: rows.reduce((sum, gateway) => sum + Number(gateway?.binding_summary?.total || 0), 0),
|
|
fallback_overrides: rows.reduce(
|
|
(sum, gateway) => sum + Number(gateway?.binding_summary?.fallback_overrides || 0),
|
|
0
|
|
),
|
|
cloud_only: rows.reduce((sum, gateway) => sum + Number(gateway?.fallback_summary?.cloud_only_relays || 0), 0),
|
|
local_only: rows.reduce((sum, gateway) => sum + Number(gateway?.fallback_summary?.local_only_relays || 0), 0),
|
|
},
|
|
operations: {
|
|
active: rows.filter((gateway) => Boolean(gateway?.active_operation)).length,
|
|
pending: rows.reduce((sum, gateway) => sum + Number(gateway?.recent_operations_summary?.pending || 0), 0),
|
|
in_progress: rows.reduce((sum, gateway) => sum + Number(gateway?.recent_operations_summary?.in_progress || 0), 0),
|
|
backlog: rows.reduce((sum, gateway) => sum + Number(gateway?.backlog_depth?.operations || 0), 0),
|
|
},
|
|
commands: {
|
|
backlog: rows.reduce((sum, gateway) => sum + Number(gateway?.backlog_depth?.commands || 0), 0),
|
|
},
|
|
system: {
|
|
latency_ms_avg: averageEdgeGatewayMetric(rows, "latency_ms"),
|
|
cpu_usage_pct_avg: averageEdgeGatewayMetric(rows, "cpu_usage_pct"),
|
|
memory_usage_pct_avg: averageEdgeGatewayMetric(rows, "memory_usage_pct"),
|
|
disk_usage_pct_avg: averageEdgeGatewayMetric(rows, "disk_usage_pct"),
|
|
},
|
|
};
|
|
}
|
|
|
|
function processPendingEdgeGatewayClaims(edgeGatewayFixture) {
|
|
edgeGatewayFixture.pendingClaims = (edgeGatewayFixture.pendingClaims || []).filter((claim) => {
|
|
claim.pollsRemaining -= 1;
|
|
if (claim.pollsRemaining > 0) {
|
|
return true;
|
|
}
|
|
|
|
const gatewayId = Number(claim.reuse_gateway_id || 0);
|
|
const claimedAt = toSqlDateTime();
|
|
const auditLog = {
|
|
id: Date.now(),
|
|
created_at: claimedAt,
|
|
action: "GATEWAY_CLAIMED",
|
|
actor_type: "USER",
|
|
};
|
|
const metadata = {
|
|
broker_connected: true,
|
|
system_metrics: {
|
|
latency_ms: 73,
|
|
cpu_usage_pct: 19,
|
|
memory_usage_pct: 44,
|
|
disk_usage_pct: 52,
|
|
},
|
|
update_window: "02:00-04:00",
|
|
last_sync_at: claimedAt,
|
|
};
|
|
const existingGateway =
|
|
gatewayId > 0 ? edgeGatewayFixture.gateways.find((gateway) => Number(gateway.id) === gatewayId) || null : null;
|
|
|
|
if (existingGateway) {
|
|
Object.assign(existingGateway, {
|
|
department_id: Number(claim.department_id || existingGateway.department_id || 1),
|
|
label: claim.label || existingGateway.label || `Gateway ${gatewayId}`,
|
|
hostname: existingGateway.hostname || `edge-${gatewayId}`,
|
|
is_primary: true,
|
|
status: "ONLINE",
|
|
transport_mode: "gateway",
|
|
department_transport_mode: "gateway",
|
|
installed_version: existingGateway.installed_version || "php-agent-v1",
|
|
target_version: existingGateway.target_version || existingGateway.installed_version || "php-agent-v1",
|
|
last_heartbeat_at: claimedAt,
|
|
last_seen_ip: "10.9.0.14",
|
|
discovery_status: existingGateway.discovery_status || "READY",
|
|
metadata: {
|
|
...(existingGateway.metadata || {}),
|
|
...metadata,
|
|
},
|
|
audit_logs: [auditLog, ...(existingGateway.audit_logs || [])],
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const newGatewayId = edgeGatewayFixture.nextGatewayId++;
|
|
edgeGatewayFixture.gateways.push({
|
|
id: newGatewayId,
|
|
department_id: Number(claim.department_id || 1),
|
|
label: claim.label || `Gateway ${newGatewayId}`,
|
|
hostname: `edge-${newGatewayId}`,
|
|
is_primary: true,
|
|
status: "ONLINE",
|
|
transport_mode: "gateway",
|
|
department_transport_mode: "gateway",
|
|
installed_version: "php-agent-v1",
|
|
target_version: "php-agent-v1",
|
|
last_heartbeat_at: claimedAt,
|
|
last_seen_ip: "10.9.0.14",
|
|
discovery_status: "READY",
|
|
metadata,
|
|
inventory: [],
|
|
bindings: [],
|
|
recent_commands: [],
|
|
operations: [],
|
|
audit_logs: [auditLog],
|
|
});
|
|
|
|
return false;
|
|
});
|
|
}
|
|
|
|
function edgeGatewayInstallStepMessage(step) {
|
|
return (
|
|
{
|
|
VERIFY_TOKEN: "Installer verified the claim token.",
|
|
INSTALL_PACKAGES: "Installer is preparing the host runtime.",
|
|
DOWNLOAD_ARTIFACTS: "Installer is downloading gateway artifacts.",
|
|
WRITE_CONFIG: "Installer is writing gateway configuration.",
|
|
START_STACK: "Installer is starting the compose stack.",
|
|
WAIT_FOR_CLAIM: "Installer is waiting for the gateway heartbeat and claim.",
|
|
CLAIMED: "Gateway claimed successfully.",
|
|
FAILED: "Installer failed before the gateway could claim.",
|
|
}[String(step || "").toUpperCase()] || "Installer is running."
|
|
);
|
|
}
|
|
|
|
function pushEdgeGatewayInstallSessionEvent(session, status, step, message) {
|
|
const event = {
|
|
status,
|
|
step,
|
|
message,
|
|
at: toSqlDateTime(),
|
|
};
|
|
if (!session.started_at && String(status || "").toUpperCase() !== "PENDING") {
|
|
session.started_at = event.at;
|
|
}
|
|
session.events = [...(session.events || []).slice(-7), event];
|
|
session.updated_at = event.at;
|
|
}
|
|
|
|
function finalizeEdgeGatewayInstallSession(edgeGatewayFixture, session) {
|
|
edgeGatewayFixture.pendingClaims.push({
|
|
department_id: Number(session.department_id || 1),
|
|
label: String(session.label || "").trim(),
|
|
pollsRemaining: 0,
|
|
reuse_gateway_id: Number(session.reuse_gateway_id || 0),
|
|
});
|
|
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
|
|
|
const claimedGateway =
|
|
(Number(session.reuse_gateway_id || 0) > 0 &&
|
|
edgeGatewayFixture.gateways.find((gateway) => Number(gateway.id) === Number(session.reuse_gateway_id))) ||
|
|
edgeGatewayFixture.gateways.find(
|
|
(gateway) =>
|
|
Number(gateway.department_id) === Number(session.department_id || 1) &&
|
|
String(gateway.label || "")
|
|
.trim()
|
|
.toLowerCase() ===
|
|
String(session.label || "")
|
|
.trim()
|
|
.toLowerCase()
|
|
) ||
|
|
edgeGatewayFixture.gateways[edgeGatewayFixture.gateways.length - 1] ||
|
|
null;
|
|
|
|
session.status = "CLAIMED";
|
|
session.step = "CLAIMED";
|
|
session.message = Number(session.reuse_gateway_id || 0) > 0 ? "Gateway reconnected." : "Gateway connected.";
|
|
session.terminal = true;
|
|
session.gateway_id = claimedGateway?.id ?? null;
|
|
session.last_error = null;
|
|
session.diagnostics = [];
|
|
pushEdgeGatewayInstallSessionEvent(session, session.status, session.step, session.message);
|
|
return session;
|
|
}
|
|
|
|
function advanceEdgeGatewayInstallSession(edgeGatewayFixture, claimTokenId) {
|
|
const session = edgeGatewayFixture.installSessionsById?.[claimTokenId] || null;
|
|
if (!session || session.terminal) {
|
|
return session;
|
|
}
|
|
|
|
session.poll_count = Number(session.poll_count || 0) + 1;
|
|
const steps = [
|
|
"VERIFY_TOKEN",
|
|
"INSTALL_PACKAGES",
|
|
"DOWNLOAD_ARTIFACTS",
|
|
"WRITE_CONFIG",
|
|
"START_STACK",
|
|
"WAIT_FOR_CLAIM",
|
|
];
|
|
const failureConfig =
|
|
session.failure && typeof session.failure === "object"
|
|
? session.failure
|
|
: edgeGatewayFixture.installSessionFailure && typeof edgeGatewayFixture.installSessionFailure === "object"
|
|
? edgeGatewayFixture.installSessionFailure
|
|
: null;
|
|
const phaseSize = Math.max(1, Math.ceil(Math.max(1, Number(session.claim_polls_remaining || 1)) / steps.length));
|
|
const stepIndex = Math.min(steps.length - 1, Math.floor((session.poll_count - 1) / phaseSize));
|
|
const step = steps[stepIndex];
|
|
const message = edgeGatewayInstallStepMessage(step);
|
|
|
|
if (session.status !== "RUNNING" || session.step !== step || session.message !== message) {
|
|
session.status = "RUNNING";
|
|
session.step = step;
|
|
session.message = message;
|
|
pushEdgeGatewayInstallSessionEvent(session, session.status, session.step, session.message);
|
|
} else {
|
|
session.updated_at = toSqlDateTime();
|
|
}
|
|
|
|
if (failureConfig) {
|
|
const failureStep = String(failureConfig.step || "START_STACK").toUpperCase();
|
|
const failureIndex = Math.max(0, steps.indexOf(failureStep));
|
|
const failurePoll = Number(failureConfig.failurePoll || (failureIndex + 1) * phaseSize);
|
|
if (session.poll_count >= failurePoll) {
|
|
session.status = "FAILED";
|
|
session.step = failureStep;
|
|
session.message = String(failureConfig.message || "truckwash-edge-gateway-stack.service failed during startup.");
|
|
session.terminal = true;
|
|
session.last_error = session.message;
|
|
session.diagnostics = Array.isArray(failureConfig.diagnostics)
|
|
? cloneJson(failureConfig.diagnostics)
|
|
: [
|
|
{
|
|
name: "systemctl status",
|
|
output:
|
|
"Job for truckwash-edge-gateway-stack.service failed because the control process exited with error code.",
|
|
},
|
|
];
|
|
pushEdgeGatewayInstallSessionEvent(session, session.status, session.step, session.message);
|
|
return session;
|
|
}
|
|
}
|
|
|
|
if (session.poll_count >= Number(session.claim_polls_remaining || 1)) {
|
|
return finalizeEdgeGatewayInstallSession(edgeGatewayFixture, session);
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
function buildEdgeGatewayInstallSessionResponse(session) {
|
|
if (!session) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
claim_token_id: session.claim_token_id,
|
|
department_id: session.department_id,
|
|
label: session.label || null,
|
|
expires_at: session.expires_at || null,
|
|
status: session.status,
|
|
step: session.step,
|
|
message: session.message,
|
|
started_at: session.started_at,
|
|
updated_at: session.updated_at,
|
|
terminal: Boolean(session.terminal),
|
|
gateway_id: session.gateway_id ?? null,
|
|
last_error: session.last_error ?? null,
|
|
diagnostics: cloneJson(session.diagnostics || []),
|
|
events: cloneJson(session.events || []),
|
|
};
|
|
}
|
|
|
|
function createHttpEdgeGatewayFixture(options = {}) {
|
|
const gatewayOverridesById = new Map(
|
|
(Array.isArray(options.gatewayOverrides) ? options.gatewayOverrides : []).map((gateway) => [
|
|
Number(gateway.id),
|
|
gateway,
|
|
])
|
|
);
|
|
const extraGateways = Array.isArray(options.gateways) ? options.gateways : [];
|
|
const installSessionsById = Object.fromEntries(
|
|
Object.entries(options.installSessionsById || {}).map(([claimTokenId, session]) => [
|
|
Number(claimTokenId),
|
|
cloneJson(session),
|
|
])
|
|
);
|
|
|
|
const baseGateways = options.empty
|
|
? []
|
|
: [
|
|
{
|
|
id: 701,
|
|
department_id: 1,
|
|
label: "CPH Edge 01",
|
|
hostname: "cph-edge-01",
|
|
is_primary: true,
|
|
status: "ONLINE",
|
|
transport_mode: "gateway",
|
|
department_transport_mode: "gateway",
|
|
installed_version: "php-agent-v1",
|
|
target_version: "php-agent-v1.1",
|
|
last_heartbeat_at: "2026-04-08 08:15:00",
|
|
last_seen_ip: "10.1.0.14",
|
|
discovery_status: "READY",
|
|
metadata: {
|
|
broker_connected: true,
|
|
system_metrics: {
|
|
latency_ms: 184,
|
|
cpu_usage_pct: 27,
|
|
memory_usage_pct: 61,
|
|
disk_usage_pct: 58,
|
|
},
|
|
update_window: "02:00-04:00",
|
|
last_sync_at: "2026-04-08 08:14:56",
|
|
container_health: {
|
|
state: "ONLINE",
|
|
summary: "6/6 containers healthy",
|
|
services: [
|
|
{ name: "edge-agent", status: "healthy" },
|
|
{ name: "lan-worker", status: "healthy" },
|
|
{ name: "redis", status: "healthy" },
|
|
{ name: "mariadb", status: "healthy" },
|
|
{ name: "minio", status: "healthy" },
|
|
{ name: "auto-updater", status: "healthy" },
|
|
],
|
|
},
|
|
outbox_status: {
|
|
state: "IN_SYNC",
|
|
queued: 0,
|
|
summary: "Outbox is empty",
|
|
last_replayed_at: "2026-04-08 08:14:56",
|
|
},
|
|
rollback_status: {
|
|
state: "IDLE",
|
|
reason: null,
|
|
rolled_back_to: null,
|
|
at: null,
|
|
},
|
|
},
|
|
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,
|
|
},
|
|
],
|
|
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",
|
|
},
|
|
],
|
|
recent_commands: [],
|
|
log_entries: [
|
|
{
|
|
id: 801,
|
|
created_at: "2026-04-08 08:14:58",
|
|
level: "INFO",
|
|
message: "Broker session connected and telemetry streaming.",
|
|
},
|
|
{
|
|
id: 803,
|
|
created_at: "2026-04-08 08:14:57",
|
|
level: "INFO",
|
|
stream: "relay",
|
|
source: "RELAY_DISPATCH",
|
|
message: "MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH",
|
|
context: {
|
|
module: "selfserve",
|
|
module_responsible: "selfserve",
|
|
reason: "Set self-serve relay ON",
|
|
handler: "local",
|
|
delivery_channel: "BROKER_FAST_PATH",
|
|
relay_id: "M-7",
|
|
relay_name: "Roskilde Maskine",
|
|
relay_role: "MACHINE",
|
|
description: "MACHINE ON Roskilde Maskine handled by local via BROKER_FAST_PATH",
|
|
action: "SWITCH",
|
|
target_on: true,
|
|
associated: {
|
|
admin_user_id: 91,
|
|
customer_number: 700123,
|
|
},
|
|
signal: {
|
|
command_type: "SET_RELAY_STATE",
|
|
relay_id: "M-7",
|
|
request: {
|
|
relayId: "M-7",
|
|
on: true,
|
|
},
|
|
},
|
|
response: {
|
|
online: true,
|
|
on: true,
|
|
},
|
|
},
|
|
},
|
|
{
|
|
id: 804,
|
|
created_at: "2026-04-08 08:14:56",
|
|
level: "INFO",
|
|
stream: "relay",
|
|
source: "RELAY_DISPATCH",
|
|
message: "Relay SWITCH IN-7 handled by local via BROKER_FAST_PATH",
|
|
context: {
|
|
module: "selfserve",
|
|
module_responsible: "selfserve",
|
|
reason: "Open self-serve ENTRY gate relay",
|
|
handler: "local",
|
|
delivery_channel: "BROKER_FAST_PATH",
|
|
relay_id: "IN-7",
|
|
relay_name: "Roskilde indkoerselspc",
|
|
relay_role: "ENTRY",
|
|
action: "SWITCH",
|
|
target_on: true,
|
|
signal: {
|
|
command_type: "SET_RELAY_STATE",
|
|
relay_id: "IN-7",
|
|
request: {
|
|
relayId: "IN-7",
|
|
on: true,
|
|
},
|
|
},
|
|
response: {
|
|
online: true,
|
|
on: true,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
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",
|
|
},
|
|
],
|
|
}
|
|
),
|
|
],
|
|
audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }],
|
|
},
|
|
{
|
|
id: 702,
|
|
department_id: 2,
|
|
label: "ODE Edge 01",
|
|
hostname: "ode-edge-01",
|
|
is_primary: true,
|
|
status: "OFFLINE",
|
|
transport_mode: "gateway",
|
|
department_transport_mode: "cloud",
|
|
installed_version: "php-agent-v1",
|
|
target_version: "php-agent-v1",
|
|
last_heartbeat_at: "2026-04-07 21:04:00",
|
|
last_seen_ip: "10.2.0.14",
|
|
discovery_status: "STALE",
|
|
metadata: {
|
|
broker_connected: false,
|
|
system_metrics: {
|
|
latency_ms: 412,
|
|
cpu_usage_pct: 9,
|
|
memory_usage_pct: 42,
|
|
disk_usage_pct: 76,
|
|
},
|
|
},
|
|
inventory: [],
|
|
bindings: [],
|
|
recent_commands: [],
|
|
log_entries: [
|
|
{
|
|
id: 802,
|
|
created_at: "2026-04-07 21:05:00",
|
|
level: "ERROR",
|
|
message: "Gateway lost broker connectivity and fell back to HTTP.",
|
|
},
|
|
],
|
|
operations: [],
|
|
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)) || {}))
|
|
.concat(extraGateways.map((gateway) => cloneJson(gateway)));
|
|
|
|
return {
|
|
nextGatewayId: Math.max(703, ...baseGateways.map((gateway) => Number(gateway.id) + 1)),
|
|
nextClaimTokenId: Number(options.nextClaimTokenId || 9001),
|
|
nextOperationId: 9901,
|
|
nextOperationEventId: 19901,
|
|
claimPollsRemaining: Number(options.claimPollsRemaining || 2),
|
|
reuseClaimGatewayId: Number(options.reuseClaimGatewayId || 0),
|
|
installSessionFailure:
|
|
options.installSessionFailure && typeof options.installSessionFailure === "object"
|
|
? cloneJson(options.installSessionFailure)
|
|
: null,
|
|
discoveryAutoCompleteFetches:
|
|
options.discoveryAutoCompleteFetches === false ? Infinity : Number(options.discoveryAutoCompleteFetches || 2),
|
|
config: {
|
|
enabled: options.config?.enabled ?? true,
|
|
default_release_channel: options.config?.default_release_channel || "stable",
|
|
default_update_window: options.config?.default_update_window || "02:00-04:00",
|
|
broker_url: options.config?.broker_url || "http://edge-broker:4300",
|
|
public_broker_url: options.config?.public_broker_url || "https://api.truckwash.io:4433/edge-broker",
|
|
broker_auth_mode: options.config?.broker_auth_mode || "manager",
|
|
broker_shared_secret: options.config?.broker_shared_secret || "truckwash-edge-dev",
|
|
},
|
|
installSessionsById,
|
|
pendingClaims: [],
|
|
pendingDiscoveryByGatewayId: {},
|
|
shellSessionsByGatewayId: cloneJson(options.shellSessionsByGatewayId || {}),
|
|
gateways: baseGateways,
|
|
};
|
|
}
|
|
|
|
export function createPosFixture(overrides = {}) {
|
|
const defaultCustomer = {
|
|
id: 1,
|
|
customerNumber: 12345679,
|
|
name: "(TEST) Pleno Vognmandsforretning",
|
|
address: "Demo Street 1",
|
|
zip: "2630",
|
|
city: "Taastrup",
|
|
mobilePhone: "12345678",
|
|
email: "2jepp9350@gmail.com",
|
|
corporateIdentificationNumber: "12345678",
|
|
economic_customer: 12345679,
|
|
barred: false,
|
|
};
|
|
|
|
const cardCustomer = {
|
|
id: 2,
|
|
customerNumber: 999,
|
|
name: "Card Terminal Customer",
|
|
address: "Terminal Street 9",
|
|
zip: "2630",
|
|
city: "Taastrup",
|
|
mobilePhone: "87654321",
|
|
email: "card@example.com",
|
|
corporateIdentificationNumber: "99999999",
|
|
economic_customer: 999,
|
|
barred: false,
|
|
};
|
|
|
|
const products = [
|
|
{
|
|
id: 40,
|
|
name: "Inkl. Pick-Up",
|
|
description: "Pickup service",
|
|
price: 100,
|
|
subscription_allowed: false,
|
|
category: 8,
|
|
piktogram: "truck",
|
|
apply_category_discount: false,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: false,
|
|
order_priority: 5,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 53,
|
|
name: "Forvogn",
|
|
description: "Primary wash product",
|
|
price: 649,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "truck",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: true,
|
|
display_in_booking_form: true,
|
|
order_priority: 1,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 63,
|
|
name: "Indvendig vask Forvogn",
|
|
description: "Add-on wash service",
|
|
price: 399,
|
|
subscription_allowed: true,
|
|
category: 4,
|
|
piktogram: "truck",
|
|
apply_category_discount: true,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 2,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 64,
|
|
name: "Vaskecertifikat - Safety Seal",
|
|
description: "Safety seal",
|
|
price: 25,
|
|
subscription_allowed: true,
|
|
category: 8,
|
|
piktogram: "certificate",
|
|
apply_category_discount: false,
|
|
requires_note: false,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 3,
|
|
addons: [],
|
|
},
|
|
{
|
|
id: 65,
|
|
name: "Ekstraordinær pr. 10 min inkl. kemi",
|
|
description: "Additional time",
|
|
price: 299,
|
|
subscription_allowed: true,
|
|
category: 8,
|
|
piktogram: "timer",
|
|
apply_category_discount: false,
|
|
requires_note: true,
|
|
is_wash: false,
|
|
display_in_booking_form: true,
|
|
order_priority: 4,
|
|
addons: [],
|
|
},
|
|
];
|
|
|
|
const baseFixture = {
|
|
departments: [
|
|
{
|
|
id: 1,
|
|
name: "Taastrup",
|
|
description: "Taastrup",
|
|
exclude_from_invoicing: true,
|
|
bookingsystem_time_based_enabled: true,
|
|
},
|
|
{
|
|
id: 12,
|
|
name: "Demo",
|
|
description: "Demo",
|
|
exclude_from_invoicing: false,
|
|
bookingsystem_time_based_enabled: true,
|
|
},
|
|
],
|
|
customersByNumber: {
|
|
[defaultCustomer.customerNumber]: defaultCustomer,
|
|
[cardCustomer.customerNumber]: cardCustomer,
|
|
},
|
|
collectedInvoices: [
|
|
{
|
|
id: 101,
|
|
customer_number: defaultCustomer.customerNumber,
|
|
customer_name: defaultCustomer.name,
|
|
total_net_amount: 100,
|
|
created_at: "2026-04-10",
|
|
closed_at: "2026-04-10",
|
|
},
|
|
{
|
|
id: 200,
|
|
customer_number: cardCustomer.customerNumber,
|
|
customer_name: cardCustomer.name,
|
|
total_net_amount: 250,
|
|
created_at: "2026-04-30",
|
|
closed_at: "2026-04-30",
|
|
},
|
|
{
|
|
id: 201,
|
|
customer_number: cardCustomer.customerNumber,
|
|
customer_name: cardCustomer.name,
|
|
total_net_amount: 325,
|
|
created_at: "2026-05-01",
|
|
closed_at: "2026-05-01",
|
|
},
|
|
...[102, 103, 104, 105, 106].map((invoiceId, index) => ({
|
|
id: invoiceId,
|
|
customer_number: 1002 + index,
|
|
customer_name: `Customer ${invoiceId}`,
|
|
total_net_amount: 110 + index * 10,
|
|
})),
|
|
],
|
|
nextCollectedInvoiceId: 300,
|
|
customerAttributesByNumber: {
|
|
[defaultCustomer.customerNumber]: [
|
|
{ id: 1, customer_number: defaultCustomer.customerNumber, attribute: "invoiceAllOrdersIndividually" },
|
|
],
|
|
[cardCustomer.customerNumber]: [
|
|
{ id: 2, customer_number: cardCustomer.customerNumber, attribute: "invoiceWithStripe" },
|
|
],
|
|
},
|
|
nextCustomerAttributeId: 3,
|
|
customerNotesByNumber: {},
|
|
products,
|
|
departmentCategories: [
|
|
{ id: 11, department_id: 12, category: { id: 4, name: "Udvendig", meta: { products: [53, 63] } } },
|
|
{ id: 12, department_id: 12, category: { id: 8, name: "Tillæg", meta: { products: [64, 65] } } },
|
|
],
|
|
vehicles: [
|
|
{
|
|
id: 7001,
|
|
reg: "EC21235",
|
|
customer_id: defaultCustomer.customerNumber,
|
|
customer_name: defaultCustomer.name,
|
|
type: 53,
|
|
status: "verified",
|
|
barred: false,
|
|
wash_subscription: false,
|
|
addons: { enabled: 0, available: 0, list: [] },
|
|
reference: "EC21233 - Test Ref. / Intern nummer",
|
|
last_order_id: 54518,
|
|
},
|
|
],
|
|
nextVehicleId: 7002,
|
|
unknownVehicles: [],
|
|
orderBookings: [],
|
|
bookingOrderAssignments: [],
|
|
completedBookingIds: [],
|
|
nextOrderBookingId: 9001,
|
|
numberPlateScanners: [
|
|
{ id: 1, name: "North scanner" },
|
|
{ id: 2, name: "South scanner" },
|
|
],
|
|
numberPlateScans: [
|
|
{
|
|
id: 801,
|
|
department_id: 12,
|
|
plate: "AB12345",
|
|
plate_scanner_id: 1,
|
|
created_at: "2026-04-08 08:44:07",
|
|
customer_number: defaultCustomer.customerNumber,
|
|
customer_name: defaultCustomer.name,
|
|
seen_before: true,
|
|
barred: false,
|
|
},
|
|
{
|
|
id: 802,
|
|
department_id: 12,
|
|
plate: "CD67890",
|
|
plate_scanner_id: 2,
|
|
created_at: "2026-04-08 08:31:05",
|
|
customer_number: null,
|
|
customer_name: "",
|
|
seen_before: false,
|
|
barred: false,
|
|
},
|
|
],
|
|
motorApiLookupByPlate: {
|
|
AB12345: {
|
|
make: "RENAULT",
|
|
model: "Captur",
|
|
variant: "dCi 90",
|
|
type: "Personbil",
|
|
use: "Privat personkørsel",
|
|
},
|
|
CD67890: {
|
|
make: "SCANIA",
|
|
model: "R500",
|
|
variant: "Highline",
|
|
type: "Lastbil",
|
|
use: "Godstransport",
|
|
},
|
|
},
|
|
ordersById: {
|
|
54518: {
|
|
id: 54518,
|
|
customer_id: defaultCustomer.customerNumber,
|
|
department_id: 12,
|
|
reference: "EC21233 - Test Ref. / Intern nummer",
|
|
po: "",
|
|
safety_seal: "",
|
|
notes: "",
|
|
reg_1: "EC21235",
|
|
reg_2: "",
|
|
reg_3: "",
|
|
invoice_collection_id: null,
|
|
booking_id: null,
|
|
completed_at: null,
|
|
closed_at: null,
|
|
created_at: "2026-04-08 08:44:07",
|
|
include_in_invoice: null,
|
|
},
|
|
},
|
|
orderItemsByOrderId: {
|
|
54518: [
|
|
{
|
|
id: 9101,
|
|
order_id: 54518,
|
|
product_id: 53,
|
|
product: products[0],
|
|
quantity: 1,
|
|
notes: "",
|
|
reference: "",
|
|
related_item_id: null,
|
|
price: 649,
|
|
},
|
|
{
|
|
id: 9102,
|
|
order_id: 54518,
|
|
product_id: 63,
|
|
product: products[1],
|
|
quantity: 1,
|
|
notes: "",
|
|
reference: "",
|
|
related_item_id: 9101,
|
|
price: 399,
|
|
},
|
|
{
|
|
id: 9103,
|
|
order_id: 54518,
|
|
product_id: 64,
|
|
product: products[2],
|
|
quantity: 1,
|
|
notes: "",
|
|
reference: "",
|
|
related_item_id: 9101,
|
|
price: 25,
|
|
},
|
|
{
|
|
id: 9104,
|
|
order_id: 54518,
|
|
product_id: 65,
|
|
product: products[3],
|
|
quantity: 1,
|
|
notes: "",
|
|
reference: "",
|
|
related_item_id: 9101,
|
|
price: 299,
|
|
},
|
|
],
|
|
},
|
|
attachmentsByOrderId: {
|
|
54518: [
|
|
{
|
|
id: 301,
|
|
object_type: "orders",
|
|
object_id: 54518,
|
|
content: {
|
|
image: null,
|
|
document: null,
|
|
relation: null,
|
|
other: "safety-seal.pdf",
|
|
src: null,
|
|
},
|
|
created_at: "2026-04-08 08:44:07",
|
|
updated_at: "2026-04-08 08:44:07",
|
|
deleted_at: null,
|
|
},
|
|
],
|
|
},
|
|
economicModuleOrdersByOrderId: {
|
|
54518: {
|
|
invoice_id: null,
|
|
invoice_draft_id: null,
|
|
},
|
|
},
|
|
stripeModuleOrdersByOrderId: {
|
|
54518: {},
|
|
},
|
|
paymentIntentsByOrderId: {},
|
|
stripeReadersError: null,
|
|
readers: [{ id: "reader_online_1", label: "Mobile Reader", status: "online", action: null }],
|
|
departmentCategoriesDelayMs: 0,
|
|
productsDelayMs: 0,
|
|
productsDelayMsByCategory: {},
|
|
customerAttributesDelayMs: 0,
|
|
customerAttributesErrorCount: 0,
|
|
orderBookingRestrictedProductIds: [],
|
|
nextOrderId: 54519,
|
|
nextOrderItemId: 9200,
|
|
nextAttachmentId: 400,
|
|
};
|
|
|
|
return {
|
|
__isPosFixture: true,
|
|
...baseFixture,
|
|
...overrides,
|
|
customersByNumber: {
|
|
...baseFixture.customersByNumber,
|
|
...(overrides.customersByNumber || {}),
|
|
},
|
|
customerAttributesByNumber: {
|
|
...baseFixture.customerAttributesByNumber,
|
|
...(overrides.customerAttributesByNumber || {}),
|
|
},
|
|
customerNotesByNumber: {
|
|
...baseFixture.customerNotesByNumber,
|
|
...(overrides.customerNotesByNumber || {}),
|
|
},
|
|
numberPlateScanners: overrides.numberPlateScanners || baseFixture.numberPlateScanners,
|
|
numberPlateScans: overrides.numberPlateScans || baseFixture.numberPlateScans,
|
|
motorApiLookupByPlate: {
|
|
...baseFixture.motorApiLookupByPlate,
|
|
...(overrides.motorApiLookupByPlate || {}),
|
|
},
|
|
ordersById: {
|
|
...baseFixture.ordersById,
|
|
...(overrides.ordersById || {}),
|
|
},
|
|
orderItemsByOrderId: {
|
|
...baseFixture.orderItemsByOrderId,
|
|
...(overrides.orderItemsByOrderId || {}),
|
|
},
|
|
attachmentsByOrderId: {
|
|
...baseFixture.attachmentsByOrderId,
|
|
...(overrides.attachmentsByOrderId || {}),
|
|
},
|
|
economicModuleOrdersByOrderId: {
|
|
...baseFixture.economicModuleOrdersByOrderId,
|
|
...(overrides.economicModuleOrdersByOrderId || {}),
|
|
},
|
|
stripeModuleOrdersByOrderId: {
|
|
...baseFixture.stripeModuleOrdersByOrderId,
|
|
...(overrides.stripeModuleOrdersByOrderId || {}),
|
|
},
|
|
paymentIntentsByOrderId: {
|
|
...baseFixture.paymentIntentsByOrderId,
|
|
...(overrides.paymentIntentsByOrderId || {}),
|
|
},
|
|
products: overrides.products || [...baseFixture.products, ...(overrides.extraProducts || [])],
|
|
};
|
|
}
|
|
|
|
function buildPosOrderItem(product, body, id) {
|
|
const quantity = Number(body.quantity || 1);
|
|
return {
|
|
id,
|
|
order_id: Number(body.order_id),
|
|
product_id: product.id,
|
|
product,
|
|
quantity,
|
|
notes: body.notes || "",
|
|
reference: body.reference || "",
|
|
related_item_id: body.related_item_id ?? null,
|
|
price: Number(body.price ?? product.price ?? 0),
|
|
reason_code: body.reason_code || "",
|
|
reason_label_snapshot: body.reason_label_snapshot || "",
|
|
reason_comment: body.reason_comment || "",
|
|
};
|
|
}
|
|
|
|
function applyFixtureFinalProductPrice(product, parsedUrl, posFixture) {
|
|
if (!product || parsedUrl.searchParams.get("final_price") !== "true") {
|
|
return product;
|
|
}
|
|
|
|
const finalPricesById = posFixture.productFinalPricesById || {};
|
|
const price = finalPricesById[Number(product.id)] ?? finalPricesById[String(product.id)];
|
|
if (price === undefined || price === null) {
|
|
return product;
|
|
}
|
|
|
|
return {
|
|
...product,
|
|
price: Number(price),
|
|
};
|
|
}
|
|
|
|
function matchesProductCategory(product, category) {
|
|
const normalizedCategory = Number(category || 0);
|
|
const productCategory = Number(product?.category || 0);
|
|
const productName = String(product?.name || "");
|
|
|
|
if (normalizedCategory === 6) {
|
|
return productCategory === 6 || (product?.is_wash && productCategory === 4);
|
|
}
|
|
|
|
if (normalizedCategory === 2) {
|
|
return productCategory === 2 || /indvendig/i.test(productName);
|
|
}
|
|
|
|
return productCategory === normalizedCategory;
|
|
}
|
|
|
|
function buildPosOrderResponse(posFixture, orderId) {
|
|
const order = withEffectiveOrderState(posFixture, posFixture.ordersById[orderId] || null);
|
|
if (!order) {
|
|
return { success: true, data: null, includes: {} };
|
|
}
|
|
|
|
const customer = posFixture.customersByNumber[order.customer_id] || null;
|
|
return {
|
|
success: true,
|
|
data: order,
|
|
includes: {
|
|
orderItems: posFixture.orderItemsByOrderId[orderId] || [],
|
|
customer: customer ? { ...customer, economic_customer: customer.customerNumber } : null,
|
|
cashier: { id: 7, display_name: "Backoffice User" },
|
|
economicModuleOrders: posFixture.economicModuleOrdersByOrderId[orderId] || {},
|
|
stripeModuleOrders: posFixture.stripeModuleOrdersByOrderId[orderId] || {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function getPosOrderStripeInvoiceTotal(posFixture, orderId) {
|
|
return (posFixture.orderItemsByOrderId[orderId] || []).reduce((sum, item) => {
|
|
return sum + Number(item?.price || 0) * Number(item?.quantity || 1);
|
|
}, 0);
|
|
}
|
|
|
|
function isTerminalStripeInvoiceStatus(status) {
|
|
return ["paid", "void", "uncollectible", "deleted"].includes(String(status || ""));
|
|
}
|
|
|
|
function createPosStripeModuleOrder(posFixture, orderId, overrides = {}) {
|
|
const total = getPosOrderStripeInvoiceTotal(posFixture, orderId);
|
|
const amountDue = Number(overrides.amount_due ?? total);
|
|
const paid = Boolean(overrides.paid ?? false);
|
|
const invoiceId = overrides.invoice_id || `in_${orderId}_${Date.now()}`;
|
|
|
|
return {
|
|
id: Number(overrides.id ?? orderId),
|
|
invoice_id: invoiceId,
|
|
customer_id: Number(overrides.customer_id ?? posFixture.ordersById?.[orderId]?.customer_id ?? 0) || null,
|
|
url: overrides.url || `https://stripe.example.test/invoices/${invoiceId}`,
|
|
created_at: overrides.created_at || toSqlDateTime(),
|
|
paid,
|
|
status: overrides.status || (paid ? "paid" : "open"),
|
|
amount_due: amountDue,
|
|
amount_paid: Number(overrides.amount_paid ?? (paid ? amountDue : 0)),
|
|
};
|
|
}
|
|
|
|
function getFixtureDelayMs(value = 0) {
|
|
const parsed = Number(value || 0);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
}
|
|
|
|
async function maybeDelayFixtureResponse(delayMs = 0) {
|
|
const normalizedDelayMs = getFixtureDelayMs(delayMs);
|
|
if (!normalizedDelayMs) {
|
|
return;
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, normalizedDelayMs));
|
|
}
|
|
|
|
async function handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture }) {
|
|
if (!posFixture) {
|
|
return false;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
|
|
await route.fulfill(json({ success: true, data: toPublicTimeBookingDepartments(posFixture.departments) }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments") && method === "GET") {
|
|
await route.fulfill(json({ success: true, data: posFixture.departments }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments/categories") && method === "GET") {
|
|
await maybeDelayFixtureResponse(posFixture.departmentCategoriesDelayMs);
|
|
await route.fulfill(json({ success: true, data: posFixture.departmentCategories || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/bookings") && method === "GET") {
|
|
await route.fulfill(
|
|
json({ success: true, data: Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [] })
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order-bookings") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const restrictedProductIds = new Set(
|
|
(posFixture.orderBookingRestrictedProductIds || []).map((productId) => Number(productId))
|
|
);
|
|
const restrictedItem = (body.items || []).find((item) => restrictedProductIds.has(Number(item?.id)));
|
|
if (restrictedItem) {
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: {
|
|
code: "CUSTOMER_RULE_PRODUCT_RESTRICTED",
|
|
message: "This product is not allowed for the selected customer",
|
|
product_id: Number(restrictedItem.id),
|
|
rules: ["restrictSpotFree"],
|
|
collections: [1],
|
|
},
|
|
},
|
|
400
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
const bookingId = Number(posFixture.nextOrderBookingId || 9001);
|
|
const customerNumber = Number(body.customer_number || 0);
|
|
const departmentId = Number(body.department || 0);
|
|
const createdAt = toSqlDateTime();
|
|
const bookingDate = String(body.datetime || createdAt).slice(0, 10);
|
|
const customer = posFixture.customersByNumber?.[customerNumber] || null;
|
|
const serviceNames = Array.isArray(body.items)
|
|
? body.items.map((item) => String(item?.name || "").trim()).filter(Boolean)
|
|
: [];
|
|
|
|
const createdBooking = {
|
|
id: bookingId,
|
|
customer_number: customerNumber,
|
|
customer_name: customer?.name || "E2E User",
|
|
department: departmentId,
|
|
date: bookingDate,
|
|
datetime: body.datetime || createdAt,
|
|
regNrTraekker: body.reg_1 || "",
|
|
regNrTrailer: body.reg_2 || "",
|
|
reg_1: body.reg_1 || "",
|
|
reg_2: body.reg_2 || "",
|
|
reg_3: body.reg_3 || "",
|
|
reference_number: body.reference || "",
|
|
reference: body.reference || "",
|
|
notes: body.note || "",
|
|
note: body.note || "",
|
|
po: body.po || "",
|
|
pickup_bool: body.pickup ? 1 : 0,
|
|
pickup: Boolean(body.pickup),
|
|
created_at: createdAt,
|
|
status: "pending",
|
|
wash_type: serviceNames.join(", "),
|
|
parsed_services: {
|
|
string: serviceNames.join(", "),
|
|
array: serviceNames,
|
|
},
|
|
wash_certificate_pdf: null,
|
|
washCertificateStatus: null,
|
|
};
|
|
|
|
posFixture.nextOrderBookingId = bookingId + 1;
|
|
posFixture.orderBookings = [createdBooking, ...(posFixture.orderBookings || [])];
|
|
|
|
await route.fulfill(json({ success: true, data: createdBooking }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order-bookings") && method === "GET") {
|
|
const id = Number(parsedUrl.searchParams.get("id") || 0);
|
|
const filters = String(parsedUrl.searchParams.get("filters") || "");
|
|
const page = Number(parsedUrl.searchParams.get("page") || 1);
|
|
const limit = Number(parsedUrl.searchParams.get("limit") || 100);
|
|
let bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
|
|
|
|
if (id > 0) {
|
|
await route.fulfill(json({ success: true, data: bookings.find((booking) => booking.id === id) || null }));
|
|
return true;
|
|
}
|
|
|
|
if (filters.includes("department:")) {
|
|
const department = filters.split("department:")[1]?.split(",")[0] || "";
|
|
bookings = bookings.filter(
|
|
(booking) => Number(booking.department ?? booking.department_id ?? 0) === Number(department)
|
|
);
|
|
}
|
|
|
|
if (filters.includes("order_id:null") || filters.includes("order_id:is null")) {
|
|
bookings = bookings.filter((booking) => !booking.order_id);
|
|
}
|
|
|
|
if (filters.includes("reg_1:")) {
|
|
const reg = filters.split("reg_1:")[1]?.split(",")[0] || "";
|
|
bookings = bookings.filter((booking) => String(booking.reg_1 || "").toUpperCase() === String(reg).toUpperCase());
|
|
}
|
|
|
|
if (filters.includes("reg_2:")) {
|
|
const reg = filters.split("reg_2:")[1]?.split(",")[0] || "";
|
|
bookings = bookings.filter((booking) => String(booking.reg_2 || "").toUpperCase() === String(reg).toUpperCase());
|
|
}
|
|
|
|
const normalizedPage = Number.isFinite(page) && page > 0 ? page : 1;
|
|
const normalizedLimit = Number.isFinite(limit) && limit > 0 ? limit : bookings.length || 1;
|
|
const offset = (normalizedPage - 1) * normalizedLimit;
|
|
bookings = bookings.slice(offset, offset + normalizedLimit);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: bookings.map((booking) =>
|
|
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true, {
|
|
stripMetadata: posFixture.orderBookingListStripsMetadata === true,
|
|
})
|
|
),
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order-bookings") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const bookingId = Number(body.id || 0);
|
|
const bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
|
|
const bookingIndex = bookings.findIndex((booking) => Number(booking.id) === bookingId);
|
|
|
|
if (bookingIndex >= 0) {
|
|
bookings[bookingIndex] = {
|
|
...bookings[bookingIndex],
|
|
order_id: body.order_id ?? body.value ?? null,
|
|
};
|
|
posFixture.bookingOrderAssignments = Array.isArray(posFixture.bookingOrderAssignments)
|
|
? posFixture.bookingOrderAssignments
|
|
: [];
|
|
posFixture.bookingOrderAssignments.push({
|
|
id: bookingId,
|
|
order_id: bookings[bookingIndex].order_id,
|
|
});
|
|
}
|
|
|
|
await route.fulfill(json({ success: true, data: bookingIndex >= 0 ? bookings[bookingIndex] : null }));
|
|
return true;
|
|
}
|
|
|
|
if (/\/order-bookings\/\d+$/.test(pathname) && method === "GET") {
|
|
const bookingId = Number(pathname.split("/").pop());
|
|
const booking = (posFixture.orderBookings || []).find((entry) => entry.id === bookingId) || null;
|
|
await route.fulfill(json({ success: true, data: booking }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order-bookings/complete") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const bookingId = Number(body.id || 0);
|
|
const bookings = Array.isArray(posFixture.orderBookings) ? posFixture.orderBookings : [];
|
|
const bookingIndex = bookings.findIndex((booking) => Number(booking.id) === bookingId);
|
|
|
|
if (bookingIndex >= 0) {
|
|
bookings[bookingIndex] = {
|
|
...bookings[bookingIndex],
|
|
status: "completed",
|
|
};
|
|
posFixture.completedBookingIds = Array.isArray(posFixture.completedBookingIds)
|
|
? posFixture.completedBookingIds
|
|
: [];
|
|
posFixture.completedBookingIds.push(bookingId);
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: bookingIndex >= 0 ? bookings[bookingIndex] : { id: bookingId, status: "completed" },
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (
|
|
(pathname.endsWith("/order-bookings/booking-confirmation/resend") ||
|
|
pathname.endsWith("/order-bookings/completion-confirmation/resend")) &&
|
|
method === "POST"
|
|
) {
|
|
const body = request.postDataJSON?.() || {};
|
|
await route.fulfill(json({ success: true, data: { id: Number(body.id || 0), sent: true } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/numberplatescanners") && method === "GET") {
|
|
await route.fulfill(json({ success: true, data: posFixture.numberPlateScanners || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/numberplatescans") && method === "GET") {
|
|
const filteredScans = filterNumberPlateScans(posFixture, {
|
|
filters: parsedUrl.searchParams.get("filters") || "",
|
|
search: parsedUrl.searchParams.get("search") || "",
|
|
order: parsedUrl.searchParams.get("order") || "created_at:desc",
|
|
});
|
|
const paginated = paginateRows(
|
|
filteredScans,
|
|
parsedUrl.searchParams.get("page") || 1,
|
|
parsedUrl.searchParams.get("limit") || 10
|
|
);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: paginated.rows,
|
|
meta: paginated.meta,
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/vehicles/search") && method === "GET") {
|
|
const search = String(parsedUrl.searchParams.get("search") || "").toUpperCase();
|
|
const vehicles = (posFixture.vehicles || []).filter(
|
|
(vehicle) =>
|
|
!search ||
|
|
String(vehicle.reg || "")
|
|
.toUpperCase()
|
|
.includes(search)
|
|
);
|
|
await route.fulfill(json({ success: true, data: vehicles }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/vehicles") && method === "GET") {
|
|
const search = String(parsedUrl.searchParams.get("search") || "").toUpperCase();
|
|
const vehicles = (posFixture.vehicles || []).filter(
|
|
(vehicle) =>
|
|
!search ||
|
|
String(vehicle.reg || "")
|
|
.toUpperCase()
|
|
.includes(search)
|
|
);
|
|
await route.fulfill(json({ success: true, data: vehicles }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/vehicles") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const customerId = Number(body.customer_id || 0);
|
|
const customer = posFixture.customersByNumber[customerId] || null;
|
|
const vehicle = {
|
|
id: posFixture.nextVehicleId || 9001,
|
|
reg: String(body.reg || "").toUpperCase(),
|
|
customer_id: customerId,
|
|
customer_name: customer?.name || "",
|
|
type: Number(body.type || 0),
|
|
status: "verified",
|
|
barred: false,
|
|
wash_subscription: Boolean(body.wash_subscription),
|
|
addons: { enabled: 0, available: 0, list: [] },
|
|
reference: body.reference || null,
|
|
};
|
|
|
|
posFixture.nextVehicleId = vehicle.id + 1;
|
|
posFixture.vehicles = [vehicle, ...(posFixture.vehicles || [])];
|
|
|
|
await route.fulfill(json({ success: true, data: vehicle }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") {
|
|
await route.fulfill(json({ success: true, data: posFixture.unknownVehicles || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/users/customer") && method === "GET") {
|
|
const customerNumber = Number(parsedUrl.searchParams.get("customer_number") || 0);
|
|
const customer = posFixture.customersByNumber[customerNumber] || Object.values(posFixture.customersByNumber)[0];
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
customer_name: customer?.name || "",
|
|
economic_customer: customer || null,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/motorapi/lookup") && method === "GET") {
|
|
const licensePlate = String(parsedUrl.searchParams.get("license_plate") || "").toUpperCase();
|
|
const vehicleData = posFixture.motorApiLookupByPlate?.[licensePlate];
|
|
|
|
if (!vehicleData) {
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: null,
|
|
},
|
|
404
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: vehicleData,
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/customers") && method === "GET") {
|
|
const search = String(parsedUrl.searchParams.get("search") || "").toLowerCase();
|
|
const customers = Object.values(posFixture.customersByNumber).filter((customer) => {
|
|
if (!search) {
|
|
return true;
|
|
}
|
|
return (
|
|
String(customer.customerNumber).includes(search) ||
|
|
String(customer.name || "")
|
|
.toLowerCase()
|
|
.includes(search)
|
|
);
|
|
});
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: customers,
|
|
meta: {
|
|
pagination: {
|
|
page: 1,
|
|
limit: 10,
|
|
total: customers.length,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/attributes") && method === "GET") {
|
|
await maybeDelayFixtureResponse(posFixture.customerAttributesDelayMs);
|
|
if (Number(posFixture.customerAttributesErrorCount || 0) > 0) {
|
|
posFixture.customerAttributesErrorCount = Number(posFixture.customerAttributesErrorCount) - 1;
|
|
await route.fulfill(json({ success: false, data: { message: "Customer attributes unavailable" } }, 503));
|
|
return true;
|
|
}
|
|
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, {
|
|
customer_number: parsedUrl.searchParams.get("customer_number"),
|
|
user_id: parsedUrl.searchParams.get("user_id"),
|
|
});
|
|
await route.fulfill(json({ success: true, data: ensureCustomerAttributeBucket(posFixture, customerNumber) }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/attributes") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, body);
|
|
const attribute = String(body.attribute || "").trim();
|
|
|
|
if (!customerNumber || !attribute) {
|
|
await route.fulfill(json({ success: false, data: null }, 422));
|
|
return true;
|
|
}
|
|
|
|
const bucket = ensureCustomerAttributeBucket(posFixture, customerNumber);
|
|
const existingAttribute = bucket.find((entry) => String(entry?.attribute || "") === attribute) || null;
|
|
|
|
if (existingAttribute) {
|
|
await route.fulfill(json({ success: true, data: existingAttribute }));
|
|
return true;
|
|
}
|
|
|
|
const createdAttribute = {
|
|
id: Number(posFixture.nextCustomerAttributeId || 1),
|
|
customer_number: customerNumber,
|
|
attribute,
|
|
};
|
|
|
|
posFixture.nextCustomerAttributeId = createdAttribute.id + 1;
|
|
bucket.push(createdAttribute);
|
|
|
|
await route.fulfill(json({ success: true, data: createdAttribute }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/attributes") && method === "DELETE") {
|
|
const customerNumber = resolveCustomerNumberFromAttributeTarget(posFixture, {
|
|
customer_number: parsedUrl.searchParams.get("customer_number"),
|
|
user_id: parsedUrl.searchParams.get("user_id"),
|
|
});
|
|
const attribute = String(parsedUrl.searchParams.get("attribute") || "").trim();
|
|
|
|
if (!customerNumber || !attribute) {
|
|
await route.fulfill(json({ success: false, data: null }, 422));
|
|
return true;
|
|
}
|
|
|
|
const bucket = ensureCustomerAttributeBucket(posFixture, customerNumber);
|
|
const existingAttribute = bucket.find((entry) => String(entry?.attribute || "") === attribute) || null;
|
|
posFixture.customerAttributesByNumber[customerNumber] = bucket.filter(
|
|
(entry) => String(entry?.attribute || "") !== attribute
|
|
);
|
|
|
|
await route.fulfill(json({ success: true, data: existingAttribute }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/customer/notes") && method === "GET") {
|
|
const customerNumber = Number(parsedUrl.searchParams.get("customer_number") || 0);
|
|
await route.fulfill(json({ success: true, data: posFixture.customerNotesByNumber[customerNumber] || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/user/discounts") && method === "GET") {
|
|
await route.fulfill(json({ success: true, data: [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/products") && method === "GET") {
|
|
const productId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
const category = Number(parsedUrl.searchParams.get("category") || 0);
|
|
const categoryDelayMap = posFixture.productsDelayMsByCategory || {};
|
|
const categoryDelayMs =
|
|
category > 0
|
|
? categoryDelayMap[category] ?? categoryDelayMap[String(category)] ?? posFixture.productsDelayMs
|
|
: posFixture.productsDelayMs;
|
|
|
|
await maybeDelayFixtureResponse(categoryDelayMs);
|
|
|
|
if (productId > 0) {
|
|
const product = (posFixture.products || []).find((product) => product.id === productId) || null;
|
|
await route.fulfill(json({ success: true, data: applyFixtureFinalProductPrice(product, parsedUrl, posFixture) }));
|
|
return true;
|
|
}
|
|
const products =
|
|
category > 0
|
|
? (posFixture.products || []).filter((product) => matchesProductCategory(product, category))
|
|
: posFixture.products || [];
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: products.map((product) => applyFixtureFinalProductPrice(product, parsedUrl, posFixture)),
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/reference-suggestions") && method === "GET") {
|
|
const suggestions = buildReferenceSuggestions(posFixture, {
|
|
search: parsedUrl.searchParams.get("search") || "",
|
|
departmentId: parsedUrl.searchParams.get("department_id") || null,
|
|
customerId: parsedUrl.searchParams.get("customer_id") || null,
|
|
plates: [
|
|
parsedUrl.searchParams.get("reg_1") || "",
|
|
parsedUrl.searchParams.get("reg_2") || "",
|
|
parsedUrl.searchParams.get("reg_3") || "",
|
|
],
|
|
limit: parsedUrl.searchParams.get("limit") || 10,
|
|
});
|
|
await route.fulfill(json({ success: true, data: suggestions }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders") && method === "GET") {
|
|
const filters = String(parsedUrl.searchParams.get("filters") || "");
|
|
const orders = filterPosOrders(posFixture, filters)
|
|
.map((order) => withEffectiveOrderState(posFixture, order))
|
|
.sort((a, b) => Number(b.id) - Number(a.id));
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: orders,
|
|
meta: {
|
|
pagination: {
|
|
page: 1,
|
|
limit: orders.length || 20,
|
|
total: orders.length,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = posFixture.nextOrderId++;
|
|
const bookingId = normalizePositiveIntegerValue(body.booking_id);
|
|
const orderPo = normalizeOrderPoValue(body.po) || getPosOrderBookingPo(posFixture, bookingId);
|
|
posFixture.ordersById[orderId] = {
|
|
id: orderId,
|
|
customer_id: Number(body.customer_id),
|
|
department_id: Number(body.department_id || body.department || 12),
|
|
reference: body.reference || "",
|
|
po: orderPo,
|
|
safety_seal: normalizeSafetySealValue(body.safety_seal),
|
|
notes: body.notes || "",
|
|
reg_1: normalizeRegistrationValue(body.reg_1),
|
|
reg_2: normalizeRegistrationValue(body.reg_2),
|
|
reg_3: normalizeRegistrationValue(body.reg_3),
|
|
invoice_collection_id: null,
|
|
booking_id: bookingId,
|
|
completed_at: null,
|
|
closed_at: null,
|
|
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
|
|
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
|
|
};
|
|
const booking = findPosOrderBookingById(posFixture, bookingId);
|
|
const bookingItems =
|
|
posFixture.preloadCreatedOrderItemsFromBooking && Array.isArray(booking?.items) ? booking.items : [];
|
|
posFixture.orderItemsByOrderId[orderId] = bookingItems
|
|
.map((bookingItem) => {
|
|
const productId = Number(bookingItem?.id || 0);
|
|
const product = (posFixture.products || []).find((entry) => Number(entry.id) === productId);
|
|
if (!product) {
|
|
return null;
|
|
}
|
|
|
|
return buildPosOrderItem(
|
|
product,
|
|
{
|
|
order_id: orderId,
|
|
product_id: productId,
|
|
quantity: bookingItem?.quantity || 1,
|
|
price: bookingItem?.price ?? product.price,
|
|
notes: bookingItem?.notes || "",
|
|
},
|
|
posFixture.nextOrderItemId++
|
|
);
|
|
})
|
|
.filter(Boolean);
|
|
posFixture.economicModuleOrdersByOrderId[orderId] = { invoice_id: null, invoice_draft_id: null };
|
|
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
|
|
await route.fulfill(json({ success: true, data: { id: orderId, po: orderPo } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
await route.fulfill(json(buildPosOrderResponse(posFixture, orderId)));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
const shouldRegenerateWashCertificate = shouldRegenerateWashCertificateForOrderUpdate(
|
|
posFixture.ordersById[orderId],
|
|
body
|
|
);
|
|
if (posFixture.ordersById[orderId]) {
|
|
const updatedOrder = {
|
|
...posFixture.ordersById[orderId],
|
|
...body,
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
|
|
? { reg_1: normalizeRegistrationValue(body.reg_1) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_2")
|
|
? { reg_2: normalizeRegistrationValue(body.reg_2) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_3")
|
|
? { reg_3: normalizeRegistrationValue(body.reg_3) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "created_at")
|
|
? { created_at: normalizeCreatedAtValue(body.created_at) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "safety_seal")
|
|
? { safety_seal: normalizeSafetySealValue(body.safety_seal) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "include_in_invoice")
|
|
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
|
|
: {}),
|
|
};
|
|
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
|
|
}
|
|
if (shouldRegenerateWashCertificate) {
|
|
replaceWashCertificateAttachment(posFixture, orderId);
|
|
}
|
|
await route.fulfill(
|
|
json({ success: true, data: withEffectiveOrderState(posFixture, posFixture.ordersById[orderId] || null) })
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
const shouldRegenerateWashCertificate = shouldRegenerateWashCertificateForOrderUpdate(
|
|
posFixture.ordersById[orderId],
|
|
body
|
|
);
|
|
if (posFixture.ordersById[orderId]) {
|
|
if (body.field) {
|
|
posFixture.ordersById[orderId][body.field] =
|
|
body.field === "include_in_invoice"
|
|
? normalizeIncludeInInvoiceValue(body.value)
|
|
: body.field === "reg_1" || body.field === "reg_2" || body.field === "reg_3"
|
|
? normalizeRegistrationValue(body.value)
|
|
: body.field === "safety_seal"
|
|
? normalizeSafetySealValue(body.value)
|
|
: body.field === "created_at"
|
|
? normalizeCreatedAtValue(body.value)
|
|
: body.value;
|
|
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, posFixture.ordersById[orderId]);
|
|
} else {
|
|
const updatedOrder = {
|
|
...posFixture.ordersById[orderId],
|
|
...body,
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
|
|
? { reg_1: normalizeRegistrationValue(body.reg_1) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_2")
|
|
? { reg_2: normalizeRegistrationValue(body.reg_2) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "reg_3")
|
|
? { reg_3: normalizeRegistrationValue(body.reg_3) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "created_at")
|
|
? { created_at: normalizeCreatedAtValue(body.created_at) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "safety_seal")
|
|
? { safety_seal: normalizeSafetySealValue(body.safety_seal) }
|
|
: {}),
|
|
...(Object.prototype.hasOwnProperty.call(body, "include_in_invoice")
|
|
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
|
|
: {}),
|
|
};
|
|
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
|
|
}
|
|
}
|
|
if (shouldRegenerateWashCertificate) {
|
|
replaceWashCertificateAttachment(posFixture, orderId);
|
|
}
|
|
await route.fulfill(
|
|
json({ success: true, data: withEffectiveOrderState(posFixture, posFixture.ordersById[orderId] || null) })
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
|
|
await route.fulfill(json({ success: true, data: posFixture.orderItemsByOrderId[orderId] || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.order_id || 0);
|
|
const productId = Number(body.product_id || 0);
|
|
const product = (posFixture.products || []).find((entry) => entry.id === productId);
|
|
if (!product || !posFixture.ordersById[orderId]) {
|
|
await route.fulfill(json({ success: false, data: { message: "Order or product not found" } }, 422));
|
|
return true;
|
|
}
|
|
const orderItem = buildPosOrderItem(product, body, posFixture.nextOrderItemId++);
|
|
if (!Array.isArray(posFixture.orderItemsByOrderId[orderId])) {
|
|
posFixture.orderItemsByOrderId[orderId] = [];
|
|
}
|
|
posFixture.orderItemsByOrderId[orderId].push(orderItem);
|
|
await route.fulfill(json({ success: true, data: orderItem }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const targetId = Number(body.id || 0);
|
|
Object.keys(posFixture.orderItemsByOrderId).forEach((orderIdKey) => {
|
|
posFixture.orderItemsByOrderId[orderIdKey] = (posFixture.orderItemsByOrderId[orderIdKey] || []).map((item) => {
|
|
if (item.id !== targetId) {
|
|
return item;
|
|
}
|
|
return {
|
|
...item,
|
|
price: Number(body.price ?? item.price),
|
|
notes: body.notes ?? item.notes,
|
|
reference: body.reference ?? item.reference,
|
|
quantity: Number(body.quantity ?? item.quantity),
|
|
};
|
|
});
|
|
});
|
|
await route.fulfill(json({ success: true, data: { id: targetId } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/items") && method === "DELETE") {
|
|
const orderItemId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
Object.keys(posFixture.orderItemsByOrderId).forEach((orderIdKey) => {
|
|
posFixture.orderItemsByOrderId[orderIdKey] = (posFixture.orderItemsByOrderId[orderIdKey] || []).filter(
|
|
(item) => item.id !== orderItemId
|
|
);
|
|
});
|
|
await route.fulfill(json({ success: true, data: true }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/attachments") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
await route.fulfill(json({ success: true, data: posFixture.attachmentsByOrderId[orderId] || [] }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/attachments/upload") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.order_id || body.id || 0);
|
|
if (!Array.isArray(posFixture.attachmentsByOrderId[orderId])) {
|
|
posFixture.attachmentsByOrderId[orderId] = [];
|
|
}
|
|
const attachmentId = posFixture.nextAttachmentId++;
|
|
const attachment = {
|
|
id: attachmentId,
|
|
object_type: "orders",
|
|
object_id: orderId,
|
|
content: {
|
|
image: null,
|
|
document: null,
|
|
relation: null,
|
|
other: `attachment-${attachmentId}.jpg`,
|
|
src: null,
|
|
},
|
|
created_at: toSqlDateTime(),
|
|
updated_at: toSqlDateTime(),
|
|
deleted_at: null,
|
|
};
|
|
posFixture.attachmentsByOrderId[orderId].push(attachment);
|
|
syncOrderAttachments(posFixture, orderId);
|
|
await route.fulfill(json({ success: true, data: attachment }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/attachments") && method === "DELETE") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(parsedUrl.searchParams.get("order_id") || body.order_id || 0);
|
|
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || body.attachment_id || 0);
|
|
posFixture.attachmentsByOrderId[orderId] = (posFixture.attachmentsByOrderId[orderId] || []).filter(
|
|
(attachment) => attachment.id !== attachmentId
|
|
);
|
|
syncOrderAttachments(posFixture, orderId);
|
|
await route.fulfill(json({ success: true, data: true }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/attachments/download") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("order_id") || 0);
|
|
const attachmentId = Number(parsedUrl.searchParams.get("attachment_id") || 0);
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
download_link: `https://cdn.example.test/orders/${orderId}/attachments/${attachmentId}`,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/order/wash-certificate") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
if (posFixture.ordersById[orderId]) {
|
|
posFixture.ordersById[orderId].safety_seal = normalizeSafetySealValue(body.safety_seal);
|
|
}
|
|
|
|
if (!Array.isArray(posFixture.attachmentsByOrderId[orderId])) {
|
|
posFixture.attachmentsByOrderId[orderId] = [];
|
|
}
|
|
|
|
const existingAttachment =
|
|
(posFixture.attachmentsByOrderId[orderId] || []).find((attachment) => {
|
|
return String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE";
|
|
}) || null;
|
|
|
|
if (existingAttachment) {
|
|
syncOrderAttachments(posFixture, orderId);
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
order_id: orderId,
|
|
created: false,
|
|
already_existed: true,
|
|
attachment_id: existingAttachment.id,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const attachmentId = posFixture.nextAttachmentId++;
|
|
const attachment = {
|
|
id: attachmentId,
|
|
object_type: "orders",
|
|
object_id: orderId,
|
|
content: {
|
|
image: null,
|
|
document: `wash_certificate_${orderId}.pdf`,
|
|
relation: null,
|
|
other: "WASH_CERTIFICATE",
|
|
src: null,
|
|
},
|
|
created_at: toSqlDateTime(),
|
|
updated_at: toSqlDateTime(),
|
|
deleted_at: null,
|
|
};
|
|
posFixture.attachmentsByOrderId[orderId].push(attachment);
|
|
syncOrderAttachments(posFixture, orderId);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
order_id: orderId,
|
|
created: true,
|
|
already_existed: false,
|
|
attachment_id: attachmentId,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/wash-certificate/resend") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
await route.fulfill(json({ success: true, data: { id: Number(body.id || 0), sent: true } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments/order/recommended") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
reg_1: {
|
|
motorapi: [53],
|
|
order_history: { 2: [], 3: [], 4: [], 5: [] },
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/mark_as_completed") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
if (posFixture.ordersById[orderId]) {
|
|
posFixture.ordersById[orderId].completed_at = new Date().toISOString();
|
|
if (orderContainsWashCertificate(posFixture, orderId)) {
|
|
ensureWashCertificateAttachment(posFixture, orderId);
|
|
}
|
|
}
|
|
await route.fulfill(json({ success: true, data: { id: orderId } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/stripe/department/terminal/readers") && method === "GET") {
|
|
if (posFixture.stripeReadersError) {
|
|
const configuredError = posFixture.stripeReadersError;
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: {
|
|
message: configuredError.message || "Unable to load Stripe readers.",
|
|
code: configuredError.code || null,
|
|
},
|
|
meta: {},
|
|
includes: {},
|
|
},
|
|
Number(configuredError.status || 409)
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
await route.fulfill(json({ success: true, data: { data: posFixture.readers || [] } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "GET") {
|
|
const orderId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
const paymentIntent = posFixture.paymentIntentsByOrderId[orderId] || null;
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
payment_intent: paymentIntent,
|
|
has_payment_intent: !!paymentIntent,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
const paymentIntent = posFixture.paymentIntentsByOrderId[orderId] || {
|
|
id: `pi_${orderId}`,
|
|
amount: 123400,
|
|
amount_capturable: 123400,
|
|
amount_received: 0,
|
|
currency: "dkk",
|
|
status: "requires_capture",
|
|
metadata: {
|
|
order_id: String(orderId),
|
|
reader_id: String(body.reader || "reader_online_1"),
|
|
tax_percentage: String(body.tax_percentage ?? 25),
|
|
},
|
|
};
|
|
posFixture.paymentIntentsByOrderId[orderId] = paymentIntent;
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
payment_intent: paymentIntent,
|
|
has_payment_intent: true,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "DELETE") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
delete posFixture.paymentIntentsByOrderId[orderId];
|
|
await route.fulfill(json({ success: true, data: { payment_intent: null, has_payment_intent: false } }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/orders/module/stripe/payment_intent/capture") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.id || 0);
|
|
const paymentIntent = {
|
|
...(posFixture.paymentIntentsByOrderId[orderId] || {}),
|
|
id: `pi_${orderId}`,
|
|
amount: 123400,
|
|
amount_capturable: 0,
|
|
amount_received: 123400,
|
|
currency: "dkk",
|
|
status: "succeeded",
|
|
metadata: {
|
|
order_id: String(orderId),
|
|
reader_id: "reader_online_1",
|
|
tax_percentage: "25",
|
|
},
|
|
};
|
|
posFixture.paymentIntentsByOrderId[orderId] = paymentIntent;
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
payment_intent: paymentIntent,
|
|
has_payment_intent: true,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/stripe/invoice") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
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
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const nextInvoice = createPosStripeModuleOrder(posFixture, orderId, {
|
|
customer_id: posFixture.ordersById?.[orderId]?.customer_id ?? null,
|
|
});
|
|
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,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/stripe/invoice") && method === "DELETE") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const orderId = Number(body.order_id || 0);
|
|
const existingInvoice = posFixture.stripeModuleOrdersByOrderId[orderId] || {};
|
|
|
|
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({
|
|
success: true,
|
|
data: {
|
|
stripeModuleOrders: [],
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function ensureEdgeGatewayHardwareFixture(edgeGatewayFixture) {
|
|
if (!edgeGatewayFixture) {
|
|
return null;
|
|
}
|
|
|
|
if (!edgeGatewayFixture.hardwareWorkspace) {
|
|
edgeGatewayFixture.hardwareWorkspace = {
|
|
nextScannerId: 3,
|
|
nextRelayId: 55,
|
|
relayOptions: [
|
|
{ id: "ENTRY-8", name: "Roskilde entry (Shelly Plus 1PM)", status_color: "Green" },
|
|
{ id: "EXIT-8", name: "Roskilde exit (Shelly 1)", status_color: "Red" },
|
|
{ id: "M-7", name: "Roskilde machine north (Shelly Plus 1PM)", status_color: "Green" },
|
|
{ id: "M-8", name: "Roskilde machine south (Shelly Pro 2PM)", status_color: "Yellow" },
|
|
{ id: "M-9", name: "Odense machine (Shelly Plus 1PM)", status_color: "Green" },
|
|
{ id: "PICKER-8", name: "Roskilde program picker (Shelly Pro 2PM)", status_color: "Yellow" },
|
|
{ id: "CLEANER-8", name: "Roskilde cleaner (Shelly Plus 1PM)", status_color: "Green" },
|
|
],
|
|
departments: [
|
|
{
|
|
id: 1,
|
|
name: "Copenhagen",
|
|
description: "Primary launch department",
|
|
order_priority: 1,
|
|
self_serve_enabled: true,
|
|
bookingsystem_time_based_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 7,
|
|
department: 1,
|
|
name: "Lane 7",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_type_id: 1,
|
|
relay_in_id: null,
|
|
relay_out_id: null,
|
|
relay_machine_id: "M-7",
|
|
relay_machine_program_picker_id: null,
|
|
relay_machine_cleaner_id: null,
|
|
dynamic_image_id: 77,
|
|
self_serve_products: ["Truck", "Van"],
|
|
},
|
|
{
|
|
id: 8,
|
|
department: 1,
|
|
name: "Lane 8",
|
|
status: "FAULT",
|
|
selfserve_enabled: true,
|
|
machine_type_id: 1,
|
|
relay_in_id: null,
|
|
relay_out_id: null,
|
|
relay_machine_id: "M-8",
|
|
relay_machine_program_picker_id: null,
|
|
relay_machine_cleaner_id: null,
|
|
dynamic_image_id: 78,
|
|
self_serve_products: ["Truck"],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "Odense",
|
|
description: "Fallback transport department",
|
|
order_priority: 2,
|
|
self_serve_enabled: true,
|
|
bookingsystem_time_based_enabled: true,
|
|
lanes: [
|
|
{
|
|
id: 9,
|
|
department: 2,
|
|
name: "Lane 9",
|
|
status: "AVAILABLE",
|
|
selfserve_enabled: true,
|
|
machine_type_id: 1,
|
|
relay_in_id: null,
|
|
relay_out_id: null,
|
|
relay_machine_id: "M-9",
|
|
relay_machine_program_picker_id: null,
|
|
relay_machine_cleaner_id: null,
|
|
dynamic_image_id: 79,
|
|
self_serve_products: ["Truck", "Car"],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
gates: [
|
|
{
|
|
id: 41,
|
|
department: 1,
|
|
name: "North Entrance",
|
|
is_entrance: true,
|
|
is_exit: false,
|
|
config: {
|
|
type: "RELAY",
|
|
relay_id: "M-7",
|
|
pulse_seconds: 1,
|
|
},
|
|
},
|
|
{
|
|
id: 42,
|
|
department: 1,
|
|
name: "Service Exit",
|
|
is_entrance: false,
|
|
is_exit: true,
|
|
config: {
|
|
type: "PHONE_CALL",
|
|
phone_number: "+4512345678",
|
|
call_duration_threshold: 3,
|
|
},
|
|
},
|
|
{
|
|
id: 43,
|
|
department: 2,
|
|
name: "Odense Main Gate",
|
|
is_entrance: true,
|
|
is_exit: false,
|
|
config: {
|
|
type: "PHONE_CALL",
|
|
phone_number: "+4598765432",
|
|
call_duration_threshold: 3,
|
|
},
|
|
},
|
|
],
|
|
relays: [
|
|
{
|
|
id: 51,
|
|
department: 1,
|
|
relay_id: "ENTRY-8",
|
|
name: "Lane 8 entry relay",
|
|
type: "SHELLY",
|
|
config: {
|
|
device_id: "shelly-plus-02",
|
|
channel: 0,
|
|
},
|
|
},
|
|
{
|
|
id: 52,
|
|
department: 1,
|
|
relay_id: "M-7",
|
|
name: "North machine relay",
|
|
type: "SHELLY",
|
|
config: {
|
|
device_id: "shelly-plus-01",
|
|
channel: 0,
|
|
},
|
|
},
|
|
{
|
|
id: 53,
|
|
department: 1,
|
|
relay_id: "M-8",
|
|
name: "South machine relay",
|
|
type: "SHELLY",
|
|
config: {
|
|
device_id: "shelly-plus-01",
|
|
channel: 1,
|
|
},
|
|
},
|
|
{
|
|
id: 54,
|
|
department: 2,
|
|
relay_id: "M-9",
|
|
name: "Odense machine relay",
|
|
type: "SHELLY",
|
|
config: {
|
|
device_id: "shelly-plus-03",
|
|
channel: 0,
|
|
},
|
|
},
|
|
],
|
|
scanners: [
|
|
{
|
|
id: 1,
|
|
department_id: 1,
|
|
name: "North scanner",
|
|
notes: "Mounted at the primary entry lane",
|
|
lane_id: 7,
|
|
api_key: "scanner-key-1",
|
|
},
|
|
{
|
|
id: 2,
|
|
department_id: 1,
|
|
name: "South scanner",
|
|
notes: "Waiting for lane assignment",
|
|
lane_id: null,
|
|
api_key: "scanner-key-2",
|
|
},
|
|
],
|
|
scans: [
|
|
{
|
|
id: 801,
|
|
department_id: 1,
|
|
plate_scanner_id: 1,
|
|
plate: "AB12345",
|
|
bay_id: "7",
|
|
created_at: "2026-04-08 08:44:07",
|
|
},
|
|
{
|
|
id: 802,
|
|
department_id: 1,
|
|
plate_scanner_id: 2,
|
|
plate: "CD67890",
|
|
bay_id: "8",
|
|
created_at: "2026-04-08 08:31:05",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
return edgeGatewayFixture.hardwareWorkspace;
|
|
}
|
|
|
|
function buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId) {
|
|
const gateways = edgeGatewayFixture.gateways.filter(
|
|
(gateway) => Number(gateway.department_id) === Number(departmentId)
|
|
);
|
|
const bindingsByRelayId = {};
|
|
|
|
gateways.forEach((gateway) => {
|
|
(gateway.bindings || []).forEach((binding) => {
|
|
const relayId = String(binding?.relay_id || "").trim();
|
|
if (!relayId) {
|
|
return;
|
|
}
|
|
|
|
if (!bindingsByRelayId[relayId]) {
|
|
bindingsByRelayId[relayId] = [];
|
|
}
|
|
|
|
bindingsByRelayId[relayId].push({
|
|
...cloneJson(binding),
|
|
gateway_id: gateway.id,
|
|
gateway_label: gateway.label || `Gateway ${gateway.id}`,
|
|
gateway_status: gateway.status || "OFFLINE",
|
|
is_primary_gateway: Boolean(gateway.is_primary),
|
|
});
|
|
});
|
|
});
|
|
|
|
Object.values(bindingsByRelayId).forEach((bindings) => {
|
|
bindings.sort((left, right) => {
|
|
if (Number(Boolean(right.is_primary_gateway)) !== Number(Boolean(left.is_primary_gateway))) {
|
|
return Number(Boolean(right.is_primary_gateway)) - Number(Boolean(left.is_primary_gateway));
|
|
}
|
|
return Number(left.gateway_id || 0) - Number(right.gateway_id || 0);
|
|
});
|
|
});
|
|
|
|
return {
|
|
gateways,
|
|
bindingsByRelayId,
|
|
};
|
|
}
|
|
|
|
function buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) {
|
|
const bindings = bindingsByRelayId[String(relayId || "").trim()] || [];
|
|
return {
|
|
relay_id: relayId,
|
|
covered: bindings.length > 0,
|
|
status: bindings.length > 0 ? "BOUND" : "MISSING",
|
|
binding_count: bindings.length,
|
|
primary_binding: bindings[0] || null,
|
|
bindings,
|
|
};
|
|
}
|
|
|
|
function normalizeMockRelaySelection(relayId) {
|
|
const normalizedRelayId = String(relayId ?? "").trim();
|
|
if (normalizedRelayId === "" || normalizedRelayId.toLowerCase() === "null") {
|
|
return null;
|
|
}
|
|
|
|
return normalizedRelayId;
|
|
}
|
|
|
|
function normalizeMockSelfServeEnabled(value) {
|
|
return !(
|
|
value === false ||
|
|
value === 0 ||
|
|
value === "0" ||
|
|
["false", "off", "no"].includes(
|
|
String(value ?? "")
|
|
.trim()
|
|
.toLowerCase()
|
|
)
|
|
);
|
|
}
|
|
|
|
function findEdgeGatewayHardwareLane(hardware, laneId) {
|
|
for (const department of hardware?.departments || []) {
|
|
const lane = (department?.lanes || []).find((entry) => Number(entry?.id) === Number(laneId));
|
|
if (lane) {
|
|
return lane;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function findEdgeGatewayHardwareRelay(hardware, relayId) {
|
|
return (hardware?.relays || []).find((relay) => Number(relay?.id) === Number(relayId)) || null;
|
|
}
|
|
|
|
function buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, includeGateways = true) {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const department = (hardware?.departments || []).find((entry) => Number(entry.id) === Number(departmentId)) || null;
|
|
if (!department) {
|
|
return null;
|
|
}
|
|
|
|
const { gateways, bindingsByRelayId } = buildEdgeGatewayHardwareBindingsIndex(edgeGatewayFixture, departmentId);
|
|
const gatewayPayloads = gateways.map((gateway) =>
|
|
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), true)
|
|
);
|
|
const relayCatalogById = Object.fromEntries(
|
|
(hardware?.relays || [])
|
|
.filter((relay) => Number(relay?.department) === Number(departmentId))
|
|
.map((relay) => [String(relay?.relay_id || "").trim(), relay])
|
|
);
|
|
const consumersByRelayId = {};
|
|
const registerRelayConsumer = (relayId, consumer) => {
|
|
const normalizedRelayId = String(relayId || "").trim();
|
|
|
|
if (!normalizedRelayId) {
|
|
return;
|
|
}
|
|
|
|
if (!consumersByRelayId[normalizedRelayId]) {
|
|
consumersByRelayId[normalizedRelayId] = [];
|
|
}
|
|
|
|
consumersByRelayId[normalizedRelayId].push(consumer);
|
|
};
|
|
|
|
const lanes = (department.lanes || []).map((lane) => {
|
|
const relaySlots = [
|
|
["ENTRY", lane.relay_in_id],
|
|
["EXIT", lane.relay_out_id],
|
|
["MACHINE", lane.relay_machine_id],
|
|
["PROGRAM_PICKER", lane.relay_machine_program_picker_id],
|
|
["CLEANER", lane.relay_machine_cleaner_id],
|
|
]
|
|
.filter(([, relayId]) => Boolean(relayId))
|
|
.map(([slot, relayId]) => {
|
|
registerRelayConsumer(relayId, {
|
|
type: "lane",
|
|
id: lane.id,
|
|
slot,
|
|
label: lane.name,
|
|
});
|
|
|
|
return {
|
|
slot,
|
|
relay_id: relayId,
|
|
catalog: relayCatalogById[String(relayId || "").trim()]
|
|
? cloneJson(relayCatalogById[String(relayId || "").trim()])
|
|
: relayId
|
|
? { relay_id: relayId }
|
|
: null,
|
|
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId),
|
|
};
|
|
});
|
|
|
|
const requiredRelayCount = relaySlots.length;
|
|
const boundRelayCount = relaySlots.filter((slot) => slot.coverage.covered).length;
|
|
|
|
return {
|
|
id: lane.id,
|
|
department: lane.department,
|
|
name: lane.name,
|
|
relay_in_id: lane.relay_in_id,
|
|
relay_out_id: lane.relay_out_id,
|
|
relay_machine_id: lane.relay_machine_id,
|
|
relay_machine_program_picker_id: lane.relay_machine_program_picker_id,
|
|
relay_machine_cleaner_id: lane.relay_machine_cleaner_id,
|
|
dynamic_image_id: lane.dynamic_image_id,
|
|
machine_type_id: lane.machine_type_id,
|
|
selfserve_enabled: lane.selfserve_enabled !== false,
|
|
status: lane.status,
|
|
self_serve_products: cloneJson(lane.self_serve_products || []),
|
|
relay_slots: relaySlots,
|
|
binding_coverage: {
|
|
required: requiredRelayCount,
|
|
bound: boundRelayCount,
|
|
missing: Math.max(0, requiredRelayCount - boundRelayCount),
|
|
state: requiredRelayCount === 0 ? "NOT_REQUIRED" : boundRelayCount === requiredRelayCount ? "READY" : "MISSING",
|
|
},
|
|
links: {
|
|
legacy: `/superuser/department/lanes/${lane.id}`,
|
|
self_serve_studio: `/admin/${department.id}/modules/self-serve/studio`,
|
|
},
|
|
};
|
|
});
|
|
|
|
const selfServe = {
|
|
enabled: Boolean(department.self_serve_enabled),
|
|
lane_count: lanes.length,
|
|
enabled_lanes: lanes.filter((lane) => lane.selfserve_enabled !== false).length,
|
|
ready_lanes: lanes.filter(
|
|
(lane) => lane.selfserve_enabled !== false && String(lane.binding_coverage?.state || "") === "READY"
|
|
).length,
|
|
configured_task_count: lanes.reduce((sum, lane) => sum + (lane.self_serve_products?.length || 0), 0),
|
|
configured_product_count: new Set(lanes.flatMap((lane) => lane.self_serve_products || [])).size,
|
|
readiness_state: !department.self_serve_enabled
|
|
? "DISABLED"
|
|
: lanes.filter((lane) => lane.selfserve_enabled !== false).length === 0
|
|
? "UNCONFIGURED"
|
|
: lanes
|
|
.filter((lane) => lane.selfserve_enabled !== false)
|
|
.every((lane) => String(lane.binding_coverage?.state || "") === "READY")
|
|
? "READY"
|
|
: "PARTIAL",
|
|
links: {
|
|
studio: `/admin/${department.id}/modules/self-serve/studio`,
|
|
legacy: "/superuser/selfserve",
|
|
},
|
|
};
|
|
|
|
const gates = (hardware.gates || [])
|
|
.filter((gate) => Number(gate.department) === Number(departmentId))
|
|
.map((gate) => {
|
|
const transportType = String(gate?.config?.type || "PHONE_CALL").toUpperCase();
|
|
const relayId = String(gate?.config?.relay_id || "").trim();
|
|
const coverage =
|
|
transportType === "RELAY" && relayId ? buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relayId) : null;
|
|
|
|
if (transportType === "RELAY" && relayId) {
|
|
registerRelayConsumer(relayId, {
|
|
type: "gate",
|
|
id: gate.id,
|
|
slot: gate.is_entrance ? "ENTRANCE" : gate.is_exit ? "EXIT" : "GENERAL",
|
|
label: gate.name,
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: gate.id,
|
|
department: gate.department,
|
|
name: gate.name,
|
|
is_entrance: Boolean(gate.is_entrance),
|
|
is_exit: Boolean(gate.is_exit),
|
|
config: cloneJson(gate.config || {}),
|
|
transport_type: transportType,
|
|
config_complete:
|
|
transportType === "PHONE_CALL"
|
|
? Boolean(gate?.config?.phone_number) && Number(gate?.config?.call_duration_threshold || 0) > 0
|
|
: Boolean(relayId),
|
|
relay: relayId ? { relay_id: relayId } : null,
|
|
coverage,
|
|
};
|
|
});
|
|
|
|
const relays = (hardware?.relays || [])
|
|
.filter((relay) => Number(relay?.department) === Number(departmentId))
|
|
.map((relay) => ({
|
|
id: relay.id,
|
|
department: relay.department,
|
|
relay_id: relay.relay_id,
|
|
name: relay.name,
|
|
type: relay.type,
|
|
config: cloneJson(relay.config || {}),
|
|
coverage: buildEdgeGatewayHardwareRelayCoverage(bindingsByRelayId, relay.relay_id),
|
|
consumer_contexts: cloneJson(consumersByRelayId[String(relay?.relay_id || "").trim()] || []),
|
|
}));
|
|
|
|
const laneIndex = Object.fromEntries(lanes.map((lane) => [Number(lane.id), lane]));
|
|
const scansByScannerId = {};
|
|
(hardware.scans || [])
|
|
.filter((scan) => Number(scan.department_id) === Number(departmentId))
|
|
.sort(
|
|
(left, right) =>
|
|
new Date(String(right.created_at || 0)).getTime() - new Date(String(left.created_at || 0)).getTime()
|
|
)
|
|
.forEach((scan) => {
|
|
const scannerId = Number(scan.plate_scanner_id || 0);
|
|
if (!scansByScannerId[scannerId]) {
|
|
scansByScannerId[scannerId] = [];
|
|
}
|
|
if (scansByScannerId[scannerId].length >= 5) {
|
|
return;
|
|
}
|
|
scansByScannerId[scannerId].push(cloneJson(scan));
|
|
});
|
|
|
|
const scanners = (hardware.scanners || [])
|
|
.filter((scanner) => Number(scanner.department_id) === Number(departmentId))
|
|
.map((scanner) => {
|
|
const assignedLane = scanner.lane_id ? laneIndex[Number(scanner.lane_id)] || null : null;
|
|
const assignmentState = !scanner.lane_id
|
|
? "UNASSIGNED"
|
|
: !assignedLane
|
|
? "INVALID"
|
|
: Number(assignedLane?.binding_coverage?.missing || 0) === 0
|
|
? "READY"
|
|
: "PARTIAL";
|
|
const recentScans = scansByScannerId[Number(scanner.id)] || [];
|
|
|
|
return {
|
|
...cloneJson(scanner),
|
|
assigned_lane: assignedLane,
|
|
assignment_state: assignmentState,
|
|
recent_scan_at: recentScans[0]?.created_at || null,
|
|
recent_scans: recentScans,
|
|
recent_scan_count: recentScans.length,
|
|
};
|
|
});
|
|
|
|
const issues = [];
|
|
const onlineGatewayCount = gatewayPayloads.filter(
|
|
(gateway) => String(gateway?.status || "").toUpperCase() === "ONLINE"
|
|
).length;
|
|
const transportMode = String(
|
|
gatewayPayloads[0]?.department_transport_mode || gateways[0]?.department_transport_mode || "cloud"
|
|
);
|
|
|
|
if (gatewayPayloads.length === 0) {
|
|
issues.push({
|
|
severity: "danger",
|
|
code: "NO_GATEWAY",
|
|
message: "No edge gateway has been claimed for this department.",
|
|
});
|
|
} else if (transportMode === "gateway" && onlineGatewayCount === 0) {
|
|
issues.push({
|
|
severity: "danger",
|
|
code: "NO_ONLINE_GATEWAY",
|
|
message: "Gateway transport mode is enabled, but no department gateway is currently online.",
|
|
});
|
|
}
|
|
|
|
lanes.forEach((lane) => {
|
|
if (Number(lane.binding_coverage?.missing || 0) > 0) {
|
|
issues.push({
|
|
severity: "warning",
|
|
code: "LANE_BINDING_GAP",
|
|
message: `Lane ${lane.name} is missing relay bindings.`,
|
|
target_type: "lane",
|
|
target_id: lane.id,
|
|
});
|
|
}
|
|
});
|
|
|
|
gates.forEach((gate) => {
|
|
if (!gate.config_complete) {
|
|
issues.push({
|
|
severity: "warning",
|
|
code: "GATE_CONFIG_INCOMPLETE",
|
|
message: `Gate ${gate.name} has incomplete transport configuration.`,
|
|
target_type: "gate",
|
|
target_id: gate.id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (gate.transport_type === "RELAY" && !gate.coverage?.covered) {
|
|
issues.push({
|
|
severity: "warning",
|
|
code: "GATE_BINDING_MISSING",
|
|
message: `Gate ${gate.name} is assigned to an unbound relay.`,
|
|
target_type: "gate",
|
|
target_id: gate.id,
|
|
});
|
|
}
|
|
});
|
|
|
|
scanners.forEach((scanner) => {
|
|
if (scanner.assignment_state === "UNASSIGNED") {
|
|
issues.push({
|
|
severity: "warning",
|
|
code: "SCANNER_UNASSIGNED",
|
|
message: `Scanner ${scanner.name} is not assigned to a default lane.`,
|
|
target_type: "scanner",
|
|
target_id: scanner.id,
|
|
});
|
|
} else if (scanner.assignment_state === "PARTIAL") {
|
|
issues.push({
|
|
severity: "info",
|
|
code: "SCANNER_LANE_PARTIAL",
|
|
message: `Scanner ${scanner.name} is assigned to a lane with missing relay coverage.`,
|
|
target_type: "scanner",
|
|
target_id: scanner.id,
|
|
});
|
|
}
|
|
});
|
|
|
|
if (selfServe.enabled && Number(selfServe.ready_lanes || 0) < Number(selfServe.enabled_lanes || 0)) {
|
|
issues.push({
|
|
severity: "warning",
|
|
code: "SELFSERVE_PARTIAL_READY",
|
|
message: "Self-serve is enabled, but one or more lanes are missing required relay coverage.",
|
|
});
|
|
}
|
|
|
|
const actions = [
|
|
{
|
|
code: "OPEN_GATEWAY_TAB",
|
|
label: "Open gateway controls",
|
|
path: `/superuser/departments/${departmentId}/gateways?tab=gateways`,
|
|
},
|
|
];
|
|
|
|
if (gatewayPayloads.length === 0) {
|
|
actions.push({
|
|
code: "INSTALL_GATEWAY",
|
|
label: "Install first edge gateway",
|
|
path: "/superuser/selfserve/edge-agents",
|
|
});
|
|
}
|
|
|
|
if (lanes.some((lane) => Number(lane.binding_coverage?.missing || 0) > 0)) {
|
|
actions.push({
|
|
code: "REVIEW_LANE_BINDINGS",
|
|
label: "Resolve lane bindings",
|
|
path: `/superuser/departments/${departmentId}/gateways?tab=lanes`,
|
|
});
|
|
}
|
|
|
|
if (gates.some((gate) => gate.transport_type === "RELAY" && !gate.coverage?.covered)) {
|
|
actions.push({
|
|
code: "REVIEW_GATE_BINDINGS",
|
|
label: "Resolve gate relay bindings",
|
|
path: `/superuser/departments/${departmentId}/gateways?tab=gates`,
|
|
});
|
|
}
|
|
|
|
if (scanners.some((scanner) => scanner.assignment_state === "UNASSIGNED")) {
|
|
actions.push({
|
|
code: "ASSIGN_SCANNERS",
|
|
label: "Assign scanners to lanes",
|
|
path: `/superuser/departments/${departmentId}/gateways?tab=scanners`,
|
|
});
|
|
}
|
|
|
|
if (selfServe.enabled && lanes.length > 0) {
|
|
actions.push({
|
|
code: "OPEN_SELFSERVE_STUDIO",
|
|
label: "Open self-serve studio",
|
|
path: `/admin/${departmentId}/modules/self-serve/studio`,
|
|
});
|
|
}
|
|
|
|
const requiredRelayIds = new Set();
|
|
const coveredRelayIds = new Set();
|
|
lanes.forEach((lane) => {
|
|
(lane.relay_slots || []).forEach((slot) => {
|
|
if (!slot?.relay_id) {
|
|
return;
|
|
}
|
|
requiredRelayIds.add(slot.relay_id);
|
|
if (slot.coverage?.covered) {
|
|
coveredRelayIds.add(slot.relay_id);
|
|
}
|
|
});
|
|
});
|
|
gates.forEach((gate) => {
|
|
const relayId = String(gate?.relay?.relay_id || gate?.config?.relay_id || "").trim();
|
|
if (!relayId) {
|
|
return;
|
|
}
|
|
requiredRelayIds.add(relayId);
|
|
if (gate.coverage?.covered) {
|
|
coveredRelayIds.add(relayId);
|
|
}
|
|
});
|
|
|
|
const primaryGateway = gatewayPayloads.find((gateway) => gateway?.is_primary) || gatewayPayloads[0] || null;
|
|
const recentScanAt =
|
|
scanners
|
|
.map((scanner) => scanner?.recent_scan_at)
|
|
.filter(Boolean)
|
|
.sort((left, right) => new Date(String(right || 0)).getTime() - new Date(String(left || 0)).getTime())[0] || null;
|
|
|
|
const health = issues.some((issue) => issue.severity === "danger")
|
|
? "AT_RISK"
|
|
: issues.length > 0
|
|
? "PARTIAL"
|
|
: "READY";
|
|
|
|
const summary = {
|
|
department_id: department.id,
|
|
department_name: department.name,
|
|
order_priority: department.order_priority,
|
|
transport_mode: transportMode,
|
|
gateway_count: gatewayPayloads.length,
|
|
online_gateway_count: onlineGatewayCount,
|
|
primary_gateway: primaryGateway
|
|
? {
|
|
id: primaryGateway.id,
|
|
label: primaryGateway.label || `Gateway ${primaryGateway.id}`,
|
|
status: primaryGateway.status || "OFFLINE",
|
|
}
|
|
: null,
|
|
lane_count: lanes.length,
|
|
self_serve_enabled: selfServe.enabled,
|
|
self_serve_ready_lanes: selfServe.ready_lanes,
|
|
required_relay_count: requiredRelayIds.size,
|
|
bound_relay_count: coveredRelayIds.size,
|
|
missing_binding_count: Math.max(0, requiredRelayIds.size - coveredRelayIds.size),
|
|
gate_count: gates.length,
|
|
gate_transport_mix: {
|
|
relay: gates.filter((gate) => gate.transport_type === "RELAY").length,
|
|
phone_call: gates.filter((gate) => gate.transport_type === "PHONE_CALL").length,
|
|
},
|
|
scanner_count: scanners.length,
|
|
assigned_scanner_count: scanners.filter((scanner) => Number(scanner.lane_id || 0) > 0).length,
|
|
recent_scan_at: recentScanAt,
|
|
issue_count: issues.length,
|
|
health,
|
|
};
|
|
|
|
return {
|
|
department: {
|
|
id: department.id,
|
|
name: department.name,
|
|
description: department.description,
|
|
order_priority: department.order_priority,
|
|
},
|
|
summary,
|
|
gateways: includeGateways ? gatewayPayloads : [],
|
|
lanes,
|
|
self_serve: selfServe,
|
|
gates,
|
|
relays,
|
|
scanners,
|
|
issues,
|
|
actions,
|
|
};
|
|
}
|
|
|
|
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture, selfServe }) {
|
|
if (!edgeGatewayFixture) {
|
|
return false;
|
|
}
|
|
|
|
const extractMatchId = (pattern) => {
|
|
const match = pathname.match(pattern);
|
|
return Number(match?.[1] || 0);
|
|
};
|
|
|
|
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);
|
|
const createOperation = (gateway, type, request = {}, overrides = {}) => {
|
|
const operation = createFixtureOperation(type, request, {
|
|
id: edgeGatewayFixture.nextOperationId++,
|
|
...overrides,
|
|
});
|
|
gateway.operations = [operation, ...(gateway.operations || [])];
|
|
return operation;
|
|
};
|
|
const edgeGatewayCollectionPattern = /\/(?:modules\/)?edge-gateways$/;
|
|
const edgeGatewayWorkspaceDepartmentsPattern = /\/modules\/edge-gateways\/workspace\/departments$/;
|
|
const edgeGatewayWorkspaceDepartmentPattern = /\/modules\/edge-gateways\/workspace\/departments\/(\d+)$/;
|
|
const edgeGatewayDetailPattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
|
const edgeGatewayTasksPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/tasks$/;
|
|
const edgeGatewayLogsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/logs$/;
|
|
const edgeGatewayStatisticsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/statistics$/;
|
|
const edgeGatewayOperationsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/operations$/;
|
|
const edgeGatewayOperationEventsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/operations\/(\d+)\/events$/;
|
|
const edgeGatewayOperationCancelPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/operations\/(\d+)\/cancel$/;
|
|
const edgeGatewayRotateCredentialsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/rotate-credentials$/;
|
|
const edgeGatewayDiscoveryPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/discovery$/;
|
|
const edgeGatewayInstallTokenPattern = /\/(?:modules\/)?edge-gateways\/install-token$/;
|
|
const edgeGatewayInstallTokenStatusPattern = /\/(?:modules\/)?edge-gateways\/install-token\/(\d+)\/status$/;
|
|
const edgeGatewayBindingsPattern = /\/(?:modules\/)?edge-gateways\/(\d+)\/bindings$/;
|
|
const numberPlateScannersPattern = /\/numberplatescanners$/;
|
|
const numberPlateScannerRotatePattern = /\/numberplatescanners\/(\d+)\/rotate-key$/;
|
|
const selfServeLaneGateOpenPattern = /\/modules\/self-serve\/lane\/gate\/open$/;
|
|
const selfServeLaneRelayPattern =
|
|
/\/modules\/self-serve\/lane\/relay\/(machine|machine_program_picker|machine_cleaner)\/(status|set)$/;
|
|
const edgeGatewayDeletePattern = /\/(?:modules\/)?edge-gateways\/(\d+)$/;
|
|
const edgeGatewayCutoverPattern =
|
|
/\/(?:modules\/edge-gateways\/departments\/(\d+)\/cutover|departments\/(\d+)\/gateway-cutover)$/;
|
|
const edgeGatewayUnsupportedPattern = /\/(?:modules\/)?edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/;
|
|
const buildConfigEntries = () => [
|
|
{ variable: "enabled", value: edgeGatewayFixture.config.enabled },
|
|
{ variable: "default_release_channel", value: edgeGatewayFixture.config.default_release_channel },
|
|
{ variable: "default_update_window", value: edgeGatewayFixture.config.default_update_window },
|
|
{ variable: "broker_url", value: edgeGatewayFixture.config.broker_url },
|
|
{ variable: "public_broker_url", value: edgeGatewayFixture.config.public_broker_url },
|
|
{ variable: "broker_auth_mode", value: edgeGatewayFixture.config.broker_auth_mode },
|
|
{ variable: "broker_shared_secret", value: edgeGatewayFixture.config.broker_shared_secret },
|
|
];
|
|
|
|
if (pathname.endsWith("/edgegateway/config") && method === "GET") {
|
|
await route.fulfill(json({ data: buildConfigEntries() }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/edgegateway/config") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const updates = Object.prototype.hasOwnProperty.call(body, "variable")
|
|
? [[String(body.variable || ""), body.value]]
|
|
: Object.entries(body);
|
|
const allowedVariables = new Set(buildConfigEntries().map((entry) => entry.variable));
|
|
|
|
if (updates.length === 0 || updates.some(([variable]) => !allowedVariables.has(String(variable)))) {
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
success: false,
|
|
data: {
|
|
message: "Variable and value not set",
|
|
},
|
|
},
|
|
400
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
for (const [variable, value] of updates) {
|
|
if (variable === "enabled") {
|
|
edgeGatewayFixture.config.enabled = !(value === false || value === "false" || value === 0 || value === "0");
|
|
} else if (variable === "default_release_channel") {
|
|
edgeGatewayFixture.config.default_release_channel = String(value || "stable");
|
|
} else if (variable === "default_update_window") {
|
|
edgeGatewayFixture.config.default_update_window = String(value || "02:00-04:00");
|
|
} else if (variable === "broker_url") {
|
|
edgeGatewayFixture.config.broker_url = String(value || "");
|
|
} else if (variable === "public_broker_url") {
|
|
edgeGatewayFixture.config.public_broker_url = String(value || "");
|
|
} else if (variable === "broker_auth_mode") {
|
|
edgeGatewayFixture.config.broker_auth_mode = String(value || "manager");
|
|
} else if (variable === "broker_shared_secret") {
|
|
edgeGatewayFixture.config.broker_shared_secret = String(value || "");
|
|
}
|
|
}
|
|
|
|
await route.fulfill(json({ data: buildConfigEntries() }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/edgegateway/config/broker-diagnostics") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const target = String(body.target || "all");
|
|
const payload = {
|
|
target,
|
|
checked_at: "2026-04-28 10:00:00",
|
|
broker_auth_mode: String(body.broker_auth_mode || edgeGatewayFixture.config.broker_auth_mode || "manager"),
|
|
broker_shared_secret_configured: Boolean(
|
|
body.broker_shared_secret || edgeGatewayFixture.config.broker_shared_secret
|
|
),
|
|
};
|
|
|
|
if (target === "internal" || target === "all") {
|
|
payload.internal_broker_connection = {
|
|
ok: Boolean(body.broker_url),
|
|
status: body.broker_url ? "connected" : "not_configured",
|
|
status_code: body.broker_url ? 200 : null,
|
|
url: body.broker_url || null,
|
|
message: body.broker_url
|
|
? "Internal broker responded to the health check."
|
|
: "Internal broker URL is not configured.",
|
|
};
|
|
}
|
|
|
|
if (target === "public" || target === "all") {
|
|
payload.public_broker_url = {
|
|
ok: Boolean(body.public_broker_url),
|
|
status: body.public_broker_url ? "connected" : "not_configured",
|
|
status_code: body.public_broker_url ? 200 : null,
|
|
url: body.public_broker_url || null,
|
|
message: body.public_broker_url
|
|
? "Public broker responded to the health check."
|
|
: "Public broker URL is not configured.",
|
|
};
|
|
}
|
|
|
|
if (target === "secret" || target === "all") {
|
|
const submittedSecret = Object.prototype.hasOwnProperty.call(body, "broker_shared_secret")
|
|
? String(body.broker_shared_secret || "")
|
|
: String(edgeGatewayFixture.config.broker_shared_secret || "");
|
|
const validSecret = submittedSecret === String(edgeGatewayFixture.config.broker_shared_secret || "");
|
|
payload.broker_shared_secret = {
|
|
ok: validSecret,
|
|
status: validSecret ? "validated" : "secret_rejected",
|
|
status_code: validSecret ? 200 : 403,
|
|
url: body.broker_url || edgeGatewayFixture.config.broker_url || null,
|
|
message: validSecret
|
|
? "Broker accepted the configured shared secret."
|
|
: "Broker rejected the configured shared secret.",
|
|
};
|
|
}
|
|
|
|
await route.fulfill(json({ data: payload }));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayWorkspaceDepartmentsPattern.test(pathname) && method === "GET") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const summaries = (hardware?.departments || [])
|
|
.map(
|
|
(department) => buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, department.id, false)?.summary
|
|
)
|
|
.filter(Boolean)
|
|
.sort((left, right) => Number(left?.order_priority || 0) - Number(right?.order_priority || 0));
|
|
await route.fulfill(json({ data: summaries }));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayWorkspaceDepartmentPattern.test(pathname) && method === "GET") {
|
|
const departmentId = extractMatchId(edgeGatewayWorkspaceDepartmentPattern);
|
|
const payload = buildEdgeGatewayHardwareDepartmentWorkspace(edgeGatewayFixture, departmentId, true);
|
|
await route.fulfill(payload ? json({ data: payload }) : json({ message: "Department not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes/relay-options") && method === "GET") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
await route.fulfill(json({ data: cloneJson(hardware?.relayOptions || []) }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes") && method === "PUT") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const laneId = Number(body.id || 0);
|
|
const lane = findEdgeGatewayHardwareLane(hardware, laneId);
|
|
|
|
if (!lane) {
|
|
await route.fulfill(json({ message: "Department lane not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
[
|
|
"relay_in_id",
|
|
"relay_out_id",
|
|
"relay_machine_id",
|
|
"relay_machine_program_picker_id",
|
|
"relay_machine_cleaner_id",
|
|
].forEach((field) => {
|
|
if (Object.prototype.hasOwnProperty.call(body, field)) {
|
|
lane[field] = normalizeMockRelaySelection(body[field]);
|
|
}
|
|
});
|
|
if (Object.prototype.hasOwnProperty.call(body, "selfserve_enabled")) {
|
|
lane.selfserve_enabled = normalizeMockSelfServeEnabled(body.selfserve_enabled);
|
|
}
|
|
|
|
await route.fulfill(json({ success: true, data: cloneJson(lane) }));
|
|
return true;
|
|
}
|
|
|
|
if (selfServeLaneGateOpenPattern.test(pathname) && method === "POST") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe?.gateRequests?.push({ path: pathname, method, body: cloneJson(body) });
|
|
const laneId = Number(body.lane_id || 0);
|
|
const lane = findEdgeGatewayHardwareLane(hardware, laneId);
|
|
|
|
if (!lane) {
|
|
await route.fulfill(json({ message: "Department lane not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
lane_id: laneId,
|
|
gate: String(body.gate || "ENTRANCE").toUpperCase(),
|
|
opened: true,
|
|
state: "AVAILABLE",
|
|
transport: String(body.transport || "local").toLowerCase() === "cloud" ? "cloud" : "local",
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (selfServeLaneRelayPattern.test(pathname) && (method === "GET" || method === "POST")) {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const match = pathname.match(selfServeLaneRelayPattern);
|
|
const relaySlug = String(match?.[1] || "machine");
|
|
const action = String(match?.[2] || "status");
|
|
const body = method === "POST" ? request.postDataJSON?.() || {} : {};
|
|
if (method === "POST") {
|
|
selfServe?.relayRequests?.push({ path: pathname, method, action, relay: relaySlug, body: cloneJson(body) });
|
|
}
|
|
const laneId = Number(body.lane_id || parsedUrl.searchParams.get("lane_id") || 0);
|
|
const lane = findEdgeGatewayHardwareLane(hardware, laneId);
|
|
|
|
if (!lane) {
|
|
await route.fulfill(json({ message: "Department lane not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
const relayDefinition =
|
|
relaySlug === "machine_program_picker"
|
|
? { relay: "PROGRAM_PICKER", field: "relay_machine_program_picker_id" }
|
|
: relaySlug === "machine_cleaner"
|
|
? { relay: "CLEANER", field: "relay_machine_cleaner_id" }
|
|
: { relay: "MACHINE", field: "relay_machine_id" };
|
|
const relayId = String(lane?.[relayDefinition.field] || "");
|
|
|
|
if (!hardware.relayTestState) {
|
|
hardware.relayTestState = {};
|
|
}
|
|
|
|
if (action === "set") {
|
|
hardware.relayTestState[relayId] = Boolean(body.on);
|
|
if (selfServe?.relayStatuses?.[relayId]) {
|
|
selfServe.relayStatuses[relayId].on = Boolean(body.on);
|
|
}
|
|
}
|
|
|
|
const mockedRelayStatus = selfServe?.relayStatuses?.[relayId] || null;
|
|
const requestedTransport =
|
|
mockedRelayStatus?.transport ||
|
|
(String(body.transport || parsedUrl.searchParams.get("transport") || "local").toLowerCase() === "cloud"
|
|
? "cloud"
|
|
: "local");
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
lane_id: laneId,
|
|
relay: relayDefinition.relay,
|
|
requested_on: action === "set" ? Boolean(body.on) : undefined,
|
|
relay_id: relayId,
|
|
online: mockedRelayStatus?.online ?? Boolean(relayId),
|
|
on: mockedRelayStatus?.on ?? Boolean(hardware.relayTestState[relayId]),
|
|
transport: requestedTransport,
|
|
source: mockedRelayStatus?.source || (requestedTransport === "cloud" ? "cloud_fallback" : "edge_gateway"),
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/relays") && method === "GET") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const relayId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
|
|
if (relayId > 0) {
|
|
const relay = findEdgeGatewayHardwareRelay(hardware, relayId);
|
|
await route.fulfill(relay ? json({ data: cloneJson(relay) }) : json({ message: "Relay not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
await route.fulfill(json({ data: cloneJson(hardware?.relays || []) }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/relays") && method === "POST") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const relay = {
|
|
id: hardware.nextRelayId++,
|
|
department: Number(body.department || 0),
|
|
relay_id: String(body.relay_id || ""),
|
|
name: String(body.name || ""),
|
|
type: String(body.type || ""),
|
|
config:
|
|
body.config && typeof body.config === "object" && !Array.isArray(body.config) ? cloneJson(body.config) : {},
|
|
};
|
|
|
|
hardware.relays.push(relay);
|
|
await route.fulfill(json({ success: true, data: cloneJson(relay) }, 201));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/relays") && method === "PUT") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const relay = findEdgeGatewayHardwareRelay(hardware, Number(body.id || 0));
|
|
|
|
if (!relay) {
|
|
await route.fulfill(json({ message: "Relay not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
["department", "relay_id", "name", "type", "config"].forEach((field) => {
|
|
if (!Object.prototype.hasOwnProperty.call(body, field)) {
|
|
return;
|
|
}
|
|
|
|
relay[field] =
|
|
field === "department"
|
|
? Number(body[field] || 0)
|
|
: field === "config" && body[field] && typeof body[field] === "object" && !Array.isArray(body[field])
|
|
? cloneJson(body[field])
|
|
: body[field];
|
|
});
|
|
|
|
await route.fulfill(json({ success: true, data: cloneJson(relay) }));
|
|
return true;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/relays") && method === "DELETE") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const relayId = Number(parsedUrl.searchParams.get("id") || 0);
|
|
hardware.relays = (hardware.relays || []).filter((relay) => Number(relay?.id) !== relayId);
|
|
await route.fulfill(json({ success: true, data: true }));
|
|
return true;
|
|
}
|
|
|
|
if (numberPlateScannersPattern.test(pathname) && method === "GET") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const rows = cloneJson(hardware?.scanners || []);
|
|
const paginated = paginateRows(
|
|
rows,
|
|
parsedUrl.searchParams.get("page") || 1,
|
|
parsedUrl.searchParams.get("limit") || 10
|
|
);
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: paginated.rows,
|
|
meta: paginated.meta,
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (numberPlateScannersPattern.test(pathname) && method === "POST") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const scanner = {
|
|
id: hardware.nextScannerId++,
|
|
department_id: Number(body.department_id || 0),
|
|
name: String(body.name || ""),
|
|
notes: String(body.notes || ""),
|
|
lane_id: body.lane_id === null || body.lane_id === undefined || body.lane_id === "" ? null : Number(body.lane_id),
|
|
api_key: `scanner-key-${Date.now()}`,
|
|
};
|
|
hardware.scanners.push(scanner);
|
|
await route.fulfill(json({ success: true, data: cloneJson(scanner) }, 201));
|
|
return true;
|
|
}
|
|
|
|
if (numberPlateScannersPattern.test(pathname) && method === "PUT") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const scannerId = Number(body.id || 0);
|
|
const scannerIndex = (hardware.scanners || []).findIndex((scanner) => Number(scanner.id) === scannerId);
|
|
if (scannerIndex === -1) {
|
|
await route.fulfill(json({ message: "Scanner not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
hardware.scanners[scannerIndex] = {
|
|
...hardware.scanners[scannerIndex],
|
|
department_id: Number(body.department_id || hardware.scanners[scannerIndex].department_id || 0),
|
|
name: body.name === undefined ? hardware.scanners[scannerIndex].name : String(body.name || ""),
|
|
notes: body.notes === undefined ? hardware.scanners[scannerIndex].notes : String(body.notes || ""),
|
|
lane_id:
|
|
body.lane_id === undefined
|
|
? hardware.scanners[scannerIndex].lane_id
|
|
: body.lane_id === null || body.lane_id === ""
|
|
? null
|
|
: Number(body.lane_id),
|
|
};
|
|
|
|
await route.fulfill(json({ success: true, data: cloneJson(hardware.scanners[scannerIndex]) }));
|
|
return true;
|
|
}
|
|
|
|
if (numberPlateScannerRotatePattern.test(pathname) && method === "POST") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const scannerId = extractMatchId(numberPlateScannerRotatePattern);
|
|
const scanner = (hardware.scanners || []).find((entry) => Number(entry.id) === scannerId) || null;
|
|
if (!scanner) {
|
|
await route.fulfill(json({ message: "Scanner not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
scanner.api_key = `rotated-scanner-key-${scanner.id}`;
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
scanner: cloneJson(scanner),
|
|
api_key: scanner.api_key,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayCollectionPattern.test(pathname) && method === "GET") {
|
|
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
|
const departmentId = Number(parsedUrl.searchParams.get("department_id") || 0);
|
|
const gateways =
|
|
departmentId > 0
|
|
? edgeGatewayFixture.gateways.filter((gateway) => Number(gateway.department_id) === departmentId)
|
|
: edgeGatewayFixture.gateways;
|
|
const gatewayRows = gateways.map((gateway) =>
|
|
buildHttpEdgeGatewayGateway(edgeGatewayFixture, settleEdgeGatewayWork(edgeGatewayFixture, gateway.id), false)
|
|
);
|
|
await route.fulfill(
|
|
json({
|
|
data: gatewayRows,
|
|
meta: {
|
|
fleet_usage: buildHttpEdgeGatewayFleetUsage(gatewayRows),
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayDetailPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayDetailPattern);
|
|
processPendingEdgeGatewayClaims(edgeGatewayFixture);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
await route.fulfill(
|
|
gateway
|
|
? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) })
|
|
: json({ message: "Gateway not found" }, 404)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayTasksPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayTasksPattern);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
await route.fulfill(
|
|
gateway
|
|
? json({ data: buildHttpEdgeGatewayTasksPage(edgeGatewayFixture, gateway) })
|
|
: json({ message: "Gateway not found" }, 404)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayLogsPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayLogsPattern);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
await route.fulfill(
|
|
gateway
|
|
? json({ data: buildHttpEdgeGatewayLogsPage(edgeGatewayFixture, gateway) })
|
|
: json({ message: "Gateway not found" }, 404)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayStatisticsPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayStatisticsPattern);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
await route.fulfill(
|
|
gateway
|
|
? json({ data: buildHttpEdgeGatewayStatisticsPage(edgeGatewayFixture, gateway) })
|
|
: json({ message: "Gateway not found" }, 404)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayOperationsPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayOperationsPattern);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
await route.fulfill(json({ data: cloneJson(gateway?.operations || []) }));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayOperationEventsPattern.test(pathname) && method === "GET") {
|
|
const gatewayId = extractMatchId(edgeGatewayOperationEventsPattern);
|
|
const operationId = Number(pathname.match(edgeGatewayOperationEventsPattern)?.[2] || 0);
|
|
const gateway = settleEdgeGatewayWork(edgeGatewayFixture, gatewayId);
|
|
const operation = (gateway?.operations || []).find((entry) => Number(entry.id) === Number(operationId)) || null;
|
|
await route.fulfill(json({ data: cloneJson(operation?.events || []) }));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayOperationCancelPattern.test(pathname) && method === "POST") {
|
|
const gatewayId = extractMatchId(edgeGatewayOperationCancelPattern);
|
|
const operationId = Number(pathname.match(edgeGatewayOperationCancelPattern)?.[2] || 0);
|
|
const gateway = findGateway(gatewayId);
|
|
const now = toSqlDateTime();
|
|
|
|
if (!gateway) {
|
|
await route.fulfill(json({ message: "Gateway not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
const operation = (gateway.operations || []).find((entry) => Number(entry.id) === Number(operationId)) || null;
|
|
if (!operation) {
|
|
await route.fulfill(json({ message: "Operation not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
if (operation.status === "IN_PROGRESS" || operation.status === "CANCEL_REQUESTED") {
|
|
operation.status = "CANCELLED";
|
|
operation.completed_at = now;
|
|
operation.updated_at = now;
|
|
operation.error_code = "EDGE_GATEWAY_CANCELLED";
|
|
operation.error_message = "Operation cancelled by operator";
|
|
operation.summary = {
|
|
...(operation.summary || {}),
|
|
label: "Cancelled",
|
|
retryable: true,
|
|
};
|
|
operation.events = [
|
|
...(operation.events || []),
|
|
{
|
|
id: edgeGatewayFixture.nextOperationEventId++,
|
|
level: "WARNING",
|
|
code: "OPERATION_CANCELLED",
|
|
message: "Operation cancelled by operator",
|
|
created_at: now,
|
|
},
|
|
];
|
|
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
|
|
} else if (operation.status === "PENDING") {
|
|
operation.status = "CANCELLED";
|
|
operation.completed_at = now;
|
|
operation.updated_at = now;
|
|
operation.error_code = "EDGE_GATEWAY_CANCELLED";
|
|
operation.error_message = "Operation cancelled by operator";
|
|
operation.summary = {
|
|
...(operation.summary || {}),
|
|
label: "Cancelled",
|
|
retryable: true,
|
|
};
|
|
}
|
|
|
|
gateway.audit_logs = [
|
|
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_CANCELLED", actor_type: "USER" },
|
|
...(gateway.audit_logs || []),
|
|
];
|
|
invalidateEdgeGatewayRuntimeSnapshot(gateway);
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
operation: cloneJson(operation),
|
|
gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true),
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayOperationsPattern.test(pathname) && method === "POST") {
|
|
const gatewayId = extractMatchId(edgeGatewayOperationsPattern);
|
|
const gateway = findGateway(gatewayId);
|
|
const body = request.postDataJSON?.() || {};
|
|
if (!gateway) {
|
|
await route.fulfill(json({ message: "Gateway not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
const activeOperation = (gateway.operations || []).find((entry) =>
|
|
["PENDING", "IN_PROGRESS", "CANCEL_REQUESTED"].includes(String(entry.status || ""))
|
|
);
|
|
if (activeOperation) {
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
data: {
|
|
message: "Another gateway operation is already active",
|
|
error_code: "EDGE_GATEWAY_CONFLICT",
|
|
},
|
|
},
|
|
409
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const operationType = String(body.type || "").toUpperCase();
|
|
const operationRequest = body.request || {};
|
|
const now = toSqlDateTime();
|
|
let operation;
|
|
|
|
if (operationType === "DISCOVERY") {
|
|
gateway.discovery_status = "PENDING";
|
|
operation = createOperation(gateway, "DISCOVERY", operationRequest, {
|
|
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,
|
|
},
|
|
],
|
|
});
|
|
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
|
|
operationId: operation.id,
|
|
fetchCount: 0,
|
|
device: {
|
|
id: (gateway.inventory?.length || 0) + 1,
|
|
device_id: "shelly-plus-new",
|
|
local_ip: "10.1.0.33",
|
|
model: "Shelly Plus 1PM",
|
|
channel_count: 1,
|
|
online: true,
|
|
capabilities: { generation: 2 },
|
|
},
|
|
};
|
|
} else if (operationType === "UPDATE") {
|
|
gateway.target_version = String(operationRequest.target_version || gateway.target_version || "");
|
|
gateway.staged_version = {
|
|
target_version: gateway.target_version,
|
|
staged_at: now,
|
|
apply_after: "2026-04-09T02:00:00+02:00",
|
|
status: "STAGED",
|
|
};
|
|
operation = createOperation(gateway, "UPDATE", operationRequest, {
|
|
status: "COMPLETED",
|
|
started_at: now,
|
|
completed_at: now,
|
|
updated_at: now,
|
|
summary: { label: "Completed", progress: 100, retryable: true },
|
|
result: {
|
|
applied: false,
|
|
installed_version: gateway.installed_version,
|
|
staged_version: gateway.target_version,
|
|
target_version: gateway.target_version,
|
|
apply_after: "2026-04-09T02:00:00+02:00",
|
|
update_window: "02:00-04:00",
|
|
restart_required: true,
|
|
rollback_status: gateway.rollback_status || { state: "IDLE" },
|
|
},
|
|
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,
|
|
},
|
|
],
|
|
});
|
|
} else if (operationType === "UNINSTALL") {
|
|
gateway.status = "OFFLINE";
|
|
gateway.metadata = {
|
|
...(gateway.metadata || {}),
|
|
uninstalled_at: now,
|
|
};
|
|
operation = createOperation(gateway, "UNINSTALL", operationRequest, {
|
|
status: "COMPLETED",
|
|
started_at: now,
|
|
completed_at: now,
|
|
updated_at: now,
|
|
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,
|
|
},
|
|
],
|
|
});
|
|
} else {
|
|
await route.fulfill(
|
|
json(
|
|
{ data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } },
|
|
422
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
gateway.audit_logs = [
|
|
{ id: Date.now(), created_at: now, action: "GATEWAY_OPERATION_QUEUED", actor_type: "USER" },
|
|
...(gateway.audit_logs || []),
|
|
];
|
|
invalidateEdgeGatewayRuntimeSnapshot(gateway);
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
data: {
|
|
operation: cloneJson(operation),
|
|
gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true),
|
|
},
|
|
},
|
|
201
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayRotateCredentialsPattern.test(pathname) && method === "POST") {
|
|
const gatewayId = extractMatchId(edgeGatewayRotateCredentialsPattern);
|
|
const gateway = findGateway(gatewayId);
|
|
if (!gateway) {
|
|
await route.fulfill(json({ message: "Gateway not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
const rotatedAt = toSqlDateTime();
|
|
gateway.metadata = {
|
|
...(gateway.metadata || {}),
|
|
credentials_rotated_at: rotatedAt,
|
|
};
|
|
gateway.audit_logs = [
|
|
{ id: Date.now(), created_at: rotatedAt, action: "GATEWAY_CREDENTIALS_ROTATED", actor_type: "USER" },
|
|
...(gateway.audit_logs || []),
|
|
];
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
gateway_id: gatewayId,
|
|
rotated_at: rotatedAt,
|
|
agent_token: "rotated-edge-agent-token",
|
|
config_json: JSON.stringify(
|
|
{
|
|
apiUrl: "https://api.truckwash.test",
|
|
gatewayId,
|
|
agentToken: "rotated-edge-agent-token",
|
|
installDir: "/opt/truckwash-edge-agent",
|
|
serviceName: "truckwash-edge-agent.service",
|
|
stackServiceName: "truckwash-edge-gateway-stack.service",
|
|
composeFileName: "docker-compose.gateway.yml",
|
|
composeProjectName: "truckwash-edge-gateway",
|
|
launcherScriptName: "gateway-launcher.sh",
|
|
runtimeDir: "/opt/truckwash-edge-agent/runtime",
|
|
stateDatabasePath: "/opt/truckwash-edge-agent/runtime/gateway-state.sqlite",
|
|
workerBaseUrl: "http://lan-worker:8090",
|
|
updateWindow: "02:00-04:00",
|
|
heartbeatIntervalSeconds: 15,
|
|
operationPollTimeoutSeconds: 20,
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
restart_instructions: [
|
|
"sudo systemctl restart truckwash-edge-gateway-stack.service",
|
|
"sudo systemctl status truckwash-edge-gateway-stack.service --no-pager",
|
|
"cd /opt/truckwash-edge-agent && sudo ./gateway-launcher.sh reconcile",
|
|
],
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayDiscoveryPattern.test(pathname) && method === "POST") {
|
|
const gatewayId = extractMatchId(edgeGatewayDiscoveryPattern);
|
|
const gateway = findGateway(gatewayId);
|
|
|
|
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,
|
|
},
|
|
],
|
|
}
|
|
);
|
|
gateway.discovery_status = "PENDING";
|
|
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
|
|
operationId: operation.id,
|
|
fetchCount: 0,
|
|
device: {
|
|
id: (gateway.inventory?.length || 0) + 1,
|
|
device_id: "shelly-plus-new",
|
|
local_ip: "10.1.0.33",
|
|
model: "Shelly Plus 1PM",
|
|
channel_count: 1,
|
|
online: true,
|
|
capabilities: { generation: 2 },
|
|
},
|
|
};
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
}
|
|
|
|
await route.fulfill(gatewayResponse(gateway, true));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayInstallTokenPattern.test(pathname) && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const claimTokenId = edgeGatewayFixture.nextClaimTokenId++;
|
|
const session = {
|
|
claim_token_id: claimTokenId,
|
|
department_id: Number(body.department_id || 1),
|
|
label: String(body.label || "").trim(),
|
|
expires_at: "2026-04-08 08:45:00",
|
|
status: "PENDING",
|
|
step: "PENDING",
|
|
message: "Installer command generated. Run it on the gateway host.",
|
|
started_at: null,
|
|
updated_at: null,
|
|
terminal: false,
|
|
gateway_id: null,
|
|
last_error: null,
|
|
diagnostics: [],
|
|
events: [],
|
|
claim_polls_remaining: Math.max(1, Number(edgeGatewayFixture.claimPollsRemaining || 1)),
|
|
reuse_gateway_id: Number(edgeGatewayFixture.reuseClaimGatewayId || 0),
|
|
};
|
|
pushEdgeGatewayInstallSessionEvent(session, session.status, session.step, session.message);
|
|
edgeGatewayFixture.installSessionsById[claimTokenId] = session;
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
data: {
|
|
claim_token_id: claimTokenId,
|
|
token: "edge-install-token",
|
|
expires_at: session.expires_at,
|
|
install_command:
|
|
"curl -fsSL https://api.truckwash.test/edge-agent/install.sh?token=edge-install-token | sudo bash",
|
|
install_url: "https://api.truckwash.test/edge-agent/install.sh?token=edge-install-token",
|
|
department_id: body.department_id ?? 1,
|
|
},
|
|
},
|
|
201
|
|
)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayInstallTokenStatusPattern.test(pathname) && method === "GET") {
|
|
const claimTokenId = extractMatchId(edgeGatewayInstallTokenStatusPattern);
|
|
const session = advanceEdgeGatewayInstallSession(edgeGatewayFixture, claimTokenId);
|
|
await route.fulfill(
|
|
session
|
|
? json({ data: buildEdgeGatewayInstallSessionResponse(session) })
|
|
: json({ message: "Install token not found" }, 404)
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayDetailPattern.test(pathname) && method === "PUT") {
|
|
const gatewayId = extractMatchId(edgeGatewayDetailPattern);
|
|
const gateway = findGateway(gatewayId);
|
|
const body = request.postDataJSON?.() || {};
|
|
|
|
if (gateway) {
|
|
gateway.label = String(body.label || gateway.label || "").trim() || gateway.label;
|
|
|
|
if (body.is_primary === true) {
|
|
edgeGatewayFixture.gateways
|
|
.filter((item) => Number(item.department_id) === Number(gateway.department_id))
|
|
.forEach((item) => {
|
|
item.is_primary = Number(item.id) === Number(gatewayId);
|
|
});
|
|
} else if (body.is_primary === false) {
|
|
gateway.is_primary = false;
|
|
}
|
|
|
|
gateway.audit_logs = [
|
|
{
|
|
id: Date.now(),
|
|
created_at: "2026-04-09 12:30:00",
|
|
action: "GATEWAY_METADATA_UPDATED",
|
|
actor_type: "USER",
|
|
},
|
|
...(gateway.audit_logs || []),
|
|
];
|
|
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
edgeGatewayFixture.gateways = edgeGatewayFixture.gateways.map((item) =>
|
|
Number(item.id) === Number(gateway.id) ? Object.assign(item, gateway) : item
|
|
);
|
|
}
|
|
|
|
await route.fulfill(gatewayResponse(gateway, true));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayBindingsPattern.test(pathname) && method === "PUT") {
|
|
const gatewayId = extractMatchId(edgeGatewayBindingsPattern);
|
|
const gateway = findGateway(gatewayId);
|
|
const body = request.postDataJSON?.() || {};
|
|
if (gateway) {
|
|
gateway.bindings = (body.bindings || []).map((binding, index) => ({
|
|
id: index + 1,
|
|
...binding,
|
|
fallback_mode: binding.fallback_mode || "PREFER_LOCAL",
|
|
}));
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
}
|
|
await route.fulfill(
|
|
json({ data: gateway ? buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) : null })
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayUnsupportedPattern.test(pathname)) {
|
|
await route.fulfill(json({ message: "Route not found" }, 404));
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayDeletePattern.test(pathname) && method === "DELETE") {
|
|
const gatewayId = extractMatchId(edgeGatewayDeletePattern);
|
|
const gateway = findGateway(gatewayId);
|
|
edgeGatewayFixture.gateways = edgeGatewayFixture.gateways.filter((item) => item.id !== gatewayId);
|
|
delete edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
deleted: true,
|
|
gateway_id: gatewayId,
|
|
department_id: gateway?.department_id ?? null,
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
if (edgeGatewayCutoverPattern.test(pathname) && method === "POST") {
|
|
const cutoverMatch = pathname.match(edgeGatewayCutoverPattern);
|
|
const departmentId = Number(cutoverMatch?.[1] || cutoverMatch?.[2] || 0);
|
|
const body = request.postDataJSON?.() || {};
|
|
edgeGatewayFixture.gateways
|
|
.filter((gateway) => gateway.department_id === departmentId)
|
|
.forEach((gateway) => {
|
|
gateway.department_transport_mode = body.transport_mode || "gateway";
|
|
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
|
|
});
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
department_id: departmentId,
|
|
transport_mode: body.transport_mode || "gateway",
|
|
},
|
|
})
|
|
);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export async function mockApi(page, options = {}) {
|
|
const shouldMockPosFixture = Boolean(options.pos || options.invoiceDistribution);
|
|
const posFixture = shouldMockPosFixture
|
|
? options.pos && options.pos !== true && options.pos.__isPosFixture
|
|
? options.pos
|
|
: createPosFixture(options.pos && options.pos !== true ? options.pos : {})
|
|
: null;
|
|
const selfServe = options.selfServe
|
|
? createSelfServeFixture(options.selfServe === true ? {} : options.selfServe)
|
|
: null;
|
|
const edgeGatewayOptions =
|
|
options.edgeGateways && typeof options.edgeGateways === "object" ? options.edgeGateways : {};
|
|
const edgeGatewayFixture = options.edgeGateways === false ? null : createHttpEdgeGatewayFixture(edgeGatewayOptions);
|
|
const notificationState = {
|
|
wash_certificate_email: null,
|
|
email_notifications_enabled: true,
|
|
sms_notifications_enabled: false,
|
|
superuser_new_customer_email_notifications_enabled: false,
|
|
...(options.sessionData?.notifications || {}),
|
|
};
|
|
|
|
await page.route(
|
|
/https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/6\.7\.1\/css\/all\.min\.css(?:[?#].*)?$/i,
|
|
async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "text/css",
|
|
body: FONT_AWESOME_CSS,
|
|
});
|
|
}
|
|
);
|
|
|
|
await page.route(
|
|
/https:\/\/cdnjs\.cloudflare\.com\/ajax\/libs\/font-awesome\/6\.7\.1\/webfonts\/[^/?#]+(?:[?#].*)?$/i,
|
|
async (route) => {
|
|
const filename = new URL(route.request().url()).pathname.split("/").pop();
|
|
const body = FONT_AWESOME_WEBFONTS.get(filename);
|
|
|
|
if (!body) {
|
|
await route.fulfill({
|
|
status: 404,
|
|
contentType: "text/plain",
|
|
body: "",
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "font/woff2",
|
|
body,
|
|
});
|
|
}
|
|
);
|
|
|
|
await page.route(/https:\/\/cdn\.example\.test\/.*$/i, async (route) => {
|
|
const request = route.request();
|
|
if (request.method() !== "GET") {
|
|
await route.continue();
|
|
return;
|
|
}
|
|
|
|
const url = new URL(request.url());
|
|
const orderAttachmentMatch = url.pathname.match(/^\/orders\/(\d+)\/attachments\/(\d+)$/i);
|
|
if (orderAttachmentMatch && posFixture) {
|
|
const orderId = Number(orderAttachmentMatch[1] || 0);
|
|
const attachmentId = Number(orderAttachmentMatch[2] || 0);
|
|
const attachment =
|
|
(posFixture.attachmentsByOrderId?.[orderId] || []).find((entry) => Number(entry.id) === attachmentId) || null;
|
|
const previewResponse = getAttachmentPreviewContentType(attachment);
|
|
|
|
await route.fulfill(binary(previewResponse.body, previewResponse.contentType));
|
|
return;
|
|
}
|
|
|
|
const previewResponse = getAttachmentPreviewContentType({
|
|
content: { other: url.pathname.split("/").pop() || "" },
|
|
});
|
|
|
|
await route.fulfill(binary(previewResponse.body, previewResponse.contentType));
|
|
});
|
|
|
|
await page.route(API_HOST, async (route) => {
|
|
const request = route.request();
|
|
const url = request.url();
|
|
const parsedUrl = new URL(url);
|
|
const pathname = parsedUrl.pathname;
|
|
const method = request.method();
|
|
|
|
if (url.includes("/auth/recaptcha/pre-check") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
recaptcha: {
|
|
enabled: false,
|
|
site_key: "",
|
|
},
|
|
rate_limit: {
|
|
enabled: false,
|
|
limit: 0,
|
|
remaining: 0,
|
|
reset: 0,
|
|
warning: null,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (url.includes("/auth/reCAPTCHA/public") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
recaptcha: {
|
|
enabled: false,
|
|
site_key: "",
|
|
},
|
|
rate_limit: {
|
|
enabled: false,
|
|
limit: 0,
|
|
remaining: 0,
|
|
reset: 0,
|
|
warning: null,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/ping") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
ok: true,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/worker/version") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
version: options.workerVersion || "unknown",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (url.includes("/auth/login") && method === "POST") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
token: options.loginToken || "e2e-token",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (url.includes("/auth/session") && method === "GET") {
|
|
if (!options.authenticated) {
|
|
await route.fulfill(json({ message: "Unauthenticated" }, 401));
|
|
return;
|
|
}
|
|
|
|
const defaultSession = {
|
|
id: 1,
|
|
customer_number: 12345,
|
|
group_id: 1,
|
|
email: "e2e@example.com",
|
|
phone: {
|
|
number: "12345678",
|
|
country_code: 45,
|
|
},
|
|
notifications: {
|
|
...notificationState,
|
|
},
|
|
created_at: "2026-01-01T00:00:00.000Z",
|
|
updated_at: "2026-01-01T00:00:00.000Z",
|
|
display_name: "E2E User",
|
|
permissions: options.permissions || ["user"],
|
|
economic_customer: [],
|
|
runtime_config: {
|
|
economic: {
|
|
transaction_draft_customer_number: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
const sessionData = {
|
|
...defaultSession,
|
|
...(options.sessionData || {}),
|
|
};
|
|
|
|
if (options.permissions) {
|
|
sessionData.permissions = options.permissions;
|
|
}
|
|
sessionData.notifications = {
|
|
...(sessionData.notifications || {}),
|
|
...notificationState,
|
|
};
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: sessionData,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/account/notifications") && method === "PUT") {
|
|
const body = request.postDataJSON?.() || {};
|
|
for (const key of [
|
|
"wash_certificate_email",
|
|
"email_notifications_enabled",
|
|
"sms_notifications_enabled",
|
|
"superuser_new_customer_email_notifications_enabled",
|
|
]) {
|
|
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
|
notificationState[key] = body[key];
|
|
}
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
message: "User notification settings updated",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/guest/validation/customer-number") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
const customerNumber = Number(body.customer_number || 0);
|
|
const sessionCustomerNumber = Number(options.sessionData?.customer_number || 0);
|
|
const exists = Boolean(
|
|
customerNumber &&
|
|
(customerNumber === sessionCustomerNumber ||
|
|
customerNumber === Number(posFixture?.defaultCustomer?.customerNumber || 0) ||
|
|
posFixture?.customersByNumber?.[customerNumber])
|
|
);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
exists,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/workfeed/config")) {
|
|
const workfeedConfig = [
|
|
{ module: "workfeed", variable: "enabled", type: "bool", value: true },
|
|
{ module: "workfeed", variable: "api_url", type: "string", value: "https://api.workfeed.test" },
|
|
{ module: "workfeed", variable: "api_key", type: "string", value: "test-api-key" },
|
|
{ module: "workfeed", variable: "CompanyID", type: "string", value: "123456" },
|
|
];
|
|
|
|
if (method === "GET") {
|
|
const variable = parsedUrl.searchParams.get("variable");
|
|
if (variable) {
|
|
const match = workfeedConfig.find((entry) => entry.variable === variable);
|
|
await route.fulfill(json({ data: match ? match.value : null }));
|
|
return;
|
|
}
|
|
await route.fulfill(json({ data: workfeedConfig }));
|
|
return;
|
|
}
|
|
|
|
if (method === "POST") {
|
|
await route.fulfill(json({ data: { updated: true } }));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/workfeed/departments") && method === "GET") {
|
|
const departments = [
|
|
{
|
|
id: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
|
|
name: "North Facility",
|
|
timezone: "Europe/Copenhagen",
|
|
active: true,
|
|
},
|
|
{
|
|
id: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N4",
|
|
name: "South Facility",
|
|
timezone: "Europe/Copenhagen",
|
|
active: true,
|
|
},
|
|
];
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
items: departments,
|
|
pagination: { cursor: null, nextCursor: null, limit: 20, total: departments.length },
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/workfeed/employees") && method === "GET") {
|
|
const employees = [
|
|
{
|
|
id: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
|
|
firstName: "Anne",
|
|
lastName: "Nielsen",
|
|
fullName: "Anne Nielsen",
|
|
email: "anne.nielsen@example.com",
|
|
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
|
|
active: true,
|
|
},
|
|
{
|
|
id: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5Q",
|
|
firstName: "Mads",
|
|
lastName: "Jensen",
|
|
fullName: "Mads Jensen",
|
|
email: "mads.jensen@example.com",
|
|
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N4",
|
|
active: true,
|
|
},
|
|
];
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
items: employees,
|
|
pagination: { cursor: null, nextCursor: null, limit: 20, total: employees.length },
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (/\/modules\/workfeed\/employees\/[^/]+$/i.test(pathname) && method === "GET") {
|
|
const id = pathname.split("/").pop();
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
id,
|
|
firstName: "Anne",
|
|
lastName: "Nielsen",
|
|
fullName: "Anne Nielsen",
|
|
email: "anne.nielsen@example.com",
|
|
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
|
|
active: true,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/workfeed/shifts") && method === "GET") {
|
|
const startFrom = parsedUrl.searchParams.get("startFrom");
|
|
const startTo = parsedUrl.searchParams.get("startTo");
|
|
const employeeID = parsedUrl.searchParams.get("employeeID");
|
|
const releasedRaw = parsedUrl.searchParams.get("released");
|
|
|
|
if (!startFrom) {
|
|
await route.fulfill(json({ message: "Missing required query parameter: startFrom" }, 422));
|
|
return;
|
|
}
|
|
if (!startTo) {
|
|
await route.fulfill(json({ message: "Missing required query parameter: startTo" }, 422));
|
|
return;
|
|
}
|
|
|
|
const shifts = [
|
|
{
|
|
id: "shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
|
|
title: "Morning Shift",
|
|
status: "published",
|
|
employeeID: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
|
|
released: true,
|
|
startAt: "2026-03-24T06:00:00Z",
|
|
endAt: "2026-03-24T14:00:00Z",
|
|
},
|
|
{
|
|
id: "shf_01J5P3X9D35R9C6BDZ1S0R4V6R",
|
|
title: "Evening Shift",
|
|
status: "draft",
|
|
employeeID: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5Q",
|
|
released: false,
|
|
startAt: "2026-03-24T14:00:00Z",
|
|
endAt: "2026-03-24T22:00:00Z",
|
|
},
|
|
];
|
|
const released = releasedRaw === null ? undefined : releasedRaw.toLowerCase() === "true";
|
|
|
|
const filtered = shifts.filter((entry) => {
|
|
if (employeeID && entry.employeeID !== employeeID) {
|
|
return false;
|
|
}
|
|
if (released !== undefined && entry.released !== released) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
items: filtered,
|
|
pagination: { cursor: null, nextCursor: null, limit: 20, total: filtered.length },
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (/\/modules\/workfeed\/shifts\/[^/]+$/i.test(pathname) && method === "GET") {
|
|
const id = pathname.split("/").pop();
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
id,
|
|
title: "Morning Shift",
|
|
status: "published",
|
|
employeeID: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
|
|
released: true,
|
|
startAt: "2026-03-24T06:00:00Z",
|
|
endAt: "2026-03-24T14:00:00Z",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
await handleEdgeGatewayRoute({
|
|
route,
|
|
request,
|
|
parsedUrl,
|
|
pathname,
|
|
method,
|
|
edgeGatewayFixture,
|
|
selfServe,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (await handlePosRoute({ route, request, parsedUrl, pathname, method, posFixture })) {
|
|
return;
|
|
}
|
|
|
|
if (selfServe) {
|
|
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
|
|
await route.fulfill(json({ data: toPublicTimeBookingDepartments(selfServe.departments) }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/guest/departments") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.departments }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments") && method === "GET") {
|
|
await route.fulfill(
|
|
json({ data: selfServe.departments.map(({ lanes: _lanes, ...department }) => department) })
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/products") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.products }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/xlvask/internal/vehicle-types") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.vehicleTypes }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/vehicles") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.customerVehicles }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/machine-types") && method === "GET") {
|
|
const id = parsedUrl.searchParams.get("id");
|
|
if (id) {
|
|
const machineType = (selfServe.machineTypes || []).find((entry) => Number(entry.id) === Number(id)) || null;
|
|
await route.fulfill(
|
|
machineType ? json({ data: machineType }) : json({ message: "Machine type not found" }, 404)
|
|
);
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: selfServe.machineTypes || [] }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/conditions") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.conditions || [] }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/condition/rules") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.rules || [] }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/questions") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.questions || [{ id: 11, question: "Is the tarp removed?" }] }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/tasks") && method === "GET") {
|
|
await route.fulfill(json({ data: selfServe.tasks || [] }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes/relay-options") && method === "GET") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
await route.fulfill(json({ data: cloneJson(hardware?.relayOptions || []) }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes") && method === "GET") {
|
|
const laneId = parsedUrl.searchParams.get("id");
|
|
|
|
if (laneId) {
|
|
await route.fulfill(json({ data: selfServe.laneById[laneId] || null }));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: selfServe.departmentLanes }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes") && method === "PUT") {
|
|
const hardware = ensureEdgeGatewayHardwareFixture(edgeGatewayFixture);
|
|
const body = request.postDataJSON?.() || {};
|
|
const laneId = Number(body.id || 0);
|
|
const lane = findEdgeGatewayHardwareLane(hardware, laneId);
|
|
|
|
if (!lane) {
|
|
await route.fulfill(json({ message: "Department lane not found" }, 404));
|
|
return;
|
|
}
|
|
|
|
[
|
|
"relay_in_id",
|
|
"relay_out_id",
|
|
"relay_machine_id",
|
|
"relay_machine_program_picker_id",
|
|
"relay_machine_cleaner_id",
|
|
].forEach((field) => {
|
|
if (Object.prototype.hasOwnProperty.call(body, field)) {
|
|
lane[field] = normalizeMockRelaySelection(body[field]);
|
|
}
|
|
});
|
|
if (Object.prototype.hasOwnProperty.call(body, "selfserve_enabled")) {
|
|
lane.selfserve_enabled = normalizeMockSelfServeEnabled(body.selfserve_enabled);
|
|
const fixtureLane = selfServe.laneById[String(laneId)];
|
|
if (fixtureLane) {
|
|
fixtureLane.selfserve_enabled = lane.selfserve_enabled;
|
|
}
|
|
}
|
|
|
|
await route.fulfill(json({ success: true, data: cloneJson(lane) }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/vehicle/allowed") && method === "GET") {
|
|
const laneId = parsedUrl.searchParams.get("lane_id");
|
|
const reg = (parsedUrl.searchParams.get("reg") || "").toUpperCase();
|
|
const previewData = selfServe.previewByKey[`${laneId}:${reg}`] || null;
|
|
await maybeDelayFixtureResponse(selfServe.previewResponseDelayMs);
|
|
await route.fulfill(
|
|
json({ data: previewData || { allowed: false, questions: [], tasks: [], conditions: [], rules: [] } })
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/washes/summary") && method === "GET") {
|
|
const sessionId = parsedUrl.searchParams.get("session_id");
|
|
const laneId = parsedUrl.searchParams.get("lane_id");
|
|
const reg = (parsedUrl.searchParams.get("reg") || "").toUpperCase();
|
|
|
|
const summaryData =
|
|
(sessionId ? selfServe.summaryBySessionId[sessionId] : null) ||
|
|
selfServe.summaryByKey[`${laneId}:${reg}`] ||
|
|
null;
|
|
|
|
await maybeDelayFixtureResponse(selfServe.summaryResponseDelayMs);
|
|
await route.fulfill(
|
|
json({ data: summaryData || { events: [], questions: [], tasks: [], conditions: [], rules: [] } })
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/vehicle/conditions") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe.answerRequests.push(cloneJson(body));
|
|
const key = `${body.lane}:${String(body.reg || "").toUpperCase()}:${body.question}:${body.value}`;
|
|
const responseSummary = selfServe.answerResponseByKey[key] || null;
|
|
|
|
await maybeDelayFixtureResponse(selfServe.answerResponseDelayMs);
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
selfserve: responseSummary,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash") && method === "GET") {
|
|
const customerNumber = Number(options.sessionData?.customer_number || 0);
|
|
const details = Object.values(selfServe.inProgressByLaneId || {}).find((entry) => {
|
|
const entryCustomerNumber = Number(entry?.session?.customer_number ?? entry?.customer?.customer_number ?? 0);
|
|
return Boolean(entry?.in_progress) && customerNumber > 0 && entryCustomerNumber === customerNumber;
|
|
});
|
|
await route.fulfill(
|
|
json({
|
|
data: details || {
|
|
lane_id: null,
|
|
in_progress: false,
|
|
session: null,
|
|
customer: null,
|
|
vehicle: null,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
|
|
const laneId = parsedUrl.searchParams.get("lane_id");
|
|
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
|
|
await route.fulfill(
|
|
json({
|
|
data: details || {
|
|
lane_id: Number(laneId || 0),
|
|
in_progress: false,
|
|
session: null,
|
|
customer: null,
|
|
vehicle: null,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/tasks/attachments") && method === "GET") {
|
|
const taskId = parsedUrl.searchParams.get("id");
|
|
await route.fulfill(
|
|
json({
|
|
data: selfServe.attachmentsByTaskId[taskId] || [],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/selfserve/tasks/attachments/download") && method === "GET") {
|
|
const taskId = parsedUrl.searchParams.get("task_id");
|
|
const attachmentId = parsedUrl.searchParams.get("attachment_id");
|
|
await route.fulfill(
|
|
json(selfServe.attachmentDownloadByKey[`${taskId}:${attachmentId}`] || { download_link: null })
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/sessions") && method === "GET") {
|
|
const filterMap = parseFilterExpressions(parsedUrl.searchParams.get("filters") || "");
|
|
const search = String(parsedUrl.searchParams.get("search") || "")
|
|
.trim()
|
|
.toUpperCase();
|
|
const openOnly = ["1", "true", "yes", "on"].includes(
|
|
String(parsedUrl.searchParams.get("open_only") || "").toLowerCase()
|
|
);
|
|
const [orderBy = "id", orderDirection = "DESC"] = String(
|
|
parsedUrl.searchParams.get("order") || "id:DESC"
|
|
).split(":");
|
|
|
|
const filteredSessions = (selfServe.sessions || [])
|
|
.filter((session) => {
|
|
if (filterMap.department_id && Number(session.department_id) !== Number(filterMap.department_id)) {
|
|
return false;
|
|
}
|
|
if (filterMap.lane_id && Number(session.lane_id) !== Number(filterMap.lane_id)) {
|
|
return false;
|
|
}
|
|
if (filterMap.status && String(session.status || "") !== String(filterMap.status)) {
|
|
return false;
|
|
}
|
|
if (
|
|
openOnly &&
|
|
(session.completed_at ||
|
|
["COMPLETED", "FORCE_STOPPED"].includes(String(session.status || "").toUpperCase()))
|
|
) {
|
|
return false;
|
|
}
|
|
if (search) {
|
|
return [session.id, session.reg, session.customer_number].some((value) =>
|
|
String(value || "")
|
|
.toUpperCase()
|
|
.includes(search)
|
|
);
|
|
}
|
|
return true;
|
|
})
|
|
.sort((left, right) => {
|
|
const leftValue = left?.[orderBy];
|
|
const rightValue = right?.[orderBy];
|
|
if (leftValue === rightValue) {
|
|
return 0;
|
|
}
|
|
const result = leftValue > rightValue ? 1 : -1;
|
|
return String(orderDirection).toUpperCase() === "ASC" ? result : -result;
|
|
});
|
|
|
|
const { rows, meta } = paginateRows(
|
|
filteredSessions,
|
|
parsedUrl.searchParams.get("page") || 1,
|
|
parsedUrl.searchParams.get("limit") || 25
|
|
);
|
|
|
|
await route.fulfill(json({ data: rows, meta }));
|
|
return;
|
|
}
|
|
|
|
const selfServeSessionDetailMatch = pathname.match(/\/modules\/self-serve\/sessions\/(\d+)$/);
|
|
if (selfServeSessionDetailMatch && method === "GET") {
|
|
const sessionId = selfServeSessionDetailMatch[1];
|
|
const detail = selfServe.sessionDetailsById?.[sessionId] || null;
|
|
await route.fulfill(detail ? json({ data: detail }) : json({ message: "Session not found" }, 404));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/force/stop") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe.forceStopRequests.push(cloneJson(body));
|
|
const sessionId = Number(body.session_id || 0);
|
|
const session = (selfServe.sessions || []).find((entry) => Number(entry.id) === sessionId) || null;
|
|
const completedAt = toSqlDateTime("2026-04-28T10:30:00Z");
|
|
const orderId = body.bill ? 8801 : null;
|
|
|
|
if (session) {
|
|
session.status = "FORCE_STOPPED";
|
|
session.completed_at = completedAt;
|
|
session.open = false;
|
|
session.order_id = orderId;
|
|
|
|
const detail = selfServe.sessionDetailsById[String(session.id)] || {
|
|
session,
|
|
lane: selfServe.laneById[String(session.lane_id)] || null,
|
|
machine_type: null,
|
|
questions: [],
|
|
tasks: [],
|
|
events: [],
|
|
config_version_id: null,
|
|
evaluation_trace: null,
|
|
};
|
|
detail.session = session;
|
|
detail.events = [
|
|
...(detail.events || []),
|
|
{
|
|
id: 9000 + Number(session.id),
|
|
type: "SESSION_FORCE_STOPPED",
|
|
payload: {
|
|
lane_id: Number(body.lane_id || session.lane_id),
|
|
bill: Boolean(body.bill),
|
|
reason: body.reason || null,
|
|
order_id: orderId,
|
|
},
|
|
created_at: completedAt,
|
|
},
|
|
];
|
|
selfServe.sessionDetailsById[String(session.id)] = detail;
|
|
}
|
|
|
|
const forceStopResponse =
|
|
Array.isArray(selfServe.forceStopResponses) && selfServe.forceStopResponses.length > 0
|
|
? selfServe.forceStopResponses.shift()
|
|
: selfServe.forceStopResponse;
|
|
|
|
await route.fulfill(
|
|
json(
|
|
forceStopResponse || {
|
|
success: true,
|
|
data: {
|
|
lane_id: Number(body.lane_id || 0),
|
|
forced: true,
|
|
bill: Boolean(body.bill),
|
|
order_id: orderId,
|
|
session: session ? selfServe.sessionDetailsById[String(session.id)] : null,
|
|
runtime_before_reset: {
|
|
status: "OCCUPIED",
|
|
state: "IN_WASH",
|
|
elapsed_wash_time: 1200,
|
|
},
|
|
},
|
|
}
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/services/allowed") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe.allowedServiceRequests.push(cloneJson(body));
|
|
const laneSpecificServices = selfServe.laneAllowedServicesByLane?.[String(body.lane_id || body.lane || "")];
|
|
const allowedServicesResponse =
|
|
Array.isArray(selfServe.laneAllowedServiceResponses) && selfServe.laneAllowedServiceResponses.length > 0
|
|
? selfServe.laneAllowedServiceResponses.shift()
|
|
: selfServe.laneAllowedServiceResponse;
|
|
await route.fulfill(
|
|
json(
|
|
allowedServicesResponse || {
|
|
data: {
|
|
allowed_services: laneSpecificServices || selfServe.laneAllowedServices,
|
|
},
|
|
}
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/relay/machine/enable") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe.relayRequests.push({
|
|
path: pathname,
|
|
method,
|
|
action: "enable",
|
|
relay: "machine",
|
|
body: cloneJson(body),
|
|
});
|
|
const relayResponse =
|
|
Array.isArray(selfServe.relayResponses) && selfServe.relayResponses.length > 0
|
|
? selfServe.relayResponses.shift()
|
|
: selfServe.relayResponse;
|
|
await route.fulfill(json(relayResponse));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/modules/self-serve/lane/command") && method === "POST") {
|
|
const body = request.postDataJSON?.() || {};
|
|
selfServe.commandRequests.push(cloneJson(body));
|
|
const commandResponse =
|
|
Array.isArray(selfServe.commandResponses) && selfServe.commandResponses.length > 0
|
|
? selfServe.commandResponses.shift()
|
|
: selfServe.commandResponse;
|
|
const commandResponseDelayMs = Array.isArray(selfServe.commandResponseDelayMs)
|
|
? selfServe.commandResponseDelayMs.shift() || 0
|
|
: selfServe.commandResponseDelayMs;
|
|
await maybeDelayFixtureResponse(commandResponseDelayMs);
|
|
await route.fulfill(json(commandResponse));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/department/lanes/dynamic-image") && method === "GET") {
|
|
const laneId = parsedUrl.searchParams.get("lane");
|
|
const dynamicImageDelayMs = Array.isArray(selfServe.dynamicImageDelayMs)
|
|
? selfServe.dynamicImageDelayMs.shift() || 0
|
|
: selfServe.dynamicImageDelayMs;
|
|
await maybeDelayFixtureResponse(dynamicImageDelayMs);
|
|
await route.fulfill(binary(selfServe.dynamicImagesByLaneId?.[String(laneId || "")] || selfServe.dynamicImage));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (pathname.endsWith("/department/timebookings/departments/public") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: toPublicTimeBookingDepartments([
|
|
{ id: 1, name: "Copenhagen", bookingsystem_time_based_enabled: true },
|
|
{ id: 2, name: "Odense", bookingsystem_time_based_enabled: true },
|
|
]),
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/departments") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: [
|
|
{ id: 1, name: "Copenhagen" },
|
|
{ id: 2, name: "Odense" },
|
|
],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/user/invoices") && method === "GET") {
|
|
const invoiceFixture = posFixture || { collectedInvoices: [] };
|
|
const customerNumber = Number(options.sessionData?.customer_number || 0);
|
|
const collectedInvoices = (invoiceFixture.collectedInvoices || []).filter((invoice) => {
|
|
if (!customerNumber) {
|
|
return true;
|
|
}
|
|
|
|
return Number(invoice.customer_number) === customerNumber;
|
|
});
|
|
const page = Number(parsedUrl.searchParams.get("page") || 1);
|
|
const limit = Number(parsedUrl.searchParams.get("limit") || collectedInvoices.length || 100);
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: collectedInvoices.slice((page - 1) * limit, page * limit),
|
|
meta: {
|
|
pagination: {
|
|
page,
|
|
per_page: limit,
|
|
total: collectedInvoices.length,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/invoices/pdf") && method === "GET") {
|
|
const invoiceId = String(parsedUrl.searchParams.get("id") || "").trim();
|
|
if (!invoiceId) {
|
|
await route.fulfill(json({ message: "Invoice not found" }, 404));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
url: `https://pdf.example.test/invoices/${invoiceId}.pdf`,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/collected-invoices") && method === "GET") {
|
|
const invoiceFixture = posFixture || { collectedInvoices: [] };
|
|
const filters = parseFilterExpressions(parsedUrl.searchParams.get("filters") || "");
|
|
const customerNumberFilter = Number(filters.customer_number || 0);
|
|
const collectedInvoices = (invoiceFixture.collectedInvoices || []).filter((invoice) => {
|
|
if (!customerNumberFilter) {
|
|
return true;
|
|
}
|
|
|
|
return Number(invoice.customer_number) === customerNumberFilter;
|
|
});
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: collectedInvoices,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/collected-invoices") && method === "POST") {
|
|
const invoiceFixture = posFixture || {
|
|
collectedInvoices: [],
|
|
customersByNumber: {},
|
|
nextCollectedInvoiceId: 200,
|
|
};
|
|
const body = request.postDataJSON?.() || {};
|
|
const invoiceId = Number(invoiceFixture.nextCollectedInvoiceId || 200);
|
|
const customerNumber = Number(body.customer_number || 0);
|
|
const customer = invoiceFixture.customersByNumber?.[customerNumber] || null;
|
|
|
|
invoiceFixture.nextCollectedInvoiceId = invoiceId + 1;
|
|
invoiceFixture.collectedInvoices = [
|
|
{
|
|
id: invoiceId,
|
|
customer_number: customerNumber,
|
|
customer_name: customer?.name || `Customer ${invoiceId}`,
|
|
total_net_amount: 0,
|
|
created_at: body.closed_at || toSqlDateTime().slice(0, 10),
|
|
closed_at: body.closed_at || null,
|
|
},
|
|
...(invoiceFixture.collectedInvoices || []),
|
|
];
|
|
|
|
await route.fulfill(
|
|
json({
|
|
success: true,
|
|
data: {
|
|
id: invoiceId,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (options.invoiceDistribution) {
|
|
const monthFromDate = parsedUrl.searchParams.get("dateFrom");
|
|
const monthNumber = monthFromDate ? Number(monthFromDate.split("-")[1]) : 1;
|
|
const monthBase = Number.isFinite(monthNumber) ? monthNumber * 10 : 10;
|
|
const firstOrderCreatedAt =
|
|
typeof options.invoiceDistributionFirstOrderDate === "string" &&
|
|
options.invoiceDistributionFirstOrderDate.trim()
|
|
? options.invoiceDistributionFirstOrderDate
|
|
: "2026-01-01T00:00:00.000Z";
|
|
const forceDistributionLegacy = Boolean(options.invoiceDistributionForceLegacyFallback);
|
|
const forceCompareLegacy = Boolean(options.invoiceDistributionForceCompareFallback);
|
|
|
|
if (pathname.endsWith("/orders") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: [
|
|
{
|
|
id: 1,
|
|
created_at: firstOrderCreatedAt,
|
|
},
|
|
],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
types: {
|
|
all: [
|
|
{
|
|
transactions: [
|
|
{ amount: 100 + monthBase, booked: true, excluded: false },
|
|
{ amount: 50 + monthBase, booked: true, excluded: false },
|
|
{ amount: 25, booked: false, excluded: false },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/all") && method === "GET") {
|
|
if (forceDistributionLegacy) {
|
|
await route.fulfill(json({ message: "v2 distribution temporarily unavailable" }, 500));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
fixed_pricing: {
|
|
customers: [
|
|
{
|
|
id: 10,
|
|
customer_number: 1001,
|
|
customer_name: "Acme Transport",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 1,
|
|
date: "2026-01-10T00:00:00.000Z",
|
|
amount: 80 + monthBase,
|
|
booked: true,
|
|
department_id: 1,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
fixed_pricing: {
|
|
created_at: "2026-01-10T00:00:00.000Z",
|
|
price: 80 + monthBase,
|
|
original_price: 120 + monthBase,
|
|
department_totals_relative: { 1: 40 + monthBase, 2: 40 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_fixed_price: 80 + monthBase,
|
|
total_department_totals_relative_parsed: {
|
|
Copenhagen: 40 + monthBase,
|
|
Odense: 40,
|
|
},
|
|
},
|
|
warnings: [],
|
|
},
|
|
wash_subscriptions: {
|
|
customers: [
|
|
{
|
|
id: 11,
|
|
customer_number: 1002,
|
|
customer_name: "Nordic Haul",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 2,
|
|
date: "2026-01-05T00:00:00.000Z",
|
|
amount: 40 + monthBase,
|
|
booked: true,
|
|
department_id: 1,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
wash_subscription: {
|
|
created_at: "2026-01-05T00:00:00.000Z",
|
|
price: 40 + monthBase,
|
|
original_price: 55 + monthBase,
|
|
department_totals_relative: { 1: 20 + monthBase, 2: 20 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_subscription_price: 40 + monthBase,
|
|
subscription_price_department_distribution_parsed: {
|
|
Copenhagen: 20 + monthBase,
|
|
Odense: 20,
|
|
},
|
|
},
|
|
warnings: [],
|
|
},
|
|
customer_prices: {
|
|
customers: [
|
|
{
|
|
id: 12,
|
|
customer_number: 1003,
|
|
customer_name: "Discount Fleet",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 3,
|
|
date: "2026-01-08T00:00:00.000Z",
|
|
amount: 15 + monthBase,
|
|
booked: true,
|
|
department_id: 2,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
customer_price: {
|
|
created_at: "2026-01-08T00:00:00.000Z",
|
|
price: 15 + monthBase,
|
|
department_totals_relative: { 2: 15 + monthBase },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_customer_price: 15 + monthBase,
|
|
customer_price_department_distribution_parsed: {
|
|
Odense: 15 + monthBase,
|
|
},
|
|
},
|
|
warnings: [],
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/fixed-pricing") && method === "GET") {
|
|
if (forceDistributionLegacy) {
|
|
await route.fulfill(json({ message: "v2 fixed pricing distribution temporarily unavailable" }, 500));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
customers: [
|
|
{
|
|
id: 10,
|
|
customer_number: 1001,
|
|
customer_name: "Acme Transport",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 1,
|
|
date: "2026-01-10T00:00:00.000Z",
|
|
amount: 80 + monthBase,
|
|
booked: true,
|
|
department_id: 1,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
fixed_pricing: {
|
|
created_at: "2026-01-10T00:00:00.000Z",
|
|
price: 80 + monthBase,
|
|
original_price: 120 + monthBase,
|
|
department_totals_relative: { 1: 40 + monthBase, 2: 40 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_fixed_price: 80 + monthBase,
|
|
total_department_totals_relative_parsed: {
|
|
Copenhagen: 40 + monthBase,
|
|
Odense: 40,
|
|
},
|
|
},
|
|
warnings: [],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/wash-subscriptions") && method === "GET") {
|
|
if (forceDistributionLegacy) {
|
|
await route.fulfill(json({ message: "v2 wash subscriptions distribution temporarily unavailable" }, 500));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
customers: [
|
|
{
|
|
id: 11,
|
|
customer_number: 1002,
|
|
customer_name: "Nordic Haul",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 2,
|
|
date: "2026-01-05T00:00:00.000Z",
|
|
amount: 40 + monthBase,
|
|
booked: true,
|
|
department_id: 1,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
wash_subscription: {
|
|
created_at: "2026-01-05T00:00:00.000Z",
|
|
price: 40 + monthBase,
|
|
original_price: 55 + monthBase,
|
|
department_totals_relative: { 1: 20 + monthBase, 2: 20 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_subscription_price: 40 + monthBase,
|
|
subscription_price_department_distribution_parsed: {
|
|
Copenhagen: 20 + monthBase,
|
|
Odense: 20,
|
|
},
|
|
},
|
|
warnings: [],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/v2/customer-prices") && method === "GET") {
|
|
if (forceDistributionLegacy) {
|
|
await route.fulfill(json({ message: "v2 customer prices distribution temporarily unavailable" }, 500));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
customers: [
|
|
{
|
|
id: 12,
|
|
customer_number: 1003,
|
|
customer_name: "Discount Fleet",
|
|
requires_action: false,
|
|
transactions: [
|
|
{
|
|
id: 3,
|
|
date: "2026-01-08T00:00:00.000Z",
|
|
amount: 15 + monthBase,
|
|
booked: true,
|
|
department_id: 2,
|
|
excluded: false,
|
|
},
|
|
],
|
|
meta: {
|
|
customer_price: {
|
|
created_at: "2026-01-08T00:00:00.000Z",
|
|
price: 15 + monthBase,
|
|
department_totals_relative: { 2: 15 + monthBase },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
collective_results: {
|
|
total_customer_price: 15 + monthBase,
|
|
customer_price_department_distribution_parsed: {
|
|
Odense: 15 + monthBase,
|
|
},
|
|
},
|
|
warnings: [],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/fixed-pricing") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: [
|
|
{
|
|
id: 10,
|
|
customer_number: 1001,
|
|
customer_name: "Acme Transport",
|
|
meta: {
|
|
fixed_pricing: {
|
|
created_at: "2026-01-10T00:00:00.000Z",
|
|
price: 80 + monthBase,
|
|
original_price: 120 + monthBase,
|
|
department_totals_relative: { 1: 40 + monthBase, 2: 40 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
includes: {
|
|
collective_fixed_pricing_results: {
|
|
total_fixed_price: 80 + monthBase,
|
|
total_department_totals_relative_parsed: {
|
|
Copenhagen: 40 + monthBase,
|
|
Odense: 40,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/invoicing/period/distribution/wash-subscriptions") && method === "GET") {
|
|
await route.fulfill(
|
|
json({
|
|
data: [
|
|
{
|
|
id: 11,
|
|
customer_number: 1002,
|
|
customer_name: "Nordic Haul",
|
|
meta: {
|
|
wash_subscription: {
|
|
created_at: "2026-01-05T00:00:00.000Z",
|
|
price: 40 + monthBase,
|
|
original_price: 55 + monthBase,
|
|
department_totals_relative: { 1: 20 + monthBase, 2: 20 },
|
|
},
|
|
},
|
|
},
|
|
],
|
|
includes: {
|
|
collective_subscription_results: {
|
|
total_subscription_price: 40 + monthBase,
|
|
subscription_price_department_distribution_parsed: {
|
|
Copenhagen: 20 + monthBase,
|
|
Odense: 20,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/collected-invoices/economic/compare") && method === "GET") {
|
|
const invoiceId = Number(parsedUrl.searchParams.get("collected_invoice_id") || 0);
|
|
const mismatch = invoiceId === 101 || invoiceId === 103;
|
|
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
collected_invoice_id: invoiceId,
|
|
warnings: mismatch ? ["Line mismatch found"] : [],
|
|
internal_total: mismatch ? 150 : 120,
|
|
booked_total: mismatch ? 145 : 120,
|
|
draft_total: null,
|
|
difference: mismatch ? 5 : 0,
|
|
order_ids: mismatch ? [1, 2] : [3],
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/collected-invoices/economic/v2/compare/bulk") && method === "POST") {
|
|
if (forceCompareLegacy) {
|
|
await route.fulfill(json({ message: "v2 compare bulk temporarily unavailable" }, 500));
|
|
return;
|
|
}
|
|
|
|
const body = request.postDataJSON?.() || {};
|
|
const ids = Array.isArray(body.collected_invoice_ids) ? body.collected_invoice_ids : [];
|
|
const results = ids.map((invoiceId) => {
|
|
const mismatch = Number(invoiceId) === 101 || Number(invoiceId) === 103;
|
|
const internalTotal = mismatch ? 150 : 120;
|
|
const targetTotal = mismatch ? 145 : 120;
|
|
|
|
return {
|
|
collected_invoice_id: Number(invoiceId),
|
|
warnings: mismatch ? ["Line mismatch found"] : [],
|
|
details: {
|
|
order_ids: mismatch ? [1, 2] : [3],
|
|
customer: {
|
|
internal_customer_number: 4000 + Number(invoiceId),
|
|
name: `Customer ${invoiceId}`,
|
|
},
|
|
internal: {
|
|
normalized: {
|
|
totals: {
|
|
net_total: internalTotal,
|
|
billable_line_count: mismatch ? 2 : 1,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
comparison: {
|
|
totals: {
|
|
internal_net_total: internalTotal,
|
|
},
|
|
targets: {
|
|
booked: {
|
|
target: "booked",
|
|
status: mismatch ? "partial_mismatch" : "exact_match",
|
|
overall_match: !mismatch,
|
|
totals: {
|
|
target_net_total: targetTotal,
|
|
},
|
|
mismatch_reasons: mismatch ? ["department_total_mismatch"] : [],
|
|
warnings: mismatch ? ["Line mismatch found"] : [],
|
|
lines: {
|
|
summary: {
|
|
internal_billable_count: mismatch ? 2 : 1,
|
|
target_billable_count: mismatch ? 2 : 1,
|
|
mismatch_count: mismatch ? 1 : 0,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
warnings: mismatch ? ["comparison warning"] : [],
|
|
},
|
|
};
|
|
});
|
|
|
|
await route.fulfill(
|
|
json({
|
|
requested: ids.length,
|
|
compared: ids.length,
|
|
failed: 0,
|
|
results,
|
|
errors: [],
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (
|
|
selfServe &&
|
|
options.fallbackPassthrough &&
|
|
!options.allowUnsafeSelfServePassthroughForLive &&
|
|
isUnsafeSelfServePassthrough(pathname, method)
|
|
) {
|
|
await route.fulfill(
|
|
json(
|
|
{
|
|
message: "Blocked unsafe self-serve API passthrough in mocked test mode.",
|
|
method,
|
|
path: pathname,
|
|
},
|
|
599
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (options.fallbackPassthrough) {
|
|
await route.fallback();
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: [] }));
|
|
});
|
|
|
|
return { selfServe, edgeGatewayFixture, posFixture };
|
|
}
|
|
|
|
export async function seedAuthenticatedState(page, token = "e2e-token") {
|
|
const writeSessionState = (value) => {
|
|
window.localStorage.setItem("token", value);
|
|
window.localStorage.setItem("lastVersionCheck", String(Date.now()));
|
|
};
|
|
|
|
await page.addInitScript(writeSessionState, token);
|
|
|
|
try {
|
|
await page.evaluate(writeSessionState, token);
|
|
} catch {
|
|
// The page may not have a document yet. addInitScript will seed storage on the next navigation.
|
|
}
|
|
}
|
|
|
|
export async function primeMockSession(page, { token = "e2e-token", bootPath = "/redirect" } = {}) {
|
|
await seedAuthenticatedState(page, token);
|
|
|
|
if (!bootPath) {
|
|
return;
|
|
}
|
|
|
|
const sessionRequest = page
|
|
.waitForResponse(
|
|
(response) => {
|
|
return response.request().method() === "GET" && response.url().includes("/auth/session");
|
|
},
|
|
{ timeout: 30_000 }
|
|
)
|
|
.catch(() => null);
|
|
await page.goto(bootPath, { waitUntil: "domcontentloaded" });
|
|
await sessionRequest;
|
|
}
|