Add unit tests and e2e coverage for POS mobile add-customer autofill functionality:

- Added unit tests for `applyPosAddCustomerSearchResult` to handle autofill field updates, manual overrides, and stale response rejection.
- Implemented a new e2e scenario for preserving manual edits during autofill updates and ignoring stale delayed responses.
- Refactored `mobilePos.js` fixture setup to include CVR search and customer registration mock logic.
- Added and integrated `PosDesktopCustomerConflictModal.vue` for UI handling of customer conflicts.
This commit is contained in:
Jeppe Bundgaard
2026-04-15 08:56:30 +02:00
parent b5a8199472
commit 00151ec237
19 changed files with 1994 additions and 308 deletions
+159
View File
@@ -53,6 +53,17 @@ async function createDisposableOrder(page: Page) {
};
}
async function selectStepOneCustomer(
page: Page,
customerNumber: number | string,
expectedName: string | RegExp = /\(TEST\) Pleno Vognmandsforretning/
) {
await page.locator("#pos_select_customer_input").fill(String(customerNumber));
await expect(page.locator(".customer-drop-down-select").first()).toBeVisible();
await page.locator(".customer-drop-down-select").first().click();
await expect(page.locator(".field.has-addons input[disabled]").last()).toHaveValue(expectedName);
}
async function clickVisibleTestId(page: Page, testId: string) {
const locator = page.getByTestId(testId);
const count = await locator.count();
@@ -275,6 +286,47 @@ function createRequiredWarningsPosFixture() {
});
}
function createCustomerConflictPosFixture() {
const baseFixture = createPosFixture();
const defaultCustomer = baseFixture.customersByNumber[12345679];
const vehicleCustomerNumber = 22334455;
const vehicleCustomerName = "(TEST) Conflict Fleet";
return createPosFixture({
customersByNumber: {
[vehicleCustomerNumber]: {
...defaultCustomer,
id: 3,
customerNumber: vehicleCustomerNumber,
economic_customer: vehicleCustomerNumber,
name: vehicleCustomerName,
email: "conflict@example.com",
mobilePhone: "22334455",
},
},
vehicles: [
...baseFixture.vehicles.filter((vehicle) => vehicle.reg !== "CONFLICT1"),
{
id: 7701,
reg: "CONFLICT1",
customer_id: vehicleCustomerNumber,
customer_name: vehicleCustomerName,
type: 53,
status: "verified",
barred: false,
wash_subscription: false,
addons: {
enabled: 0,
available: 0,
list: [],
},
reference: "CONFLICT-VEHICLE-REF",
last_order_id: null,
},
],
});
}
test.describe("Admin POS Orders - desktop settings", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order settings coverage");
@@ -1426,6 +1478,113 @@ test.describe("Admin POS Orders - desktop required warning states", () => {
});
});
test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only step 1 customer ownership coverage");
});
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
page,
}) => {
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: createRequiredWarningsPosFixture(),
});
await primeOperatorSession(page, "pos-orders-manual-customer-reg-edit-token");
await page.goto("/admin/12/modules/pos?step=1");
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("MAN");
await selectStepOneCustomer(page, 12345679);
const referenceInput = page.locator("#reference");
await referenceInput.fill("MANUAL-REF-001");
await page.locator("#reg_1").fill("MANUAL1");
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: /\(TEST\) Pleno/ }).first()).toBeVisible();
await expect(referenceInput).toHaveValue("MANUAL-REF-001");
await page.locator("#reg_1").fill("");
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: /\(TEST\) Pleno/ }).first()).toBeVisible();
await expect(referenceInput).toHaveValue("MANUAL-REF-001");
await page.locator("#reg_1").fill("MANUAL2");
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: /\(TEST\) Pleno/ }).first()).toBeVisible();
await expect(referenceInput).toHaveValue("MANUAL-REF-001");
});
test("asks whether to keep the selected customer when an exact plate belongs to another customer", async ({
page,
}) => {
const fixture = createCustomerConflictPosFixture();
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeOperatorSession(page, "pos-orders-customer-conflict-keep-token");
await page.goto("/admin/12/modules/pos?step=1");
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("CON");
await selectStepOneCustomer(page, 12345679);
await page.locator("#reference").fill("KEEP-REF");
await page.locator("#reg_1").fill("CONFLICT1");
const activeConflictModal = page.locator('[data-testid="pos-desktop-customer-conflict-modal"].is-active');
await expect(activeConflictModal).toBeVisible();
await activeConflictModal.getByTestId("pos-desktop-customer-conflict-keep").click();
await expect(activeConflictModal).toHaveCount(0);
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: /\(TEST\) Pleno/ }).first()).toBeVisible();
await expect(page.locator("#reference")).toHaveValue("KEEP-REF");
await expect(page.locator("#reg_1")).toHaveValue("CONFLICT1");
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=12345679&step=2/);
});
test("can switch to the matched vehicle customer from the conflict modal", async ({ page }) => {
const fixture = createCustomerConflictPosFixture();
await mockApi(page, {
authenticated: true,
permissions: POS_PERMISSIONS,
edgeGateways: false,
pos: fixture,
});
await primeOperatorSession(page, "pos-orders-customer-conflict-use-vehicle-token");
await page.goto("/admin/12/modules/pos?step=1");
await expect(page.getByTestId("pos-step-1")).toBeVisible();
await page.locator("#reg_1").fill("CON");
await selectStepOneCustomer(page, 12345679);
await page.locator("#reference").fill("MANUAL-REF");
await page.locator("#reg_1").fill("CONFLICT1");
const activeConflictModal = page.locator('[data-testid="pos-desktop-customer-conflict-modal"].is-active');
await expect(activeConflictModal).toBeVisible();
await activeConflictModal.getByTestId("pos-desktop-customer-conflict-use-vehicle").click();
await expect(activeConflictModal).toHaveCount(0);
await expect(page.locator(".pos-selected-customer__title").filter({ hasText: /\(TEST\) Conflict Fleet/ }).first()).toBeVisible();
await expect(page.locator("#reference")).toHaveValue("CONFLICT-VEHICLE-REF");
await expect(page.locator("#reg_1")).toHaveValue("CONFLICT1");
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
await expect(page).toHaveURL(/\/admin\/12\/modules\/pos\?id=\d+&customer_id=22334455&step=2/);
});
});
test.describe("Admin POS Orders - desktop attachment discovery", () => {
test.beforeEach(async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only attachment discovery coverage");
+154
View File
@@ -139,6 +139,14 @@ async function selectCustomerFromPopup(page, customerNumber = REGULAR_CUSTOMER_I
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
}
async function openAddCustomerPopup(page) {
const popup = page.locator('[data-testid="pos-mobile-popup"][data-popup-id="select_customer"]');
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 });
await popup.getByText("Ny kunde", { exact: true }).click();
await expect(page.getByTestId("pos-mobile-add-customer-popup")).toBeVisible({ timeout: 10_000 });
}
async function waitForStepReset(page) {
await expect
.poll(
@@ -404,6 +412,152 @@ test.describe("POS mobile order flow", () => {
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeVisible({ timeout: 10_000 });
});
test("add-customer popup preserves manual edits while refreshing untouched fields from a later CVR lookup", async ({
page,
}) => {
const fixture = createMobilePosFixture({
cvrSearchResponses: {
"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",
},
},
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-add-customer-reset-fix-token",
seedState: {
customerId: null,
reg: "FREE123",
reference: "ADD-CUSTOMER-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
await page.getByTestId("pos-mobile-next-step").click();
await openAddCustomerPopup(page);
const searchInput = page.getByTestId("pos-mobile-add-customer-search-input");
const companyPhone = page.getByTestId("pos-mobile-add-customer-company-phone");
const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email");
const contactEmail = page.getByTestId("pos-mobile-add-customer-contact-email");
const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone");
const submitButton = page.getByTestId("pos-mobile-add-customer-submit");
await searchInput.fill("41004355");
await expect(companyPhone).toHaveValue("21754690", { timeout: 10_000 });
await expect(invoiceEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactEmail).toHaveValue("mikkel@truckwash.dk");
await expect(contactPhone).toHaveValue("21754690");
await expect(submitButton).toBeVisible({ timeout: 10_000 });
await companyPhone.fill("55550000");
await contactEmail.fill("dispatch@truckwash.test");
await searchInput.fill("43423010");
await expect(companyPhone).toHaveValue("55550000", { timeout: 10_000 });
await expect(contactEmail).toHaveValue("dispatch@truckwash.test");
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await expect(submitButton).toBeVisible({ timeout: 10_000 });
});
test("add-customer popup ignores stale delayed CVR responses after a newer lookup resolves", async ({ page }) => {
const fixture = createMobilePosFixture({
cvrSearchResponses: {
"41004355": {
delayMs: 700,
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",
},
},
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-add-customer-stale-search-token",
seedState: {
customerId: null,
reg: "FREE123",
reference: "ADD-CUSTOMER-RACE-REF",
includePrimaryItem: false,
vehicleType: null,
lastOrderId: null,
},
route: {
step: 1,
},
});
await page.getByTestId("pos-mobile-next-step").click();
await openAddCustomerPopup(page);
const searchInput = page.getByTestId("pos-mobile-add-customer-search-input");
const companyPhone = page.getByTestId("pos-mobile-add-customer-company-phone");
const invoiceEmail = page.getByTestId("pos-mobile-add-customer-invoice-email");
const contactPhone = page.getByTestId("pos-mobile-add-customer-contact-phone");
await searchInput.fill("41004355");
await page.waitForTimeout(350);
await searchInput.fill("43423010");
await expect(companyPhone).toHaveValue("42331128", { timeout: 10_000 });
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await page.waitForTimeout(900);
await expect(companyPhone).toHaveValue("42331128");
await expect(invoiceEmail).toHaveValue("billing@wash-group.test");
await expect(contactPhone).toHaveValue("42331128");
await expect.poll(() => fixture.requestCounters.cvrSearchGet, { timeout: 10_000 }).toBe(2);
});
test("transaction history keeps each wash visually separated with a border", async ({ page }) => {
const todayDate = new Date().toISOString().split("T")[0];
const fixture = createMobilePosFixture({
+125
View File
@@ -280,6 +280,8 @@ function createRequestCounters(overrides = {}) {
attachmentsDelete: 0,
attachmentUpload: 0,
attachmentDownload: 0,
cvrSearchGet: 0,
customerRegistrationPost: 0,
lprPost: 0,
...overrides,
};
@@ -297,6 +299,8 @@ function createRequestLog(overrides = {}) {
bookingOrderAssignments: [],
attachmentUploads: [],
attachmentDeletes: [],
cvrSearches: [],
customerRegistrations: [],
...overrides,
};
}
@@ -444,6 +448,32 @@ function buildDefaultFixture() {
{ 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,
@@ -622,6 +652,13 @@ function buildDefaultFixture() {
8102: nonWashBooking,
8103: safetySealBooking,
},
cvrSearchResponses: defaultCvrSearchResponses,
customerRegistrationResponse: {
status: 200,
data: {
success: true,
},
},
pendingBookings: null,
bookingStatusById: {
[DEFAULT_BOOKING_ID]: "pending",
@@ -653,6 +690,8 @@ function normalizeFixture(fixture) {
fixture.attachmentsByOrderId = mergeObjectMaps({}, fixture.attachmentsByOrderId);
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 : [];
@@ -695,6 +734,8 @@ export function createMobilePosFixture(overrides = {}) {
attachmentsByOrderId: mergeObjectMaps(base.attachmentsByOrderId, overrides.attachmentsByOrderId),
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),
@@ -1298,6 +1339,90 @@ export async function mockMobilePosApi(page, fixture) {
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 =
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import {
applyPosAddCustomerSearchResult,
createPosAddCustomerFields,
createPosAddCustomerManualOverrides,
createPosAddCustomerRequestState,
nextPosAddCustomerRequestState,
resolvePosAddCustomerRequestState,
setPosAddCustomerManualOverride,
} from "@/components/displays/department/pos/steps/mobile/elements/posAddCustomerAutofill.js";
function createSearchResult(overrides = {}) {
return {
vat: 41004355,
name: "Truckwash ApS",
phone: "21754690",
email: "mikkel@truckwash.dk",
...overrides,
};
}
describe("POS mobile add-customer autofill", () => {
it("fills all eligible fields on the initial successful lookup", () => {
const nextFields = applyPosAddCustomerSearchResult({
fields: createPosAddCustomerFields(),
manualOverrides: createPosAddCustomerManualOverrides(),
searchResult: createSearchResult(),
notFoundLabel: "Not found",
});
expect(nextFields).toEqual({
cvr: "41004355",
companyPhone: "21754690",
invoiceEmail: "mikkel@truckwash.dk",
contactEmail: "mikkel@truckwash.dk",
contactPhone: "21754690",
});
});
it("updates only untouched fields when a later lookup resolves", () => {
const initialFields = applyPosAddCustomerSearchResult({
fields: createPosAddCustomerFields(),
manualOverrides: createPosAddCustomerManualOverrides(),
searchResult: createSearchResult(),
notFoundLabel: "Not found",
});
const nextFields = applyPosAddCustomerSearchResult({
fields: {
...initialFields,
companyPhone: "55550000",
},
manualOverrides: createPosAddCustomerManualOverrides({
companyPhone: true,
}),
searchResult: createSearchResult({
vat: 43423010,
name: "Wash Group ApS",
phone: "42331128",
email: "billing@wash-group.test",
}),
notFoundLabel: "Not found",
});
expect(nextFields).toEqual({
cvr: "43423010",
companyPhone: "55550000",
invoiceEmail: "billing@wash-group.test",
contactEmail: "billing@wash-group.test",
contactPhone: "42331128",
});
});
it("preserves manually edited fields across later autofill passes", () => {
const manualOverrides = createPosAddCustomerManualOverrides({
companyPhone: true,
contactEmail: true,
});
const nextFields = applyPosAddCustomerSearchResult({
fields: createPosAddCustomerFields({
cvr: "41004355",
companyPhone: "99998888",
invoiceEmail: "old-invoice@example.com",
contactEmail: "dispatch@example.com",
contactPhone: "21754690",
}),
manualOverrides,
searchResult: createSearchResult({
vat: 43423010,
phone: "42331128",
email: "billing@wash-group.test",
}),
notFoundLabel: "Not found",
});
expect(nextFields.companyPhone).toBe("99998888");
expect(nextFields.contactEmail).toBe("dispatch@example.com");
expect(nextFields.invoiceEmail).toBe("billing@wash-group.test");
expect(nextFields.contactPhone).toBe("42331128");
expect(nextFields.cvr).toBe("43423010");
});
it("refills a field after the operator clears a manual override", () => {
const withManualEdit = setPosAddCustomerManualOverride(
createPosAddCustomerManualOverrides({
companyPhone: true,
}),
"companyPhone",
""
);
expect(withManualEdit.companyPhone).toBe(false);
const nextFields = applyPosAddCustomerSearchResult({
fields: createPosAddCustomerFields({
companyPhone: "",
}),
manualOverrides: withManualEdit,
searchResult: createSearchResult({
phone: "42331128",
}),
notFoundLabel: "Not found",
});
expect(nextFields.companyPhone).toBe("42331128");
});
it("rejects slower stale responses after a newer lookup wins", () => {
const initialState = createPosAddCustomerRequestState();
const firstRequest = nextPosAddCustomerRequestState(initialState);
const secondRequest = nextPosAddCustomerRequestState(firstRequest.state);
const staleResolution = resolvePosAddCustomerRequestState(secondRequest.state, firstRequest.requestId);
const latestResolution = resolvePosAddCustomerRequestState(secondRequest.state, secondRequest.requestId);
expect(staleResolution.shouldApply).toBe(false);
expect(staleResolution.state).toEqual(secondRequest.state);
expect(latestResolution.shouldApply).toBe(true);
expect(latestResolution.state).toEqual({
latestRequestedId: secondRequest.requestId,
latestAppliedId: secondRequest.requestId,
});
});
});