Add unit tests for Orders navigation count refresh and fallback customer caching, update POS mobile accessibility labels:

- Introduced `orders-draft-count-refresh.spec.js` for testing `Orders` navigation hooks on customer changes and order deletions.
- Enhanced `use-draft-transaction-customer.spec.js` with tests for clearing cached fallback customers.
- Updated POS mobile components with accessibility labels and localization for image viewer and customer suggestion buttons.
- Added support for `vehicleCustomerSuggestionsGet` handling in e2e mocks and tests.
This commit is contained in:
Jeppe Bundgaard
2026-04-21 15:20:50 +02:00
parent 44f77eadc1
commit 817c174e0e
19 changed files with 575 additions and 50 deletions
@@ -1,11 +1,6 @@
<script setup lang="ts">
import { defineProps, ref } from 'vue';
import VerifiedCustomer from "@/components/viewport/elements/icons/VerifiedCustomer.vue";
import BookedCustomer from "@/components/viewport/elements/icons/BookedCustomer.vue";
import UnknownCustomer from "@/components/viewport/elements/icons/UnknownCustomer.vue";
import KnownCustomer from "@/components/viewport/elements/icons/KnownCustomer.vue";
import CardPaymentCustomer from "@/components/viewport/elements/icons/CardPaymentCustomer.vue";
import { VehicleStatusKey, determineStatusKey, VehicleStatusComponents, statusKeyToComponent } from '../objects/PosVehicleStatus.vue';
import { defineProps, type PropType } from 'vue';
import { VehicleStatusKey, statusKeyToComponent } from '../objects/PosVehicleStatus.vue';
const getBackgroundColor = (status: VehicleStatusKey) => {
switch (status) {
@@ -27,8 +22,8 @@ const getBackgroundColor = (status: VehicleStatusKey) => {
const props = defineProps({
status: {
type: String as () => VehicleStatusKey,
required: true
type: String as PropType<VehicleStatusKey>,
default: 'unknown'
},
registrationNumber: {
type: String,
@@ -152,4 +147,4 @@ const props = defineProps({
width: 100%;
height: 100%;
}
</style>
</style>
@@ -335,6 +335,7 @@ const step2 = async () => {
popups.select("complete_booking", {
props: {
safetySeal: getResolvedMobileSafetySeal(),
onCompleteWithCertificate: onCompleteBookingWithSafetySeal,
onCompleteWithoutCertificate: onCompleteBookingWithSafetySeal,
},
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, defineEmits, ref } from "vue";
import { metadata, popups } from "../objects/PosDepartmentStepMobileFlow.vue";
import { getResolvedMobileSafetySeal } from "../objects/mobileOrderCompletion.js";
import SessionUser from "@/components/session/token/SessionUser.vue";
const emit = defineEmits<{
(e: 'close'): void;
@@ -8,7 +9,15 @@ const emit = defineEmits<{
const popupProps = computed(() => popups.get()?.props || {});
const isSubmitting = ref(false);
const safetySealNumber = ref("");
const resolveExistingSafetySeal = () => {
const popupSafetySeal = popupProps.value?.safetySeal;
if (popupSafetySeal !== null && popupSafetySeal !== undefined) {
return String(popupSafetySeal).trim();
}
return getResolvedMobileSafetySeal();
};
const safetySealNumber = ref(resolveExistingSafetySeal());
const closePopup = () => {
emit('close');
@@ -64,7 +73,7 @@ const completeWithCertificate = async () => {
return;
}
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim();
const normalizedSafetySeal = String(safetySealNumber.value ?? "").trim() || resolveExistingSafetySeal();
const callback = popupProps.value?.onCompleteWithCertificate || popupProps.value?.onComplete;
if (typeof callback === "function") {
await runPopupCompletionCallback(callback, normalizedSafetySeal);
@@ -36,6 +36,15 @@ const toPositiveInteger = (value: unknown) => {
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const resolveSelectedCustomerId = (value: unknown) => {
if (value && typeof value === "object") {
const candidate = value as Record<string, unknown>;
return toPositiveInteger(candidate.customerId ?? candidate.customerNumber ?? candidate.id ?? null);
}
return toPositiveInteger(value);
};
const getCurrentCustomerNumber = () => toPositiveInteger(customer_id.value);
const getResolvedQuickAction = () => {
@@ -116,14 +125,24 @@ const focusCustomerSearchInput = async () => {
};
const onClick = async (result: Partial<PosSearchResult>) => {
const selectedCustomerId = resolveSelectedCustomerId(result?.customerId);
if (!selectedCustomerId) {
return;
}
activeQuickAction.value = CUSTOMER_PICKER_MODE_INVOICE;
await searchAndSelectCustomer(result.customerId);
metadata.setCustomerId(result.customerId);
await searchAndSelectCustomer(selectedCustomerId);
metadata.setCustomerId(selectedCustomerId);
emit("close");
};
const onClickSuggestion = async (customerId: number) => {
await onClick({ customerId });
const onClickSuggestion = async (payload: unknown) => {
const selectedCustomerId = resolveSelectedCustomerId(payload);
if (!selectedCustomerId) {
return;
}
await onClick({ customerId: selectedCustomerId });
};
const onSelectCustomerInvoice = async () => {
@@ -13,6 +13,7 @@ import {
doesTransactionContainWashCertificateProduct,
WASH_CERTIFICATE_PRODUCT_ID,
} from "@/components/displays/department/pos/utils/washCertificate.js";
import { determineStatusKey, type VehicleStatusKey } from './PosVehicleStatus.vue';
import i18n from '@/i18n';
/** Views */
@@ -433,21 +434,48 @@ const setActiveVehicleIndex = (index: number): void => {
activeVehicleIndex.value = index;
}
const normalizeVehicleStatus = (vehicle: PosVehicle): VehicleStatusKey => {
if (typeof vehicle.status === 'string' && vehicle.status.length > 0) {
return vehicle.status as VehicleStatusKey;
}
return determineStatusKey({
hasBooking: Array.isArray(vehicle.booking_matches) ? vehicle.booking_matches.length > 0 : !!vehicle.booking_id,
customer_id: vehicle.customer_id,
customer_name: vehicle.customer_name,
barred: vehicle.barred,
wash_subscription: vehicle.wash_subscription,
});
}
const normalizeVehicleSelection = (vehicle: PosVehicle | null): PosVehicle | null => {
if (!vehicle) {
return null;
}
return {
...vehicle,
reg: String(vehicle.reg ?? ''),
status: normalizeVehicleStatus(vehicle),
};
}
// Function to select a vehicle
const selectVehicle = (index: number, vehicle: PosVehicle | null) => {
const normalizedVehicle = normalizeVehicleSelection(vehicle);
switch (index) {
case 1:
vehicle_1.value = vehicle;
vehicle_1.value = normalizedVehicle;
// If the vehicle has a reference, and it is currently not set, set it
if (vehicle?.reference && !metadata.getReference()) {
metadata.setReference(vehicle.reference);
if (normalizedVehicle?.reference && !metadata.getReference()) {
metadata.setReference(normalizedVehicle.reference);
}
break;
case 2:
vehicle_2.value = vehicle;
vehicle_2.value = normalizedVehicle;
break;
case 3:
vehicle_3.value = vehicle;
vehicle_3.value = normalizedVehicle;
break;
default:
console.warn(`Invalid vehicle index: ${index}. Please use 1, 2, or 3.`);
@@ -1659,9 +1687,9 @@ const retrievePos = () => {
// Restore vehicles data
if (parsedData.vehicles) {
vehicles.vehicle_1.value = parsedData.vehicles.vehicle_1 ?? null;
vehicles.vehicle_2.value = parsedData.vehicles.vehicle_2 ?? null;
vehicles.vehicle_3.value = parsedData.vehicles.vehicle_3 ?? null;
vehicles.vehicle_1.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_1 ?? null);
vehicles.vehicle_2.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_2 ?? null);
vehicles.vehicle_3.value = normalizeVehicleSelection(parsedData.vehicles.vehicle_3 ?? null);
vehicles.activeVehicleIndex.value = parsedData.vehicles.activeVehicleIndex ?? 1;
}
@@ -1,9 +1,10 @@
<script setup lang="ts">
import {defineEmits, ref, watch, computed} from "vue";
import {metadata, popups, actionButtons} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
import SessionUser from "@/components/session/token/SessionUser.vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { popups } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
// Define the close event to emit when the component is closed
const emit = defineEmits(["close"]);
const { t } = useI18n();
const props = popups.get()?.props; // props: {base64String: attachment.base64String, filename: attachment.filename}
const imageZoomDefault = ref<number>(1);
@@ -134,36 +135,55 @@ const handleMouseWheel = (e: WheelEvent) => {
</div>
</div>
<div class="column is-one-third">
<button class="button is-fullwidth is-white" :disabled="imageZoom >= imageZoomMax" @pointerdown="imageZoomIn" @pointerup="clearInterval" @pointerleave="clearInterval">
<button
class="button is-fullwidth is-white"
data-testid="pos-mobile-image-viewer-zoom-in"
:disabled="imageZoom >= imageZoomMax"
@pointerdown="imageZoomIn"
@pointerup="clearInterval"
@pointerleave="clearInterval"
>
<span class="icon is-small">
<i class="fas fa-search-plus"></i>
</span>
<small>Zoom</small>
<small>{{ t("global.zoom") }}</small>
</button>
</div>
<div class="column is-one-third">
<button class="button is-fullwidth is-white" :disabled="imageZoom <= imageZoomMin" @pointerdown="imageZoomOut" @pointerup="clearInterval" @pointerleave="clearInterval">
<button
class="button is-fullwidth is-white"
data-testid="pos-mobile-image-viewer-zoom-out"
:disabled="imageZoom <= imageZoomMin"
@pointerdown="imageZoomOut"
@pointerup="clearInterval"
@pointerleave="clearInterval"
>
<span class="icon is-small">
<i class="fas fa-search-minus"></i>
</span>
<small>Zoom ud</small>
<small>{{ t("global.zoom_out") }}</small>
</button>
</div>
<div class="column is-one-third">
<button class="button is-fullwidth is-white" @click="imageZoomReset" :disabled="imageZoom === imageZoomDefault">
<button
class="button is-fullwidth is-white"
data-testid="pos-mobile-image-viewer-reset"
@click="imageZoomReset"
:disabled="imageZoom === imageZoomDefault"
>
<span class="icon is-small">
<i class="fas fa-redo"></i>
</span>
<small>Nulstil</small>
<small>{{ t("global.reset") }}</small>
</button>
</div>
</div>
<!-- Close button -->
<button class="button is-fullwidth is-white mt-2" @click="close">
<button class="button is-fullwidth is-white mt-2" data-testid="pos-mobile-image-viewer-close" @click="close">
<span class="icon is-small">
<i class="fas fa-times"></i>
</span>
<small>Luk</small>
<small>{{ t("global.close") }}</small>
</button>
</div>
</div>
@@ -199,4 +219,4 @@ const handleMouseWheel = (e: WheelEvent) => {
width: 95vw;
max-width: 600px;
}
</style>
</style>
@@ -165,7 +165,7 @@ if (props.reg_1) {
</script>
<template>
<div class="field" :class="{ 'is-hidden': isCustomerSelected() }">
<div class="field" :class="{ 'is-hidden': isCustomerSelected() }" data-testid="pos-customer-suggestions">
<div class="control">
<template v-if="customer_suggestions.length > 0">
<div class="columns is-multiline is-mobile">
@@ -189,6 +189,7 @@ if (props.reg_1) {
<button
class="button is-light is-fullwidth"
@click="onClick(customer)"
:data-testid="`pos-customer-suggestion-select-${customer.customer_number}`"
:class="{
'is-loading': isSettingCustomerTo(customer.customer_number),
'is-danger': customer.barred,
@@ -7,6 +7,7 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import { editOrderItem, getOrderItems } from "@/components/shop/OrdersItems.vue";
import {createApp} from "vue";
import i18n from '@/i18n';
import { dispatchNavigationCountRefresh } from "@/components/models/navigation/items/navigationCountEvents.js";
const t = (key) => i18n.global.t(key);
const mountOrderCustomerAssignmentModal = ({
@@ -150,6 +151,10 @@ const normalizePositiveInteger = (value) => {
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const refreshDraftNavigationCount = () => {
dispatchNavigationCountRefresh();
};
const getFinalProductPriceForCustomer = async (productId, departmentId, customerId) => {
const normalizedProductId = normalizePositiveInteger(productId);
const normalizedDepartmentId = normalizePositiveInteger(departmentId);
@@ -455,7 +460,7 @@ const assignDraftOrderCustomer = async ({
};
},
add: async (customer_id, cashier_id, department_id, reference, reg_1, reg_2, reg_3, notes, invoice_collection_id) => {
return ObjectsGlobal.add.object(
const response = await ObjectsGlobal.add.object(
Orders.meta.endpoint,
{
customer_id: parseInt(customer_id),
@@ -469,15 +474,19 @@ const assignDraftOrderCustomer = async ({
invoice_collection_id: parseInt(invoice_collection_id),
}
);
refreshDraftNavigationCount();
return response;
},
set: {
customer_id: async (id, customer_id) => {
return ObjectsGlobal.set.column(
const response = await ObjectsGlobal.set.column(
Orders.meta.endpoint,
id,
"customer_id",
parseInt(customer_id)
)
);
refreshDraftNavigationCount();
return response;
},
cashier_id: async (id, cashier_id) => {
return ObjectsGlobal.set.column(
@@ -629,7 +638,9 @@ const assignDraftOrderCustomer = async ({
},
delete: {
single: async (id) => {
return ObjectsGlobal.delete.object(Orders.meta.endpoint, id);
const response = await ObjectsGlobal.delete.object(Orders.meta.endpoint, id);
refreshDraftNavigationCount();
return response;
},
},
functions: {
@@ -16,6 +16,16 @@ const resolveConfiguredDraftTransactionCustomerNumber = () => {
);
};
export const setDraftTransactionCustomerNumber = (customerNumber) => {
const normalizedCustomerNumber = toPositiveInteger(customerNumber);
SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value = normalizedCustomerNumber;
fallbackDraftTransactionCustomerNumber.value = normalizedCustomerNumber;
draftTransactionCustomerConfigRequest = null;
return normalizedCustomerNumber;
};
export const ensureDraftTransactionCustomerLoaded = async () => {
if (resolveConfiguredDraftTransactionCustomerNumber() !== null) {
return resolveConfiguredDraftTransactionCustomerNumber();
+3
View File
@@ -348,6 +348,7 @@
"edit": "Rediger",
"enter_safety_seal_number": "Indtast sikkerhedsforseglingsnummer",
"generate_wash_certificate": "Generer vaskecertifikat",
"wash_certificate": "Vaskecertifikat",
"invoice_email": "Faktura-e-mail",
"license_plate": "Nummerplade",
"license_plates": "Nummerplader",
@@ -1960,6 +1961,8 @@
"required": "Påkrævet",
"requires_action": "Kræver handling",
"reset": "Nulstil",
"zoom": "Zoom",
"zoom_out": "Zoom ud",
"rows": "Rækker",
"save": "Gem",
"scanning": "Scanner",
+3
View File
@@ -348,6 +348,7 @@
"edit": "Bearbeiten",
"enter_safety_seal_number": "Sicherheitsplomben-Nummer eingeben",
"generate_wash_certificate": "Waschzertifikat erstellen",
"wash_certificate": "Waschzertifikat",
"invoice_email": "Rechnungs-E-Mail",
"license_plate": "Kennzeichen",
"license_plates": "Kennzeichen",
@@ -1926,6 +1927,8 @@
"required": "Erforderlich",
"requires_action": "Aktion erforderlich",
"reset": "Zurücksetzen",
"zoom": "Zoom",
"zoom_out": "Herauszoomen",
"rows": "Zeilen",
"save": "Speichern",
"scanning": "Scannen",
+3
View File
@@ -348,6 +348,7 @@
"edit": "Edit",
"enter_safety_seal_number": "Enter safety seal number",
"generate_wash_certificate": "Generate wash certificate",
"wash_certificate": "Wash certificate",
"invoice_email": "Invoice email",
"license_plate": "License plate",
"license_plates": "License plates",
@@ -1960,6 +1961,8 @@
"required": "Required",
"requires_action": "Requires action",
"reset": "Reset",
"zoom": "Zoom",
"zoom_out": "Zoom out",
"rows": "Rows",
"save": "Save",
"scanning": "Scanning",
+3
View File
@@ -348,6 +348,7 @@
"edit": "Redigere",
"enter_safety_seal_number": "Skriv inn sikkerhetsforseglingsnummeret",
"generate_wash_certificate": "Generer vaskesertifikat",
"wash_certificate": "Vaskesertifikat",
"invoice_email": "Faktura e-post",
"license_plate": "Nummerskilt",
"license_plates": "Nummerskilt",
@@ -1926,6 +1927,8 @@
"required": "Påkrevd",
"requires_action": "Krever handling",
"reset": "Nullstill",
"zoom": "Zoom",
"zoom_out": "Zoom ut",
"rows": "Rader",
"save": "Lagre",
"scanning": "Skanning",
+3
View File
@@ -348,6 +348,7 @@
"edit": "Edit",
"enter_safety_seal_number": "Ange säkerhetsfärseglingsnummer",
"generate_wash_certificate": "Generera tvättcertifikat",
"wash_certificate": "Tvättcertifikat",
"invoice_email": "Faktura-e-post",
"license_plate": "License plate",
"license_plates": "License plates",
@@ -1926,6 +1927,8 @@
"required": "Obligatorisk",
"requires_action": "Kräver åtgärd",
"reset": "Återställ",
"zoom": "Zoom",
"zoom_out": "Zooma ut",
"rows": "Rows",
"save": "Spara",
"scanning": "Scanning",
@@ -13,6 +13,7 @@ import ConfigurationSelect from "@/components/displays/superuser/configuration/C
import Swal from "sweetalert2";
import ConfigurationInputNumber from "@/components/displays/superuser/configuration/ConfigurationInputNumber.vue";
import { useI18n } from 'vue-i18n';
import { setDraftTransactionCustomerNumber } from "@/composables/useDraftTransactionCustomer.js";
const { t } = useI18n();
@@ -88,6 +89,21 @@ const parseNullableConfigNumber = (variable) => {
return Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null;
};
const saveDraftTransactionCustomerNumber = async (customerNumber) => {
const normalizedCustomerNumber = Number.isInteger(Number.parseInt(String(customerNumber ?? ''), 10))
&& Number.parseInt(String(customerNumber ?? ''), 10) > 0
? Number.parseInt(String(customerNumber ?? ''), 10)
: null;
const response = await SessionUser.superUser.modules.economic.config.transactionDraftCustomerNumber.set(
normalizedCustomerNumber
);
setDraftTransactionCustomerNumber(normalizedCustomerNumber);
return response;
};
const load = async () => {
await getLayouts();
await getModuleConfig();
@@ -206,7 +222,7 @@ load();
:title="$t('configuration.economic.transaction_draft_customer_number')"
:description="$t('configuration.economic.transaction_draft_customer_number_desc')"
:value="parseNullableConfigNumber('transactionDraftCustomerNumber')"
:on-save="SessionUser.superUser.modules.economic.config.transactionDraftCustomerNumber.set"
:on-save="saveDraftTransactionCustomerNumber"
:min="0"
/>
</ConfigurationCategory>
+271 -8
View File
@@ -116,6 +116,12 @@ function buildTodayTimestamp(time = "08:00:00.000Z") {
return `${todayIsoDate}T${time}`;
}
async function forceLocale(page, locale = "en") {
await page.addInitScript((value) => {
window.localStorage.setItem("locale", value);
}, locale);
}
function createMultiBookingFixture({ reg, vehicle = {}, bookings = [] }) {
const baseFixture = createMobilePosFixture();
return createMobilePosFixture({
@@ -436,7 +442,7 @@ test("mobile customer popup exposes the draft quick action and selects the confi
seedState: {
customerId: null,
includePrimaryItem: false,
reg: "AB12345",
reg: "FREE123",
reference: "MOBILE-DRAFT",
},
route: {
@@ -469,6 +475,94 @@ test("mobile customer popup exposes the draft quick action and selects the confi
await expect(page.getByTestId("pos-mobile-customer-name")).toContainText(MOBILE_DRAFT_TRANSACTION_CUSTOMER_NAME);
});
test("mobile customer popup applies a previous-customer suggestion", async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "Mobile POS order suite is scoped to chromium-mobile.");
const suggestedCustomerId = 55667788;
const suggestedCustomerName = "Suggestion Logistics";
const secondarySuggestedCustomerId = 66778899;
const fixture = createMobilePosFixture({
customersByNumber: {
[suggestedCustomerId]: {
id: suggestedCustomerId,
customerNumber: suggestedCustomerId,
name: suggestedCustomerName,
address: "Suggestion Street 8",
zip: "2630",
city: "Taastrup",
mobilePhone: "55667788",
email: "suggestion@example.com",
corporateIdentificationNumber: "55667788",
barred: false,
economic_customer: suggestedCustomerId,
},
[secondarySuggestedCustomerId]: {
id: secondarySuggestedCustomerId,
customerNumber: secondarySuggestedCustomerId,
name: "Fallback Suggestion",
address: "Fallback Street 9",
zip: "2630",
city: "Taastrup",
mobilePhone: "66778899",
email: "fallback@example.com",
corporateIdentificationNumber: "66778899",
barred: false,
economic_customer: secondarySuggestedCustomerId,
},
},
vehicleCustomerSuggestionsByReg: {
FREE123: [
{
id: 901,
customer_number: suggestedCustomerId,
customer_name: suggestedCustomerName,
barred: false,
},
{
id: 902,
customer_number: secondarySuggestedCustomerId,
customer_name: "Fallback Suggestion",
barred: false,
},
],
},
});
await setupMobilePosPage(page, fixture, {
token: "pos-mobile-previous-customer-suggestion-token",
seedState: {
customerId: null,
includePrimaryItem: false,
reg: "FREE123",
reference: "MOBILE-SUGGESTION",
lastOrderId: null,
},
route: {
step: 1,
},
});
await openCustomerPopupFromStep1(page);
await expect(page.getByTestId("pos-customer-suggestions")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId(`pos-customer-suggestion-select-${suggestedCustomerId}`)).toBeVisible({
timeout: 10_000,
});
await page.getByTestId(`pos-customer-suggestion-select-${suggestedCustomerId}`).click();
await expect(page.getByTestId("pos-mobile-customer-popup")).toBeHidden({ timeout: 10_000 });
await expect
.poll(async () => {
const snapshot = await getStoredPosSnapshot(page);
return snapshot?.metadata?.customerId ?? null;
})
.toBe(suggestedCustomerId);
await waitForMobileNextStepCooldown(page);
await page.getByTestId("pos-mobile-next-step").click();
await waitForMobileStepTwoReady(page);
await expect(page.getByTestId("pos-mobile-customer-name")).toContainText(suggestedCustomerName);
});
test("mobile customer popup defaults to customer invoice mode and keeps customer search visible", async ({
page,
}, testInfo) => {
@@ -998,6 +1092,74 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("stored step 1 vehicles without status normalize to unknown without Vue prop warnings", async ({ page }) => {
const fixture = createMobilePosFixture();
const consoleProblems = [];
const pageErrors = [];
page.on("console", (message) => {
if (message.type() === "error" || message.type() === "warning" || message.text().includes('prop "status"')) {
consoleProblems.push(`${message.type()}: ${message.text()}`);
}
});
page.on("pageerror", (error) => {
pageErrors.push(error.stack || error.message);
});
await setupMobilePosPage(page, fixture, {
token: "stored-step1-missing-status-token",
seedState: false,
route: {
step: 1,
},
});
await page.evaluate(() => {
const storedValue = window.localStorage.getItem("pos");
const snapshot = storedValue ? JSON.parse(storedValue) : {};
snapshot.vehicles = snapshot.vehicles || {};
snapshot.vehicles.vehicle_1 = {
reg: "EC2123",
customer_id: null,
type: null,
booking_id: null,
booking_matches: [],
wash_subscription: false,
barred: false,
reference: "",
last_order_id: null,
};
snapshot.vehicles.vehicle_2 = null;
snapshot.vehicles.vehicle_3 = null;
snapshot.vehicles.activeVehicleIndex = 1;
window.localStorage.setItem("pos", JSON.stringify(snapshot));
});
await page.reload();
await expect(page.getByTestId("pos-mobile-step-1-shell")).toBeVisible({ timeout: 10_000 });
await expect
.poll(
async () => {
const snapshot = await getStoredPosSnapshot(page);
return {
reg: snapshot?.vehicles?.vehicle_1?.reg ?? null,
status: snapshot?.vehicles?.vehicle_1?.status ?? null,
};
},
{ timeout: 10_000 }
)
.toEqual({
reg: "EC2123",
status: "unknown",
});
const combinedProblems = [...consoleProblems, ...pageErrors].join("\n");
expect(combinedProblems).not.toContain('Invalid prop: type check failed for prop "status"');
expect(combinedProblems).not.toContain('Expected String with value "undefined"');
});
test("matched vehicle manual input seeds the step 2 reference and primary product defaults", async ({ page }) => {
const fixture = createMobilePosFixture();
await createOrderFromStep1(page, fixture, {
@@ -2292,6 +2454,8 @@ test.describe("POS mobile order flow", () => {
},
});
await forceLocale(page, "en");
await setupMobilePosPage(page, fixture, {
token: "mobile-action-metadata-token",
seedState: {
@@ -2317,7 +2481,7 @@ test.describe("POS mobile order flow", () => {
await expect(clearAllButton).toHaveAttribute("data-action-key", "pos-mobile-clear-all");
await expect(completeButton).toHaveAttribute("data-copy-key", "complete");
await expect(clearAllButton).toHaveAttribute("data-copy-key", "clear_all");
await expect(completeButton).toContainText(/Afslut|Fuldf/);
await expect(completeButton).toContainText(/Complete|Afslut|Fuldf/);
await expectCleanActionText(completeButton);
await expectCleanActionText(clearAllButton);
@@ -2329,21 +2493,63 @@ test.describe("POS mobile order flow", () => {
const attachmentsToggle = page.getByTestId("pos-mobile-attachments-toggle");
await expect(attachmentsToggle).toBeVisible({ timeout: 10_000 });
await expect(attachmentsToggle).toHaveAttribute("data-action-key", "pos-mobile-attachments-toggle");
await expect(attachmentsToggle).toContainText("Attachments");
await expectCleanActionText(attachmentsToggle);
await attachmentsToggle.click();
for (const actionKey of [
"pos-mobile-attachment-view-take-picture",
"pos-mobile-attachment-view-close",
"pos-mobile-attachments-upload-file",
"pos-mobile-attachments-wash-certificate",
]) {
const expectedLabelsByActionKey = {
"pos-mobile-attachment-view-take-picture": "Take picture",
"pos-mobile-attachment-view-close": "Close",
"pos-mobile-attachments-upload-file": "Upload",
"pos-mobile-attachments-wash-certificate": "Wash certificate",
};
for (const [actionKey, label] of Object.entries(expectedLabelsByActionKey)) {
const action = getByActionKey(page, actionKey);
await expect(action).toBeVisible({ timeout: 10_000 });
await expect(action).toHaveAttribute("data-action-key", actionKey);
await expect(action).toContainText(label);
await expectCleanActionText(action);
}
await expect(page.locator("body")).not.toContainText("admin.pos.wash_certificate");
});
test("mobile image viewer uses localized controls", async ({ page }) => {
const imageAttachment = {
filename: "damage.svg",
base64String:
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiI+PHJlY3Qgd2lkdGg9IjMyIiBoZWlnaHQ9IjMyIiBmaWxsPSJyZWQiLz48L3N2Zz4=",
};
await forceLocale(page, "en");
await setupMobilePosPage(page, createMobilePosFixture(), {
token: "mobile-image-viewer-locale-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "AB12345",
includePrimaryItem: false,
attachmentsBase64: [imageAttachment],
lastOrderId: null,
},
route: {
step: 1,
},
});
await expect(page.getByTestId("pos-mobile-step-1")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("pos-mobile-attachments-toggle").click();
const thumbnail = page.getByAltText("damage.svg");
await expect(thumbnail).toBeVisible({ timeout: 10_000 });
await thumbnail.click();
await expect(page.getByTestId("pos-mobile-image-viewer-zoom-in")).toContainText("Zoom", { timeout: 10_000 });
await expect(page.getByTestId("pos-mobile-image-viewer-zoom-out")).toContainText("Zoom out");
await expect(page.getByTestId("pos-mobile-image-viewer-reset")).toContainText("Reset");
await expect(page.getByTestId("pos-mobile-image-viewer-close")).toContainText("Close");
});
test("step 2 sync is idempotent when the order already matches the local transaction", async ({ page }) => {
@@ -2649,6 +2855,63 @@ test.describe("POS mobile order flow", () => {
await waitForStepReset(page);
});
test("booking completion popup reuses the step 2 safety seal without asking for it again", async ({ page }) => {
const orderId = 9412;
const fixture = createMobilePosFixture({
ordersById: {
[orderId]: buildRegularOrder(orderId, {
reg_1: "SEAL321",
reference: "",
}),
},
orderItemsByOrderId: {
[orderId]: [],
},
});
await setupMobilePosPage(page, fixture, {
token: "mobile-booking-prefilled-seal-token",
seedState: {
customerId: REGULAR_CUSTOMER_ID,
reg: "SEAL321",
reference: "",
includePrimaryItem: false,
vehicleType: null,
bookingId: 8103,
vehicleStatus: "booked",
lastOrderId: null,
},
route: {
step: 2,
orderId,
customerId: REGULAR_CUSTOMER_ID,
},
});
await expect(page.getByTestId("pos-mobile-step-2")).toBeVisible({ timeout: 10_000 });
await waitForBookingHydration(page, {
primaryId: 53,
addonProductIds: [41],
});
const safetySealInput = page.getByTestId("pos-mobile-safety-seal-step-2-input");
await expect(safetySealInput).toBeVisible({ timeout: 10_000 });
await safetySealInput.fill("5150");
await page.getByTestId("pos-mobile-next-step").click();
const popupSafetySealInput = page.getByTestId("pos-mobile-booking-safety-seal-input");
await expect(page.getByTestId("pos-mobile-popup")).toBeVisible({ timeout: 10_000 });
await expect(popupSafetySealInput).toHaveValue("5150");
await page.getByTestId("pos-mobile-booking-complete-with-certificate").click();
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
await expect.poll(() => fixture.bookingsById[8103]?.status ?? "").toBe("completed");
await expect.poll(() => String(fixture.bookingsById[8103]?.safety_seal ?? "")).toBe("5150");
await waitForStepReset(page);
});
test("booking completion failure keeps the mobile booking popup open for retry", async ({ page }) => {
const orderId = 9410;
const fixture = createMobilePosFixture({
+15
View File
@@ -265,6 +265,7 @@ function createRequestCounters(overrides = {}) {
vehiclesSearchGet: 0,
vehiclesGet: 0,
unknownVehiclesGet: 0,
vehicleCustomerSuggestionsGet: 0,
usersCustomerGet: 0,
customersGet: 0,
customerNotesGet: 0,
@@ -664,6 +665,7 @@ function buildDefaultFixture() {
8103: safetySealBooking,
},
cvrSearchResponses: defaultCvrSearchResponses,
vehicleCustomerSuggestionsByReg: {},
customerRegistrationResponse: {
status: 200,
data: {
@@ -696,6 +698,7 @@ function normalizeFixture(fixture) {
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);
@@ -743,6 +746,10 @@ export function createMobilePosFixture(overrides = {}) {
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),
@@ -1469,6 +1476,14 @@ export async function mockMobilePosApi(page, fixture) {
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();
@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const addObjectMock = vi.hoisted(() => vi.fn(async () => ({ data: { data: { id: 91 } } })));
const setColumnMock = vi.hoisted(() => vi.fn(async () => ({ data: { success: true } })));
const deleteObjectMock = vi.hoisted(() => vi.fn(async () => ({ data: { success: true } })));
const dispatchNavigationCountRefreshMock = vi.hoisted(() => vi.fn());
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(() => Promise.resolve()),
close: vi.fn(),
},
}));
vi.mock("@/i18n", () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock("@/components/models/navigation/items/navigationCountEvents.js", () => ({
dispatchNavigationCountRefresh: dispatchNavigationCountRefreshMock,
}));
vi.mock("@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue", () => ({
ObjectsGlobal: {
add: {
object: addObjectMock,
},
set: {
column: setColumnMock,
},
delete: {
object: deleteObjectMock,
},
showDeleteConfirmationModal: vi.fn(),
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
orders: {
functions: {
get_customer_id: vi.fn(),
},
set: {
customer_id: vi.fn(),
invoice_collection_id: vi.fn(),
},
},
collectedOrderInvoices: {
functions: {
showInvoiceCollectionPickerForm: vi.fn(),
},
},
},
request: vi.fn(() => Promise.resolve({ data: { data: {} } })),
functions: {
parseErrorMessage: vi.fn(() => "error"),
},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
}));
vi.mock("@/components/shop/OrdersItems.vue", () => ({
editOrderItem: vi.fn(),
getOrderItems: vi.fn(async () => ({ data: { data: [] } })),
}));
import { Orders } from "@/components/session/token/SessionUser/Objects/Orders.vue";
describe("Orders draft count refresh hooks", () => {
beforeEach(() => {
addObjectMock.mockClear();
setColumnMock.mockClear();
deleteObjectMock.mockClear();
dispatchNavigationCountRefreshMock.mockClear();
});
it("refreshes navigation counts after changing an order customer", async () => {
await Orders.set.customer_id(45, 6001);
expect(setColumnMock).toHaveBeenCalledWith("/orders", 45, "customer_id", 6001);
expect(dispatchNavigationCountRefreshMock).toHaveBeenCalledTimes(1);
});
it("refreshes navigation counts after deleting an order", async () => {
await Orders.delete.single(45);
expect(deleteObjectMock).toHaveBeenCalledWith("/orders", 45);
expect(dispatchNavigationCountRefreshMock).toHaveBeenCalledTimes(1);
});
});
@@ -66,4 +66,27 @@ describe("useDraftTransactionCustomer", () => {
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBe(778899);
expect(module.getDraftTransactionCustomerNumber()).toBe(778899);
});
it("can clear a previously cached fallback customer number", async () => {
sessionState.canAccessSuperUser.mockReturnValue(true);
sessionState.getTransactionDraftCustomerNumberConfig.mockResolvedValue({
data: {
data: [
{
variable: "transactionDraftCustomerNumber",
value: 778899,
},
],
},
});
const module = await import("@/composables/useDraftTransactionCustomer.js");
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBe(778899);
expect(module.getDraftTransactionCustomerNumber()).toBe(778899);
expect(module.setDraftTransactionCustomerNumber(null)).toBeNull();
expect(module.getDraftTransactionCustomerNumber()).toBeNull();
expect(sessionState.runtimeConfigValue.value).toBeNull();
});
});