Refactor and migrate Playwright E2E tests:

- Remove `playwright.config.js` to clean up configuration.
- Add new E2E test suites (`admin-pos-orders`, `admin-module-goals`, `admin-module-pos-mobile-order-flow`) to structure Admin module tests.
- Introduce reusable authentication and data seeding utilities in `fixtures` for streamlined test flows and maintainability.
This commit is contained in:
Jeppe Bundgaard
2026-03-19 12:46:47 +01:00
parent acbeef438b
commit 60742bf947
34 changed files with 3408 additions and 75 deletions
@@ -0,0 +1,493 @@
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();
});
});