Fix wash start recovery from API failures

This commit is contained in:
Jeppe B
2026-06-02 08:07:03 +02:00
parent 114ca9d956
commit 85529de3f8
4 changed files with 170 additions and 60 deletions
+8 -5
View File
@@ -48,10 +48,13 @@ export const getDepartmentDescription = (id) => {
export const getDepartmentsGuest = async (queryParams = {}) => {
isLoading.value = true;
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
isLoading.value = false;
return departments.value;
try {
const queryString = new URLSearchParams(queryParams).toString();
const request = await unauthenticatedRequest("/guest/departments" + (queryString ? `?${queryString}` : ""), "get");
departments.value = sortByDepartmentPriorityOrder(request.data.data || []);
return departments.value;
} finally {
isLoading.value = false;
}
};
</script>
+63 -34
View File
@@ -2,12 +2,17 @@ import { computed, nextTick, onUnmounted, ref, watch } from "vue";
import { getDepartmentsGuest } from "@/components/pagination/departmentTabs.vue";
import { locations } from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
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 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 toDepartmentViewModel = (department, distance = null) => ({
id: department.id,
@@ -18,12 +23,22 @@ const toDepartmentViewModel = (department, distance = null) => ({
self_serve_enabled: department.self_serve_enabled,
});
const extractDepartmentFetchErrorMessage = (error) => {
const candidates = [
error?.response?.data?.data?.message,
error?.response?.data?.message,
error?.response?.data?.error,
error?.message,
];
return (
candidates.find((candidate) => typeof candidate === "string" && candidate.trim() !== "") ||
"Kunne ikke hente vaskeafdelinger. Prøv igen."
);
};
export function useWashDepartments(options = {}) {
const {
includeLanes = false,
refreshIntervalMs = 0,
canAccessSuperUser = () => false,
} = options;
const { includeLanes = false, refreshIntervalMs = 0, canAccessSuperUser = () => false } = options;
const guestDepartments = ref([]);
const nearestDepartment = ref(null);
@@ -31,6 +46,7 @@ export function useWashDepartments(options = {}) {
const forceNearestDepartmentEvaluationId = ref(0);
const isSearchingDepartments = ref(false);
const lastDepartmentFetchTime = ref(null);
const departmentFetchError = ref(null);
let refreshInterval = null;
@@ -57,13 +73,18 @@ export function useWashDepartments(options = {}) {
return nearestDepartment.value.self_serve_enabled === true;
});
const buildGuestDepartmentParams = () => (
includeLanes ? { include_lanes: true } : {}
);
const buildGuestDepartmentParams = () => (includeLanes ? { include_lanes: true } : {});
const fetchDepartments = async () => {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
try {
guestDepartments.value = await getDepartmentsGuest(buildGuestDepartmentParams());
lastDepartmentFetchTime.value = Date.now();
departmentFetchError.value = null;
} catch (error) {
departmentFetchError.value = extractDepartmentFetchErrorMessage(error);
console.warn("Failed to fetch wash departments:", error);
}
evaluateLocationDepartments(locations.location.value);
return guestDepartments.value;
};
@@ -74,19 +95,15 @@ export function useWashDepartments(options = {}) {
}
const currentTime = Date.now();
if (
!lastDepartmentFetchTime.value
|| (currentTime - lastDepartmentFetchTime.value) >= refreshIntervalMs
) {
if (!lastDepartmentFetchTime.value || currentTime - lastDepartmentFetchTime.value >= refreshIntervalMs) {
return fetchDepartments();
}
return guestDepartments.value;
};
const getForcedDepartment = () => guestDepartments.value.find(
(department) => department.id === forceNearestDepartmentEvaluationId.value
);
const getForcedDepartment = () =>
guestDepartments.value.find((department) => department.id === forceNearestDepartmentEvaluationId.value);
const evaluateLocationDepartments = (locationValue = locations.location.value) => {
if (isForcingNearestDepartment.value && forceNearestDepartmentEvaluationId.value) {
@@ -191,19 +208,30 @@ export function useWashDepartments(options = {}) {
}
};
watch(() => guestDepartments.value, () => {
evaluateLocationDepartments(locations.location.value);
}, { deep: true });
watch(() => locations.location.value, (newValue) => {
evaluateLocationDepartments(newValue);
}, { deep: true });
watch(() => isSearchingDepartments.value, (isSearching) => {
if (!isSearching) {
watch(
() => guestDepartments.value,
() => {
evaluateLocationDepartments(locations.location.value);
},
{ deep: true }
);
watch(
() => locations.location.value,
(newValue) => {
evaluateLocationDepartments(newValue);
},
{ deep: true }
);
watch(
() => isSearchingDepartments.value,
(isSearching) => {
if (!isSearching) {
evaluateLocationDepartments(locations.location.value);
}
}
});
);
onUnmounted(() => {
stopAutoRefresh();
@@ -216,6 +244,7 @@ export function useWashDepartments(options = {}) {
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
lastDepartmentFetchTime,
departmentFetchError,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -83,6 +83,7 @@ const {
isForcingNearestDepartment,
forceNearestDepartmentEvaluationId,
isSearchingDepartments,
departmentFetchError,
availableProductIds,
doesCurrentDepartmentSelectionHaveSelfServeEnabled,
fetchDepartments,
@@ -436,6 +437,8 @@ const extractErrorMessage = (error: any, fallback: string) => {
return fallback;
};
const getRequestStatus = (error: any) => Number(error?.response?.status ?? error?.status ?? 0);
watch(dynamicImageUrl, () => {
hideDynamicImage.value = false;
});
@@ -779,7 +782,17 @@ const fetchServerActiveWash = async () => {
return null;
}
const response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
let response = null;
try {
response = await SessionUser.request(SERVER_ACTIVE_WASH_ENDPOINT, "GET");
} catch (error) {
if (getRequestStatus(error) === 404) {
return null;
}
throw error;
}
const details = unwrapApiData(response);
if (!isAuthenticatedCustomerActiveWash(details, customerNumber)) {
return null;
@@ -848,7 +861,10 @@ const applyServerActiveWash = async (activeWash: any) => {
const summary = await fetchWashSummary(summaryParams, false);
if (!summary) {
await fetchSelfServeDataInternal(departmentId || nearestDepartment.value?.id, selectedVehicleTypeId, laneId, reg);
const fallbackDepartmentId = departmentId || nearestDepartment.value?.id;
if (fallbackDepartmentId) {
await fetchSelfServeDataInternal(fallbackDepartmentId, selectedVehicleTypeId, laneId, reg);
}
}
saveProgress("serverActiveWash");
@@ -1439,6 +1455,28 @@ watch(
</div>
</section>
<b-message
v-if="departmentFetchError"
type="is-warning"
has-icon
:closable="false"
data-testid="self-serve-departments-error"
>
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
<span class="mr-3">{{ departmentFetchError }}</span>
<b-button
size="is-small"
type="is-warning is-light"
icon-pack="fas"
icon-left="sync-alt"
data-testid="self-serve-departments-retry"
@click="fetchDepartments"
>
{{ $t("common.try_again") }}
</b-button>
</div>
</b-message>
<b-message
v-if="selfServeDataError"
type="is-danger"
+59 -19
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { defineComponent, h, nextTick } from "vue";
import { mount } from "@vue/test-utils";
import { nextTick } from "vue";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
@@ -27,27 +27,30 @@ vi.mock("@/components/displays/department/pos/steps/mobile/objects/PosDepartment
},
}));
import { useWashDepartments } from "@/composables/useWashDepartments.js";
const { useWashDepartments } = await import("@/composables/useWashDepartments.js");
const mountComposable = (factory) => {
let result;
const mountedWrappers = [];
mount(
defineComponent({
setup() {
result = factory();
return () => h("div");
},
})
);
const mountDepartments = (options = {}) => {
let departments = null;
const wrapper = mount({
setup() {
departments = useWashDepartments(options);
return {};
},
template: "<div />",
});
mountedWrappers.push(wrapper);
return result;
return departments;
};
describe("useWashDepartments", () => {
beforeEach(() => {
mountedWrappers.splice(0).forEach((wrapper) => wrapper.unmount());
mocks.getDepartmentsGuest.mockReset();
mocks.getDistance.mockReset();
vi.restoreAllMocks();
mocks.locationRef.value = {
coords: {
latitude: 55.5,
@@ -88,7 +91,7 @@ describe("useWashDepartments", () => {
},
]);
const departments = mountComposable(() => useWashDepartments({ includeLanes: true }));
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
@@ -104,11 +107,9 @@ describe("useWashDepartments", () => {
{ id: 2, name: "South", address: "B", latitude: 55.7, longitude: 12.6, lanes: [], self_serve_enabled: true },
]);
const departments = mountComposable(() =>
useWashDepartments({
canAccessSuperUser: () => true,
})
);
const departments = mountDepartments({
canAccessSuperUser: () => true,
});
await departments.fetchDepartments();
departments.startDepartmentSearch();
@@ -125,4 +126,43 @@ describe("useWashDepartments", () => {
expect(departments.forceNearestDepartmentEvaluationId.value).toBe(0);
expect(departments.nearestDepartment.value).toMatchObject({ id: 2, name: "South" });
});
it("keeps the wash start page usable when the guest departments request fails", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
mocks.getDepartmentsGuest.mockRejectedValueOnce(new Error("Network Error"));
const departments = mountDepartments({ includeLanes: true });
await expect(departments.fetchDepartments()).resolves.toEqual([]);
expect(departments.guestDepartments.value).toEqual([]);
expect(departments.nearestDepartment.value).toBeNull();
expect(departments.departmentFetchError.value).toBe("Network Error");
expect(mocks.getDepartmentsGuest).toHaveBeenCalledWith({ include_lanes: true });
});
it("clears the department fetch error after a successful retry", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const department = {
id: 3,
name: "Glostrup",
address: "Fabriksparken 18",
latitude: 55.6,
longitude: 12.5,
self_serve_enabled: true,
lanes: [{ id: 7, status: "AVAILABLE", selfserve_enabled: true }],
};
mocks.getDepartmentsGuest.mockRejectedValueOnce(new Error("Gateway Timeout")).mockResolvedValueOnce([department]);
const departments = mountDepartments({ includeLanes: true });
await departments.fetchDepartments();
await departments.fetchDepartments();
expect(departments.departmentFetchError.value).toBeNull();
expect(departments.guestDepartments.value).toEqual([department]);
expect(departments.nearestDepartment.value).toMatchObject({
id: 3,
name: "Glostrup",
lanes: [{ id: 7 }],
});
});
});