- 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.
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|