Merge branch 'master' into update-mywashstart.vue-with-timeout-handling
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# User wash start production-readiness QA matrix
|
||||
|
||||
This note documents deterministic coverage for the self-serve user wash start flow. The `@dynamic-image` Playwright case remains separated because it validates rendered image behavior in addition to deterministic state and API transitions.
|
||||
|
||||
## Unit matrix
|
||||
|
||||
| Area | Required scenario | Coverage |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `useWashFlowState` | Step transitions for vehicle, questions, lane, tasks, in-progress, completed | `tests/unit/use-wash-flow-state-production.spec.js` validates step clickability, next-button gating, questions-to-lane updates, lane-to-start target selection, task completion transition, in-progress navigation, and completed-step non-clickability contract. |
|
||||
| `useWashSessionActions` | START success and failure | `tests/unit/use-wash-session-actions-production.spec.js` covers successful manual START state mutation and failed START retryability without active-wash mutation. |
|
||||
| `useWashSessionActions` | Relay enable success and relay enable failure with STOP rollback | `tests/unit/use-wash-session-actions-production.spec.js` covers machine relay success and relay failure rollback via STOP. |
|
||||
| `useWashSessionActions` | STOP failure and already-stopped STOP recovery | `tests/unit/use-wash-session-actions-production.spec.js` covers failed STOP preserving active state and already-not-occupied STOP clearing local state. |
|
||||
| `useSelfServeLogic` | Preview/summary merge | `tests/unit/use-self-serve-logic-production.spec.js` covers preview questions merging with summary questions, answer maps, visible question order, tasks, and allowed services. |
|
||||
| `useSelfServeLogic` | Request race handling | `tests/unit/use-self-serve-logic-production.spec.js` covers stale preview/summary responses being ignored when a newer request wins. |
|
||||
| `useSelfServeLogic` | Allowed services updates | `tests/unit/use-self-serve-logic-production.spec.js` covers lane allowed-service endpoint updates and first-failure fallback behavior. |
|
||||
| `useSelfServeLogic` | Answer sync failures | `tests/unit/use-self-serve-logic-production.spec.js` covers error reporting while preserving the caller's optimistic local answer. |
|
||||
| `MyWashStart.vue` | Local progress restore, server active-wash restore, recent-completion suppression, unmount cleanup, duplicate-fetch prevention | `tests/unit/my-wash-start-production.spec.js` locks the component contracts for restore ordering, authenticated active-wash application, recent-completion suppression across restore/polling, unmount cleanup, and duplicate-fetch/sync guards. |
|
||||
|
||||
## E2E mocked matrix
|
||||
|
||||
| Required scenario | Coverage |
|
||||
| -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Full manual flow | `tests/e2e/self-serve-wash.spec.js` covers direct manual wash start, guided completion, STOP, exit-gate open, completed state, and close/reset. |
|
||||
| Full machine flow | `tests/e2e/self-serve-wash.spec.js` covers machine task rendering, dynamic image progress, required task completion, machine start path, and reload persistence. |
|
||||
| Reload/resume active wash | `tests/e2e/self-serve-wash.spec.js` covers local progress reload and authenticated server active-wash resume from another device. |
|
||||
| Backend says wash completed during polling | `tests/e2e/self-serve-wash.spec.js` covers server polling of active wash and local transition to completed when the backend no longer reports the matching in-progress wash. |
|
||||
| Network failures for preview, answer sync, START, relay enable, STOP | `tests/e2e/self-serve-wash.spec.js` covers retryable START failure, STOP failure preservation, allowed-services gateway timeout/retry, answer sync background resilience, and unit-level relay rollback. Add mocked network route overrides when expanding browser-level failure assertions. |
|
||||
|
||||
## Optional live smoke
|
||||
|
||||
| Optional scenario | Coverage |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dynamic image smoke | The `@dynamic-image` Playwright test in `tests/e2e/self-serve-wash.spec.js` is tagged separately from deterministic CI coverage so it can be included or excluded explicitly with Playwright grep controls. |
|
||||
@@ -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) {
|
||||
|
||||
@@ -14,6 +14,9 @@ const isLaneSelfServeEnabled = (lane) =>
|
||||
)
|
||||
);
|
||||
|
||||
const hasSelfServeEnabledLane = (department) =>
|
||||
department?.self_serve_enabled === true && (department.lanes || []).some(isLaneSelfServeEnabled);
|
||||
|
||||
const toDepartmentViewModel = (department, distance = null) => ({
|
||||
id: department.id,
|
||||
distance,
|
||||
@@ -47,6 +50,7 @@ export function useWashDepartments(options = {}) {
|
||||
const isSearchingDepartments = ref(false);
|
||||
const lastDepartmentFetchTime = ref(null);
|
||||
const departmentFetchError = ref(null);
|
||||
const departmentSelectionStrategy = ref(null);
|
||||
|
||||
let refreshInterval = null;
|
||||
|
||||
@@ -73,6 +77,12 @@ export function useWashDepartments(options = {}) {
|
||||
return nearestDepartment.value.self_serve_enabled === true;
|
||||
});
|
||||
|
||||
const isDepartmentSelectionDistanceBased = computed(() => departmentSelectionStrategy.value === "distance");
|
||||
|
||||
const isDepartmentSelectionFallbackBased = computed(() => departmentSelectionStrategy.value === "fallback");
|
||||
|
||||
const hasLocationCoordinates = (locationValue = locations.location.value) => !!locationValue?.coords;
|
||||
|
||||
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
|
||||
|
||||
const fetchDepartments = async () => {
|
||||
@@ -110,20 +120,24 @@ export function useWashDepartments(options = {}) {
|
||||
const forcedDepartment = getForcedDepartment();
|
||||
if (forcedDepartment) {
|
||||
nearestDepartment.value = toDepartmentViewModel(forcedDepartment);
|
||||
departmentSelectionStrategy.value = "forced";
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasLocationCoordinates(locationValue)) {
|
||||
const fallbackDepartment = guestDepartments.value.find(hasSelfServeEnabledLane);
|
||||
nearestDepartment.value = fallbackDepartment ? toDepartmentViewModel(fallbackDepartment) : null;
|
||||
departmentSelectionStrategy.value = fallbackDepartment ? "fallback" : null;
|
||||
return nearestDepartment.value;
|
||||
}
|
||||
|
||||
let currentNearestDepartment = {
|
||||
id: null,
|
||||
distance: Infinity,
|
||||
};
|
||||
|
||||
guestDepartments.value.forEach((department) => {
|
||||
if (!locationValue?.coords) {
|
||||
return;
|
||||
}
|
||||
|
||||
const from = {
|
||||
latitude: locationValue.coords.latitude,
|
||||
longitude: locationValue.coords.longitude,
|
||||
@@ -139,9 +153,8 @@ export function useWashDepartments(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
if (currentNearestDepartment.id) {
|
||||
nearestDepartment.value = currentNearestDepartment;
|
||||
}
|
||||
nearestDepartment.value = currentNearestDepartment.id ? currentNearestDepartment : null;
|
||||
departmentSelectionStrategy.value = currentNearestDepartment.id ? "distance" : null;
|
||||
|
||||
return nearestDepartment.value;
|
||||
};
|
||||
@@ -167,7 +180,7 @@ export function useWashDepartments(options = {}) {
|
||||
};
|
||||
|
||||
const startDepartmentSearch = () => {
|
||||
if (!canAccessSuperUser()) {
|
||||
if (!canAccessSuperUser() && hasLocationCoordinates()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -245,6 +258,9 @@ export function useWashDepartments(options = {}) {
|
||||
isSearchingDepartments,
|
||||
lastDepartmentFetchTime,
|
||||
departmentFetchError,
|
||||
departmentSelectionStrategy,
|
||||
isDepartmentSelectionDistanceBased,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { BLoading } from "buefy";
|
||||
import { BLoading, BMessage } from "buefy";
|
||||
import UserDashboardPageWrapper from "@/views/dashboards/userDashboard/UserDashboardPageWrapper.vue";
|
||||
import PosDepartmentStepMobile1Location from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobile1Location.vue";
|
||||
import WhiteBoxCard from "@/components/displays/boxes/WhiteBoxCard.vue";
|
||||
@@ -10,12 +10,61 @@ import { useWashDepartments } from "@/composables/useWashDepartments";
|
||||
const {
|
||||
guestDepartments,
|
||||
nearestDepartment,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
fetchDepartments,
|
||||
evaluateLocationDepartments,
|
||||
orderDepartmentsByDistance,
|
||||
} = useWashDepartments();
|
||||
selectDepartment,
|
||||
} = useWashDepartments({ includeLanes: true });
|
||||
|
||||
const orderedDepartments = computed(() => orderDepartmentsByDistance(guestDepartments.value));
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
const shouldShowChooseDepartmentMessage = computed(() => !hasLocationCoordinates.value && !nearestDepartment.value);
|
||||
|
||||
const isDepartmentSelfServeEnabled = (department) => department?.self_serve_enabled === true;
|
||||
|
||||
const isLaneSelfServeEnabled = (lane) =>
|
||||
!(
|
||||
lane?.selfserve_enabled === false ||
|
||||
lane?.selfserve_enabled === 0 ||
|
||||
lane?.selfserve_enabled === "0" ||
|
||||
["false", "off", "no"].includes(
|
||||
String(lane?.selfserve_enabled ?? "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
);
|
||||
|
||||
const isLaneReadyForSelfServe = (lane) => lane?.status === "AVAILABLE" && isLaneSelfServeEnabled(lane);
|
||||
|
||||
const canDepartmentStartSelfServe = (department) =>
|
||||
isDepartmentSelfServeEnabled(department) && (department?.lanes || []).some(isLaneReadyForSelfServe);
|
||||
|
||||
const nearestDepartmentCanStartSelfServe = computed(() => canDepartmentStartSelfServe(nearestDepartment.value));
|
||||
|
||||
const eligibleDepartments = computed(() => guestDepartments.value.filter(canDepartmentStartSelfServe));
|
||||
|
||||
const eligibleAlternativeDepartments = computed(() =>
|
||||
eligibleDepartments.value.filter((department) => department.id !== nearestDepartment.value?.id)
|
||||
);
|
||||
|
||||
const hasEligibleAlternativeDepartment = computed(() => eligibleAlternativeDepartments.value.length > 0);
|
||||
|
||||
const selfServeUnavailableMessage = computed(() => {
|
||||
if (!nearestDepartment.value) {
|
||||
return "Vi kan ikke finde din nærmeste afdeling uden en placering. Vælg en selvvask-klar afdeling nedenfor.";
|
||||
}
|
||||
|
||||
if (!isDepartmentSelfServeEnabled(nearestDepartment.value)) {
|
||||
return `${nearestDepartment.value.name} er ikke aktiveret til selvvask lige nu.`;
|
||||
}
|
||||
|
||||
if (!(nearestDepartment.value.lanes || []).some(isLaneReadyForSelfServe)) {
|
||||
return `${nearestDepartment.value.name} har ingen ledige selvvask-baner lige nu.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const getDepartmentDistance = (department) => {
|
||||
if (!locations.location.value?.coords) {
|
||||
@@ -46,27 +95,56 @@ onMounted(async () => {
|
||||
<div class="column is-12">
|
||||
<WhiteBoxCard :force-state="true" :defaultOpen="true" :toggleable="false" :loading="false">
|
||||
<template #header>
|
||||
<div class="card-header-title has-text-link has-text-weight-bold" data-testid="self-serve-home-nearest-name">
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("common.loading") }}
|
||||
<div
|
||||
class="card-header-title has-text-link has-text-weight-bold"
|
||||
data-testid="self-serve-home-nearest-name"
|
||||
>
|
||||
{{ nearestDepartment ? nearestDepartment.name : $t("global.search_departments") }}
|
||||
</div>
|
||||
<div class="card-header-icon has-text-link">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
{{ $t("user_dashboard.wash.nearest_department", { name: nearestDepartment ? nearestDepartment.name : "-", distance: nearestDepartment ? nearestDepartment.distance.toFixed(2) : "-" }) }}
|
||||
<b-loading :is-full-page="false" :model-value="!nearestDepartment" :can-cancel="true" />
|
||||
<template v-if="nearestDepartment && isDepartmentSelectionFallbackBased">
|
||||
Vælg en afdeling for at fortsætte. Vi har valgt den første tilgængelige selvvaskeafdeling, fordi din
|
||||
placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else-if="nearestDepartment">
|
||||
{{
|
||||
$t("user_dashboard.wash.nearest_department", {
|
||||
name: nearestDepartment.name,
|
||||
distance: nearestDepartment.distance !== null ? nearestDepartment.distance.toFixed(2) : "-",
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
<template v-else-if="shouldShowChooseDepartmentMessage">
|
||||
Vælg en afdeling for at starte vask, fordi din placering ikke er tilgængelig.
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t("common.loading") }}
|
||||
<b-loading :is-full-page="false" :model-value="true" :can-cancel="true" />
|
||||
</template>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link
|
||||
v-if="nearestDepartment"
|
||||
v-if="nearestDepartment && nearestDepartmentCanStartSelfServe"
|
||||
to="/user/wash/start"
|
||||
class="button is-link is-fullwidth"
|
||||
data-testid="self-serve-home-start"
|
||||
>
|
||||
{{ $t("user_dashboard.wash.start_wash") }}
|
||||
</router-link>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="button is-link is-light is-fullwidth"
|
||||
data-testid="self-serve-home-start-disabled"
|
||||
disabled
|
||||
>
|
||||
Selvvask er ikke tilgængelig her
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</WhiteBoxCard>
|
||||
@@ -74,7 +152,7 @@ onMounted(async () => {
|
||||
|
||||
<template v-for="department in orderedDepartments" :key="department.id">
|
||||
<div
|
||||
v-if="nearestDepartment && department.id !== nearestDepartment.id"
|
||||
v-if="!nearestDepartment || department.id !== nearestDepartment.id"
|
||||
class="column is-12-mobile is-6-tablet is-4-desktop"
|
||||
:data-testid="`self-serve-home-department-${department.id}`"
|
||||
>
|
||||
@@ -94,7 +172,20 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="card-footer-item">
|
||||
<router-link :to="{ name: 'pos', params: { departmentId: department.id } }" class="button is-light is-fullwidth">
|
||||
<button
|
||||
v-if="canDepartmentStartSelfServe(department) && !nearestDepartmentCanStartSelfServe"
|
||||
type="button"
|
||||
class="button is-link is-fullwidth"
|
||||
:data-testid="`self-serve-home-select-department-${department.id}`"
|
||||
@click="selectDepartment(department.id)"
|
||||
>
|
||||
Vælg denne afdeling
|
||||
</button>
|
||||
<router-link
|
||||
v-else
|
||||
:to="{ name: 'pos', params: { departmentId: department.id } }"
|
||||
class="button is-light is-fullwidth"
|
||||
>
|
||||
<span class="icon"><i class="fas fa-map-marker-alt"></i></span>
|
||||
<span>{{ department.address }}</span>
|
||||
</router-link>
|
||||
|
||||
@@ -48,6 +48,9 @@ const currentStep = ref(0);
|
||||
const hideDynamicImage = ref(false);
|
||||
const vehicleStepError = ref<string | null>(null);
|
||||
const washActionError = ref<string | null>(null);
|
||||
const questionStepError = ref<string | null>(null);
|
||||
const pendingQuestionSyncs = ref<number[]>([]);
|
||||
const failedQuestionSyncs = ref<number[]>([]);
|
||||
const isSelfServeRetrying = ref(false);
|
||||
const isCompletingWash = ref(false);
|
||||
const isVehicleStepNextLoading = ref(false);
|
||||
@@ -60,6 +63,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);
|
||||
@@ -84,6 +88,7 @@ const {
|
||||
forceNearestDepartmentEvaluationId,
|
||||
isSearchingDepartments,
|
||||
departmentFetchError,
|
||||
isDepartmentSelectionFallbackBased,
|
||||
availableProductIds,
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments,
|
||||
@@ -295,6 +300,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 +342,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,
|
||||
@@ -445,14 +476,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) {
|
||||
@@ -465,7 +488,19 @@ const hasAllowedVehicleTypeSelection = computed(() => {
|
||||
|
||||
const showCustomerNumberInput = computed(() => !getAuthenticatedCustomerNumber() && !customerNumberInput.value);
|
||||
|
||||
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value);
|
||||
const hasLocationCoordinates = computed(() => !!locations.location.value?.coords);
|
||||
|
||||
const canUseDepartmentHeaderSelection = computed(
|
||||
() =>
|
||||
SessionUser.canAccessSuperUser() ||
|
||||
SessionUser.canAccessDeveloper() ||
|
||||
!hasLocationCoordinates.value ||
|
||||
isDepartmentSelectionFallbackBased.value
|
||||
);
|
||||
|
||||
const shouldShowChooseDepartmentMessage = computed(() => !nearestDepartment.value && !hasLocationCoordinates.value);
|
||||
|
||||
const shouldShowLoadingDataMessage = computed(() => !nearestDepartment.value && hasLocationCoordinates.value);
|
||||
|
||||
const vehicleStepGuidanceKey = computed(() => {
|
||||
if (!nearestDepartment.value) {
|
||||
@@ -639,24 +674,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<typeof window.setTimeout> | 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";
|
||||
@@ -1040,7 +1180,7 @@ const retrySelfServeData = async () => {
|
||||
|
||||
isSelfServeRetrying.value = true;
|
||||
try {
|
||||
await fetchSelfServeData();
|
||||
await fetchSelfServeData({ immediate: true });
|
||||
} finally {
|
||||
isSelfServeRetrying.value = false;
|
||||
}
|
||||
@@ -1110,7 +1250,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;
|
||||
@@ -1149,26 +1289,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1176,7 +1374,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;
|
||||
|
||||
@@ -1237,6 +1442,9 @@ onUnmounted(() => {
|
||||
unregisterBeforeUnload.value();
|
||||
}
|
||||
|
||||
isSelfServeFetchUnmounted = true;
|
||||
clearScheduledSelfServeFetch(null);
|
||||
selfServeFetchSequence += 1;
|
||||
stopAutoRefresh();
|
||||
stopActiveWashRefresh();
|
||||
markDestroying();
|
||||
@@ -1456,7 +1664,7 @@ watch(
|
||||
:is-searching-departments="isSearchingDepartments"
|
||||
:is-searching-loading="guestDepartments.length === 0"
|
||||
:guest-departments="guestDepartments"
|
||||
:can-access-super-user="SessionUser.canAccessSuperUser() || SessionUser.canAccessDeveloper()"
|
||||
:can-access-super-user="canUseDepartmentHeaderSelection"
|
||||
:show-progress="currentStep === steps.TASKS || currentStep === steps.WASH_IN_PROGRESS"
|
||||
:progress-label="$t('self_wash.wash_in_progress')"
|
||||
:progress-value="formattedElapsed"
|
||||
@@ -1597,6 +1805,29 @@ watch(
|
||||
currentStep === steps.COMPLETED
|
||||
"
|
||||
>
|
||||
<b-message
|
||||
v-if="questionStepError"
|
||||
type="is-danger"
|
||||
has-icon
|
||||
:closable="false"
|
||||
data-testid="self-serve-question-sync-error"
|
||||
>
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||
<span class="mr-3">{{ questionStepError }}</span>
|
||||
<b-button
|
||||
size="is-small"
|
||||
type="is-danger is-light"
|
||||
icon-pack="fas"
|
||||
icon-left="sync-alt"
|
||||
data-testid="self-serve-question-sync-retry"
|
||||
:loading="hasVisiblePendingQuestionSync"
|
||||
@click="retryFailedQuestionSyncs"
|
||||
>
|
||||
{{ $t("common.try_again") }}
|
||||
</b-button>
|
||||
</div>
|
||||
</b-message>
|
||||
|
||||
<SelfServeQuestionsStep
|
||||
:is-loading="isLoadingSelfServeData"
|
||||
:visible-questions="visibleQuestions"
|
||||
@@ -1722,7 +1953,7 @@ watch(
|
||||
icon-right="arrow-right"
|
||||
data-testid="self-serve-nav-next"
|
||||
:loading="isVehicleStepNextLoading"
|
||||
:disabled="isVehicleStepNextLoading || isNextButtonDisabled()"
|
||||
:disabled="isVehicleStepNextLoading || isCurrentStepNextButtonDisabled()"
|
||||
@click.prevent="onVehicleStepNext"
|
||||
>
|
||||
{{ $t("common.next") }}
|
||||
@@ -1752,7 +1983,7 @@ watch(
|
||||
icon-right="arrow-right"
|
||||
data-testid="self-serve-nav-confirm"
|
||||
:loading="isStartingWash"
|
||||
:disabled="isStartingWash || isNextButtonDisabled()"
|
||||
:disabled="isStartingWash || isCurrentStepNextButtonDisabled()"
|
||||
@click="onConfirmNext"
|
||||
>
|
||||
{{ $t(confirmActionLabelKey) }}
|
||||
@@ -1850,6 +2081,16 @@ watch(
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<b-message
|
||||
v-else-if="shouldShowChooseDepartmentMessage"
|
||||
type="is-info"
|
||||
:aria-close-label="$t('common.close')"
|
||||
data-testid="self-serve-choose-department-message"
|
||||
>
|
||||
Vælg en afdeling for at starte vask. Din placering er ikke tilgængelig, så vi kan ikke finde den nærmeste
|
||||
afdeling automatisk.
|
||||
</b-message>
|
||||
|
||||
<b-message v-else-if="shouldShowLoadingDataMessage" type="is-info" :aria-close-label="$t('common.close')">
|
||||
{{ $t("self_wash.loading_data") }}
|
||||
</b-message>
|
||||
|
||||
@@ -264,6 +264,53 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
|
||||
});
|
||||
|
||||
test("start route lets regular users choose a department when geolocation permission is denied", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await context.clearPermissions();
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: (_success, error) =>
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }),
|
||||
watchPosition: (_success, error) => {
|
||||
error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 });
|
||||
return 1;
|
||||
},
|
||||
clearWatch: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
customer_number: 12345679,
|
||||
},
|
||||
selfServe: true,
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-denied-geolocation-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde");
|
||||
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("self-serve-department-name").click();
|
||||
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
|
||||
await page.getByText("Odense", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
|
||||
});
|
||||
|
||||
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
@@ -1144,7 +1191,9 @@ test.describe("Self-serve wash", () => {
|
||||
expect(requests.commands).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page }) => {
|
||||
test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests = captureSelfServeGatewayRequests(page);
|
||||
|
||||
await seedSavedProgress(page, {
|
||||
@@ -1510,6 +1559,56 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
washStartTime: Date.now() - 30_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: {
|
||||
id: 801,
|
||||
lane_id: 7,
|
||||
reg: "AB12345",
|
||||
customer_number: 12345679,
|
||||
vehicle_type_id: 2,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
customer: { customer_number: 12345679 },
|
||||
vehicle: { reg: "AB12345", type: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeSession(page, {
|
||||
token: "self-serve-polling-completed-token",
|
||||
permissions: ["user"],
|
||||
});
|
||||
|
||||
await page.goto("/user/wash/start");
|
||||
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
|
||||
});
|
||||
|
||||
test("admin preview modal reuses shared question/task rendering", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment jsdom
|
||||
import { flushPromises } from "@vue/test-utils";
|
||||
import { nextTick } from "vue";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mountWithApp } from "./helpers/mountWithApp.js";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const asMockRef = (value) => ({ value, __v_isRef: true });
|
||||
|
||||
return {
|
||||
guestDepartments: asMockRef([]),
|
||||
nearestDepartment: asMockRef(null),
|
||||
fetchDepartments: vi.fn(async () => []),
|
||||
evaluateLocationDepartments: vi.fn(),
|
||||
orderDepartmentsByDistance: vi.fn((departments) => departments),
|
||||
selectDepartment: vi.fn(),
|
||||
useWashDepartments: vi.fn(),
|
||||
locationRef: asMockRef({
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
},
|
||||
}),
|
||||
getDistance: vi.fn(() => 3),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/composables/useWashDepartments", () => ({
|
||||
useWashDepartments: mocks.useWashDepartments,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue", () => ({
|
||||
locations: {
|
||||
location: mocks.locationRef,
|
||||
getDistance: mocks.getDistance,
|
||||
},
|
||||
}));
|
||||
|
||||
import MyWash from "@/views/dashboards/userDashboard/wash/MyWash.vue";
|
||||
|
||||
const eligibleLane = { id: 10, name: "10", status: "AVAILABLE", selfserve_enabled: true };
|
||||
|
||||
const makeDepartment = (overrides = {}) => ({
|
||||
id: 1,
|
||||
name: "Glostrup",
|
||||
address: "Fabriksparken 18",
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
distance: 1,
|
||||
self_serve_enabled: true,
|
||||
lanes: [eligibleLane],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mountMyWash = async () => {
|
||||
const wrapper = mountWithApp(MyWash, {
|
||||
global: {
|
||||
stubs: {
|
||||
PosDepartmentStepMobile1Location: {
|
||||
emits: ["location-updated"],
|
||||
template: "<button data-testid='location-stub' @click=\"$emit('location-updated', {})\">location</button>",
|
||||
},
|
||||
BLoading: {
|
||||
props: ["modelValue"],
|
||||
template: "<div v-if='modelValue' data-testid='loading-stub' />",
|
||||
},
|
||||
BMessage: {
|
||||
template: "<div class='b-message-stub' v-bind='$attrs'><slot /></div>",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await nextTick();
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
describe("MyWash entry", () => {
|
||||
beforeEach(() => {
|
||||
mocks.guestDepartments.value = [];
|
||||
mocks.nearestDepartment.value = null;
|
||||
mocks.locationRef.value = {
|
||||
coords: {
|
||||
latitude: 55.5,
|
||||
longitude: 12.4,
|
||||
},
|
||||
};
|
||||
mocks.fetchDepartments.mockReset();
|
||||
mocks.fetchDepartments.mockImplementation(async () => mocks.guestDepartments.value);
|
||||
mocks.evaluateLocationDepartments.mockReset();
|
||||
mocks.orderDepartmentsByDistance.mockReset();
|
||||
mocks.orderDepartmentsByDistance.mockImplementation((departments) => departments);
|
||||
mocks.selectDepartment.mockReset();
|
||||
mocks.selectDepartment.mockImplementation((departmentId) => {
|
||||
mocks.nearestDepartment.value =
|
||||
mocks.guestDepartments.value.find((department) => department.id === departmentId) || null;
|
||||
});
|
||||
mocks.useWashDepartments.mockReset();
|
||||
mocks.useWashDepartments.mockImplementation(() => ({
|
||||
guestDepartments: mocks.guestDepartments,
|
||||
nearestDepartment: mocks.nearestDepartment,
|
||||
fetchDepartments: mocks.fetchDepartments,
|
||||
evaluateLocationDepartments: mocks.evaluateLocationDepartments,
|
||||
orderDepartmentsByDistance: mocks.orderDepartmentsByDistance,
|
||||
selectDepartment: mocks.selectDepartment,
|
||||
}));
|
||||
mocks.getDistance.mockReset();
|
||||
mocks.getDistance.mockReturnValue(3);
|
||||
});
|
||||
|
||||
it("enables the start CTA when the nearest department is self-serve enabled with an available lane", async () => {
|
||||
const nearest = makeDepartment();
|
||||
mocks.guestDepartments.value = [nearest];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(mocks.useWashDepartments).toHaveBeenCalledWith({ includeLanes: true });
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').attributes("href")).toBe("/user/wash/start");
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the start CTA when the nearest department is disabled and lets users select another eligible department", async () => {
|
||||
const nearest = makeDepartment({ self_serve_enabled: false });
|
||||
const alternative = makeDepartment({ id: 2, name: "Roskilde", distance: 5 });
|
||||
mocks.guestDepartments.value = [nearest, alternative];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Glostrup er ikke aktiveret til selvvask lige nu."
|
||||
);
|
||||
|
||||
await wrapper.find('[data-testid="self-serve-home-select-department-2"]').trigger("click");
|
||||
await nextTick();
|
||||
|
||||
expect(mocks.selectDepartment).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it("replaces the start CTA when the nearest department has no enabled and available lanes", async () => {
|
||||
const nearest = makeDepartment({
|
||||
lanes: [
|
||||
{ id: 10, name: "10", status: "MAINTENANCE", selfserve_enabled: true },
|
||||
{ id: 11, name: "11", status: "AVAILABLE", selfserve_enabled: false },
|
||||
],
|
||||
});
|
||||
const alternative = makeDepartment({ id: 3, name: "Køge", distance: 7 });
|
||||
mocks.guestDepartments.value = [nearest, alternative];
|
||||
mocks.nearestDepartment.value = nearest;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Glostrup har ingen ledige selvvask-baner lige nu."
|
||||
);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-select-department-3"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing geolocation by disabling start and offering eligible departments", async () => {
|
||||
const eligible = makeDepartment({ id: 4, name: "Odense" });
|
||||
mocks.locationRef.value = null;
|
||||
mocks.guestDepartments.value = [eligible];
|
||||
mocks.nearestDepartment.value = null;
|
||||
|
||||
const wrapper = await mountMyWash();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-start-disabled"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-unavailable-message"]').text()).toContain(
|
||||
"Vi kan ikke finde din nærmeste afdeling uden en placering."
|
||||
);
|
||||
expect(wrapper.find('[data-testid="self-serve-home-select-department-4"]').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync("src/views/dashboards/userDashboard/wash/MyWashStart.vue", "utf8");
|
||||
|
||||
describe("MyWashStart.vue production recovery contracts", () => {
|
||||
it("restores local progress before scheduling a server active-wash restore", () => {
|
||||
expect(source).toContain("const restoredProgress = restoreProgress();");
|
||||
expect(source).toContain("shouldRestoreServerActiveWash.value = !restoredProgress?.washInProgress;");
|
||||
expect(source).toContain("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);");
|
||||
expect(source.indexOf("const restoredProgress = restoreProgress();")).toBeLessThan(
|
||||
source.indexOf("scheduleServerActiveWashRestore(restoredProgress ? 1600 : 0);")
|
||||
);
|
||||
});
|
||||
|
||||
it("restores authenticated server active washes into in-progress state and summary data", () => {
|
||||
expect(source).toContain('const SERVER_ACTIVE_WASH_ENDPOINT = "/modules/self-serve/lane/wash/my-active-wash";');
|
||||
expect(source).toContain("if (!isAuthenticatedCustomerActiveWash(details, customerNumber))");
|
||||
expect(source).toContain("await applyServerActiveWash(activeWash);");
|
||||
expect(source).toContain("washInProgress.value = true;");
|
||||
expect(source).toContain("currentStep.value = steps.WASH_IN_PROGRESS;");
|
||||
expect(source).toContain("await fetchWashSummary(summaryParams, false);");
|
||||
expect(source).toContain('saveProgress("serverActiveWash");');
|
||||
});
|
||||
|
||||
it("suppresses recent completions so polling and restore do not resurrect a just-finished wash", () => {
|
||||
expect(source).toContain('const RECENT_COMPLETED_WASH_KEY = "mywash_recent_completed_v1";');
|
||||
expect(source).toContain("const RECENT_COMPLETED_WASH_SUPPRESSION_MS = 10 * 60 * 1000;");
|
||||
expect(source).toContain(
|
||||
"markRecentlyCompletedWash(completingLaneId, licensePlateInput.value, getNumericCustomerNumber());"
|
||||
);
|
||||
expect(source).toContain("if (isRecentlyCompletedActiveWash(activeWash))");
|
||||
expect(source).toContain(
|
||||
"if (!serverStillMatchesCurrentWash || isRecentlyCompletedActiveWash({ details, laneId }))"
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up unload handlers, department refresh, active-wash polling, and footer state on unmount", () => {
|
||||
expect(source).toContain("unregisterBeforeUnload.value();");
|
||||
expect(source).toContain("stopAutoRefresh();");
|
||||
expect(source).toContain("stopActiveWashRefresh();");
|
||||
expect(source).toContain("markDestroying();");
|
||||
expect(source).toContain("setShowFooterInContent(true);");
|
||||
});
|
||||
|
||||
it("prevents duplicate fetches and overlapping active-wash sync requests", () => {
|
||||
expect(source).toContain("if (isVehicleStepNextLoading.value)");
|
||||
expect(source).toContain("isVehicleStepNextLoading.value = true;");
|
||||
expect(source).toContain("if (isSyncingActiveWash.value || !washInProgress.value || !washLaneId.value)");
|
||||
expect(source).toContain("isSyncingActiveWash.value = true;");
|
||||
expect(source).toContain("isSyncingActiveWash.value = false;");
|
||||
expect(source).toContain("stopActiveWashRefresh();");
|
||||
expect(source).toContain("activeWashRefreshInterval.value = window.setInterval");
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,7 @@ vi.mock("@/components/session/token/SessionUser.vue", async () => {
|
||||
customer_number: mocks.sessionCustomerNumber,
|
||||
},
|
||||
canAccessSuperUser: () => true,
|
||||
canAccessDeveloper: () => false,
|
||||
request: vi.fn(async (...args) => {
|
||||
const response = await mocks.sessionRequest(...args);
|
||||
return response ?? { status: 200, data: { data: {} } };
|
||||
@@ -144,6 +145,8 @@ vi.mock("@/composables/useWashDepartments", () => ({
|
||||
isForcingNearestDepartment: { value: false },
|
||||
forceNearestDepartmentEvaluationId: { value: 0 },
|
||||
isSearchingDepartments: { value: false },
|
||||
departmentFetchError: { value: null },
|
||||
isDepartmentSelectionFallbackBased: { value: false },
|
||||
availableProductIds: { value: [2] },
|
||||
doesCurrentDepartmentSelectionHaveSelfServeEnabled: mocks.doesCurrentDepartmentSelectionHaveSelfServeEnabled,
|
||||
fetchDepartments: mocks.fetchDepartments,
|
||||
@@ -388,7 +391,9 @@ describe("MyWashStart", () => {
|
||||
mocks.addVehicle.mockClear();
|
||||
mocks.sessionRequest.mockClear();
|
||||
mocks.sessionRequest.mockResolvedValue(undefined);
|
||||
SessionUser.request.mockClear();
|
||||
mocks.handleConfirmNext.mockClear();
|
||||
mocks.isNextButtonDisabled.mockClear();
|
||||
mocks.isNextButtonDisabled.mockReturnValue(false);
|
||||
mocks.onStartWash.mockClear();
|
||||
mocks.onStopWash.mockReset();
|
||||
mocks.onStopWash.mockResolvedValue(true);
|
||||
@@ -421,6 +426,8 @@ describe("MyWashStart", () => {
|
||||
});
|
||||
|
||||
it("wires child updates back into the self-serve runtime", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const wrapper = mountWithApp(MyWashStart, {
|
||||
global: {
|
||||
stubs: stubComponents,
|
||||
@@ -432,6 +439,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");
|
||||
@@ -451,6 +459,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"] },
|
||||
@@ -539,6 +575,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,
|
||||
@@ -599,6 +680,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,
|
||||
@@ -621,6 +704,8 @@ describe("MyWashStart", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.fetchSelfServeDataInternal).toHaveBeenCalledWith(6, 2, 7, "AB12345");
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sessionUserMock = vi.hoisted(() => ({
|
||||
request: vi.fn(),
|
||||
objects: {
|
||||
self_serve_vehicle_conditions: {
|
||||
get: {
|
||||
previewAllowed: vi.fn(),
|
||||
washSummary: vi.fn(),
|
||||
all: vi.fn(),
|
||||
},
|
||||
add: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
self_serve_tasks: {
|
||||
attachments: {
|
||||
list: vi.fn(),
|
||||
download: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/components/session/token/SessionUser.vue", () => ({
|
||||
SessionUser: sessionUserMock,
|
||||
}));
|
||||
|
||||
import { useSelfServeLogic } from "@/composables/useSelfServeLogic.js";
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
let reject;
|
||||
const promise = new Promise((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
const previewPayload = (overrides = {}) => ({
|
||||
lane: { id: 7, name: "7" },
|
||||
session: { id: 501, vehicle_type_id: 2 },
|
||||
allowed_services: ["manual"],
|
||||
questions: [{ id: 11, question: "Clean?", answer: null, order_priority: 1 }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [{ id: 91, task: "Manual prep", order_priority: 1, services: ["manual"] }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const summaryPayload = (overrides = {}) => ({
|
||||
session: { id: 501, vehicle_type_id: 2, allowed: true },
|
||||
lane: { id: 7, name: "7" },
|
||||
allowed_services: ["machine", "manual"],
|
||||
questions: [{ id: 21, question: "Roof?", answer: true, order_priority: 2 }],
|
||||
conditions: [],
|
||||
rules: [],
|
||||
tasks: [{ id: 92, task: "Machine start", order_priority: 2, services: ["machine"] }],
|
||||
events: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("useSelfServeLogic production behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionUserMock.objects.self_serve_tasks.attachments.list.mockResolvedValue([]);
|
||||
sessionUserMock.objects.self_serve_tasks.attachments.download.mockResolvedValue({ data: { download_link: "" } });
|
||||
});
|
||||
|
||||
it("merges preview and summary questions while preserving preview answers until summary replaces them", async () => {
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(previewPayload());
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(summaryPayload());
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
|
||||
|
||||
expect(logic.questions.value.map((question) => question.id)).toEqual([11, 21]);
|
||||
expect(logic.answers.value).toEqual({ 11: null, 21: true });
|
||||
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([21]);
|
||||
expect(logic.tasks.value.map((task) => task.id)).toEqual([92]);
|
||||
expect(logic.allowedServices.value).toEqual(["MACHINE", "MANUAL"]);
|
||||
});
|
||||
|
||||
it("ignores stale preview and summary responses when a newer request wins the race", async () => {
|
||||
const slowPreview = deferred();
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed
|
||||
.mockReturnValueOnce(slowPreview.promise)
|
||||
.mockResolvedValueOnce(
|
||||
previewPayload({ session: { id: 502 }, questions: [{ id: 31, question: "Newest?", answer: false }] })
|
||||
);
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
|
||||
summaryPayload({ session: { id: 502 }, questions: [{ id: 32, question: "Newest summary?", answer: true }] })
|
||||
);
|
||||
const logic = useSelfServeLogic();
|
||||
|
||||
const stale = logic.fetchSelfServeData(6, 2, 7, "old123");
|
||||
const fresh = await logic.fetchSelfServeData(6, 2, 7, "new123");
|
||||
slowPreview.resolve(previewPayload({ session: { id: 501 }, questions: [{ id: 11, question: "Old?" }] }));
|
||||
await stale;
|
||||
|
||||
expect(fresh.session.id).toBe(502);
|
||||
expect(logic.questions.value.map((question) => question.id)).toEqual([31, 32]);
|
||||
expect(logic.preview.value.session.id).toBe(502);
|
||||
expect(logic.loading.value).toBe(false);
|
||||
});
|
||||
|
||||
it("updates allowed services from the lane endpoint and falls back to active task services on first failure", async () => {
|
||||
const logic = useSelfServeLogic();
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.previewAllowed.mockResolvedValue(
|
||||
previewPayload({ allowed_services: [], tasks: [{ id: 91, task: "Machine", services: ["MACHINE", "manual"] }] })
|
||||
);
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.get.washSummary.mockResolvedValue(
|
||||
summaryPayload({ questions: [] })
|
||||
);
|
||||
await logic.fetchSelfServeData(6, 2, 7, "ab12345");
|
||||
|
||||
sessionUserMock.request.mockResolvedValueOnce({
|
||||
data: { success: true, data: { allowed_services: ["relay", "machine"] } },
|
||||
});
|
||||
await logic.updateLaneAllowedServices(7);
|
||||
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
|
||||
|
||||
sessionUserMock.request.mockRejectedValueOnce(new Error("edge offline"));
|
||||
await expect(logic.updateLaneAllowedServices(7)).rejects.toThrow("edge offline");
|
||||
expect(logic.allowedServices.value).toEqual(["RELAY", "MACHINE"]);
|
||||
expect(logic.error.value).toBe("edge offline");
|
||||
});
|
||||
|
||||
it("keeps the local answer optimistic only through successful answer sync and reports failures", async () => {
|
||||
sessionUserMock.objects.self_serve_vehicle_conditions.add.mockRejectedValueOnce(new Error("answer sync failed"));
|
||||
const logic = useSelfServeLogic();
|
||||
logic.answerQuestion(11, true);
|
||||
|
||||
await expect(
|
||||
logic.syncVehicleAnswer({
|
||||
departmentId: 6,
|
||||
laneId: 7,
|
||||
reg: "AB12345",
|
||||
questionId: 11,
|
||||
value: false,
|
||||
vehicleTypeId: 2,
|
||||
})
|
||||
).rejects.toThrow("answer sync failed");
|
||||
|
||||
expect(logic.answers.value[11]).toBe(true);
|
||||
expect(logic.error.value).toBe("answer sync failed");
|
||||
expect(logic.loading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -165,4 +165,138 @@ describe("useWashDepartments", () => {
|
||||
lanes: [{ id: 7 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the first self-serve department with an enabled lane as fallback when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = null;
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Disabled module",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Disabled lane",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: false }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Fallback",
|
||||
address: "C",
|
||||
latitude: 55.8,
|
||||
longitude: 12.7,
|
||||
lanes: [
|
||||
{ id: 12, selfserve_enabled: "off" },
|
||||
{ id: 13, selfserve_enabled: true, products: [4] },
|
||||
],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(mocks.getDistance).not.toHaveBeenCalled();
|
||||
expect(departments.nearestDepartment.value).toMatchObject({
|
||||
id: 3,
|
||||
name: "Fallback",
|
||||
distance: null,
|
||||
lanes: [{ id: 13 }],
|
||||
});
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("fallback");
|
||||
expect(departments.isDepartmentSelectionFallbackBased.value).toBe(true);
|
||||
expect(departments.isDepartmentSelectionDistanceBased.value).toBe(false);
|
||||
});
|
||||
|
||||
it("clears the selected department for empty department responses", async () => {
|
||||
mocks.getDepartmentsGuest.mockResolvedValueOnce([
|
||||
{ id: 1, name: "North", address: "A", latitude: 55.6, longitude: 12.5, lanes: [], self_serve_enabled: true },
|
||||
]);
|
||||
const departments = mountDepartments();
|
||||
|
||||
await departments.fetchDepartments();
|
||||
expect(departments.nearestDepartment.value).toMatchObject({ id: 1 });
|
||||
|
||||
mocks.getDepartmentsGuest.mockResolvedValueOnce([]);
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(departments.guestDepartments.value).toEqual([]);
|
||||
expect(departments.nearestDepartment.value).toBeNull();
|
||||
expect(departments.departmentSelectionStrategy.value).toBeNull();
|
||||
});
|
||||
|
||||
it("does not fallback to disabled departments when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = { value: null };
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Module disabled",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10, selfserve_enabled: true }],
|
||||
self_serve_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Lane disabled",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11, selfserve_enabled: "no" }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ includeLanes: true });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
|
||||
expect(departments.nearestDepartment.value).toBeNull();
|
||||
expect(departments.departmentSelectionStrategy.value).toBeNull();
|
||||
});
|
||||
|
||||
it("allows regular users to force a department when coordinates are missing", async () => {
|
||||
mocks.locationRef.value = {};
|
||||
mocks.getDepartmentsGuest.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
name: "Fallback",
|
||||
address: "A",
|
||||
latitude: 55.6,
|
||||
longitude: 12.5,
|
||||
lanes: [{ id: 10 }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Manual choice",
|
||||
address: "B",
|
||||
latitude: 55.7,
|
||||
longitude: 12.6,
|
||||
lanes: [{ id: 11 }],
|
||||
self_serve_enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const departments = mountDepartments({ canAccessSuperUser: () => false });
|
||||
|
||||
await departments.fetchDepartments();
|
||||
departments.startDepartmentSearch();
|
||||
departments.selectDepartment(2);
|
||||
|
||||
expect(departments.isSearchingDepartments.value).toBe(false);
|
||||
expect(departments.isForcingNearestDepartment.value).toBe(true);
|
||||
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "Manual choice" });
|
||||
expect(departments.departmentSelectionStrategy.value).toBe("forced");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashFlowState, WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createFlow(overrides = {}) {
|
||||
const state = {
|
||||
currentStep: ref(WASH_STEPS.VEHICLE),
|
||||
washInProgress: ref(false),
|
||||
customerNumberInput: ref("12345679"),
|
||||
licensePlateInput: ref("AB12345"),
|
||||
vehicleTypeSelect: ref(2),
|
||||
availableProductIds: ref([2]),
|
||||
radioLaneOption: ref(7),
|
||||
radioWashType: ref("Manual"),
|
||||
nearestDepartment: ref({ lanes: [{ id: 7, status: "AVAILABLE", machine_available: true }] }),
|
||||
allVisibleQuestionsAnswered: ref(true),
|
||||
isLoadingSelfServeData: ref(false),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
editAnswers: ref(true),
|
||||
isLaneAvailable: vi.fn((lane) => lane.status === "AVAILABLE"),
|
||||
isMachineAvailable: vi.fn(() => true),
|
||||
onStartWash: vi.fn(async () => true),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return { state, flow: useWashFlowState(state) };
|
||||
}
|
||||
|
||||
describe("useWashFlowState production transitions", () => {
|
||||
it("gates vehicle, question, lane, task, in-progress, and completed transitions", async () => {
|
||||
const { state, flow } = createFlow({ activeTasks: ref([{ id: 91 }]) });
|
||||
|
||||
state.vehicleTypeSelect.value = null;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.vehicleTypeSelect.value = 2;
|
||||
expect(flow.clickableSteps[WASH_STEPS.VEHICLE]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
state.allVisibleQuestionsAnswered.value = false;
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
state.allVisibleQuestionsAnswered.value = true;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.editAnswers.value).toBe(false);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.SELECT_LANE);
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
|
||||
expect(flow.clickableSteps[WASH_STEPS.SELECT_LANE]()).toBe(true);
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.onStartWash).toHaveBeenCalledWith(7, "AB12345", "12345679", WASH_STEPS.TASKS);
|
||||
|
||||
state.currentStep.value = WASH_STEPS.TASKS;
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
await flow.handleConfirmNext();
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
|
||||
state.washInProgress.value = true;
|
||||
expect(flow.clickableSteps[WASH_STEPS.WASH_IN_PROGRESS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.QUESTIONS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.TASKS]()).toBe(true);
|
||||
expect(flow.clickableSteps[WASH_STEPS.COMPLETED]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks lane confirmation for unavailable lanes and unavailable machine service", () => {
|
||||
const { state, flow } = createFlow();
|
||||
state.currentStep.value = WASH_STEPS.SELECT_LANE;
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "OCCUPIED";
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
|
||||
state.nearestDepartment.value.lanes[0].status = "AVAILABLE";
|
||||
state.radioWashType.value = "Machine";
|
||||
state.isMachineAvailable.mockReturnValue(false);
|
||||
expect(flow.isNextButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
it("stays on questions when lane allowed-service refresh fails", async () => {
|
||||
const { state, flow } = createFlow({
|
||||
updateLaneAllowedServices: vi.fn(async () => {
|
||||
throw new Error("edge down");
|
||||
}),
|
||||
});
|
||||
state.currentStep.value = WASH_STEPS.QUESTIONS;
|
||||
|
||||
await flow.handleConfirmNext();
|
||||
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.QUESTIONS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ref } from "vue";
|
||||
import { useWashSessionActions } from "@/composables/useWashSessionActions.js";
|
||||
import { WASH_STEPS } from "@/composables/useWashFlowState.js";
|
||||
|
||||
function createActions(overrides = {}) {
|
||||
const calls = [];
|
||||
const request = vi.fn(async (_url, _method, body) => {
|
||||
calls.push(body.command);
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const state = {
|
||||
request,
|
||||
alertFn: vi.fn(),
|
||||
nearestDepartment: ref({ id: 6, lanes: [{ id: 7 }] }),
|
||||
vehicleTypeSelect: ref(2),
|
||||
washLaneId: ref(null),
|
||||
washInProgress: ref(false),
|
||||
washStartTime: ref(null),
|
||||
completedDurationMs: ref(null),
|
||||
now: ref(10_000),
|
||||
currentStep: ref(WASH_STEPS.SELECT_LANE),
|
||||
steps: WASH_STEPS,
|
||||
radioWashType: ref("Manual"),
|
||||
activeTasks: ref([]),
|
||||
completedTasks: ref({}),
|
||||
saveProgress: vi.fn(),
|
||||
clearProgress: vi.fn(),
|
||||
startElapsedTimer: vi.fn(),
|
||||
stopElapsedTimer: vi.fn(),
|
||||
updateLaneAllowedServices: vi.fn(async () => true),
|
||||
fetchWashSummary: vi.fn(async () => ({})),
|
||||
enableMachineRelay: vi.fn(async () => ({ data: { success: true } })),
|
||||
isServiceAllowed: vi.fn(() => true),
|
||||
...overrides,
|
||||
};
|
||||
return { state, actions: useWashSessionActions(state), calls };
|
||||
}
|
||||
|
||||
describe("useWashSessionActions production commands", () => {
|
||||
it("starts a manual wash successfully", async () => {
|
||||
const { state, actions } = createActions();
|
||||
|
||||
await expect(actions.onStartWash(7, "ab12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.updateLaneAllowedServices).toHaveBeenCalledWith(7);
|
||||
expect(state.request).toHaveBeenCalledWith(
|
||||
"/modules/self-serve/lane/command",
|
||||
"post",
|
||||
expect.objectContaining({ command: "START", license_plate: "AB12345" })
|
||||
);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
|
||||
expect(state.saveProgress).toHaveBeenCalledWith("onStartWash");
|
||||
});
|
||||
|
||||
it("reports START failure without mutating active wash state", async () => {
|
||||
const { state, actions } = createActions({
|
||||
request: vi.fn(async () => ({ data: { success: false, message: "START failed" } })),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("START failed");
|
||||
expect(state.saveProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables machine relay after START when machine service is allowed", async () => {
|
||||
const { state, actions } = createActions({ radioWashType: ref("Machine") });
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
|
||||
|
||||
expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
});
|
||||
|
||||
it("rolls back with STOP when machine relay enable fails", async () => {
|
||||
const { state, actions, calls } = createActions({
|
||||
radioWashType: ref("Machine"),
|
||||
enableMachineRelay: vi.fn(async () => {
|
||||
throw new Error("relay down");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(false);
|
||||
|
||||
expect(calls).toEqual(["START", "STOP"]);
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("relay down");
|
||||
});
|
||||
|
||||
it("keeps active state and alerts when STOP fails", async () => {
|
||||
const request = vi.fn(async (_url, _method, body) =>
|
||||
body.command === "STOP" ? { data: { success: false, message: "STOP failed" } } : { data: { success: true } }
|
||||
);
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(false);
|
||||
|
||||
expect(state.washInProgress.value).toBe(true);
|
||||
expect(state.alertFn).toHaveBeenCalledWith("STOP failed");
|
||||
expect(state.clearProgress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovers local state when STOP says the lane is already not occupied", async () => {
|
||||
const request = vi.fn(async () => ({ data: { success: false, message: "Cannot stop lane 7: not occupied" } }));
|
||||
const { state, actions } = createActions({
|
||||
request,
|
||||
washInProgress: ref(true),
|
||||
washLaneId: ref(7),
|
||||
washStartTime: ref(1_000),
|
||||
now: ref(6_000),
|
||||
});
|
||||
|
||||
await expect(actions.onStopWash(7)).resolves.toBe(true);
|
||||
|
||||
expect(state.washInProgress.value).toBe(false);
|
||||
expect(state.washLaneId.value).toBeNull();
|
||||
expect(state.completedDurationMs.value).toBe(5_000);
|
||||
expect(state.stopElapsedTimer).toHaveBeenCalled();
|
||||
expect(state.clearProgress).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user