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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")');
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user