diff --git a/src/composables/useWashDepartments.js b/src/composables/useWashDepartments.js index e6c6e5f4..71ce5fcc 100644 --- a/src/composables/useWashDepartments.js +++ b/src/composables/useWashDepartments.js @@ -141,6 +141,13 @@ export function useWashDepartments(options = {}) { if (currentNearestDepartment.id) { nearestDepartment.value = currentNearestDepartment; + return nearestDepartment.value; + } + + const fallbackDepartment = + guestDepartments.value.find((department) => department.self_serve_enabled === true) || guestDepartments.value[0]; + if (!nearestDepartment.value && fallbackDepartment) { + nearestDepartment.value = toDepartmentViewModel(fallbackDepartment); } return nearestDepartment.value; diff --git a/src/views/dashboards/userDashboard/wash/MyWashStart.vue b/src/views/dashboards/userDashboard/wash/MyWashStart.vue index 5fc93542..fcb412b6 100644 --- a/src/views/dashboards/userDashboard/wash/MyWashStart.vue +++ b/src/views/dashboards/userDashboard/wash/MyWashStart.vue @@ -48,6 +48,7 @@ const currentStep = ref(0); const hideDynamicImage = ref(false); const vehicleStepError = ref(null); const washActionError = ref(null); +const answerSyncError = ref(null); const isSelfServeRetrying = ref(false); const isCompletingWash = ref(false); const isVehicleStepNextLoading = ref(false); @@ -120,6 +121,7 @@ const { enableMachineRelay, downloadAttachment, answerQuestion, + removeAnswer, } = useSelfServeLogic(); const { @@ -307,10 +309,11 @@ const { steps, clickableSteps, isNextButtonDisabled, handleConfirmNext, targetSt nearestDepartment, allVisibleQuestionsAnswered: computed( () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) + !answerSyncError.value && + (selfServeQuestionsAnswered.value || + visibleQuestions.value.every( + (question) => answers.value[question.id] === true || answers.value[question.id] === false + )) ), isLoadingSelfServeData, activeTasks: displayedActiveTasks, @@ -445,10 +448,11 @@ watch(dynamicImageUrl, () => { const allVisibleQuestionsAnswered = computed( () => - selfServeQuestionsAnswered.value || - visibleQuestions.value.every( - (question) => answers.value[question.id] === true || answers.value[question.id] === false - ) + !answerSyncError.value && + (selfServeQuestionsAnswered.value || + visibleQuestions.value.every( + (question) => answers.value[question.id] === true || answers.value[question.id] === false + )) ); const hasAllowedVehicleTypeSelection = computed(() => { @@ -1047,6 +1051,7 @@ const onSelectVehicleType = (selection: VehicleTypeTemplate) => { markStartFormUserInput(); vehicleStepError.value = null; washActionError.value = null; + answerSyncError.value = null; vehicleTypeSelect.value = selection.id; }; @@ -1054,6 +1059,7 @@ const onUpdateCustomerNumber = (value: string) => { markStartFormUserInput(); vehicleStepError.value = null; washActionError.value = null; + answerSyncError.value = null; const authenticatedCustomerNumber = getAuthenticatedCustomerNumber(); customerNumberInput.value = authenticatedCustomerNumber ? String(authenticatedCustomerNumber) : value; }; @@ -1062,18 +1068,21 @@ const onUpdateRegistrationNumber = (value: string) => { markStartFormUserInput(); vehicleStepError.value = null; washActionError.value = null; + answerSyncError.value = null; licensePlateInput.value = normalizeLicensePlate(value); }; const onUpdateSelectedLaneId = (laneId: number) => { markStartFormUserInput(); washActionError.value = null; + answerSyncError.value = null; radioLaneOption.value = laneId; }; const onUpdateWashType = (washType: string) => { markStartFormUserInput(); washActionError.value = null; + answerSyncError.value = null; radioWashType.value = washType; }; @@ -1133,6 +1142,7 @@ const onToggleTask = (taskId: number, value: boolean) => { }; const submitQuestionAnswer = async (questionId: number, value: boolean) => { + answerSyncError.value = null; answerQuestion(questionId, value); const laneId = getEffectiveLaneId(); @@ -1152,6 +1162,8 @@ const submitQuestionAnswer = async (questionId: number, value: boolean) => { }); } catch (error) { console.error("Error synchronizing self-serve answer:", error); + removeAnswer(questionId); + answerSyncError.value = extractErrorMessage(error, "Kunne ikke gemme svaret. Prøv igen."); } }; @@ -1185,6 +1197,7 @@ const onCloseCompleted = () => { editAnswers.value = false; vehicleStepError.value = null; washActionError.value = null; + answerSyncError.value = null; hasStartFormUserInput.value = false; markQuestionReviewRequired(); resetGuidedWashStep(); @@ -1584,6 +1597,15 @@ watch( @toggle-edit="toggleEditAnswers" @answer-question="submitQuestionAnswer" /> + + + {{ answerSyncError }} + { + const appendStyle = () => { + const parent = document.documentElement || document.head || document.body; + if (!parent) { + document.addEventListener("DOMContentLoaded", appendStyle, { once: true }); + return; + } + + const style = document.createElement("style"); + style.textContent = + "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }"; + parent.appendChild(style); + }; + + appendStyle(); + }); +} + +async function seedGeolocation(context) { + await context.grantPermissions(["geolocation"]); + await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803 }); +} + +function buildSavedProgress(overrides = {}) { + return { + washInProgress: false, + washLaneId: null, + washStartTime: null, + currentStep: 0, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioWashType: "Manual", + radioLaneOption: 7, + customerNumberInput: "12345679", + answers: {}, + completedTasks: {}, + nearestDepartmentId: 6, + forceNearestDepartmentEvaluationId: 0, + isForcingNearestDepartment: false, + savedAt: Date.now(), + ...overrides, + }; +} + +async function seedSavedProgress(page, overrides = {}) { + await page.addInitScript((savedProgress) => { + window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); + }, buildSavedProgress(overrides)); +} + +async function fillRegistration(page, value = "ab12345") { + const registration = page.locator('input[placeholder*="registreringsnummer"]').first(); + await registration.fill(value); + await expect(registration).toHaveValue(value.toUpperCase()); + await registration.press("Escape"); +} + +async function selectVehicleType(page, vehicleTypeId = 2) { + await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click(); + await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/); +} + +async function answerRequiredQuestions(page) { + await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("self-serve-question-11-yes").click(); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); +} + +async function completeGuidedWash(page) { + for (let index = 0; index < 5; index += 1) { + await page.getByTestId("self-serve-guided-next").click(); + } +} + +function collectSelfServeRequests(page) { + const requests = { + departments: [], + vehicleTypes: [], + customerVehicles: [], + allowed: [], + conditions: [], + commands: [], + relays: [], + summaries: [], + activeWash: [], + inProgress: [], + }; + + page.on("request", (request) => { + const url = new URL(request.url()); + const entry = { + method: request.method(), + url, + body: request.method() === "GET" ? null : request.postDataJSON?.() || null, + }; + + if (url.pathname.endsWith("/guest/departments")) requests.departments.push(entry); + if (url.pathname.endsWith("/modules/xlvask/internal/vehicle-types")) requests.vehicleTypes.push(entry); + if (url.pathname.endsWith("/vehicles")) requests.customerVehicles.push(entry); + if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) requests.allowed.push(entry); + if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) requests.conditions.push(entry); + if (url.pathname.endsWith("/modules/self-serve/lane/command")) requests.commands.push(entry); + if (url.pathname.endsWith("/modules/self-serve/lane/relay/machine/enable")) requests.relays.push(entry); + if (url.pathname.endsWith("/department/selfserve/washes/summary")) requests.summaries.push(entry); + if (url.pathname.endsWith("/modules/self-serve/lane/wash/my-active-wash")) requests.activeWash.push(entry); + if (url.pathname.endsWith("/modules/self-serve/lane/wash/in-progress")) requests.inProgress.push(entry); + }); + + return requests; +} + +test.describe("User MyWashStart mocked flow", () => { + test.describe.configure({ timeout: 120_000 }); + + test.skip(({ browserName }) => browserName !== "chromium", "MyWashStart flow coverage is maintained on Chromium."); + + test.beforeEach(async ({ page, context }) => { + await suppressVueDevtoolsOverlay(page); + await seedGeolocation(context); + }); + + test("manual wash selects vehicle, answers questions, starts, stops, and shows completed step", async ({ page }) => { + const requests = collectSelfServeRequests(page); + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: true, + }); + + await primeMockSession(page, { token: "user-my-wash-manual-token", bootPath: "/user/wash/start" }); + + await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 }); + await fillRegistration(page); + await selectVehicleType(page, 2); + await page.getByTestId("self-serve-nav-next").click(); + await answerRequiredQuestions(page); + await page.getByTestId("self-serve-nav-confirm").click(); + + await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("self-serve-lane-option-7").click(); + await page.getByTestId("self-serve-wash-type-manual").click(); + await page.getByTestId("self-serve-nav-confirm").click(); + + await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("self-serve-task-9001-toggle").click(); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled(); + await page.getByTestId("self-serve-nav-confirm").click(); + await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); + + await completeGuidedWash(page); + await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible(); + await page.getByTestId("self-serve-nav-complete").click(); + await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 }); + + expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true"); + expect(requests.vehicleTypes.length).toBeGreaterThan(0); + expect(requests.customerVehicles.length).toBeGreaterThan(0); + expect(requests.allowed.length).toBeGreaterThan(0); + expect(requests.conditions.some((request) => request.body?.question === 11 && request.body?.value === true)).toBe( + true + ); + expect(requests.summaries.length).toBeGreaterThan(0); + expect(requests.activeWash.length).toBeGreaterThan(0); + expect(requests.inProgress.length).toBeGreaterThan(0); + expect(requests.commands.map((request) => request.body?.command)).toEqual( + expect.arrayContaining(["START", "STOP", "OPEN_PROPERTY_EXIT_GATE"]) + ); + }); + + test("machine wash enables the relay after START and rolls back when relay enable fails", async ({ page }) => { + const requests = collectSelfServeRequests(page); + await seedSavedProgress(page, { + currentStep: 2, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioLaneOption: 7, + radioWashType: "Machine", + customerNumberInput: "12345679", + answers: { 11: true }, + }); + + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: { + relayResponses: [{ success: true }, { success: false, data: { message: "Machine relay unavailable" } }], + }, + }); + + await primeMockSession(page, { token: "user-my-wash-machine-token", bootPath: "/user/wash/start" }); + await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("self-serve-wash-type-machine").click(); + await page.getByTestId("self-serve-nav-confirm").click(); + await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); + + expect(requests.commands.map((request) => request.body?.command)).toContain("START"); + expect(requests.relays).toHaveLength(1); + const firstStartIndex = requests.commands.findIndex((request) => request.body?.command === "START"); + expect(firstStartIndex).toBeGreaterThanOrEqual(0); + + await page.getByTestId("self-serve-nav-previous").click(); + await expect(page.getByTestId("self-serve-lane-step")).toBeVisible(); + await page.getByTestId("self-serve-nav-confirm").click(); + + await expect(page.getByTestId("self-serve-action-error")).toContainText("Machine relay unavailable"); + await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden(); + expect(requests.relays).toHaveLength(2); + expect(requests.commands.map((request) => request.body?.command)).toEqual( + expect.arrayContaining(["START", "STOP"]) + ); + }); + + test("active wash restore reloads start route and resumes at WASH_IN_PROGRESS", async ({ page }) => { + const requests = collectSelfServeRequests(page); + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: { + inProgressByLaneId: { + 7: { + lane_id: 7, + in_progress: true, + session: { + id: 801, + lane_id: 7, + department_id: 6, + reg: "AB12345", + customer_number: 12345679, + vehicle_type_id: 2, + machine_relay_enabled: false, + wash_started_at: "2026-06-02 08:00:00", + status: "IN_PROGRESS", + }, + customer: { customer_number: 12345679 }, + vehicle: { reg: "AB12345", type: 2 }, + }, + }, + summaryBySessionId: { + 801: { + session: { id: 801, lane_id: 7, reg: "AB12345", status: "IN_PROGRESS", vehicle_type_id: 2 }, + lane: { id: 7, name: "7", department: 6 }, + questions: [], + conditions: [], + rules: [], + tasks: [], + events: [{ id: 8011, type: "STARTED", created_at: "2026-06-02T08:00:00.000Z" }], + }, + }, + }, + }); + + await primeMockSession(page, { token: "user-my-wash-active-token", bootPath: "/user/wash/start" }); + await page.reload({ waitUntil: "domcontentloaded" }); + + await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde"); + await expect(page.getByTestId("self-serve-vehicle-step")).toBeHidden(); + expect(requests.activeWash.length).toBeGreaterThan(0); + expect(requests.commands).toHaveLength(0); + }); + + test("server-completed wash refresh moves local in-progress state to completed", async ({ page }) => { + await seedSavedProgress(page, { + washInProgress: true, + washLaneId: 7, + washStartTime: Date.now() - 60_000, + currentStep: 4, + licensePlateInput: "AB12345", + vehicleTypeSelect: 2, + radioLaneOption: 7, + radioWashType: "Manual", + customerNumberInput: "12345679", + }); + + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: { + inProgressByLaneId: { + 7: { + lane_id: 7, + in_progress: false, + session: null, + customer: null, + vehicle: null, + }, + }, + }, + }); + + await primeMockSession(page, { token: "user-my-wash-server-completed-token", bootPath: "/user/wash/start" }); + await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden(); + }); + + test("answer-sync failure shows an error and blocks progression", async ({ page }) => { + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: true, + }); + await page.route(/\/department\/selfserve\/vehicle\/conditions$/i, async (route) => { + if (route.request().method() === "POST") { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ message: "Answer sync failed" }), + }); + return; + } + await route.fallback(); + }); + + await primeMockSession(page, { token: "user-my-wash-answer-sync-token", bootPath: "/user/wash/start" }); + await fillRegistration(page); + await selectVehicleType(page, 2); + await page.getByTestId("self-serve-nav-next").click(); + await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); + await page.getByTestId("self-serve-question-11-yes").click(); + + await expect(page.getByTestId("self-serve-answer-sync-error")).toContainText("Answer sync failed"); + await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); + await expect(page.getByTestId("self-serve-lane-step")).toBeHidden(); + }); + + test("geolocation unavailable still provides a department-selection fallback", async ({ browser }) => { + const context = await browser.newContext({ permissions: [] }); + const page = await context.newPage(); + await suppressVueDevtoolsOverlay(page); + const requests = collectSelfServeRequests(page); + await mockApi(page, { + authenticated: true, + permissions: ["user"], + sessionData: { customer_number: 12345679 }, + selfServe: true, + }); + + await primeMockSession(page, { token: "user-my-wash-no-geolocation-token", bootPath: "/user/wash/start" }); + + await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde", { timeout: 15_000 }); + await fillRegistration(page); + await selectVehicleType(page, 2); + await expect(page.getByTestId("self-serve-nav-next")).toBeEnabled(); + expect(requests.departments[0]?.url.searchParams.get("include_lanes")).toBe("true"); + + await context.close(); + }); +});