From 63942045bebf71ef5260fb6d3912bd40257e86e9 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 13:41:51 +0200 Subject: [PATCH 1/6] Block wash question flow on failed answer sync --- .../userDashboard/wash/MyWashStart.vue | 171 ++++++++++++++---- tests/unit/my-wash-start.spec.js | 48 +++++ 2 files changed, 188 insertions(+), 31 deletions(-) diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 5fc93542..d05d843a 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -48,6 +48,9 @@ const currentStep = ref(0); const hideDynamicImage = ref(false); const vehicleStepError = ref(null); const washActionError = ref(null); +const questionStepError = ref(null); +const pendingQuestionSyncs = ref([]); +const failedQuestionSyncs = ref([]); const isSelfServeRetrying = ref(false); const isCompletingWash = ref(false); const isVehicleStepNextLoading = ref(false); @@ -295,6 +298,38 @@ const { isServiceAllowed, }); +const normalizeQuestionId = (questionId: any) => Number.parseInt(String(questionId ?? 0), 10); + +const visibleQuestionIds = computed( + () => + new Set( + visibleQuestions.value + .map((question: any) => normalizeQuestionId(question?.id)) + .filter((questionId: number) => Number.isInteger(questionId) && questionId > 0) + ) +); + +const hasVisiblePendingQuestionSync = computed(() => + pendingQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId)) +); + +const hasVisibleFailedQuestionSync = computed(() => + failedQuestionSyncs.value.some((questionId) => visibleQuestionIds.value.has(questionId)) +); + +const hasBlockingVisibleQuestionSync = computed( + () => hasVisiblePendingQuestionSync.value || hasVisibleFailedQuestionSync.value +); + +const allVisibleQuestionsAnswered = computed( + () => + !hasBlockingVisibleQuestionSync.value && + (selfServeQuestionsAnswered.value || + visibleQuestions.value.every( + (question) => answers.value[question.id] === true || answers.value[question.id] === false + )) +); + const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetStepForStart } = useWashFlowState({ currentStep, washInProgress, @@ -305,13 +340,7 @@ const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetSt radioLaneOption, radioWashType, nearestDepartment, - allVisibleQuestionsAnswered: computed( - () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) - ), + allVisibleQuestionsAnswered, isLoadingSelfServeData, activeTasks: displayedActiveTasks, completedTasks, @@ -443,14 +472,6 @@ watch(dynamicImageUrl, () => { hideDynamicImage.value = false; }); -const allVisibleQuestionsAnswered = computed( - () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) -); - const hasAllowedVehicleTypeSelection = computed(() => { const selectedVehicleTypeId = vehicleTypeSelect.value; if (selectedVehicleTypeId === null || selectedVehicleTypeId === undefined) { @@ -1132,26 +1153,84 @@ const onToggleTask = (taskId: number, value: boolean) => { }; }; -const submitQuestionAnswer = async (questionId: number, value: boolean) => { - answerQuestion(questionId, value); +const addQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => { + if (!target.value.includes(questionId)) { + target.value = [...target.value, questionId]; + } +}; +const removeQuestionSyncId = (target: typeof pendingQuestionSyncs, questionId: number) => { + target.value = target.value.filter((candidateId) => candidateId !== questionId); +}; + +const buildQuestionSyncPayload = (questionId: number, value: boolean) => { const laneId = getEffectiveLaneId(); if (!nearestDepartment.value || !laneId || !licensePlateInput.value?.trim()) { + return null; + } + + return { + departmentId: nearestDepartment.value.id, + laneId, + customerNumber: getNumericCustomerNumber(), + reg: licensePlateInput.value, + questionId, + value, + vehicleTypeId: vehicleTypeSelect.value || null, + }; +}; + +const syncQuestionAnswer = async (questionId: number, value: boolean) => { + const normalizedQuestionId = normalizeQuestionId(questionId); + if (!normalizedQuestionId) { + return false; + } + + const payload = buildQuestionSyncPayload(normalizedQuestionId, value); + if (!payload) { + return true; + } + + addQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId); + removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + + try { + await syncVehicleAnswer(payload); + removeQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + if (!hasVisibleFailedQuestionSync.value) { + questionStepError.value = null; + } + return true; + } catch (error) { + console.error("Error synchronizing self-serve answer:", error); + addQuestionSyncId(failedQuestionSyncs, normalizedQuestionId); + questionStepError.value = extractErrorMessage(error, "Kunne ikke gemme dit svar. Prøv igen, før du fortsætter."); + return false; + } finally { + removeQuestionSyncId(pendingQuestionSyncs, normalizedQuestionId); + } +}; + +const submitQuestionAnswer = async (questionId: number, value: boolean) => { + answerQuestion(questionId, value); + await syncQuestionAnswer(questionId, value); +}; + +const retryFailedQuestionSyncs = async () => { + const retryQuestionIds = failedQuestionSyncs.value.filter((questionId) => visibleQuestionIds.value.has(questionId)); + if (retryQuestionIds.length === 0) { + questionStepError.value = null; return; } - try { - await syncVehicleAnswer({ - departmentId: nearestDepartment.value.id, - laneId, - customerNumber: getNumericCustomerNumber(), - reg: licensePlateInput.value, - questionId, - value, - vehicleTypeId: vehicleTypeSelect.value || null, - }); - } catch (error) { - console.error("Error synchronizing self-serve answer:", error); + for (const questionId of retryQuestionIds) { + const value = answers.value[questionId]; + if (value !== true && value !== false) { + removeQuestionSyncId(failedQuestionSyncs, questionId); + continue; + } + + await syncQuestionAnswer(questionId, value); } }; @@ -1159,7 +1238,14 @@ const toggleEditAnswers = () => { editAnswers.value = !editAnswers.value; }; +const isCurrentStepNextButtonDisabled = () => + isNextButtonDisabled() || (currentStep.value === steps.QUESTIONS && hasBlockingVisibleQuestionSync.value); + const onConfirmNext = async () => { + if (isCurrentStepNextButtonDisabled()) { + return; + } + const wasQuestionsStep = currentStep.value === steps.QUESTIONS; const previousQuestionReviewKey = confirmedQuestionReviewKey.value; @@ -1576,6 +1662,29 @@ watch( currentStep === steps.COMPLETED " > + +
+ {{ questionStepError }} + + {{ $t("common.try_again") }} + +
+
+ {{ $t("common.next") }} @@ -1731,7 +1840,7 @@ watch( icon-right="arrow-right" data-testid="self-serve-nav-confirm" :loading="isStartingWash" - :disabled="isStartingWash || isNextButtonDisabled()" + :disabled="isStartingWash || isCurrentStepNextButtonDisabled()" @click="onConfirmNext" > {{ $t(confirmActionLabelKey) }} diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index e5307b59..c5406407 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -387,6 +387,9 @@ describe("MyWashStart", () => { mocks.addVehicle.mockClear(); mocks.sessionRequest.mockClear(); mocks.sessionRequest.mockResolvedValue(undefined); + mocks.handleConfirmNext.mockClear(); + mocks.isNextButtonDisabled.mockClear(); + mocks.isNextButtonDisabled.mockReturnValue(false); mocks.onStartWash.mockClear(); mocks.onStopWash.mockReset(); mocks.onStopWash.mockResolvedValue(true); @@ -537,6 +540,51 @@ describe("MyWashStart", () => { await flushPromises(); }); + it("surfaces failed question answer syncs and blocks question confirmation until retry succeeds", async () => { + mocks.syncVehicleAnswer.mockRejectedValueOnce(new Error("Question sync failed")).mockResolvedValueOnce(undefined); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + await wrapper.get('[data-testid="emit-lane"]').trigger("click"); + await wrapper.get('[data-testid="emit-registration"]').trigger("click"); + await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); + await nextTick(); + await wrapper.get('[data-testid="self-serve-nav-next"]').trigger("click"); + await flushPromises(); + + await wrapper.get('[data-testid="emit-answer"]').trigger("click"); + await flushPromises(); + + expect(wrapper.get('[data-testid="self-serve-question-sync-error"]').text()).toContain("Question sync failed"); + + const confirmButton = wrapper.get('[data-testid="self-serve-nav-confirm"]'); + expect(confirmButton.attributes("disabled")).toBeDefined(); + + await confirmButton.trigger("click"); + expect(mocks.handleConfirmNext).not.toHaveBeenCalled(); + + await wrapper.get('[data-testid="self-serve-question-sync-retry"]').trigger("click"); + await flushPromises(); + + expect(mocks.syncVehicleAnswer).toHaveBeenCalledTimes(2); + expect(mocks.syncVehicleAnswer).toHaveBeenNthCalledWith(2, { + departmentId: 6, + laneId: 7, + customerNumber: 12345679, + reg: "AB12345", + questionId: 11, + value: true, + vehicleTypeId: 2, + }); + expect(wrapper.find('[data-testid="self-serve-question-sync-error"]').exists()).toBe(false); + expect(wrapper.get('[data-testid="self-serve-nav-confirm"]').attributes("disabled")).toBeUndefined(); + }); + it("shows disabled warning without showing loading state when department self-serve is unavailable", async () => { mocks.nearestDepartment.value = { ...mocks.nearestDepartment.value, From 18ab007f8723ebbaea6f17d495625dd617b868c8 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 13:41:55 +0200 Subject: [PATCH 2/6] Debounce self-serve wash data fetches --- src/composables/useSelfServeLogic.js | 45 +++++- .../userDashboard/wash/MyWashStart.vue | 131 ++++++++++++++++-- tests/unit/my-wash-start.spec.js | 35 +++++ 3 files changed, 197 insertions(+), 14 deletions(-) diff --git a/src/composables/useSelfServeLogic.js b/src/composables/useSelfServeLogic.js index 2d859909..a9c47327 100644 --- a/src/composables/useSelfServeLogic.js +++ b/src/composables/useSelfServeLogic.js @@ -297,6 +297,30 @@ export function useSelfServeLogic() { const summaryVisibleQuestionIds = ref([]); const summaryQuestionOrder = ref({}); const latestFetchRequestId = ref(0); + const latestSuccessfulFetchKey = ref(null); + const inFlightFetchKey = ref(null); + + const createFetchKey = (departmentId, vehicleTypeId, laneId, reg) => { + const normalizedDepartmentId = parseInt(departmentId); + const normalizedLaneId = parseInt(laneId); + const normalizedReg = String(reg || "").trim().toUpperCase(); + const normalizedVehicleTypeId = parseInt(vehicleTypeId); + const vehicleTypeKey = !Number.isNaN(normalizedVehicleTypeId) && normalizedVehicleTypeId > 0 + ? normalizedVehicleTypeId + : ""; + + if ( + Number.isNaN(normalizedDepartmentId) + || normalizedDepartmentId <= 0 + || Number.isNaN(normalizedLaneId) + || normalizedLaneId <= 0 + || normalizedReg.length < 2 + ) { + return null; + } + + return [normalizedDepartmentId, normalizedLaneId, normalizedReg, vehicleTypeKey].join("|"); + }; const beginFetchRequest = () => { latestFetchRequestId.value += 1; @@ -575,10 +599,17 @@ export function useSelfServeLogic() { } }; - const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null) => { + const fetchSelfServeData = async (_departmentId, _vehicleTypeId, laneId = null, reg = null, options = {}) => { + const fetchKey = createFetchKey(_departmentId, _vehicleTypeId, laneId, reg); + if (!options.force && fetchKey && (fetchKey === latestSuccessfulFetchKey.value || fetchKey === inFlightFetchKey.value)) { + return null; + } + const requestId = beginFetchRequest(); if (!laneId || !reg || reg.trim().length < 2) { + latestSuccessfulFetchKey.value = null; + inFlightFetchKey.value = null; preview.value = null; summary.value = null; session.value = null; @@ -606,6 +637,7 @@ export function useSelfServeLogic() { loading.value = true; requestError.value = null; + inFlightFetchKey.value = fetchKey; try { const normalizedReg = reg.trim().toUpperCase(); const normalizedVehicleTypeId = parseInt(_vehicleTypeId); @@ -687,6 +719,10 @@ export function useSelfServeLogic() { setSummaryVisibleQuestions(questions.value); } + if (isFetchRequestActive(requestId)) { + latestSuccessfulFetchKey.value = fetchKey; + } + return previewData; } catch (error) { console.error("Error fetching self-serve preview:", error); @@ -695,6 +731,9 @@ export function useSelfServeLogic() { } return null; } finally { + if (inFlightFetchKey.value === fetchKey) { + inFlightFetchKey.value = null; + } if (isFetchRequestActive(requestId)) { loading.value = false; } @@ -749,7 +788,7 @@ export function useSelfServeLogic() { updateResolvedVehicleTypeId(responseSummary, responseSummary?.session); } - await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg); + await fetchSelfServeData(departmentId, refreshVehicleTypeId, laneId, normalizedReg, { force: true }); answers.value = { ...answers.value, [parseInt(questionId)]: value, @@ -814,7 +853,7 @@ export function useSelfServeLogic() { ? normalizedRefreshVehicleTypeId : null; - await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg); + await fetchSelfServeData(departmentId, refreshVehicleTypeId, parseInt(laneId), normalizedReg, { force: true }); return { deletedCount: conditionIdsToDelete.length }; } catch (error) { diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 5fc93542..fbcd5c50 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -60,6 +60,7 @@ const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1"; const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000; const ACTIVE_WASH_REFRESH_MS = 5 * 1000; const WASH_START_SERVER_SYNC_GRACE_MS = 10 * 1000; +const SELF_SERVE_FETCH_DEBOUNCE_MS = 300; const normalizePositiveInteger = (value: any) => { const parsed = parseInt(String(value ?? ""), 10); @@ -637,24 +638,129 @@ const fetchVehicleTypes = async () => { } }; -const fetchSelfServeData = async () => { - if (isRestoring.value || !nearestDepartment.value) { - return; +type SelfServeFetchRequest = { + key: string; + departmentId: number; + laneId: number; + registration: string; + vehicleTypeId: number | null; +}; + +let selfServeFetchDebounceTimer: ReturnType | null = null; +let selfServeFetchResolvers: Array<(value: any) => void> = []; +let latestSuccessfulSelfServeFetchKey: string | null = null; +let inFlightSelfServeFetchKey: string | null = null; +let selfServeFetchSequence = 0; +let isSelfServeFetchUnmounted = false; + +const resolvePendingSelfServeFetches = (value: any = null) => { + const resolvers = selfServeFetchResolvers; + selfServeFetchResolvers = []; + resolvers.forEach((resolve) => resolve(value)); +}; + +const clearScheduledSelfServeFetch = (resolveValue: any = null) => { + if (selfServeFetchDebounceTimer) { + window.clearTimeout(selfServeFetchDebounceTimer); + selfServeFetchDebounceTimer = null; } - const normalizedReg = normalizeLicensePlate(licensePlateInput.value); - if (normalizedReg.length < 2) { - return; + if (selfServeFetchResolvers.length > 0) { + resolvePendingSelfServeFetches(resolveValue); + } +}; + +const createSelfServeFetchRequest = (): SelfServeFetchRequest | null => { + if (isRestoring.value || !nearestDepartment.value) { + return null; + } + + const departmentId = normalizePositiveInteger(nearestDepartment.value.id); + if (!departmentId) { + return null; + } + + const registration = normalizeLicensePlate(licensePlateInput.value); + if (registration.length < 2) { + return null; } const laneId = ensureEffectiveLaneSelection(); if (!laneId) { - return; + return null; } - const departmentId = nearestDepartment.value.id; + const vehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value); + const key = [departmentId, laneId, registration, vehicleTypeId ?? ""].join("|"); - await fetchSelfServeDataInternal(departmentId, vehicleTypeSelect.value || null, laneId, normalizedReg); + return { + key, + departmentId, + laneId, + registration, + vehicleTypeId, + }; +}; + +const executeSelfServeFetch = async (request: SelfServeFetchRequest | null = createSelfServeFetchRequest()) => { + if (!request || isSelfServeFetchUnmounted) { + return null; + } + + if (request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey) { + return null; + } + + const requestSequence = ++selfServeFetchSequence; + inFlightSelfServeFetchKey = request.key; + + try { + const result = await fetchSelfServeDataInternal( + request.departmentId, + request.vehicleTypeId, + request.laneId, + request.registration + ); + + if (!isSelfServeFetchUnmounted && requestSequence === selfServeFetchSequence) { + latestSuccessfulSelfServeFetchKey = request.key; + } + + return result; + } finally { + if (inFlightSelfServeFetchKey === request.key) { + inFlightSelfServeFetchKey = null; + } + } +}; + +const fetchSelfServeData = async (options: { immediate?: boolean } = {}) => { + const request = createSelfServeFetchRequest(); + + if (options.immediate) { + clearScheduledSelfServeFetch(null); + return executeSelfServeFetch(request); + } + + if (!request || isSelfServeFetchUnmounted) { + clearScheduledSelfServeFetch(null); + return null; + } + + return new Promise((resolve) => { + selfServeFetchResolvers.push(resolve); + + if (selfServeFetchDebounceTimer) { + window.clearTimeout(selfServeFetchDebounceTimer); + } + + selfServeFetchDebounceTimer = window.setTimeout(async () => { + selfServeFetchDebounceTimer = null; + const latestRequest = createSelfServeFetchRequest(); + const result = await executeSelfServeFetch(latestRequest); + resolvePendingSelfServeFetches(result); + }, SELF_SERVE_FETCH_DEBOUNCE_MS); + }); }; const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash"; @@ -1023,7 +1129,7 @@ const retrySelfServeData = async () => { isSelfServeRetrying.value = true; try { - await fetchSelfServeData(); + await fetchSelfServeData({ immediate: true }); } finally { isSelfServeRetrying.value = false; } @@ -1093,7 +1199,7 @@ const onVehicleStepNext = async () => { return; } - await fetchSelfServeData(); + await fetchSelfServeData({ immediate: true }); await nextTick(); markQuestionReviewRequired(); currentStep.value = visibleQuestions.value.length > 0 ? steps.QUESTIONS : steps.SELECT_LANE; @@ -1216,6 +1322,9 @@ onUnmounted(() => { unregisterBeforeUnload.value(); } + isSelfServeFetchUnmounted = true; + clearScheduledSelfServeFetch(null); + selfServeFetchSequence += 1; stopAutoRefresh(); stopActiveWashRefresh(); markDestroying(); diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index e5307b59..cc6d4a3f 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -419,6 +419,8 @@ describe("MyWashStart", () => { }); it("wires child updates back into the self-serve runtime", async () => { + vi.useFakeTimers(); + const wrapper = mountWithApp(MyWashStart, { global: { stubs: stubComponents, @@ -430,6 +432,7 @@ describe("MyWashStart", () => { await wrapper.get('[data-testid="emit-registration"]').trigger("click"); await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); await nextTick(); + await vi.advanceTimersByTimeAsync(300); await flushPromises(); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345"); @@ -449,6 +452,34 @@ describe("MyWashStart", () => { }); }); + it("debounces registration, lane, and vehicle type updates into one self-serve fetch", async () => { + vi.useFakeTimers(); + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + mocks.fetchSelfServeDataInternal.mockClear(); + + await wrapper.get('[data-testid="emit-registration"]').trigger("click"); + await wrapper.get('[data-testid="emit-lane"]').trigger("click"); + await wrapper.get('[data-testid="emit-vehicle-type"]').trigger("click"); + await nextTick(); + + await vi.advanceTimersByTimeAsync(299); + await flushPromises(); + expect(mocks.fetchSelfServeDataInternal).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await flushPromises(); + + expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledTimes(1); + expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345"); + }); + it("hides machine tasks while manual wash is selected", async () => { mocks.activeTasks.value = [ { id: 31, task: "Machine checklist", services: ["MACHINE"] }, @@ -597,6 +628,8 @@ describe("MyWashStart", () => { }); it("falls back to available lane when restored lane is stale for the selected department", async () => { + vi.useFakeTimers(); + mocks.restoredProgressPayload = { washInProgress: false, washLaneId: null, @@ -619,6 +652,8 @@ describe("MyWashStart", () => { }, }); + await flushPromises(); + await vi.advanceTimersByTimeAsync(300); await flushPromises(); expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345"); From af68748c16079b3e2ef444e6db52c56682a4f858 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 13:42:02 +0200 Subject: [PATCH 3/6] Fix active wash restore timeout cleanup --- .../userDashboard/wash/MyWashStart.vue | 25 +++++++++++-- tests/unit/my-wash-start.spec.js | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 5fc93542..277a9769 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -379,6 +379,8 @@ const completeGuidedWash = async () => { const unregisterBeforeUnload = ref void)>(null); const activeWashRefreshInterval = ref | null>(null); +const activeWashRestoreTimeout = ref | null>(null); +const isMyWashStartUnmounted = ref(false); const registrationOptions = computed(() => customerVehicles.value @@ -901,12 +903,27 @@ const restoreServerActiveWash = async () => { } }; +const clearServerActiveWashRestoreTimeout = () => { + if (activeWashRestoreTimeout.value !== null) { + window.clearTimeout(activeWashRestoreTimeout.value); + activeWashRestoreTimeout.value = null; + } +}; + const scheduleServerActiveWashRestore = (delayMs = 0) => { - if (!shouldRestoreServerActiveWash.value || washInProgress.value) { + clearServerActiveWashRestoreTimeout(); + + if (!shouldRestoreServerActiveWash.value || washInProgress.value || isMyWashStartUnmounted.value) { return; } - window.setTimeout(() => { + activeWashRestoreTimeout.value = window.setTimeout(() => { + activeWashRestoreTimeout.value = null; + + if (isMyWashStartUnmounted.value) { + return; + } + restoreServerActiveWash(); }, delayMs); }; @@ -1197,6 +1214,7 @@ const onClearDynamicImage = () => { }; onMounted(async () => { + isMyWashStartUnmounted.value = false; setShowFooterInContent(false); unregisterBeforeUnload.value = registerBeforeUnload(); @@ -1212,6 +1230,9 @@ onMounted(async () => { }); onUnmounted(() => { + isMyWashStartUnmounted.value = true; + clearServerActiveWashRestoreTimeout(); + if (unregisterBeforeUnload.value) { unregisterBeforeUnload.value(); } diff --git a/tests/unit/my-wash-start.spec.js b/tests/unit/my-wash-start.spec.js index e5307b59..f408b84a 100644 --- a/tests/unit/my-wash-start.spec.js +++ b/tests/unit/my-wash-start.spec.js @@ -250,6 +250,7 @@ vi.mock("@/composables/useWashSessionActions", async () => { }; }); +import { SessionUser } from "@/components/session/token/SessionUser.vue"; import MyWashStart from "@/views/dashboards/userDashboard/wash/MyWashStart.vue"; import { mountWithApp } from "./helpers/mountWithApp.js"; @@ -387,6 +388,7 @@ describe("MyWashStart", () => { mocks.addVehicle.mockClear(); mocks.sessionRequest.mockClear(); mocks.sessionRequest.mockResolvedValue(undefined); + SessionUser.request.mockClear(); mocks.onStartWash.mockClear(); mocks.onStopWash.mockReset(); mocks.onStopWash.mockResolvedValue(true); @@ -823,6 +825,40 @@ describe("MyWashStart", () => { wrapper.unmount(); }); + it("does not call SessionUser.request for delayed server restore after unmount", async () => { + vi.useFakeTimers(); + mocks.restoredProgressPayload = { + washInProgress: false, + washLaneId: null, + washStartTime: null, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioWashType: "Manual", + radioLaneOption: 7, + customerNumberInput: 12345679, + isForcingNearestDepartment: false, + forceNearestDepartmentEvaluationId: 0, + answers: {}, + completedTasks: {}, + currentStep: 0, + }; + + const wrapper = mountWithApp(MyWashStart, { + global: { + stubs: stubComponents, + }, + }); + + await flushPromises(); + expect(SessionUser.request).not.toHaveBeenCalled(); + + wrapper.unmount(); + await vi.advanceTimersByTimeAsync(1600); + await flushPromises(); + + expect(SessionUser.request).not.toHaveBeenCalled(); + }); + it("retries server active wash restore when the authenticated customer number arrives after mount", async () => { mocks.sessionCustomerNumber.value = null; mocks.sessionRequest.mockImplementation(async (path, method, payload) => { From 23429c047fc45bd12c39fc7331417c6546c7a92c Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 13:42:09 +0200 Subject: [PATCH 4/6] Guard wash entry self-serve CTA --- .../dashboards/userDashboard/wash/MyWash.vue | 112 ++++++++++- tests/unit/my-wash-entry.spec.js | 183 ++++++++++++++++++ 2 files changed, 287 insertions(+), 8 deletions(-) create mode 100644 tests/unit/my-wash-entry.spec.js diff --git a/src/views/dashboards/userDashboard/wash/MyWash.vue b/src/views/dashboards/userDashboard/wash/MyWash.vue index e8c453c8..c2d96402 100644 --- a/src/views/dashboards/userDashboard/wash/MyWash.vue +++ b/src/views/dashboards/userDashboard/wash/MyWash.vue @@ -1,6 +1,6 @@