import { test, expect, Page } from '@playwright/test'; import { bookingTestData, userCredentials, operatorCredentials, } from './fixtures'; /** Admin Module - POS Mobile Order Flow Tests */ test.describe('Admin Module - POS Mobile Order Flow', () => { test.beforeEach(async ({ page }, testInfo) => { test.skip(!testInfo.project.name.toLowerCase().includes('mobile'), 'Mobile-only POS flow suite'); await page.goto('/admin/login'); await page.fill('input[name="user_id"]', operatorCredentials.userId); await page.fill('input[name="password"]', operatorCredentials.password); await page.click('button[id="operator_login_button"]'); await expect(page).toHaveURL(/\/admin(\?.*)?$/, { timeout: 15000 }); }); const gotoPos = async ( page: Page, options: { step?: number; orderId?: number; customerId?: number } = {} ) => { const departmentId = bookingTestData.departmentId.toString(); const params = new URLSearchParams(); if (options.step !== undefined) params.set('step', String(options.step)); if (options.orderId !== undefined) params.set('id', String(options.orderId)); if (options.customerId !== undefined) params.set('customer_id', String(options.customerId)); const query = params.toString(); const url = `/admin/${departmentId}/modules/pos${query ? `?${query}` : ''}`; await page.goto(url); await expect(page).toHaveURL(new RegExp(`/admin/${departmentId}/modules/pos`)); }; const seedMobilePosState = async (page: Page, reg1: string, customerNumber: number) => { await page.evaluate( ({ reg, customerId }) => { const defaultReference = 'AUTO-REF-E2E'; const snapshot = { vehicles: { vehicle_1: { reg, status: 'unknown', reference: defaultReference }, vehicle_2: null, vehicle_3: null, activeVehicleIndex: 1, }, views: { manualInput: false, vehicleSelection: false, additionalItemSelection: false, transactionHistoryView: false, }, transactionItems: { primaryItem: null, additionalItems: [], }, categories: { list: [], selected: null, }, productList: { list: [], }, metadata: { customerId, notes: '', reference: defaultReference, washId: null, bookingId: null, laneId: null, }, attachments: { files: [], base64: [], wash_certificate: false, }, transactionHistory: [], lastVehicleOrders: { vehicle_1: null, vehicle_2: null, vehicle_3: null, }, timestamp: Date.now(), }; localStorage.removeItem('pos_order_id'); localStorage.setItem('pos', JSON.stringify(snapshot)); }, { reg: reg1, customerId: customerNumber } ); }; const seedMobileStepTwoState = async ( page: Page, options: { reg: string; customerId: number; reference?: string; productId?: number; productName?: string } ) => { await page.evaluate( ({ reg, customerId, reference, productId, productName }) => { const defaultReference = reference || 'AUTO-REF-E2E'; const snapshot = { vehicles: { vehicle_1: { reg, status: 'verified', reference: defaultReference, type: productId || 1, }, vehicle_2: null, vehicle_3: null, activeVehicleIndex: 1, }, views: { manualInput: false, vehicleSelection: false, additionalItemSelection: false, transactionHistoryView: false, }, transactionItems: { primaryItem: { id: productId || 1, name: productName || 'Trækker', price: 579, addons: [], }, additionalItems: [], }, categories: { list: [], selected: null, }, productList: { list: [], }, metadata: { customerId, notes: '', reference: defaultReference, washId: null, bookingId: null, laneId: null, }, attachments: { files: [], base64: [], wash_certificate: false, }, transactionHistory: [], lastVehicleOrders: { vehicle_1: null, vehicle_2: null, vehicle_3: null, }, timestamp: Date.now(), }; window.localStorage.removeItem('pos_order_id'); window.localStorage.setItem('pos', JSON.stringify(snapshot)); }, options ); }; const resolveReferencePromptIfPresent = async (page: Page) => { const swalInput = page.locator('.swal2-popup .swal2-input').first(); if (!(await swalInput.isVisible())) return; await swalInput.fill('AUTO-REF-E2E'); await page.locator('.swal2-popup .swal2-confirm').click(); await expect(page.locator('.swal2-container')).toBeHidden({ timeout: 10000 }); }; const selectCustomerFromPopupIfNeeded = async (page: Page, customerNumber: number) => { const popup = page.locator('.popup-container'); if (!(await popup.isVisible())) return; const searchInput = page.locator('.popup-container input[type="text"]').first(); await expect(searchInput).toBeVisible(); await searchInput.fill(String(customerNumber)); const firstResultRow = page.locator('.popup-container .custom-wrapper .columns').first(); await expect(firstResultRow).toBeVisible({ timeout: 10000 }); await firstResultRow.click({ force: true }); try { await expect(popup).toBeHidden({ timeout: 3000 }); } catch { const popupFooterAction = page.locator('.popup-container .card-footer-item').first(); if (await popupFooterAction.isVisible()) { await popupFooterAction.click({ force: true }); } } }; const getStoredPosSnapshot = async (page: Page) => page.evaluate(() => { const value = window.localStorage.getItem('pos'); return value ? JSON.parse(value) : null; }); const fetchOrderBookingForVehicle = async (page: Page, registrationNumber: string, departmentId: number) => page.evaluate( async ({ reg, deptId }) => { const token = window.localStorage.getItem('token'); if (!token) { throw new Error('Missing auth token in localStorage'); } const response = await fetch( `https://api.truckwash.io/order-bookings?filters=${encodeURIComponent(`department:${deptId},reg_1:${reg}`)}`, { headers: { Authorization: `Bearer ${token}`, }, } ); if (!response.ok) { throw new Error(`Failed to fetch order bookings: ${response.status}`); } const payload = await response.json(); const booking = payload?.data?.[0] ?? null; if (!booking?.id) { throw new Error(`No order booking found for ${reg}`); } return booking; }, { reg: registrationNumber, deptId: departmentId } ); const seedAcceptedBookingStepTwoState = async ( page: Page, options: { reg: string; customerId: number; bookingId: number; reference?: string | null } ) => { await page.evaluate( ({ reg, customerId, bookingId, reference }) => { const snapshot = { vehicles: { vehicle_1: { reg, status: 'booked', reference: reference || 'AUTO-REF-E2E', booking_id: bookingId, type: null, }, vehicle_2: null, vehicle_3: null, activeVehicleIndex: 1, }, views: { manualInput: false, vehicleSelection: false, additionalItemSelection: false, transactionHistoryView: false, }, transactionItems: { primaryItem: null, additionalItems: [], }, categories: { list: [], selected: null, }, productList: { list: [], }, metadata: { customerId, notes: '', reference: reference || 'AUTO-REF-E2E', washId: null, bookingId, laneId: null, }, attachments: { files: [], base64: [], wash_certificate: false, }, transactionHistory: [], lastVehicleOrders: { vehicle_1: null, vehicle_2: null, vehicle_3: null, }, timestamp: Date.now(), }; window.localStorage.setItem('pos', JSON.stringify(snapshot)); }, options ); }; test('loads mobile step 1 scanner shell and base CTA', async ({ page }) => { await gotoPos(page); await expect(page.getByText('Scan nummerplader')).toBeVisible(); await expect(page.getByText('Hold kameraet op mod nummerpladerne.')).toBeVisible(); await expect(page.getByText('Skriv registreringsnummer manuelt')).toBeVisible(); }); test('opens manual registration input and normalizes registration text to uppercase', async ({ page }) => { await gotoPos(page); await page.getByText('Skriv registreringsnummer manuelt').click(); await expect(page.getByText('Reg 1*')).toBeVisible(); const regInputs = page.locator('input.custom-input'); await expect(regInputs.first()).toBeVisible(); await regInputs.first().fill('ec21233'); await expect(regInputs.first()).toHaveValue('EC21233'); }); test('opens select-customer popup when progressing without a selected customer', async ({ page }) => { await gotoPos(page); await page.locator('button.has-background-primary').first().click(); await expect(page.locator('.popup-container')).toBeVisible(); await expect(page.locator('.popup-container input[type="text"]').first()).toBeVisible(); }); test('creates a new mobile POS order from step 1 and routes to step 2', async ({ page }) => { const customerNumber = Number.parseInt(userCredentials.customerNumber, 10); const regNr = bookingTestData.registrationNumber; await page.goto('/admin'); await seedMobilePosState(page, regNr, customerNumber); await gotoPos(page, { step: 1, customerId: customerNumber }); const primaryAction = page.locator('button.has-background-primary').first(); await expect(primaryAction).toBeEnabled({ timeout: 15000 }); for (let attempt = 0; attempt < 5; attempt++) { if (page.url().includes('step=2')) break; await resolveReferencePromptIfPresent(page); await selectCustomerFromPopupIfNeeded(page, customerNumber); const popup = page.locator('.popup-container'); if (await popup.isVisible()) { const popupFooterAction = page.locator('.popup-container .card-footer-item').first(); if (await popupFooterAction.isVisible()) { await popupFooterAction.click({ force: true }); } } if (await primaryAction.isEnabled()) { await primaryAction.click(); } await page.waitForTimeout(1200); } await expect(page).toHaveURL(/\/admin\/\d+\/modules\/pos\?id=\d+&customer_id=\d+&step=2/, { timeout: 20000, }); const currentUrl = page.url(); const parsed = new URL(currentUrl); const createdOrderId = Number(parsed.searchParams.get('id')); expect(Number.isFinite(createdOrderId)).toBeTruthy(); expect(createdOrderId).toBeGreaterThan(0); }); test('supports query-param routing into mobile step 2 when order id is provided', async ({ page }) => { const customerNumber = Number.parseInt(userCredentials.customerNumber, 10); await page.goto('/admin'); await seedMobileStepTwoState(page, { reg: bookingTestData.registrationNumber, customerId: customerNumber, reference: bookingTestData.reference, }); await gotoPos(page, { step: 2, orderId: 1, customerId: customerNumber }); await expect(page.getByText('Afslut')).toBeVisible(); }); test('does not render step 2 form fields when step=2 lacks order id', async ({ page }) => { await gotoPos(page, { step: 2 }); await expect(page.getByText('Notes')).toHaveCount(0); await expect(page.getByText('Reference')).toHaveCount(0); await expect(page.getByText('Afslut')).toHaveCount(0); }); test('step 2 notes and reference inputs accept user changes', async ({ page }) => { const customerNumber = Number.parseInt(userCredentials.customerNumber, 10); await page.goto('/admin'); await seedMobileStepTwoState(page, { reg: bookingTestData.registrationNumber, customerId: customerNumber, reference: bookingTestData.reference, }); await gotoPos(page, { step: 2, orderId: 1, customerId: customerNumber }); const allTextInputs = page.locator('input[type="text"]'); await expect(allTextInputs).toHaveCount(2); const notesInput = allTextInputs.nth(0); const referenceInput = allTextInputs.nth(1); await notesInput.fill('mobile-pos-notes'); await referenceInput.fill('mobile-pos-reference'); await expect(notesInput).toHaveValue('mobile-pos-notes'); await expect(referenceInput).toHaveValue('mobile-pos-reference'); }); test('keeps scanner/manual-input entrypoint visible after navigating back to base route', async ({ page }) => { await gotoPos(page, { step: 2, orderId: 1 }); await gotoPos(page); await expect(page.getByText('Scan nummerplader')).toBeVisible(); await expect(page.getByText('Skriv registreringsnummer manuelt')).toBeVisible(); }); test('accepting an order booking skips mobile selector in step 2 and auto-loads booking product data', async ({ page }) => { const regNr = 'EL40921'; const customerNumber = Number.parseInt(userCredentials.customerNumber, 10); const departmentId = bookingTestData.departmentId; await page.goto('/admin'); const booking = await fetchOrderBookingForVehicle(page, regNr, departmentId); await seedAcceptedBookingStepTwoState(page, { reg: regNr, customerId: customerNumber, bookingId: booking.id, reference: booking.reference ?? bookingTestData.reference, }); await gotoPos(page, { step: 2, orderId: 1, customerId: customerNumber }); await expect(page).toHaveURL( new RegExp(`\\/admin\\/${departmentId}\\/modules\\/pos\\?.*step=2.*id=1.*customer_id=\\d+|\\/admin\\/${departmentId}\\/modules\\/pos\\?.*id=1.*customer_id=\\d+.*step=2`), { timeout: 20000 } ); await expect .poll( async () => { const snapshot = await getStoredPosSnapshot(page); return { bookingId: snapshot?.metadata?.bookingId ?? null, vehicleBookingId: snapshot?.vehicles?.vehicle_1?.booking_id ?? null, vehicleSelection: snapshot?.views?.vehicleSelection ?? null, primaryItemId: snapshot?.transactionItems?.primaryItem?.id ?? null, addonCount: snapshot?.transactionItems?.primaryItem?.addons?.filter((addon: { quantity?: number }) => (addon.quantity ?? 0) > 0) ?.length ?? 0, }; }, { timeout: 15000 } ) .toMatchObject({ bookingId: booking.id, vehicleBookingId: booking.id, vehicleSelection: false, primaryItemId: expect.anything(), }); const posSnapshot = await getStoredPosSnapshot(page); // Primary product selection hidden await expect(page.getByText('Produkter')).not.toBeVisible({ timeout: 5000 }); // Step 2 content visible await expect(page.getByText('Afslut')).toBeVisible(); expect(posSnapshot?.metadata?.bookingId).toBeTruthy(); expect(posSnapshot?.vehicles?.vehicle_1?.booking_id).toBe(posSnapshot?.metadata?.bookingId); expect(posSnapshot?.transactionItems?.primaryItem?.id).toBeTruthy(); expect(Array.isArray(posSnapshot?.transactionItems?.primaryItem?.addons)).toBeTruthy(); expect( posSnapshot.transactionItems.primaryItem.addons.filter((addon: { quantity?: number }) => (addon.quantity ?? 0) > 0) .length ).toBeGreaterThan(0); await expect(page.getByText('Add-ons')).toBeVisible(); }); });