Add enhanced admin access checks and error handling for draft transaction customers and self-serve processes. Extend edge gateway diagnostics, refine terminal readiness detection, and improve live session test coverage.

This commit is contained in:
Jeppe Bundgaard
2026-04-28 09:01:52 +02:00
parent d411da2a12
commit e26e37d9d7
19 changed files with 701 additions and 81 deletions
@@ -37,6 +37,15 @@ const emitAnswerQuestion = (questionId: number, value: boolean) => {
<p>{{ $t("self_wash.loading_data") }}</p>
</div>
<div v-else>
<div
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-4"
data-testid="self-serve-questions-inline-loading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div class="is-flex is-justify-content-center is-align-items-center mb-4">
<h1 class="title mb-0">{{ $t("self_wash.answer_questions") }}</h1>
<b-button
@@ -28,11 +28,20 @@ const emitDownloadAttachment = (taskId: number, attachmentId: number) => {
<template>
<div data-testid="self-serve-tasks-step">
<div v-if="isLoading" class="has-text-centered p-6">
<div v-if="isLoading && activeTasks.length === 0" class="has-text-centered p-6">
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
<p>{{ $t("self_wash.loading_data") }}</p>
</div>
<div v-else>
<div
v-if="isLoading"
class="notification is-info is-light py-2 px-3 mb-4"
data-testid="self-serve-tasks-inline-loading"
>
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-small" />
<span class="ml-2">{{ $t("self_wash.loading_data") }}</span>
</div>
<div v-if="dynamicImageUrl" class="mb-4">
<img
:src="dynamicImageUrl"
@@ -65,6 +65,7 @@ let draftCountRefreshQueued = false;
let queuedBookingLoadingIndicator = false;
let queuedDraftLoadingIndicator = false;
const getDepartmentIdNumber = () => Number.parseInt(String(department_id.value), 10);
const canUseAdminNavigationCounts = () => SessionUser.canAccessAdmin();
const getDepartmentById = (id: number) => {
return departments_cache.value?.find((department: any) => Number(department?.id) === Number(id)) || null;
};
@@ -277,6 +278,26 @@ const fetchCurrentDepartmentDraftCount = async ({ showLoadingIndicator = false }
};
const refreshNavigationCounts = ({ showBookingLoadingIndicator = false, showDraftLoadingIndicator = false } = {}) => {
if (!canUseAdminNavigationCounts()) {
bookingCountRequestId += 1;
draftCountRequestId += 1;
department_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
department_booking_counts_loading.value = false;
department_draft_count.value = 0;
department_draft_count_loading.value = false;
bookingCountFetchInFlight = false;
draftCountFetchInFlight = false;
bookingCountRefreshQueued = false;
draftCountRefreshQueued = false;
queuedBookingLoadingIndicator = false;
queuedDraftLoadingIndicator = false;
return;
}
if (!hasValidDepartmentId.value) {
department_booking_counts.value = {
past: 0,
@@ -328,7 +349,7 @@ if (typeof window !== "undefined") {
// Fetch the booking count every 5 seconds when department context is valid
setInterval(() => {
if (hasValidDepartmentId.value) {
if (canUseAdminNavigationCounts() && hasValidDepartmentId.value) {
refreshNavigationCounts();
}
}, 5000);
@@ -32,6 +32,27 @@ let superUserDraftCountRefreshQueued = false;
let queuedSuperUserBookingLoadingIndicator = false;
let queuedSuperUserDraftLoadingIndicator = false;
const canUseSuperUserNavigationCounts = () => SessionUser.canAccessSuperUser();
const resetSuperUserNavigationCounts = () => {
bookingCountRequestId += 1;
draftCountRequestId += 1;
superuser_booking_counts.value = {
past: 0,
current: 0,
future: 0,
};
superuser_booking_count_loading.value = false;
superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false;
superUserBookingCountFetchInFlight = false;
superUserDraftCountFetchInFlight = false;
superUserBookingCountRefreshQueued = false;
superUserDraftCountRefreshQueued = false;
queuedSuperUserBookingLoadingIndicator = false;
queuedSuperUserDraftLoadingIndicator = false;
};
const getSuperUserBookingsBadges = () => {
if (superuser_booking_count_loading.value) {
return [{
@@ -98,6 +119,11 @@ const getSuperUserDraftsBadge = () => {
};
const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
return;
}
if (superUserBookingCountFetchInFlight) {
superUserBookingCountRefreshQueued = true;
queuedSuperUserBookingLoadingIndicator = queuedSuperUserBookingLoadingIndicator || showLoadingIndicator;
@@ -150,6 +176,11 @@ const fetchCurrentSuperUserBookingCount = async ({ showLoadingIndicator = false
};
const fetchCurrentSuperUserDraftCount = async ({ showLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
return;
}
if (draftTransactionCustomerNumber.value === null) {
superuser_draft_count.value = 0;
superuser_draft_count_loading.value = false;
@@ -213,6 +244,11 @@ const fetchCurrentSuperUserDraftCount = async ({ showLoadingIndicator = false }
};
const refreshSuperUserNavigationCounts = ({ showBookingLoadingIndicator = false, showDraftLoadingIndicator = false } = {}) => {
if (!canUseSuperUserNavigationCounts()) {
resetSuperUserNavigationCounts();
return;
}
void fetchCurrentSuperUserBookingCount({
showLoadingIndicator: showBookingLoadingIndicator,
});
@@ -245,7 +281,9 @@ if (typeof window !== "undefined") {
}
setInterval(() => {
refreshSuperUserNavigationCounts();
if (canUseSuperUserNavigationCounts()) {
refreshSuperUserNavigationCounts();
}
}, 5000);
const items = computed<NavigationItemProps[]>(() => [
@@ -9,7 +9,18 @@ const toPositiveInteger = (value) => {
const fallbackDraftTransactionCustomerNumber = ref(null);
let draftTransactionCustomerConfigRequest = null;
const canUseDraftTransactionCustomerNumber = () => {
const canAccessAdmin = typeof SessionUser?.canAccessAdmin === "function" && SessionUser.canAccessAdmin();
const canAccessSuperUser = typeof SessionUser?.canAccessSuperUser === "function" && SessionUser.canAccessSuperUser();
return canAccessAdmin || canAccessSuperUser;
};
const resolveConfiguredDraftTransactionCustomerNumber = () => {
if (!canUseDraftTransactionCustomerNumber()) {
return null;
}
return (
toPositiveInteger(SessionUser.runtimeConfig.economic.transactionDraftCustomerNumber.value) ??
fallbackDraftTransactionCustomerNumber.value
@@ -27,6 +38,10 @@ export const setDraftTransactionCustomerNumber = (customerNumber) => {
};
export const ensureDraftTransactionCustomerLoaded = async () => {
if (!canUseDraftTransactionCustomerNumber()) {
return null;
}
if (resolveConfiguredDraftTransactionCustomerNumber() !== null) {
return resolveConfiguredDraftTransactionCustomerNumber();
}
+31
View File
@@ -78,6 +78,23 @@ const normalizePositiveInt = (value) => {
return !Number.isNaN(parsed) && parsed > 0 ? parsed : null;
};
const extractErrorMessage = (error, fallback = "Kunne ikke hente selvvaskdata. Prov igen.") => {
const candidates = [
error?.response?.data?.data?.message,
error?.response?.data?.message,
error?.response?.data?.error,
error?.data?.data?.message,
error?.data?.message,
error?.message,
];
const message = candidates.find((candidate) => (
typeof candidate === "string" && candidate.trim() !== ""
));
return message || fallback;
};
const extractResolvedVehicleTypeId = (payload) => {
if (!payload || typeof payload !== "object") {
return null;
@@ -192,6 +209,7 @@ const mergeByNumericId = (existingItems, incomingItems) => {
export function useSelfServeLogic() {
const loading = ref(false);
const requestError = ref(null);
const preview = ref(null);
const summary = ref(null);
const lane = ref(null);
@@ -446,9 +464,13 @@ export function useSelfServeLogic() {
if (!didApply) {
return null;
}
requestError.value = null;
return summaryData;
} catch (error) {
console.error("Error fetching self-serve summary:", error);
if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error, "Kunne ikke hente vaskestatus. Prov igen.");
}
return null;
} finally {
if (manageLoading) {
@@ -486,6 +508,7 @@ export function useSelfServeLogic() {
}
loading.value = true;
requestError.value = null;
try {
const normalizedReg = reg.trim().toUpperCase();
const normalizedVehicleTypeId = parseInt(_vehicleTypeId);
@@ -570,6 +593,9 @@ export function useSelfServeLogic() {
return previewData;
} catch (error) {
console.error("Error fetching self-serve preview:", error);
if (isFetchRequestActive(requestId)) {
requestError.value = extractErrorMessage(error);
}
return null;
} finally {
if (isFetchRequestActive(requestId)) {
@@ -624,6 +650,7 @@ export function useSelfServeLogic() {
return payload;
} catch (error) {
console.error("Error synchronizing vehicle answer:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prov igen.");
throw error;
} finally {
loading.value = false;
@@ -685,6 +712,7 @@ export function useSelfServeLogic() {
return { deletedCount: conditionIdsToDelete.length };
} catch (error) {
console.error("Error clearing self-serve answers:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke nulstille svar. Prov igen.");
throw error;
} finally {
loading.value = false;
@@ -842,6 +870,7 @@ export function useSelfServeLogic() {
return response;
} catch (error) {
console.error("Error updating lane allowed services:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke opdatere vaskebanens tjenester. Prov igen.");
allowedServices.value = activeTaskServices.value;
}
};
@@ -858,12 +887,14 @@ export function useSelfServeLogic() {
return await SessionUser.request('/modules/self-serve/lane/relay/machine/enable', 'post', payload);
} catch (error) {
console.error("Error enabling machine relay:", error);
requestError.value = extractErrorMessage(error, "Kunne ikke starte maskinen. Prov igen.");
throw error;
}
};
return {
loading,
error: requestError,
preview,
summary,
lane,
+51 -9
View File
@@ -30,6 +30,28 @@ export function useWashSessionActions(options) {
const machineStartCurrentStep = ref(0);
const openingPropertyAccessGate = ref(false);
const openingPropertyExitGate = ref(false);
const isStartingWash = ref(false);
const extractCommandErrorMessage = (
source,
fallback = "Der opstod en fejl ved udforelse af kommandoen. Prov igen senere."
) => {
const candidates = [
source?.response?.data?.data?.message,
source?.response?.data?.message,
source?.response?.data?.error,
source?.data?.data?.message,
source?.data?.message,
source?.data?.error,
source?.message,
];
const message = candidates.find((candidate) => (
typeof candidate === "string" && candidate.trim() !== ""
));
return message || fallback;
};
const getButtonsToPress = () => {
const buttons = new Set();
@@ -98,14 +120,21 @@ export function useWashSessionActions(options) {
}
try {
return await request("/modules/self-serve/lane/command", "post", {
const response = await request("/modules/self-serve/lane/command", "post", {
lane_id: laneId,
command,
...args,
});
const successValue = response?.data?.success ?? response?.success;
if (successValue === false) {
alertFn(extractCommandErrorMessage(response));
return null;
}
return response;
} catch (error) {
console.error(`Error executing command ${command} on lane ${laneId}:`, error);
alertFn("Der opstod en fejl ved udforelse af kommandoen. Prov igen senere.");
alertFn(extractCommandErrorMessage(error));
return null;
}
};
@@ -147,36 +176,44 @@ export function useWashSessionActions(options) {
);
const onStartWash = async (laneId, licensePlate, customerNumber, targetStep = steps.WASH_IN_PROGRESS) => {
if (isStartingWash.value) {
return false;
}
if (washInProgress.value && currentStep.value === targetStep) {
return;
return true;
}
if (washInProgress.value) {
currentStep.value = targetStep;
return;
return true;
}
if (!nearestDepartment.value) {
alertFn("Ingen afdeling valgt.");
return;
return false;
}
if (!licensePlate || licensePlate.trim() === "") {
alertFn("Indtast venligst et registreringsnummer.");
return;
return false;
}
if (!parseInt(customerNumber)) {
alertFn("Indtast venligst et kundenummer.");
return;
return false;
}
isStartingWash.value = true;
try {
await updateLaneAllowedServices(laneId);
await executeSelfServeCommand(laneId, "START", {
const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(),
});
if (!startResponse) {
return false;
}
washLaneId.value = laneId;
washStartTime.value = Date.now();
@@ -194,9 +231,13 @@ export function useWashSessionActions(options) {
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) {
await enableMachineRelay(laneId);
}
return true;
} catch (error) {
console.error("Error starting self-serve wash:", error);
alertFn("Der opstod en fejl ved start af vasken. Prov igen senere.");
alertFn(extractCommandErrorMessage(error, "Der opstod en fejl ved start af vasken. Prov igen senere."));
return false;
} finally {
isStartingWash.value = false;
}
};
@@ -235,5 +276,6 @@ export function useWashSessionActions(options) {
openPropertyExitGate,
openingPropertyAccessGate,
openingPropertyExitGate,
isStartingWash,
};
}
@@ -191,6 +191,9 @@ const terminalGatewayBrokerDiagnostics = (gateway = selectedGatewayView.value) =
push("Broker state", broker.state);
push("Broker connection", presence.connection_id);
push("Broker last seen", broker.last_seen_at || presence.last_seen_at);
push("Broker age", Number.isFinite(Number(broker.age_seconds ?? presence.age_seconds))
? `${Number(broker.age_seconds ?? presence.age_seconds)}s`
: null);
push("Broker last error", broker.last_error || presence.last_error || gateway?.metadata?.broker_last_error);
push("Broker disconnect reason", broker.disconnect_reason || presence.disconnect_reason);
return lines;
@@ -204,13 +207,19 @@ const terminalBrokerReadinessBlock = (gateway = selectedGatewayView.value) => {
Object.prototype.hasOwnProperty.call(presence, "connected") ||
Object.prototype.hasOwnProperty.call(gateway?.metadata || {}, "broker_connected");
const connected = Boolean(broker.connected ?? presence.connected ?? gateway?.metadata?.broker_connected);
const stale =
/stale/i.test(String(broker.state || presence.state || "")) ||
(Number.isFinite(Number(broker.age_seconds ?? presence.age_seconds)) &&
Number(broker.age_seconds ?? presence.age_seconds) > 90);
if (!hasBrokerSignal || connected) {
if (!hasBrokerSignal || (connected && !stale)) {
return null;
}
return {
message: "Gateway agent is not connected to the broker. Restart the edge agent or check the broker URL.",
message: stale
? "Gateway broker presence is stale. Wait for the agent to reconnect before opening a shell."
: "Gateway agent is not connected to the broker. Restart the edge agent or check the broker URL.",
details: terminalGatewayBrokerDiagnostics(gateway),
};
};
@@ -207,14 +207,14 @@ const createMockShellConnection = (session, handlers = {}, afterOpen = null) =>
);
}, Number(session.mock_close_before_open.delay_ms ?? 20));
} else {
schedule(() => {
readyState = 1;
lifecycle.emitOpen();
lifecycle.emitMessage({ type: "opened" });
emitOutput(String(session?.mock_banner || "Connected to gateway shell.\n"));
emitOutput(prompt);
afterOpen?.({ send });
}, 20);
schedule(() => {
readyState = 1;
lifecycle.emitOpen();
lifecycle.emitMessage({ type: "opened" });
emitOutput(String(session?.mock_banner || "Connected to gateway shell.\n"));
emitOutput(prompt);
afterOpen?.({ send });
}, 20);
}
return {
@@ -47,9 +47,28 @@ const currentGuidedWashStep = ref(0);
const currentStep = ref(0);
const hideDynamicImage = ref(false);
const vehicleStepError = ref<string | null>(null);
const washActionError = ref<string | null>(null);
const isSelfServeRetrying = ref(false);
const loadingQuestionId = ref<number | null>(null);
const loadingAnswerValue = ref<boolean | null>(null);
const normalizePositiveInteger = (value: any) => {
const parsed = parseInt(String(value ?? ""), 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const getAuthenticatedCustomerNumber = () => normalizePositiveInteger(SessionUser.user.customer_number.value);
const resolveEffectiveCustomerNumber = (candidate: any = null) => (
getAuthenticatedCustomerNumber() ?? normalizePositiveInteger(candidate)
);
const applyEffectiveCustomerNumberInput = (candidate: any = null) => {
const effectiveCustomerNumber = resolveEffectiveCustomerNumber(candidate);
customerNumberInput.value = effectiveCustomerNumber ? String(effectiveCustomerNumber) : "";
return effectiveCustomerNumber;
};
const {
guestDepartments,
nearestDepartment,
@@ -81,6 +100,7 @@ const {
answers,
completedTasks,
allowedServices,
error: selfServeDataError,
visibleQuestions,
activeTasks,
allVisibleQuestionsAnswered: selfServeQuestionsAnswered,
@@ -133,7 +153,7 @@ const {
vehicleTypeSelect.value = savedProgress.vehicleTypeSelect;
radioWashType.value = savedProgress.radioWashType || "Manual";
radioLaneOption.value = savedProgress.radioLaneOption || "Any";
customerNumberInput.value = savedProgress.customerNumberInput;
applyEffectiveCustomerNumberInput(savedProgress.customerNumberInput);
isForcingNearestDepartment.value = !!savedProgress.isForcingNearestDepartment;
forceNearestDepartmentEvaluationId.value = savedProgress.forceNearestDepartmentEvaluationId || 0;
@@ -156,8 +176,12 @@ const {
openPropertyExitGate,
openingPropertyAccessGate,
openingPropertyExitGate,
isStartingWash,
} = useWashSessionActions({
request: SessionUser.request,
alertFn: (message: string) => {
washActionError.value = message;
},
nearestDepartment,
vehicleTypeSelect,
washLaneId,
@@ -248,15 +272,7 @@ const displayedDynamicImageUrl = computed(() => (
const normalizeLicensePlate = (value: string | null) => (value || "").trim().toUpperCase();
const getNumericCustomerNumber = () => {
const candidate = customerNumberInput.value || SessionUser.user.customer_number.value || null;
if (candidate === null || candidate === undefined || String(candidate).trim() === "") {
return null;
}
const parsed = parseInt(String(candidate), 10);
return Number.isNaN(parsed) ? null : parsed;
};
const getNumericCustomerNumber = () => resolveEffectiveCustomerNumber(customerNumberInput.value);
const extractErrorMessage = (error: any, fallback: string) => {
if (error?.response?.data?.data?.message) {
@@ -281,7 +297,7 @@ const allVisibleQuestionsAnswered = computed(() => (
));
const showCustomerNumberInput = computed(() => (
!SessionUser.user.customer_number.value || !customerNumberInput.value
!getAuthenticatedCustomerNumber() && !customerNumberInput.value
));
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value);
@@ -438,22 +454,49 @@ const fetchSelfServeData = async () => {
);
};
const retrySelfServeData = async () => {
if (isSelfServeRetrying.value) {
return;
}
isSelfServeRetrying.value = true;
try {
await fetchSelfServeData();
} finally {
isSelfServeRetrying.value = false;
}
};
const retryWashAction = async () => {
washActionError.value = null;
if (currentStep.value === steps.SELECT_LANE) {
await handleConfirmNext();
return;
}
await retrySelfServeData();
};
const onSelectVehicleType = (selection: VehicleTypeTemplate) => {
if (isRestoring.value) {
return;
}
vehicleStepError.value = null;
washActionError.value = null;
vehicleTypeSelect.value = selection.id;
};
const onUpdateCustomerNumber = (value: string) => {
vehicleStepError.value = null;
customerNumberInput.value = value;
washActionError.value = null;
const authenticatedCustomerNumber = getAuthenticatedCustomerNumber();
customerNumberInput.value = authenticatedCustomerNumber ? String(authenticatedCustomerNumber) : value;
};
const onUpdateRegistrationNumber = (value: string) => {
vehicleStepError.value = null;
washActionError.value = null;
licensePlateInput.value = normalizeLicensePlate(value);
};
@@ -498,7 +541,7 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => {
await syncVehicleAnswer({
departmentId: nearestDepartment.value.id,
laneId,
customerNumber: parseInt(customerNumberInput.value || "") || SessionUser.user.customer_number.value || null,
customerNumber: getNumericCustomerNumber(),
reg: licensePlateInput.value,
questionId,
value,
@@ -525,6 +568,7 @@ const onCloseCompleted = () => {
answers.value = {};
editAnswers.value = false;
vehicleStepError.value = null;
washActionError.value = null;
currentStep.value = steps.VEHICLE;
clearProgress();
saveProgress("onCloseCompleted");
@@ -541,7 +585,7 @@ onMounted(async () => {
await fetchDepartments();
startAutoRefresh();
customerNumberInput.value = SessionUser.user.customer_number.value || "";
applyEffectiveCustomerNumberInput(customerNumberInput.value);
await fetchCustomerVehicles();
await fetchVehicleTypes();
restoreProgress();
@@ -600,7 +644,7 @@ watch(() => currentStep.value, (newStep, oldStep) => {
});
watch(() => SessionUser.user.customer_number.value, (newValue) => {
customerNumberInput.value = newValue;
applyEffectiveCustomerNumberInput(newValue);
});
watch(() => customerNumberInput.value, (newValue, oldValue) => {
@@ -707,6 +751,52 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
</div>
</section>
<b-message
v-if="selfServeDataError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-runtime-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ selfServeDataError }}</span>
<b-button
size="is-small"
type="is-danger is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-runtime-retry"
:loading="isSelfServeRetrying"
@click="retrySelfServeData"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<b-message
v-if="washActionError"
type="is-danger"
has-icon
:closable="false"
data-testid="self-serve-action-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ washActionError }}</span>
<b-button
size="is-small"
type="is-danger is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-action-retry"
:loading="isStartingWash"
@click="retryWashAction"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<b-steps
v-if="doesCurrentDepartmentSelectionHaveSelfServeEnabled"
v-model="currentStep"
@@ -785,8 +875,8 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
:department-name="nearestDepartment?.name || null"
:is-lane-available="isLaneAvailable"
:is-machine-available="isMachineAvailable"
@update:selected-lane-id="radioLaneOption = $event"
@update:wash-type="radioWashType = $event"
@update:selected-lane-id="washActionError = null; radioLaneOption = $event"
@update:wash-type="washActionError = null; radioWashType = $event"
/>
</b-step-item>
@@ -880,7 +970,8 @@ watch(() => forceNearestDepartmentEvaluationId.value, () => {
icon-pack="fas"
icon-right="arrow-right"
data-testid="self-serve-nav-confirm"
:disabled="isNextButtonDisabled()"
:loading="isStartingWash"
:disabled="isStartingWash || isNextButtonDisabled()"
@click="handleConfirmNext"
>
{{ $t("common.confirm") }}
+69 -1
View File
@@ -1039,14 +1039,82 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.getByTestId("gateway-recent-commands")).not.toContainText("TIMED_OUT");
});
test("@smoke blocks the live terminal when broker readiness is disconnected", async ({ page }) => {
test.slow();
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
metadata: {
broker_connected: false,
broker_last_error: "Broker unavailable",
broker_presence: {
connected: false,
last_seen_at: "2026-04-08 08:10:00",
last_error: "Broker unavailable",
disconnect_reason: "broker_disconnected",
},
},
},
],
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/not connected to the broker/i);
await expect(page.getByTestId("gateway-terminal-diagnostics")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText("Broker connected: no");
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText(
"Broker last error: Broker unavailable"
);
});
test("@smoke surfaces diagnostics for a terminal websocket close before shell open", async ({ page }) => {
test.slow();
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
shellSessionMockClose: {
reason: "socket_closed",
code: 1006,
wasClean: false,
details: {
stage: "shell_open",
},
},
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/Broker did not complete/i, {
timeout: 5_000,
});
await expect(page.getByTestId("gateway-terminal-diagnostics")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText("WebSocket code: 1006");
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText(
"WebSocket URL: mock-ws://edge-broker/browser-shell"
);
});
test("@smoke opens the live terminal, streams output, and closes the shell session", async ({ page }) => {
test.slow();
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway/701/terminal");
await page.goto("/superuser/configuration/edgegateway/701/terminal", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/connecting|open/i);
+111 -15
View File
@@ -17,8 +17,8 @@ async function primeSession(page, { token, permissions, sessionData = {} }) {
async function seedGeolocation(context) {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({
latitude: 55.6761,
longitude: 12.5683,
latitude: 55.6415,
longitude: 12.0803,
});
}
@@ -58,10 +58,10 @@ function buildSavedProgress(overrides = {}) {
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: "12345",
customerNumberInput: "12345679",
answers: {},
completedTasks: {},
nearestDepartmentId: 1,
nearestDepartmentId: 6,
forceNearestDepartmentEvaluationId: 0,
isForcingNearestDepartment: false,
savedAt: Date.now(),
@@ -103,7 +103,7 @@ test.describe("Self-serve wash", () => {
await page.goto("/user/wash");
await expect(page.getByTestId("self-serve-wash-home")).toBeVisible();
await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Copenhagen");
await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Roskilde");
const orderedDepartmentIds = await page
.locator('[data-testid^="self-serve-home-department-"]')
@@ -112,7 +112,7 @@ test.describe("Self-serve wash", () => {
await page.getByTestId("self-serve-home-start").click();
await expect(page).toHaveURL(/\/user\/wash\/start$/);
await expect(page.getByTestId("self-serve-department-name")).toContainText("Copenhagen");
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await fillRegistration(page, "ab12345");
await selectVehicleType(page, 2);
@@ -151,7 +151,7 @@ test.describe("Self-serve wash", () => {
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-department-name")).toContainText("Copenhagen");
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
await page.getByTestId("self-serve-department-name").click();
const departmentSearch = page.locator('input[placeholder*="afdelinger"]').first();
@@ -162,7 +162,7 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-department-clear")).toBeVisible();
await page.getByTestId("self-serve-department-clear").click();
await expect(page.getByTestId("self-serve-department-name")).toContainText("Copenhagen");
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
});
test("lane flow gates invalid lanes, direct wash path auto-starts, and expired progress is discarded", async ({
@@ -177,7 +177,7 @@ test.describe("Self-serve wash", () => {
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: "12345",
customerNumberInput: "12345679",
savedAt: Date.now() - 3 * 60 * 60 * 1000,
});
@@ -217,6 +217,102 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
});
test("customer session uses authenticated customer number instead of stale local values", async ({ page }) => {
const orderFilters = [];
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname.endsWith("/orders")) {
orderFilters.push(url.searchParams.get("filters") || "");
}
});
await seedSavedProgress(page, {
currentStep: 2,
licensePlateInput: "ZZ00000",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "777",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
runtime_config: {
economic: {
transaction_draft_customer_number: 777,
},
},
},
selfServe: true,
});
await primeSession(page, {
token: "self-serve-customer-number-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 });
const startCommandRequestPromise = waitForLaneCommandRequest(page, "START");
await page.getByTestId("self-serve-nav-confirm").click();
const startCommandRequest = await startCommandRequestPromise;
expect(startCommandRequest.postDataJSON?.()).toMatchObject({
command: "START",
customer_number: 12345679,
});
await page.waitForTimeout(250);
expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false);
});
test("start command failure stays retryable without entering in-progress state", async ({ page }) => {
await seedSavedProgress(page, {
currentStep: 2,
licensePlateInput: "ZZ00000",
vehicleTypeSelect: 2,
radioLaneOption: 7,
radioWashType: "Manual",
customerNumberInput: "777",
});
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: {
commandResponses: [
{
success: false,
data: {
message: "Edge gateway command timed out",
},
},
{ success: true },
],
},
});
await primeSession(page, {
token: "self-serve-start-retry-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-nav-confirm").click();
await expect(page.getByTestId("self-serve-action-error")).toContainText("Edge gateway command timed out");
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
await page.getByTestId("self-serve-action-retry").click();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
});
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
await seedSavedProgress(page, {
washInProgress: true,
@@ -226,7 +322,7 @@ test.describe("Self-serve wash", () => {
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
customerNumberInput: "12345",
customerNumberInput: "12345679",
completedTasks: {},
});
@@ -319,7 +415,7 @@ test.describe("Self-serve wash", () => {
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
customerNumberInput: "12345",
customerNumberInput: "12345679",
});
await page.reload();
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
@@ -334,7 +430,7 @@ test.describe("Self-serve wash", () => {
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioLaneOption: 7,
customerNumberInput: "12345",
customerNumberInput: "12345679",
});
await mockApi(page, {
@@ -394,15 +490,15 @@ test.describe("Self-serve wash", () => {
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["admin", "department_access_1"],
permissions: ["admin", "department_access_6"],
selfServe: true,
});
await primeSession(page, {
token: "self-serve-admin-token",
permissions: ["admin", "department_access_1"],
permissions: ["admin", "department_access_6"],
});
await page.goto("/admin/1/modules/wash-lanes/7");
await page.goto("/admin/6/modules/wash-lanes/7");
await expect(page.getByTestId("department-wash-lane-page")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("self-serve-admin-open-try-modal").first().click();
+58 -8
View File
@@ -465,16 +465,16 @@ function paginateRows(rows, page = 1, limit = 10) {
function createSelfServeFixture(overrides = {}) {
const departments = [
{
id: 1,
name: "Copenhagen",
address: "Alpha 1",
latitude: 55.6761,
longitude: 12.5683,
id: 6,
name: "Roskilde",
address: "Industrivej 45, 4000 Roskilde",
latitude: 55.6415,
longitude: 12.0803,
self_serve_enabled: true,
lanes: [
{
id: 7,
department: 1,
department: 6,
name: "7",
status: "AVAILABLE",
machine_available: true,
@@ -489,7 +489,7 @@ function createSelfServeFixture(overrides = {}) {
},
{
id: 8,
department: 1,
department: 6,
name: "8",
status: "FAULT",
machine_available: false,
@@ -725,6 +725,7 @@ function createSelfServeFixture(overrides = {}) {
},
laneAllowedServices: ["MACHINE"],
commandResponse: { success: true },
commandResponses: null,
relayResponse: { success: true },
dynamicImage: TINY_PNG,
};
@@ -1830,6 +1831,14 @@ function createHttpEdgeGatewayFixture(options = {}) {
nextOperationEventId: 19901,
claimPollsRemaining: Number(options.claimPollsRemaining || 2),
reuseClaimGatewayId: Number(options.reuseClaimGatewayId || 0),
shellSessionFailure:
options.shellSessionFailure && typeof options.shellSessionFailure === "object"
? cloneJson(options.shellSessionFailure)
: null,
shellSessionMockClose:
options.shellSessionMockClose && typeof options.shellSessionMockClose === "object"
? cloneJson(options.shellSessionMockClose)
: null,
installSessionFailure:
options.installSessionFailure && typeof options.installSessionFailure === "object"
? cloneJson(options.installSessionFailure)
@@ -4942,6 +4951,26 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
return true;
}
if (edgeGatewayFixture.shellSessionFailure) {
const failure = edgeGatewayFixture.shellSessionFailure;
await route.fulfill(
json(
{
success: false,
data: {
message:
failure.message ||
"Gateway agent is not connected to the broker. Restart the edge agent or check the broker URL.",
error_code: failure.error_code || "BROKER_DISCONNECTED",
diagnostics: cloneJson(failure.diagnostics || {}),
},
},
Number(failure.status || 409)
)
);
return true;
}
const sessionId = edgeGatewayFixture.nextShellSessionId++;
const body = request.postDataJSON?.() || {};
const session = {
@@ -4986,6 +5015,23 @@ async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, met
expires_at: "2026-04-09 10:15:00",
broker_url: "https://broker.example.test",
ws_url: "mock-ws://edge-broker/browser-shell",
diagnostics: {
ready: true,
reason_code: "READY",
message: "Gateway shell broker path is ready.",
gateway_id: gatewayId,
broker_url: "https://broker.example.test",
ws_url: "mock-ws://edge-broker/browser-shell",
broker_presence: {
connected: true,
connection_id: "mock-broker-1",
last_seen_at: "2026-04-09 09:14:59",
age_seconds: 2,
last_error: null,
disconnect_reason: null,
},
},
mock_close_before_open: edgeGatewayFixture.shellSessionMockClose,
mock_banner: `Connected to ${gateway.label || gateway.hostname}.\n`,
mock_prompt: "edge@truckwash:/opt/truckwash-edge-agent$ ",
},
@@ -5566,7 +5612,11 @@ export async function mockApi(page, options = {}) {
}
if (pathname.endsWith("/modules/self-serve/lane/command") && method === "POST") {
await route.fulfill(json(selfServe.commandResponse));
const commandResponse =
Array.isArray(selfServe.commandResponses) && selfServe.commandResponses.length > 0
? selfServe.commandResponses.shift()
: selfServe.commandResponse;
await route.fulfill(json(commandResponse));
return;
}
@@ -91,16 +91,23 @@ describe("edge gateway workspace contract", () => {
expect(managerSource).toContain("createGatewayShellClient(");
expect(managerSource).toContain('message.type === "output" || message.type === "raw"');
expect(managerSource).toContain("const normalizeTerminalClosePayload = (message = {}) =>");
expect(managerSource).toContain("const terminalErrorDetails = ref([]);");
expect(managerSource).toContain("const terminalBrokerReadinessBlock = (gateway = selectedGatewayView.value) =>");
expect(managerSource).toContain("const terminalCloseDiagnostics = (message = {}) =>");
expect(managerSource).toContain("const terminalCloseMessage = (message = {}, status = terminalStatus.value) =>");
expect(managerSource).toContain("Gateway agent is not connected to the broker.");
expect(managerSource).toContain(':diagnostics="terminalErrorDetails"');
expect(managerSource).toContain("applyTerminalClosed(message);");
expect(managerSource).toContain("onClose: (closeEvent = {}) =>");
expect(managerSource).toContain("applyTerminalClosed(closeEvent);");
expect(managerSource).toContain("WebSocket ${code}");
expect(terminalSource).toContain('data-testid="gateway-terminal-diagnostics"');
expect(managerSource).toContain("terminalClient.sendInput(`${commandText}\\n`)");
expect(managerSource).not.toContain("terminalMockResponse");
expect(managerSource).not.toContain("createEdgeGatewayShellSession");
expect(liveSessionsSource).toContain("export async function createGatewayShellClient");
expect(liveSessionsSource).toContain("normalizeGatewayWebSocketClose");
expect(liveSessionsSource).toContain("mock_close_before_open");
expect(liveSessionsSource).toContain('reason: event.reason || "socket_closed"');
expect(liveSessionsSource).toContain("if (connection.readyState === 1)");
expect(liveSessionsSource).toContain('connection.close("shell_closed")');
+57 -16
View File
@@ -8,9 +8,9 @@ const mocks = vi.hoisted(() => {
const nearestDepartment = {
value: {
id: 1,
name: "Copenhagen",
address: "Main street 1",
id: 6,
name: "Roskilde",
address: "Industrivej 45, 4000 Roskilde",
self_serve_enabled: true,
lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }],
},
@@ -77,7 +77,7 @@ const mocks = vi.hoisted(() => {
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
user: {
customer_number: { value: 12345 },
customer_number: { value: 12345679 },
},
canAccessSuperUser: () => true,
request: vi.fn(async (...args) => {
@@ -153,6 +153,7 @@ vi.mock("@/composables/useWashDepartments", () => ({
vi.mock("@/composables/useSelfServeLogic", () => ({
useSelfServeLogic: () => ({
loading: { value: false },
error: { value: null },
lane: { value: { id: 7 } },
questions: { value: [] },
conditions: mocks.conditions,
@@ -238,6 +239,7 @@ vi.mock("@/composables/useWashSessionActions", async () => {
openPropertyExitGate: mocks.openPropertyExitGate,
openingPropertyAccessGate: ref(false),
openingPropertyExitGate: ref(false),
isStartingWash: ref(false),
}),
};
});
@@ -312,9 +314,9 @@ const stubComponents = {
describe("MyWashStart", () => {
beforeEach(() => {
mocks.nearestDepartment.value = {
id: 1,
name: "Copenhagen",
address: "Main street 1",
id: 6,
name: "Roskilde",
address: "Industrivej 45, 4000 Roskilde",
self_serve_enabled: true,
lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }],
};
@@ -377,16 +379,16 @@ describe("MyWashStart", () => {
await nextTick();
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345");
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
await flushPromises();
expect(mocks.answerQuestion).toHaveBeenCalledWith(11, true);
expect(mocks.syncVehicleAnswer).toHaveBeenCalledWith({
departmentId: 1,
departmentId: 6,
laneId: 7,
customerNumber: 12345,
customerNumber: 12345679,
reg: "AB12345",
questionId: 11,
value: true,
@@ -429,9 +431,9 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
expect(mocks.addVehicle).toHaveBeenCalledWith(2, "ZZ99999", false, 12345);
expect(mocks.addVehicle).toHaveBeenCalledWith(2, "ZZ99999", false, 12345679);
expect(mocks.fetchCustomerVehicles.mock.calls.length).toBeGreaterThan(1);
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "ZZ99999");
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "ZZ99999");
});
it("uses an effective lane when moving from vehicle to questions without manual lane selection", async () => {
@@ -449,7 +451,7 @@ describe("MyWashStart", () => {
await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click");
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345");
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
});
it("falls back to available lane when restored lane is stale for the selected department", async () => {
@@ -461,7 +463,7 @@ describe("MyWashStart", () => {
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 999,
customerNumberInput: 12345,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
@@ -477,7 +479,46 @@ describe("MyWashStart", () => {
await flushPromises();
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(1, 2, 7, "AB12345");
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
wrapper.unmount();
});
it("overrides a restored customer number with the authenticated customer's number", async () => {
mocks.restoredProgressPayload = {
washInProgress: false,
washLaneId: null,
washStartTime: null,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 777,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
completedTasks: {},
currentStep: 1,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await wrapper.get('[data-testid="emit-answer"]').trigger("click");
await flushPromises();
expect(mocks.syncVehicleAnswer).toHaveBeenCalledWith({
departmentId: 6,
laneId: 7,
customerNumber: 12345679,
reg: "AB12345",
questionId: 11,
value: true,
});
wrapper.unmount();
});
@@ -512,7 +553,7 @@ describe("MyWashStart", () => {
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: 12345,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: {},
@@ -61,6 +61,7 @@ describe("SelfServeQuestionsStep", () => {
},
});
expect(wrapper.find('[data-testid="self-serve-questions-inline-loading"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-question-cards"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="self-serve-question-11-yes"]').classes()).toContain("is-loading");
expect(wrapper.find('[data-testid="self-serve-question-11-no"]').classes()).not.toContain("is-loading");
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionState = vi.hoisted(() => ({
runtimeConfigValue: { value: null },
canAccessAdmin: vi.fn(() => false),
canAccessSuperUser: vi.fn(() => false),
getTransactionDraftCustomerNumberConfig: vi.fn(async () => ({ data: { data: [] } })),
}));
@@ -13,6 +14,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
transactionDraftCustomerNumber: sessionState.runtimeConfigValue,
},
},
canAccessAdmin: sessionState.canAccessAdmin,
canAccessSuperUser: sessionState.canAccessSuperUser,
superUser: {
modules: {
@@ -32,6 +34,8 @@ describe("useDraftTransactionCustomer", () => {
beforeEach(() => {
vi.resetModules();
sessionState.runtimeConfigValue.value = null;
sessionState.canAccessAdmin.mockReset();
sessionState.canAccessAdmin.mockReturnValue(false);
sessionState.canAccessSuperUser.mockReset();
sessionState.canAccessSuperUser.mockReturnValue(false);
sessionState.getTransactionDraftCustomerNumberConfig.mockReset();
@@ -39,6 +43,18 @@ describe("useDraftTransactionCustomer", () => {
});
it("prefers the runtime-configured customer number when available", async () => {
sessionState.canAccessAdmin.mockReturnValue(true);
sessionState.runtimeConfigValue.value = 44556677;
const module = await import("@/composables/useDraftTransactionCustomer.js");
expect(module.getDraftTransactionCustomerNumber()).toBe(44556677);
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBe(44556677);
expect(sessionState.getTransactionDraftCustomerNumberConfig).not.toHaveBeenCalled();
});
it("allows superuser sessions to use the configured customer number without admin access", async () => {
sessionState.canAccessSuperUser.mockReturnValue(true);
sessionState.runtimeConfigValue.value = 44556677;
const module = await import("@/composables/useDraftTransactionCustomer.js");
@@ -49,6 +65,7 @@ describe("useDraftTransactionCustomer", () => {
});
it("falls back to the e-conomic module config when runtime config is missing", async () => {
sessionState.canAccessAdmin.mockReturnValue(true);
sessionState.canAccessSuperUser.mockReturnValue(true);
sessionState.getTransactionDraftCustomerNumberConfig.mockResolvedValue({
data: {
@@ -68,6 +85,7 @@ describe("useDraftTransactionCustomer", () => {
});
it("can clear a previously cached fallback customer number", async () => {
sessionState.canAccessAdmin.mockReturnValue(true);
sessionState.canAccessSuperUser.mockReturnValue(true);
sessionState.getTransactionDraftCustomerNumberConfig.mockResolvedValue({
data: {
@@ -89,4 +107,14 @@ describe("useDraftTransactionCustomer", () => {
expect(module.getDraftTransactionCustomerNumber()).toBeNull();
expect(sessionState.runtimeConfigValue.value).toBeNull();
});
it("ignores draft customer config for authenticated customer-only sessions", async () => {
sessionState.runtimeConfigValue.value = 777;
const module = await import("@/composables/useDraftTransactionCustomer.js");
expect(module.getDraftTransactionCustomerNumber()).toBeNull();
await expect(module.ensureDraftTransactionCustomerLoaded()).resolves.toBeNull();
expect(sessionState.getTransactionDraftCustomerNumberConfig).not.toHaveBeenCalled();
});
});
+22
View File
@@ -197,6 +197,28 @@ describe("useSelfServeLogic", () => {
expect(logic.tasks.value.map((task) => task.id)).toEqual([1001, 1002]);
});
it("exposes preview errors for retry UI", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mocks.previewAllowed.mockRejectedValue({
response: {
data: {
data: {
message: "Edge gateway command timed out",
},
},
},
});
const logic = useSelfServeLogic();
const result = await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(result).toBeNull();
expect(logic.loading.value).toBe(false);
expect(logic.error.value).toBe("Edge gateway command timed out");
consoleErrorSpy.mockRestore();
});
it("evaluates conditional rules for task activation", () => {
const logic = useSelfServeLogic();
+43 -1
View File
@@ -113,7 +113,49 @@ describe("useWashSessionActions property gate commands", () => {
expect(result).toBeNull();
expect(actions.openingPropertyAccessGate.value).toBe(false);
expect(alertFn).toHaveBeenCalledWith("Der opstod en fejl ved udforelse af kommandoen. Prov igen senere.");
expect(alertFn).toHaveBeenCalledWith("command failed");
consoleErrorSpy.mockRestore();
});
it("does not enter wash-in-progress state when the start command fails", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const washInProgress = ref(false);
const washLaneId = ref(null);
const washStartTime = ref(null);
const currentStep = ref(2);
const request = vi.fn(async (_url, _method, body) => {
if (body.command === "START") {
return {
data: {
success: false,
data: {
message: "Edge gateway command timed out",
},
},
};
}
return { data: { success: true } };
});
const { actions, alertFn } = createActions({
request,
washInProgress,
washLaneId,
washStartTime,
currentStep,
});
const result = await actions.onStartWash(7, "ab12345", 12345679, 4);
expect(result).toBe(false);
expect(washInProgress.value).toBe(false);
expect(washLaneId.value).toBeNull();
expect(washStartTime.value).toBeNull();
expect(currentStep.value).toBe(2);
expect(actions.isStartingWash.value).toBe(false);
expect(alertFn).toHaveBeenCalledWith("Edge gateway command timed out");
consoleErrorSpy.mockRestore();
});