import { mockApi, primeMockSession } from "./network.js"; export const API_HOST = /https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i; export const DEFAULT_DEPARTMENT_ID = 1; export const DEFAULT_ORDER_ID = 9201; export const DEFAULT_LAST_ORDER_ID = 9100; export const DEFAULT_BOOKING_ID = 8101; export const REGULAR_CUSTOMER_ID = 12345; export const CARD_CUSTOMER_ID = 999; export const WASH_CERTIFICATE_PRODUCT_ID = 41; const EXTRAORDINARY_CHEMISTRY_PRODUCT_ID = 27; const EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME = "Ekstraordinær pr. 10 min inkl. kemi"; const AUDITED_ORDER_ITEM_PRODUCT_IDS = new Set([21, 22, 25, 26, 27]); export const MOBILE_PERMISSIONS = ["admin", "department_access_1"]; export const MOBILE_NEXT_STEP_COOLDOWN_MS = 2100; const ATTACHMENT_DOWNLOAD_URL = "https://cdn.example.test/mobile-pos"; export function getByActionKey(scope, actionKey) { return scope.locator(`[data-action-key="${actionKey}"]`); } export function json(body, status = 200) { return { status, contentType: "application/json", body: JSON.stringify(body), }; } function clone(value) { if (value === undefined) { return undefined; } return JSON.parse(JSON.stringify(value)); } function toPositiveInteger(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : null; } function normalizeRegistrationValue(value) { if (value === null || value === undefined) { return ""; } return String(value) .trim() .toUpperCase() .replace(/[^A-Z0-9]/g, ""); } function normalizeSafetySealValue(value) { if (value === null || value === undefined) { return ""; } return String(value).trim(); } function createCustomer(customerNumber, overrides = {}) { return { id: customerNumber, customerNumber, name: `Customer ${customerNumber}`, address: "Demo Street 1", zip: "2630", city: "Taastrup", mobilePhone: "12345678", email: `customer-${customerNumber}@example.com`, corporateIdentificationNumber: String(customerNumber).padStart(8, "0"), barred: false, economic_customer: customerNumber, ...overrides, }; } function createAddon(product, overrides = {}) { return { id: product.id, name: product.name, price: Number(product.price ?? 0), product: { ...clone(product), addons: [], }, quantity: 0, min: -1, max: -1, ...overrides, }; } function createOrderRecord(orderId, overrides = {}) { return { id: orderId, customer_id: REGULAR_CUSTOMER_ID, department_id: DEFAULT_DEPARTMENT_ID, reference: "", notes: "", po: "", safety_seal: "", reg_1: "AB12345", reg_2: "", reg_3: "", invoice_collection_id: null, booking_id: null, completed_at: null, created_at: "2026-01-01T10:00:00.000Z", ...overrides, }; } function buildOrderItem(product, overrides = {}, id = null) { const orderId = toPositiveInteger(overrides.order_id) ?? DEFAULT_ORDER_ID; return { id, order_id: orderId, product_id: Number(overrides.product_id ?? product.id), product: { ...clone(product), addons: clone(product.addons || []), }, quantity: Number(overrides.quantity ?? 1), notes: String(overrides.notes ?? ""), reference: String(overrides.reference ?? ""), related_item_id: overrides.related_item_id ?? null, price: Number(overrides.price ?? product.price ?? 0), reason_code: String(overrides.reason_code ?? ""), reason_label_snapshot: String(overrides.reason_label_snapshot ?? ""), reason_comment: String(overrides.reason_comment ?? ""), }; } function createBookingRecord(id, overrides = {}) { return { id, customer_number: REGULAR_CUSTOMER_ID, department: DEFAULT_DEPARTMENT_ID, reg_1: "BOOK123", reg_2: "", reg_3: "", reference: `BOOK-${id}`, notes: `Booking notes ${id}`, po: `PO-${id}`, order_id: null, status: "pending", items: [], created_at: "2026-01-01T09:00:00.000Z", ...overrides, }; } function buildDefaultProducts() { const products = [ { id: 53, name: "Tank truck wash", description: "Primary mobile wash product", price: 599, 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: "Box trailer wash", description: "Alternate mobile wash product", price: 499, subscription_allowed: true, category: 4, piktogram: "truck", apply_category_discount: true, requires_note: false, is_wash: true, display_in_booking_form: true, order_priority: 2, addons: [], }, { id: 71, name: "Interior rinse", description: "Mobile addon", price: 99, subscription_allowed: true, category: 8, piktogram: "spray", apply_category_discount: false, requires_note: false, is_wash: false, display_in_booking_form: true, order_priority: 1, addons: [], }, { id: 41, name: "Safety seal certificate", description: "Wash certificate", 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: 2, addons: [], }, { id: 81, name: "Tank inspection", description: "Non-wash booking product", price: 150, subscription_allowed: true, category: 8, piktogram: "clipboard", apply_category_discount: false, requires_note: false, is_wash: false, display_in_booking_form: true, order_priority: 3, addons: [], }, { id: 91, name: "Extra detergent", description: "Additional service", price: 75, subscription_allowed: true, category: 8, piktogram: "sparkles", apply_category_discount: false, requires_note: false, is_wash: false, display_in_booking_form: true, order_priority: 4, addons: [], }, ]; const byId = Object.fromEntries(products.map((product) => [product.id, product])); byId[53].addons = [createAddon(byId[71], { min: 0, max: 3 }), createAddon(byId[41], { min: 0, max: 1 })]; byId[63].addons = [createAddon(byId[71], { min: 0, max: 2 })]; return clone(products); } function createRequestCounters(overrides = {}) { return { readersGet: 0, paymentIntentGet: 0, paymentIntentCreate: 0, paymentIntentDelete: 0, paymentIntentCapture: 0, stripeInvoiceSend: 0, stripeInvoiceDelete: 0, departmentsGet: 0, departmentCategoriesGet: 0, bookingsGet: 0, orderBookingsGet: 0, bookingComplete: 0, bookingSetOrderId: 0, vehiclesSearchGet: 0, vehiclesGet: 0, unknownVehiclesGet: 0, vehicleCustomerSuggestionsGet: 0, usersCustomerGet: 0, customersGet: 0, customerNotesGet: 0, customerAttributesGet: 0, discountsGet: 0, productsGet: 0, recommendedGet: 0, ordersGet: 0, orderGet: 0, orderCreate: 0, orderUpdate: 0, orderDelete: 0, orderItemsGet: 0, orderItemsPost: 0, orderItemsPut: 0, orderItemsDelete: 0, markAsCompleted: 0, bookingCompletionConfirmationEmail: 0, attachmentsGet: 0, attachmentsDelete: 0, attachmentUpload: 0, attachmentDownload: 0, cvrSearchGet: 0, customerRegistrationPost: 0, lprPost: 0, ...overrides, }; } function createRequestLog(overrides = {}) { return { orderCreates: [], orderUpdates: [], orderDeletes: [], orderItemCreates: [], orderItemUpdates: [], orderItemDeletes: [], bookingCompletions: [], bookingCompletionConfirmationEmails: [], bookingOrderAssignments: [], attachmentUploads: [], attachmentDeletes: [], stripeInvoiceSends: [], stripeInvoiceDeletes: [], cvrSearches: [], customerRegistrations: [], ...overrides, }; } function isWashCertificateProduct(product) { const productId = Number(product?.id ?? product?.product_id ?? product?.product?.id ?? 0); if (productId === WASH_CERTIFICATE_PRODUCT_ID) { return true; } return /vaskecertifikat|wash certificate|safety seal/i.test(String(product?.name ?? product?.product?.name ?? "")); } function isEnabledFlag(value) { return value === true || value === 1 || value === "1" || value === "true"; } function productRequiresOrderItemNote(product) { if (!product) { return false; } const productId = Number(product?.product?.id ?? product?.product_id ?? product?.id ?? 0); return ( isEnabledFlag(product?.requires_note ?? product?.product?.requires_note) || AUDITED_ORDER_ITEM_PRODUCT_IDS.has(productId) || productId === EXTRAORDINARY_CHEMISTRY_PRODUCT_ID || String(product?.product?.name ?? product?.name ?? "").trim() === EXTRAORDINARY_CHEMISTRY_PRODUCT_NAME ); } function orderContainsWashCertificate(fixture, orderId) { return (fixture.orderItemsByOrderId[orderId] || []).some((item) => isWashCertificateProduct(item?.product || item)); } function ensureWashCertificateAttachment(fixture, orderId) { if (!Array.isArray(fixture.attachmentsByOrderId[orderId])) { fixture.attachmentsByOrderId[orderId] = []; } const existingAttachment = (fixture.attachmentsByOrderId[orderId] || []).find((attachment) => { return String(attachment?.content?.other || "").toUpperCase() === "WASH_CERTIFICATE"; }) || null; if (existingAttachment) { return existingAttachment; } const attachmentId = fixture.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: new Date().toISOString(), updated_at: new Date().toISOString(), deleted_at: null, }; fixture.attachmentsByOrderId[orderId].push(attachment); return attachment; } function createFailureBudget(overrides = {}) { return { paymentIntentGet: 0, paymentIntentCreate: 0, paymentIntentCapture: 0, orderCreate: 0, bookingComplete: 0, attachmentUpload: 0, orderDelete: 0, customerAttributesGet: 0, orderItemCreate: 0, // Per-product-id order-item creation failure budget. Keys are product // ids, values are how many POST /order/items calls for that product // should be rejected with 400. Used by regression tests for the mobile // POS step 2 partial-sync rollback (see // tests/unit/pos-mobile-step-2-addon-sync.spec.js for the matching // unit-level contract). orderItemCreateForProductId: {}, ...overrides, }; } function mergeObjectMaps(base, overrides = {}) { const result = {}; Object.entries(base || {}).forEach(([key, value]) => { result[key] = clone(value); }); Object.entries(overrides || {}).forEach(([key, value]) => { result[key] = clone(value); }); return result; } function buildDefaultFixture() { const products = buildDefaultProducts(); const productById = Object.fromEntries(products.map((product) => [product.id, product])); const regularCustomer = createCustomer(REGULAR_CUSTOMER_ID, { name: "Pleno Logistics", email: "pos-mobile@example.com", }); const cardCustomer = createCustomer(CARD_CUSTOMER_ID, { name: "Card Terminal Customer", email: "card-customer@example.com", corporateIdentificationNumber: "99999999", }); const lastOrder = createOrderRecord(DEFAULT_LAST_ORDER_ID, { customer_id: REGULAR_CUSTOMER_ID, reference: "LAST-ORDER-REF", reg_1: "AB12345", created_at: "2026-01-01T08:30:00.000Z", }); const cardOrder = createOrderRecord(DEFAULT_ORDER_ID, { customer_id: CARD_CUSTOMER_ID, reference: "CARD-REF-9201", reg_1: "AB12345", created_at: "2026-01-01T10:00:00.000Z", }); const lastOrderPrimary = buildOrderItem( productById[53], { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, }, 9001 ); const lastOrderAddon = buildOrderItem( productById[71], { order_id: DEFAULT_LAST_ORDER_ID, quantity: 1, related_item_id: 9001, }, 9002 ); const bookingPrimary = createBookingRecord(DEFAULT_BOOKING_ID, { reg_1: "BOOK123", reference: "BOOKING-REF-8101", notes: "Booking notes from planner", po: "PO-8101", items: [ { id: 53, name: productById[53].name, price: productById[53].price, quantity: 1 }, { id: 71, name: productById[71].name, price: productById[71].price, quantity: 1 }, ], }); const nonWashBooking = createBookingRecord(8102, { reg_1: "NOWASH1", reference: "BOOKING-REF-8102", notes: "Needs wash fallback", po: "PO-8102", items: [ { id: 81, name: productById[81].name, price: productById[81].price, quantity: 1 }, { id: 71, name: productById[71].name, price: productById[71].price, quantity: 1 }, ], }); const safetySealBooking = createBookingRecord(8103, { reg_1: "SEAL123", reference: "BOOKING-REF-8103", notes: "Requires safety seal", po: "PO-8103", items: [ { id: 53, name: productById[53].name, price: productById[53].price, quantity: 1 }, { id: 41, name: productById[41].name, price: productById[41].price, quantity: 1 }, ], }); const defaultCvrSearchResponses = { 41004355: { data: { vat: 41004355, status: "Normal", name: "Truckwash ApS", address: "Letland Alle 2", zipcode: 2630, city: "Taastrup", phone: "21754690", email: "mikkel@truckwash.dk", }, }, 43423010: { data: { vat: 43423010, status: "Normal", name: "Wash Group ApS", address: "Nordhavn 4", zipcode: 2100, city: "Kobenhavn O", phone: "42331128", email: "billing@wash-group.test", }, }, }; return { departmentId: DEFAULT_DEPARTMENT_ID, cardCustomerId: CARD_CUSTOMER_ID, regularCustomerId: REGULAR_CUSTOMER_ID, departments: [ { id: DEFAULT_DEPARTMENT_ID, name: "Taastrup", }, ], failureBudget: createFailureBudget(), requestCounters: createRequestCounters(), requestLog: createRequestLog(), customersByNumber: { [REGULAR_CUSTOMER_ID]: regularCustomer, [CARD_CUSTOMER_ID]: cardCustomer, }, customerAttributesByNumber: { [REGULAR_CUSTOMER_ID]: [], [CARD_CUSTOMER_ID]: [ { id: 1, customer_number: CARD_CUSTOMER_ID, attribute: "invoiceWithStripe", }, ], }, customerNotesByNumber: { [REGULAR_CUSTOMER_ID]: [ { id: 1, note: "Customer note for mobile POS", }, ], }, employees: [ { id: 7, display_name: "Jeppe", }, ], products, departmentCategories: [ { id: 11, department_id: DEFAULT_DEPARTMENT_ID, category: { id: 4, name: "Wash", meta: { products: [53, 63], }, }, }, { id: 12, department_id: DEFAULT_DEPARTMENT_ID, category: { id: 8, name: "Extras", meta: { products: [71, 41, 81, 91], }, }, }, ], vehicles: [ { id: 7001, reg: "AB12345", customer_id: REGULAR_CUSTOMER_ID, customer_name: regularCustomer.name, type: 53, status: "verified", barred: false, wash_subscription: false, addons: { enabled: 1, available: 2, list: [71, 41], }, reference: "REF-AB12345", last_order_id: DEFAULT_LAST_ORDER_ID, }, { id: 7002, reg: "BOOK123", customer_id: REGULAR_CUSTOMER_ID, customer_name: regularCustomer.name, type: null, status: "booked", barred: false, wash_subscription: false, addons: { enabled: 1, available: 2, list: [71], }, reference: "BOOKING-REF-8101", booking_id: DEFAULT_BOOKING_ID, last_order_id: null, }, { id: 7003, reg: "NOWASH1", customer_id: REGULAR_CUSTOMER_ID, customer_name: regularCustomer.name, type: null, status: "booked", barred: false, wash_subscription: false, addons: { enabled: 1, available: 2, list: [71], }, reference: "BOOKING-REF-8102", booking_id: 8102, last_order_id: null, }, { id: 7004, reg: "SEAL123", customer_id: REGULAR_CUSTOMER_ID, customer_name: regularCustomer.name, type: null, status: "booked", barred: false, wash_subscription: false, addons: { enabled: 1, available: 2, list: [41], }, reference: "BOOKING-REF-8103", booking_id: 8103, last_order_id: null, }, ], unknownVehicles: [ { reg_1: "ZZ00000", }, ], ordersById: { [DEFAULT_LAST_ORDER_ID]: lastOrder, [DEFAULT_ORDER_ID]: cardOrder, }, orderItemsByOrderId: { [DEFAULT_LAST_ORDER_ID]: [lastOrderPrimary, lastOrderAddon], [DEFAULT_ORDER_ID]: [], }, attachmentsByOrderId: { [DEFAULT_LAST_ORDER_ID]: [ { id: 301, content: { other: "last-order-note.pdf", }, }, ], [DEFAULT_ORDER_ID]: [], }, stripeModuleOrdersByOrderId: { [DEFAULT_ORDER_ID]: {}, }, paymentIntentsByOrderId: {}, readers: [ { id: "reader_online_1", label: "Mobile Reader", status: "online", action: null, }, ], bookingsById: { [DEFAULT_BOOKING_ID]: bookingPrimary, 8102: nonWashBooking, 8103: safetySealBooking, }, cvrSearchResponses: defaultCvrSearchResponses, vehicleCustomerSuggestionsByReg: {}, customerRegistrationResponse: { status: 200, data: { success: true, }, }, pendingBookings: null, bookingStatusById: { [DEFAULT_BOOKING_ID]: "pending", 8102: "pending", 8103: "pending", }, deletedOrderIds: [], markCompletedOrderIds: [], nextOrderId: 9300, nextOrderItemId: 9800, nextAttachmentId: 400, }; } function normalizeFixture(fixture) { fixture.products = Array.isArray(fixture.products) ? fixture.products : []; fixture.vehicles = Array.isArray(fixture.vehicles) ? fixture.vehicles : []; fixture.departments = Array.isArray(fixture.departments) ? fixture.departments : []; fixture.departmentCategories = Array.isArray(fixture.departmentCategories) ? fixture.departmentCategories : []; fixture.employees = Array.isArray(fixture.employees) ? fixture.employees : []; fixture.requestCounters = createRequestCounters(fixture.requestCounters); fixture.requestLog = createRequestLog(fixture.requestLog); fixture.failureBudget = createFailureBudget(fixture.failureBudget); fixture.customersByNumber = mergeObjectMaps({}, fixture.customersByNumber); fixture.customerAttributesByNumber = mergeObjectMaps({}, fixture.customerAttributesByNumber); fixture.customerNotesByNumber = mergeObjectMaps({}, fixture.customerNotesByNumber); fixture.vehicleCustomerSuggestionsByReg = mergeObjectMaps({}, fixture.vehicleCustomerSuggestionsByReg); fixture.ordersById = mergeObjectMaps({}, fixture.ordersById); fixture.orderItemsByOrderId = mergeObjectMaps({}, fixture.orderItemsByOrderId); fixture.attachmentsByOrderId = mergeObjectMaps({}, fixture.attachmentsByOrderId); fixture.stripeModuleOrdersByOrderId = mergeObjectMaps({}, fixture.stripeModuleOrdersByOrderId); fixture.paymentIntentsByOrderId = mergeObjectMaps({}, fixture.paymentIntentsByOrderId); fixture.bookingsById = mergeObjectMaps({}, fixture.bookingsById); fixture.cvrSearchResponses = mergeObjectMaps({}, fixture.cvrSearchResponses); fixture.customerRegistrationResponse = clone( fixture.customerRegistrationResponse || { status: 200, data: { success: true } } ); fixture.deletedOrderIds = Array.isArray(fixture.deletedOrderIds) ? fixture.deletedOrderIds : []; fixture.markCompletedOrderIds = Array.isArray(fixture.markCompletedOrderIds) ? fixture.markCompletedOrderIds : []; if (!fixture.pendingBookings) { fixture.pendingBookings = Object.values(fixture.bookingsById).filter((booking) => !booking.order_id); } else { fixture.pendingBookings = clone(fixture.pendingBookings); } if (!fixture.bookingStatusById) { fixture.bookingStatusById = {}; } Object.values(fixture.bookingsById).forEach((booking) => { fixture.bookingStatusById[booking.id] = booking.status || fixture.bookingStatusById[booking.id] || "pending"; }); return fixture; } export function createMobilePosFixture(overrides = {}) { const base = buildDefaultFixture(); const fixture = { ...base, ...clone(overrides), departments: overrides.departments ? clone(overrides.departments) : clone(base.departments), products: overrides.products ? clone(overrides.products) : clone(base.products), departmentCategories: overrides.departmentCategories ? clone(overrides.departmentCategories) : clone(base.departmentCategories), employees: overrides.employees ? clone(overrides.employees) : clone(base.employees), vehicles: overrides.vehicles ? clone(overrides.vehicles) : clone(base.vehicles), unknownVehicles: overrides.unknownVehicles ? clone(overrides.unknownVehicles) : clone(base.unknownVehicles), failureBudget: createFailureBudget(overrides.failureBudget), requestCounters: createRequestCounters(overrides.requestCounters), requestLog: createRequestLog(overrides.requestLog), customersByNumber: mergeObjectMaps(base.customersByNumber, overrides.customersByNumber), customerAttributesByNumber: mergeObjectMaps(base.customerAttributesByNumber, overrides.customerAttributesByNumber), customerNotesByNumber: mergeObjectMaps(base.customerNotesByNumber, overrides.customerNotesByNumber), vehicleCustomerSuggestionsByReg: mergeObjectMaps( base.vehicleCustomerSuggestionsByReg, overrides.vehicleCustomerSuggestionsByReg ), ordersById: mergeObjectMaps(base.ordersById, overrides.ordersById), orderItemsByOrderId: mergeObjectMaps(base.orderItemsByOrderId, overrides.orderItemsByOrderId), attachmentsByOrderId: mergeObjectMaps(base.attachmentsByOrderId, overrides.attachmentsByOrderId), stripeModuleOrdersByOrderId: mergeObjectMaps( base.stripeModuleOrdersByOrderId, overrides.stripeModuleOrdersByOrderId ), paymentIntentsByOrderId: mergeObjectMaps(base.paymentIntentsByOrderId, overrides.paymentIntentsByOrderId), bookingsById: mergeObjectMaps(base.bookingsById, overrides.bookingsById), cvrSearchResponses: mergeObjectMaps(base.cvrSearchResponses, overrides.cvrSearchResponses), customerRegistrationResponse: clone(overrides.customerRegistrationResponse ?? base.customerRegistrationResponse), bookingStatusById: mergeObjectMaps(base.bookingStatusById, overrides.bookingStatusById), deletedOrderIds: clone(overrides.deletedOrderIds ?? base.deletedOrderIds), markCompletedOrderIds: clone(overrides.markCompletedOrderIds ?? base.markCompletedOrderIds), }; return normalizeFixture(fixture); } function getProductById(fixture, productId) { return fixture.products.find((product) => Number(product.id) === Number(productId)) || null; } function createStateProductSnapshot(fixture, productId) { const product = getProductById(fixture, productId); return product ? clone(product) : null; } export function createAttachmentFile(name, id = null) { return { id, filename: name, base64String: `data:text/plain;base64,${Buffer.from(name).toString("base64")}`, }; } export function buildMobilePosState(options = {}) { const fixture = options.fixture || createMobilePosFixture(); const customerId = Object.prototype.hasOwnProperty.call(options, "customerId") ? options.customerId : fixture.regularCustomerId ?? REGULAR_CUSTOMER_ID; const primaryItemId = options.primaryItemId ?? 53; const includePrimaryItem = options.includePrimaryItem ?? true; const vehicleType = Object.prototype.hasOwnProperty.call(options, "vehicleType") ? options.vehicleType : includePrimaryItem ? primaryItemId : 53; const bookingId = options.bookingId ?? null; const reference = options.reference ?? (bookingId ? `BOOKING-${bookingId}` : "REF-9201"); const primaryItem = Object.prototype.hasOwnProperty.call(options, "primaryItem") ? clone(options.primaryItem) : includePrimaryItem ? createStateProductSnapshot(fixture, primaryItemId) : null; return { vehicles: { vehicle_1: { reg: options.reg ?? "AB12345", reg_2: options.reg2 ?? "", reg_3: options.reg3 ?? "", customer_id: customerId, type: vehicleType, status: options.vehicleStatus ?? (bookingId ? "booked" : "verified"), barred: false, booking_id: bookingId, booking_matches: Object.prototype.hasOwnProperty.call(options, "bookingMatches") ? clone(options.bookingMatches) : [], addons: [], reference, last_order_id: Object.prototype.hasOwnProperty.call(options, "lastOrderId") ? options.lastOrderId : DEFAULT_LAST_ORDER_ID, }, vehicle_2: options.vehicle_2 ?? null, vehicle_3: options.vehicle_3 ?? null, activeVehicleIndex: options.activeVehicleIndex ?? 1, }, views: { manualInput: options.manualInput ?? false, vehicleSelection: options.vehicleSelection ?? false, additionalItemSelection: options.additionalItemSelection ?? false, transactionHistoryView: options.transactionHistoryView ?? false, }, transactionItems: { primaryItem, additionalItems: Object.prototype.hasOwnProperty.call(options, "additionalItems") ? clone(options.additionalItems) : [], }, categories: { list: clone(options.categoriesList ?? []), selected: clone(options.selectedCategory ?? null), }, productList: { list: clone(options.productList ?? []), }, metadata: { customerId, notes: options.notes ?? "", reference, safetySeal: options.safetySeal ?? "", washId: options.washId ?? null, bookingId, bookingSelectionSkippedPlate: options.bookingSelectionSkippedPlate ?? "", laneId: options.laneId ?? null, }, attachments: { files: Object.prototype.hasOwnProperty.call(options, "attachmentFiles") ? clone(options.attachmentFiles) : [], base64: Object.prototype.hasOwnProperty.call(options, "attachmentsBase64") ? clone(options.attachmentsBase64) : [], wash_certificate: options.washCertificate ?? false, }, transactionHistory: clone(options.transactionHistory ?? []), lastVehicleOrders: clone( options.lastVehicleOrders ?? { vehicle_1: null, vehicle_2: null, vehicle_3: null, } ), timestamp: options.timestamp ?? Date.now(), }; } export function seedMobilePosState(page, optionsOrSnapshot = {}) { const snapshot = optionsOrSnapshot?.vehicles ? clone(optionsOrSnapshot) : buildMobilePosState(optionsOrSnapshot); return page.addInitScript((payload) => { window.localStorage.setItem("pos", JSON.stringify(payload)); }, snapshot); } export function seedStoredPosOrderId(page, orderId) { return page.addInitScript((value) => { if (value) { window.localStorage.setItem("pos_order_id", String(value)); return; } window.localStorage.removeItem("pos_order_id"); }, orderId); } export function getStoredPosSnapshot(page) { return page.evaluate(() => { const storedValue = window.localStorage.getItem("pos"); return storedValue ? JSON.parse(storedValue) : null; }); } export function suppressVueDevtoolsOverlay(page) { return page.addInitScript(() => { const selectors = [ "#__vue-devtools-container__", ".vue-devtools__anchor-btn", ".vue-devtools__panel", ".vue-devtools__panel-content", ]; const hideDevtools = () => { const root = document.documentElement; if (root && !root.querySelector("style[data-test-hide-vue-devtools='1']")) { const style = document.createElement("style"); style.setAttribute("data-test-hide-vue-devtools", "1"); style.textContent = `${selectors.join( ", " )} { display: none !important; visibility: hidden !important; pointer-events: none !important; opacity: 0 !important; }`; root.appendChild(style); } selectors.forEach((selector) => { document.querySelectorAll(selector).forEach((element) => { element.setAttribute("aria-hidden", "true"); element.style.setProperty("display", "none", "important"); element.style.setProperty("visibility", "hidden", "important"); element.style.setProperty("pointer-events", "none", "important"); element.style.setProperty("opacity", "0", "important"); }); }); }; hideDevtools(); document.addEventListener("DOMContentLoaded", hideDevtools); new MutationObserver(hideDevtools).observe(document, { childList: true, subtree: true }); }); } export function waitForMobileNextStepCooldown(page, timeout = MOBILE_NEXT_STEP_COOLDOWN_MS) { return page.waitForTimeout(timeout); } export async function primeOperatorSession(page, { token } = {}) { await primeMockSession(page, { token }); } export function createPaymentIntent( orderId, { status = "requires_capture", amount = 123400, readerId = "reader_online_1", taxPercentage = 25 } = {} ) { const isSucceeded = status === "succeeded"; return { id: `pi_${orderId}`, amount, amount_capturable: isSucceeded ? 0 : amount, amount_received: isSucceeded ? amount : 0, currency: "dkk", status, payment_method_types: ["card_present"], created: Math.floor(Date.now() / 1000), metadata: { order_id: String(orderId), reader_id: String(readerId), reader: String(readerId), tax_percentage: String(taxPercentage), }, }; } function getStripeInvoiceAmount(fixture, orderId) { return (fixture.orderItemsByOrderId[orderId] || []).reduce((sum, item) => { return sum + Number(item?.price || 0) * Number(item?.quantity || 1); }, 0); } function isStripeInvoiceTerminalStatus(status) { return ["paid", "void", "uncollectible", "deleted"].includes(String(status || "")); } export function createStripeModuleOrder( orderId, { customerId = CARD_CUSTOMER_ID, invoiceId = `in_${orderId}`, status = "open", paid = false, amountDue = 0, amountPaid = 0, url = null, } = {} ) { return { id: orderId, invoice_id: invoiceId, customer_id: customerId, url: url || `https://stripe.example.test/invoices/${invoiceId}`, created_at: new Date().toISOString(), paid, status, amount_due: amountDue, amount_paid: amountPaid, }; } export function paymentIntentResponse(paymentIntent, extra = {}) { return { success: true, data: { payment_intent: paymentIntent || null, has_payment_intent: !!paymentIntent, ...extra, }, }; } function extractRequestBody(request) { try { return request.postDataJSON?.() || {}; } catch { return {}; } } function extractOrderId(request, parsedUrl) { const queryId = toPositiveInteger(parsedUrl.searchParams.get("id")); if (queryId) { return queryId; } const body = extractRequestBody(request); return toPositiveInteger(body.id ?? body.order_id); } function removeBookingFromPending(fixture, bookingId) { fixture.pendingBookings = fixture.pendingBookings.filter((booking) => Number(booking.id) !== Number(bookingId)); } function upsertPendingBooking(fixture, booking) { const existingIndex = fixture.pendingBookings.findIndex((entry) => Number(entry.id) === Number(booking.id)); if (booking.order_id) { if (existingIndex >= 0) { fixture.pendingBookings.splice(existingIndex, 1); } return; } if (existingIndex >= 0) { fixture.pendingBookings[existingIndex] = clone(booking); return; } fixture.pendingBookings.push(clone(booking)); } function filterBookings(bookings, filters) { if (!filters) { return bookings; } const entries = String(filters) .split(",") .map((entry) => entry.split(":")) .filter(([key, value]) => key && value !== undefined); return bookings.filter((booking) => { return entries.every(([key, value]) => { if (key === "department") { return Number(booking.department) === Number(value); } if (key === "order_id") { if (value === "null" || value === "is null") { return !booking.order_id; } return Number(booking.order_id) === Number(value); } if (key === "reg_1") { return String(booking.reg_1 || "").toUpperCase() === String(value || "").toUpperCase(); } if (key === "status") { return String(booking.status || "").toLowerCase() === String(value || "").toLowerCase(); } return true; }); }); } function toOrderBookingListEntry(booking, stripDetails = false) { if (!stripDetails || !booking || typeof booking !== "object") { return booking; } const summaryBooking = { ...booking }; delete summaryBooking.items; delete summaryBooking.parsed_services; return summaryBooking; } function recordCounter(fixture, key) { fixture.requestCounters[key] = Number(fixture.requestCounters[key] || 0) + 1; } function recordLog(fixture, key, value) { if (!Array.isArray(fixture.requestLog[key])) { fixture.requestLog[key] = []; } fixture.requestLog[key].push(clone(value)); } function recordBookingCompletionConfirmationEmail(fixture, booking, safetySeal) { if (!booking) { return; } const customerNumber = Number(booking.customer_number || 0); const customer = fixture.customersByNumber[customerNumber] || {}; const normalizedSafetySeal = normalizeSafetySealValue(safetySeal); recordCounter(fixture, "bookingCompletionConfirmationEmail"); recordLog(fixture, "bookingCompletionConfirmationEmails", { booking_id: Number(booking.id), order_id: toPositiveInteger(booking.order_id), customer_number: customerNumber || null, recipient: String(booking.contact_email || booking.wash_certificate_email || customer.email || "").trim(), safety_seal: normalizedSafetySeal, has_safety_seal: normalizedSafetySeal !== "", }); } export async function mockMobilePosApi(page, fixture) { await page.route(API_HOST, async (route) => { const request = route.request(); const parsedUrl = new URL(request.url()); const pathname = parsedUrl.pathname; const method = request.method(); const body = extractRequestBody(request); if (pathname.endsWith("/modules/stripe/department/terminal/readers") && method === "GET") { recordCounter(fixture, "readersGet"); await route.fulfill( json({ success: true, data: { data: clone(fixture.readers), }, }) ); return; } if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "GET") { recordCounter(fixture, "paymentIntentGet"); if (fixture.failureBudget.paymentIntentGet > 0) { fixture.failureBudget.paymentIntentGet -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to load payment intent state", }, }, 500 ) ); return; } const orderId = extractOrderId(request, parsedUrl); await route.fulfill( json( paymentIntentResponse(orderId ? fixture.paymentIntentsByOrderId[orderId] || null : null, { message: "No active payment intent for this order.", }) ) ); return; } if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "POST") { recordCounter(fixture, "paymentIntentCreate"); if (fixture.failureBudget.paymentIntentCreate > 0) { fixture.failureBudget.paymentIntentCreate -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to create payment intent", }, }, 500 ) ); return; } const orderId = toPositiveInteger(body.id); const readerId = body.reader || "reader_online_1"; const existingIntent = fixture.paymentIntentsByOrderId[orderId] || null; const nextIntent = existingIntent || createPaymentIntent(orderId, { status: "requires_capture", readerId, taxPercentage: Number(body.tax_percentage ?? 25), }); nextIntent.metadata = { ...(nextIntent.metadata || {}), order_id: String(orderId), reader_id: String(readerId), reader: String(readerId), tax_percentage: String(body.tax_percentage ?? nextIntent.metadata?.tax_percentage ?? 25), }; fixture.paymentIntentsByOrderId[orderId] = nextIntent; await route.fulfill( json( paymentIntentResponse(nextIntent, { reused: !!existingIntent, }) ) ); return; } if (pathname.endsWith("/orders/module/stripe/payment_intent") && method === "DELETE") { recordCounter(fixture, "paymentIntentDelete"); const orderId = toPositiveInteger(body.id) ?? extractOrderId(request, parsedUrl); delete fixture.paymentIntentsByOrderId[orderId]; await route.fulfill(json(paymentIntentResponse(null, { cleared: true }))); return; } if (pathname.endsWith("/orders/module/stripe/payment_intent/capture") && method === "POST") { recordCounter(fixture, "paymentIntentCapture"); if (fixture.failureBudget.paymentIntentCapture > 0) { fixture.failureBudget.paymentIntentCapture -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to capture payment intent", }, }, 500 ) ); return; } const orderId = toPositiveInteger(body.id); const currentIntent = fixture.paymentIntentsByOrderId[orderId] || createPaymentIntent(orderId); const succeededIntent = { ...currentIntent, status: "succeeded", amount_capturable: 0, amount_received: Number(currentIntent.amount || 0), }; fixture.paymentIntentsByOrderId[orderId] = succeededIntent; await route.fulfill(json(paymentIntentResponse(succeededIntent))); return; } if (pathname.endsWith("/modules/stripe/invoice") && method === "POST") { recordCounter(fixture, "stripeInvoiceSend"); recordLog(fixture, "stripeInvoiceSends", body); const orderId = toPositiveInteger(body.order_id); const existingInvoice = fixture.stripeModuleOrdersByOrderId[orderId] || {}; if (existingInvoice?.invoice_id && !isStripeInvoiceTerminalStatus(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: clone(existingInvoice), }, }, 409 ) ); return; } const amountDue = getStripeInvoiceAmount(fixture, orderId); const nextInvoice = createStripeModuleOrder(orderId, { customerId: fixture.ordersById?.[orderId]?.customer_id ?? CARD_CUSTOMER_ID, invoiceId: `in_${orderId}_${fixture.requestCounters.stripeInvoiceSend}`, amountDue, }); fixture.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; } if (pathname.endsWith("/modules/stripe/invoice") && method === "DELETE") { recordCounter(fixture, "stripeInvoiceDelete"); recordLog(fixture, "stripeInvoiceDeletes", body); const orderId = toPositiveInteger(body.order_id); const existingInvoice = fixture.stripeModuleOrdersByOrderId[orderId] || {}; if (!existingInvoice?.invoice_id) { fixture.stripeModuleOrdersByOrderId[orderId] = {}; await route.fulfill(json({ success: true, data: { stripeModuleOrders: [] } })); return; } 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: clone(existingInvoice), }, }, 409 ) ); return; } fixture.stripeModuleOrdersByOrderId[orderId] = {}; await route.fulfill(json({ success: true, data: { stripeModuleOrders: [] } })); return; } if (pathname.endsWith("/departments") && method === "GET") { recordCounter(fixture, "departmentsGet"); await route.fulfill(json({ success: true, data: clone(fixture.departments) })); return; } if (pathname.endsWith("/departments/categories") && method === "GET") { recordCounter(fixture, "departmentCategoriesGet"); const departmentId = toPositiveInteger(parsedUrl.searchParams.get("id")); const categories = departmentId ? fixture.departmentCategories.filter((entry) => Number(entry.department_id) === Number(departmentId)) : fixture.departmentCategories; const delayMs = Number(fixture.departmentCategoriesDelayMs ?? 0); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } await route.fulfill(json({ success: true, data: clone(categories) })); return; } if (pathname.endsWith("/departments/order/recommended") && method === "GET") { recordCounter(fixture, "recommendedGet"); await route.fulfill( json({ success: true, data: { reg_1: { motorapi: [53], order_history: { 2: [], 3: [], 4: [], 5: [] }, }, }, }) ); return; } if (pathname.endsWith("/bookings") && method === "GET") { recordCounter(fixture, "bookingsGet"); const bookingId = toPositiveInteger(parsedUrl.searchParams.get("id")); if (bookingId) { await route.fulfill( json({ success: true, data: clone(fixture.bookingsById[bookingId] || null), }) ); return; } const bookings = filterBookings(Object.values(fixture.bookingsById), parsedUrl.searchParams.get("filters")); await route.fulfill(json({ success: true, data: clone(bookings) })); return; } if (pathname.endsWith("/order-bookings/complete") && method === "POST") { recordCounter(fixture, "bookingComplete"); recordLog(fixture, "bookingCompletions", body); const delayMs = Number(fixture.bookingCompleteDelayMs ?? 0); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (fixture.failureBudget.bookingComplete > 0) { fixture.failureBudget.bookingComplete -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to complete booking", }, }, 500 ) ); return; } const bookingId = toPositiveInteger(body.id); const booking = fixture.bookingsById[bookingId]; if (booking) { const normalizedSafetySeal = normalizeSafetySealValue(body.safety_seal); booking.status = "completed"; booking.safety_seal = normalizedSafetySeal; const linkedOrderId = toPositiveInteger(booking.order_id); if (linkedOrderId && fixture.ordersById[linkedOrderId]) { fixture.ordersById[linkedOrderId].safety_seal = normalizedSafetySeal; if (orderContainsWashCertificate(fixture, linkedOrderId)) { ensureWashCertificateAttachment(fixture, linkedOrderId); } } fixture.bookingStatusById[bookingId] = "completed"; removeBookingFromPending(fixture, bookingId); recordBookingCompletionConfirmationEmail(fixture, booking, normalizedSafetySeal); } await route.fulfill( json({ success: true, data: clone(booking || { id: bookingId, status: "completed" }), }) ); return; } if (pathname.endsWith("/order-bookings") && method === "GET") { recordCounter(fixture, "orderBookingsGet"); const bookingId = toPositiveInteger(parsedUrl.searchParams.get("id")); if (bookingId) { await route.fulfill( json({ success: true, data: clone(fixture.bookingsById[bookingId] || null), }) ); return; } const bookings = filterBookings( fixture.pendingBookings ?? Object.values(fixture.bookingsById), parsedUrl.searchParams.get("filters") ); await route.fulfill( json({ success: true, data: clone( bookings.map((booking) => toOrderBookingListEntry(booking, fixture.orderBookingListStripsDetails === true)) ), }) ); return; } if (pathname.endsWith("/order-bookings") && method === "PUT") { recordCounter(fixture, "bookingSetOrderId"); recordLog(fixture, "bookingOrderAssignments", body); const bookingId = toPositiveInteger(body.id); const booking = fixture.bookingsById[bookingId]; if (booking) { booking.order_id = body.order_id ?? body.value ?? null; upsertPendingBooking(fixture, booking); } await route.fulfill(json({ success: true, data: clone(booking || null) })); return; } if (pathname.endsWith("/vehicles/search") && method === "GET") { recordCounter(fixture, "vehiclesSearchGet"); const search = String(parsedUrl.searchParams.get("search") || "").toUpperCase(); const vehicles = fixture.vehicles.filter( (vehicle) => !search || String(vehicle.reg || "") .toUpperCase() .includes(search) ); await route.fulfill(json({ success: true, data: clone(vehicles) })); return; } if (pathname.endsWith("/vehicles") && method === "GET") { recordCounter(fixture, "vehiclesGet"); const search = String(parsedUrl.searchParams.get("search") || "").toUpperCase(); const vehicles = fixture.vehicles.filter( (vehicle) => !search || String(vehicle.reg || "") .toUpperCase() .includes(search) ); await route.fulfill(json({ success: true, data: clone(vehicles) })); return; } if (pathname.endsWith("/department/vehicles/unknown-customer") && method === "GET") { recordCounter(fixture, "unknownVehiclesGet"); const search = String(parsedUrl.searchParams.get("search") || "").toUpperCase(); const vehicles = fixture.unknownVehicles.filter( (vehicle) => !search || String(vehicle.reg_1 || "") .toUpperCase() .includes(search) ); await route.fulfill(json({ success: true, data: clone(vehicles) })); return; } if (pathname.endsWith("/department/vehicle/customer-suggestions") && method === "GET") { recordCounter(fixture, "vehicleCustomerSuggestionsGet"); const reg = normalizeRegistrationValue(parsedUrl.searchParams.get("reg_1")); const suggestions = fixture.vehicleCustomerSuggestionsByReg[reg] || []; await route.fulfill(json({ success: true, data: clone(suggestions) })); return; } if (pathname.endsWith("/cvr/search") && method === "GET") { recordCounter(fixture, "cvrSearchGet"); const query = String(parsedUrl.searchParams.get("query") || "").trim(); recordLog(fixture, "cvrSearches", { query }); const responseConfig = fixture.cvrSearchResponses?.[query] || null; const delayMs = Number(responseConfig?.delayMs ?? 0); const status = Number(responseConfig?.status ?? 200); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (status >= 400) { await route.fulfill( json( { success: false, data: { message: responseConfig?.message || "Unable to load CVR details", }, }, status ) ); return; } await route.fulfill( json({ success: true, data: clone(responseConfig?.data ?? null), }) ); return; } if (pathname.endsWith("/auth/register/cvr") && method === "POST") { recordCounter(fixture, "customerRegistrationPost"); recordLog(fixture, "customerRegistrations", body); const responseConfig = fixture.customerRegistrationResponse || {}; const status = Number(responseConfig.status ?? 200); if (status >= 400) { await route.fulfill( json( { success: false, data: { message: responseConfig.message || "Unable to register customer", }, }, status ) ); return; } const customerNumber = toPositiveInteger(body.companyPhone); if (customerNumber) { fixture.customersByNumber[customerNumber] = createCustomer(customerNumber, { name: body?.searchResult?.name || `Customer ${customerNumber}`, address: body?.searchResult?.address || "Demo Street 1", zip: String(body?.searchResult?.zipcode || "2630"), city: body?.searchResult?.city || "Taastrup", mobilePhone: String(body.contactPhone || body.companyPhone || ""), email: body.contactEmail || body.invoiceEmail || "", corporateIdentificationNumber: String(body.cvr || "").padStart(8, "0"), economic_customer: customerNumber, }); fixture.customerAttributesByNumber[customerNumber] = fixture.customerAttributesByNumber[customerNumber] || []; fixture.customerNotesByNumber[customerNumber] = fixture.customerNotesByNumber[customerNumber] || []; } await route.fulfill( json({ success: true, data: clone(responseConfig.data ?? { success: true }), }) ); return; } if (pathname.endsWith("/users/customer") && method === "GET") { recordCounter(fixture, "usersCustomerGet"); const customerNumber = toPositiveInteger(parsedUrl.searchParams.get("customer_number")) ?? fixture.regularCustomerId; const customer = fixture.customersByNumber[customerNumber] || null; if (!customer) { await route.fulfill( json( { success: false, data: { message: "Customer not found", }, }, 404 ) ); return; } await route.fulfill( json({ success: true, data: { customer_name: customer.name, economic_customer: clone(customer), }, }) ); return; } if (pathname.endsWith("/customers") && method === "GET") { recordCounter(fixture, "customersGet"); const search = String(parsedUrl.searchParams.get("search") || "").toLowerCase(); const customers = Object.values(fixture.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: clone(customers), meta: { pagination: { page: 1, limit: 10, total: customers.length, }, }, }) ); return; } if (pathname.endsWith("/customer/notes") && method === "GET") { recordCounter(fixture, "customerNotesGet"); const customerNumber = toPositiveInteger(parsedUrl.searchParams.get("customer_number")) ?? toPositiveInteger(parsedUrl.searchParams.get("id")) ?? fixture.regularCustomerId; await route.fulfill( json({ success: true, data: clone(fixture.customerNotesByNumber[customerNumber] || []), }) ); return; } if (pathname.endsWith("/public/employees") && method === "GET") { await route.fulfill( json({ success: true, data: clone(fixture.employees || []), }) ); return; } if (pathname.endsWith("/customer/attributes") && method === "GET") { recordCounter(fixture, "customerAttributesGet"); const delayMs = Number(fixture.customerAttributesDelayMs ?? 0); if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } if (fixture.failureBudget.customerAttributesGet > 0) { fixture.failureBudget.customerAttributesGet -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to load customer attributes" }, }, 500 ) ); return; } const customerNumber = toPositiveInteger(parsedUrl.searchParams.get("customer_number")) ?? toPositiveInteger(parsedUrl.searchParams.get("id")) ?? fixture.regularCustomerId; await route.fulfill( json({ success: true, data: clone(fixture.customerAttributesByNumber[customerNumber] || []), }) ); return; } if (pathname.endsWith("/superuser/user/discounts") && method === "GET") { recordCounter(fixture, "discountsGet"); await route.fulfill(json({ success: true, data: [] })); return; } if (pathname.endsWith("/products") && method === "GET") { recordCounter(fixture, "productsGet"); const productId = toPositiveInteger(parsedUrl.searchParams.get("id")); const category = toPositiveInteger(parsedUrl.searchParams.get("category")); const isWash = parsedUrl.searchParams.get("is_wash"); const limit = Number(parsedUrl.searchParams.get("limit") || 0); const delayMs = Number( (category ? fixture.productsDelayMsByCategory?.[category] : null) ?? fixture.productsDelayMs ?? 0 ); if (productId) { if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } await route.fulfill( json({ success: true, data: clone(getProductById(fixture, productId)), }) ); return; } let products = fixture.products.slice(); if (category) { products = products.filter((product) => Number(product.category) === Number(category)); } if (isWash !== null && isWash !== "") { const expected = String(isWash) === "true"; products = products.filter((product) => Boolean(product.is_wash) === expected); } if (Number.isFinite(limit) && limit > 0) { products = products.slice(0, limit); } if (delayMs > 0) { await new Promise((resolve) => setTimeout(resolve, delayMs)); } await route.fulfill(json({ success: true, data: clone(products) })); return; } if (pathname.endsWith("/orders") && method === "GET") { recordCounter(fixture, "ordersGet"); await route.fulfill( json({ success: true, data: clone(Object.values(fixture.ordersById)), }) ); return; } if (pathname.endsWith("/orders") && method === "POST") { recordCounter(fixture, "orderCreate"); recordLog(fixture, "orderCreates", body); if (fixture.failureBudget.orderCreate > 0) { fixture.failureBudget.orderCreate -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to create order", }, }, 500 ) ); return; } const orderId = fixture.nextOrderId++; fixture.ordersById[orderId] = createOrderRecord(orderId, { customer_id: Number(body.customer_id), department_id: Number(body.department_id), reference: body.reference || "", notes: body.notes || "", po: body.po || "", safety_seal: normalizeSafetySealValue(body.safety_seal), reg_1: body.reg_1 || "", reg_2: body.reg_2 || "", reg_3: body.reg_3 || "", booking_id: toPositiveInteger(body.booking_id), created_at: new Date().toISOString(), }); fixture.orderItemsByOrderId[orderId] = []; fixture.attachmentsByOrderId[orderId] = []; fixture.stripeModuleOrdersByOrderId[orderId] = {}; await route.fulfill( json({ success: true, data: { id: orderId, }, }) ); return; } if ((pathname.endsWith("/orders") || pathname.endsWith("/order")) && method === "PUT") { recordCounter(fixture, "orderUpdate"); recordLog(fixture, "orderUpdates", body); const orderId = toPositiveInteger(body.id); const order = fixture.ordersById[orderId]; if (order) { if (body.field) { order[body.field] = body.field === "reg_1" || body.field === "reg_2" || body.field === "reg_3" ? normalizeRegistrationValue(body.value) : body.field === "safety_seal" ? normalizeSafetySealValue(body.value) : body.value; } else { Object.assign(order, body); if (Object.prototype.hasOwnProperty.call(body, "reg_1")) { order.reg_1 = normalizeRegistrationValue(body.reg_1); } if (Object.prototype.hasOwnProperty.call(body, "reg_2")) { order.reg_2 = normalizeRegistrationValue(body.reg_2); } if (Object.prototype.hasOwnProperty.call(body, "reg_3")) { order.reg_3 = normalizeRegistrationValue(body.reg_3); } if (Object.prototype.hasOwnProperty.call(body, "safety_seal")) { order.safety_seal = normalizeSafetySealValue(body.safety_seal); } } } await route.fulfill(json({ success: true, data: clone(order || null) })); return; } if (pathname.endsWith("/orders") && method === "DELETE") { recordCounter(fixture, "orderDelete"); const orderId = toPositiveInteger(parsedUrl.searchParams.get("id")) ?? toPositiveInteger(body.id); const confirmed = parsedUrl.searchParams.get("confirmed") === "true" || body.confirmed === true; recordLog(fixture, "orderDeletes", { id: orderId, confirmed }); if (fixture.failureBudget.orderDelete > 0) { fixture.failureBudget.orderDelete -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to delete order", }, }, 500 ) ); return; } const protectedReasons = []; const order = fixture.ordersById[orderId] || null; const orderItemCount = (fixture.orderItemsByOrderId[orderId] || []).length; const attachmentCount = (fixture.attachmentsByOrderId[orderId] || []).length; if (order?.completed_at) { protectedReasons.push("completed"); } if (orderItemCount > 0) { protectedReasons.push("order_items"); } if (attachmentCount > 0) { protectedReasons.push("attachments"); } if (protectedReasons.length > 0 && !confirmed) { await route.fulfill( json( { success: false, data: { message: "Order deletion requires confirmation", requires_confirmation: true, protected_reasons: protectedReasons, order_item_count: orderItemCount, attachment_count: attachmentCount, completed_at: order?.completed_at ?? null, }, }, 409 ) ); return; } if (orderId) { delete fixture.ordersById[orderId]; delete fixture.orderItemsByOrderId[orderId]; delete fixture.attachmentsByOrderId[orderId]; delete fixture.stripeModuleOrdersByOrderId[orderId]; delete fixture.paymentIntentsByOrderId[orderId]; fixture.deletedOrderIds.push(orderId); } await route.fulfill(json({ success: true, data: true })); return; } if (pathname.endsWith("/order") && method === "GET") { recordCounter(fixture, "orderGet"); const orderId = toPositiveInteger(parsedUrl.searchParams.get("id")); await route.fulfill( json({ success: true, data: clone(fixture.ordersById[orderId] || null), includes: { stripeModuleOrders: clone(fixture.stripeModuleOrdersByOrderId[orderId] || {}), }, }) ); return; } if (pathname.endsWith("/order/items") && method === "GET") { recordCounter(fixture, "orderItemsGet"); const orderId = toPositiveInteger(parsedUrl.searchParams.get("order_id")); await route.fulfill( json({ success: true, data: clone(fixture.orderItemsByOrderId[orderId] || []), }) ); return; } if (pathname.endsWith("/order/items") && method === "POST") { recordCounter(fixture, "orderItemsPost"); recordLog(fixture, "orderItemCreates", body); if (fixture.failureBudget.orderItemCreate > 0) { fixture.failureBudget.orderItemCreate -= 1; await route.fulfill( json( { success: false, data: { message: "Notes is required for this product", }, }, 400 ) ); return; } const orderId = toPositiveInteger(body.order_id); const productId = toPositiveInteger(body.product_id); const perProductBudget = fixture.failureBudget.orderItemCreateForProductId || {}; if (perProductBudget[productId] > 0) { perProductBudget[productId] -= 1; await route.fulfill( json( { success: false, data: { message: `Product ${productId} rejected`, }, }, 400 ) ); return; } const order = fixture.ordersById[orderId]; const product = getProductById(fixture, productId); if (!order || !product) { await route.fulfill( json( { success: false, data: { message: "Order or product not found", }, }, 422 ) ); return; } if (productRequiresOrderItemNote(product) && String(body.notes ?? "").trim() === "") { await route.fulfill( json( { success: false, data: { message: "Notes is required for this product", }, }, 400 ) ); return; } const orderItemId = fixture.nextOrderItemId++; const item = buildOrderItem( product, { ...body, order_id: orderId, }, orderItemId ); if (!Array.isArray(fixture.orderItemsByOrderId[orderId])) { fixture.orderItemsByOrderId[orderId] = []; } fixture.orderItemsByOrderId[orderId].push(item); await route.fulfill(json({ success: true, data: clone(item) })); return; } if (pathname.endsWith("/order/items") && method === "PUT") { recordCounter(fixture, "orderItemsPut"); recordLog(fixture, "orderItemUpdates", body); const orderItemId = toPositiveInteger(body.id); Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => { fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).map((item) => { if (Number(item.id) !== orderItemId) { 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: orderItemId, }, }) ); return; } if (pathname.endsWith("/order/items") && method === "DELETE") { recordCounter(fixture, "orderItemsDelete"); const orderItemId = toPositiveInteger(parsedUrl.searchParams.get("id")) ?? toPositiveInteger(body.id); recordLog(fixture, "orderItemDeletes", { id: orderItemId }); Object.keys(fixture.orderItemsByOrderId).forEach((orderIdKey) => { fixture.orderItemsByOrderId[orderIdKey] = (fixture.orderItemsByOrderId[orderIdKey] || []).filter( (item) => Number(item.id) !== orderItemId ); }); await route.fulfill(json({ success: true, data: true })); return; } if (pathname.endsWith("/orders/mark_as_completed") && method === "POST") { recordCounter(fixture, "markAsCompleted"); const orderId = toPositiveInteger(body.id); if (orderId) { fixture.markCompletedOrderIds.push(orderId); if (fixture.ordersById[orderId]) { fixture.ordersById[orderId].completed_at = new Date().toISOString(); if (orderContainsWashCertificate(fixture, orderId)) { ensureWashCertificateAttachment(fixture, orderId); } } } await route.fulfill( json({ success: true, data: { id: orderId, }, }) ); return; } if (pathname.endsWith("/orders/attachments") && method === "GET") { recordCounter(fixture, "attachmentsGet"); const orderId = toPositiveInteger(parsedUrl.searchParams.get("id")) ?? toPositiveInteger(parsedUrl.searchParams.get("order_id")); await route.fulfill( json({ success: true, data: clone(fixture.attachmentsByOrderId[orderId] || []), }) ); return; } if (pathname.endsWith("/orders/attachments") && method === "DELETE") { recordCounter(fixture, "attachmentsDelete"); const orderId = toPositiveInteger(body.order_id) ?? toPositiveInteger(parsedUrl.searchParams.get("order_id")); const attachmentId = toPositiveInteger(body.attachment_id) ?? toPositiveInteger(parsedUrl.searchParams.get("attachment_id")); recordLog(fixture, "attachmentDeletes", { order_id: orderId, attachment_id: attachmentId }); fixture.attachmentsByOrderId[orderId] = (fixture.attachmentsByOrderId[orderId] || []).filter( (attachment) => Number(attachment.id) !== attachmentId ); await route.fulfill(json({ success: true, data: true })); return; } if (pathname.endsWith("/orders/attachments/upload") && method === "POST") { recordCounter(fixture, "attachmentUpload"); recordLog(fixture, "attachmentUploads", body); if (fixture.failureBudget.attachmentUpload > 0) { fixture.failureBudget.attachmentUpload -= 1; await route.fulfill( json( { success: false, data: { message: "Unable to upload attachment", }, }, 500 ) ); return; } const orderId = toPositiveInteger(body.order_id); const attachment = { id: fixture.nextAttachmentId++, content: { other: body.file_name || `upload-${fixture.nextAttachmentId}.png`, }, }; if (!Array.isArray(fixture.attachmentsByOrderId[orderId])) { fixture.attachmentsByOrderId[orderId] = []; } fixture.attachmentsByOrderId[orderId].push(attachment); await route.fulfill(json({ success: true, data: clone(attachment) })); return; } if (pathname.endsWith("/orders/attachments/download") && method === "GET") { recordCounter(fixture, "attachmentDownload"); const orderId = toPositiveInteger(parsedUrl.searchParams.get("order_id")); const attachmentId = toPositiveInteger(parsedUrl.searchParams.get("attachment_id")); await route.fulfill( json({ success: true, data: { download_link: `${ATTACHMENT_DOWNLOAD_URL}/${orderId}/${attachmentId}`, }, }) ); return; } if (pathname.endsWith("/vehicle/product-suggestions") && method === "GET") { await route.fulfill(json({ success: true, data: [] })); return; } if (pathname.endsWith("/modules/scanner/lpr") && method === "POST") { recordCounter(fixture, "lprPost"); await route.fulfill( json({ success: false, data: { message: "No license plate detected.", reason: "no_license_plate_detected", }, }) ); return; } await route.fallback(); }); } export async function gotoMobilePos( page, { step = 1, orderId = null, customerId = null, departmentId = DEFAULT_DEPARTMENT_ID } = {} ) { const params = new URLSearchParams(); if (orderId !== null && orderId !== undefined) { params.set("id", String(orderId)); } if (customerId !== null && customerId !== undefined) { params.set("customer_id", String(customerId)); } if (step !== null && step !== undefined) { params.set("step", String(step)); } const query = params.toString(); await page.goto(`/admin/${departmentId}/modules/pos${query ? `?${query}` : ""}`); } export async function setupMobilePosPage( page, fixture, { token = "pos-mobile-token", permissions = MOBILE_PERMISSIONS, seedState = {}, route = {}, sessionData = {}, storedOrderId = null, } = {} ) { await mockApi(page, { authenticated: true, permissions, sessionData: { customer_number: 555555, display_name: "POS Mobile E2E", ...sessionData, }, }); await mockMobilePosApi(page, fixture); if (seedState !== false) { await seedMobilePosState(page, { fixture, customerId: null, includePrimaryItem: false, ...seedState, }); } if (storedOrderId) { await seedStoredPosOrderId(page, storedOrderId); } await primeOperatorSession(page, { token, permissions, }); await gotoMobilePos(page, { departmentId: fixture.departmentId ?? DEFAULT_DEPARTMENT_ID, ...route, }); await page .evaluate(() => { const selectors = [ "#__vue-devtools-container__", ".vue-devtools__anchor-btn", ".vue-devtools__panel", ".vue-devtools__panel-content", ]; selectors.forEach((selector) => { document.querySelectorAll(selector).forEach((element) => { element.setAttribute("aria-hidden", "true"); element.style.setProperty("display", "none", "important"); element.style.setProperty("visibility", "hidden", "important"); element.style.setProperty("pointer-events", "none", "important"); element.style.setProperty("opacity", "0", "important"); }); }); }) .catch(() => {}); }