Resume active self-serve washes for authenticated customers across devices. Update OpenAPI, UI components, and extend E2E/unit tests.

This commit is contained in:
Jeppe Bundgaard
2026-04-28 18:00:09 +02:00
parent 503fb84f10
commit a658fe7863
5 changed files with 333 additions and 3 deletions
+2
View File
@@ -4772,6 +4772,7 @@ paths:
tags:
- Self-Serve
summary: Check whether self-serve is allowed for a vehicle on a lane
description: Customers with own self-serve permissions may evaluate any registration plate for their wash. Persisted self-serve answers are only applied when they are scoped to the authenticated customer.
operationId: getSelfserveVehicleAllowed
parameters:
- name: lane_id
@@ -8737,6 +8738,7 @@ paths:
description: |
Send a command (e.g., start, stop, reset) to a self-serve lane.
Property gate commands (`OPEN_PROPERTY_ACCESS_GATE`, `OPEN_PROPERTY_EXIT_GATE`) are also supported here.
Property gate command permissions are bypassed for authenticated customers with an active self-serve wash in the target department.
operationId: sendSelfServeLaneCommand
requestBody:
required: true
@@ -274,6 +274,22 @@ const normalizeLicensePlate = (value: string | null) => (value || "").trim().toU
const getNumericCustomerNumber = () => resolveEffectiveCustomerNumber(customerNumberInput.value);
const unwrapApiData = (response: any) => response?.data?.data ?? response?.data ?? response;
const parseServerDateTimeMs = (value: any) => {
if (!value) {
return null;
}
const normalized = String(value).trim();
if (!normalized) {
return null;
}
const timestamp = Date.parse(normalized.includes("T") ? normalized : normalized.replace(" ", "T"));
return Number.isFinite(timestamp) ? timestamp : null;
};
const extractErrorMessage = (error: any, fallback: string) => {
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
@@ -454,6 +470,144 @@ const fetchSelfServeData = async () => {
);
};
const getServerActiveWashCandidates = () => {
const departments = Array.isArray(guestDepartments.value) ? guestDepartments.value : [];
return departments.flatMap((department: any) => {
const lanes = Array.isArray(department?.lanes) ? department.lanes : [];
return lanes
.map((lane: any) => ({
department,
lane,
laneId: normalizeLaneId(lane?.id),
}))
.filter((candidate: any) => candidate.laneId);
});
};
const isAuthenticatedCustomerActiveWash = (details: any, customerNumber: number) => {
if (!details?.in_progress) {
return false;
}
const sessionCustomerNumber = normalizePositiveInteger(
details?.session?.customer_number
?? details?.customer?.customer_number
);
return sessionCustomerNumber === customerNumber;
};
const activeWashTimestamp = (details: any) => (
parseServerDateTimeMs(details?.session?.wash_started_at)
?? parseServerDateTimeMs(details?.session?.machine_start_triggered_at)
?? parseServerDateTimeMs(details?.session?.machine_relay_enabled_at)
?? parseServerDateTimeMs(details?.session?.updated_at)
?? parseServerDateTimeMs(details?.session?.created_at)
?? 0
);
const fetchServerActiveWash = async () => {
const customerNumber = getAuthenticatedCustomerNumber();
if (!customerNumber) {
return null;
}
const candidates = getServerActiveWashCandidates();
if (candidates.length === 0) {
return null;
}
const responses = await Promise.allSettled(candidates.map(async (candidate: any) => {
const response = await SessionUser.request("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: candidate.laneId,
});
return {
...candidate,
details: unwrapApiData(response),
};
}));
const activeMatches = responses
.filter((entry): entry is PromiseFulfilledResult<any> => entry.status === "fulfilled")
.map((entry) => entry.value)
.filter((entry) => isAuthenticatedCustomerActiveWash(entry.details, customerNumber))
.sort((left, right) => activeWashTimestamp(right.details) - activeWashTimestamp(left.details));
return activeMatches[0] || null;
};
const applyServerActiveWash = async (activeWash: any) => {
const details = activeWash?.details;
const session = details?.session || {};
const vehicle = details?.vehicle || {};
const laneId = normalizeLaneId(details?.lane_id ?? session?.lane_id ?? activeWash?.laneId);
const departmentId = normalizeLaneId(activeWash?.department?.id ?? session?.department_id ?? activeWash?.lane?.department);
const reg = normalizeLicensePlate(session?.reg ?? vehicle?.reg ?? licensePlateInput.value);
if (!laneId || !reg) {
return false;
}
if (departmentId && nearestDepartment.value?.id !== departmentId) {
isForcingNearestDepartment.value = true;
forceNearestDepartmentEvaluationId.value = departmentId;
evaluateLocationDepartments(locations.location.value);
}
washInProgress.value = true;
washLaneId.value = laneId;
radioLaneOption.value = laneId;
licensePlateInput.value = reg;
applyEffectiveCustomerNumberInput(session?.customer_number ?? details?.customer?.customer_number);
vehicleTypeSelect.value = normalizePositiveInteger(session?.vehicle_type_id ?? vehicle?.type) ?? vehicleTypeSelect.value;
radioWashType.value = session?.machine_relay_enabled ? "Machine" : "Manual";
washStartTime.value = (
parseServerDateTimeMs(session?.wash_started_at)
?? parseServerDateTimeMs(session?.machine_start_triggered_at)
?? parseServerDateTimeMs(session?.machine_relay_enabled_at)
?? Date.now()
);
completedDurationMs.value = null;
editAnswers.value = false;
washActionError.value = null;
vehicleStepError.value = null;
currentStep.value = steps.WASH_IN_PROGRESS;
startElapsedTimer();
const summaryParams: Record<string, any> = session?.id
? { session_id: session.id }
: { lane_id: laneId, reg };
const selectedVehicleTypeId = normalizePositiveInteger(vehicleTypeSelect.value);
if (selectedVehicleTypeId) {
summaryParams.vehicle_type = selectedVehicleTypeId;
}
const summary = await fetchWashSummary(summaryParams, false);
if (!summary) {
await fetchSelfServeDataInternal(
departmentId || nearestDepartment.value?.id,
selectedVehicleTypeId,
laneId,
reg
);
}
saveProgress("serverActiveWash");
return true;
};
const restoreServerActiveWash = async () => {
try {
const activeWash = await fetchServerActiveWash();
if (activeWash) {
await applyServerActiveWash(activeWash);
}
} catch (error) {
console.warn("Failed to restore active self-serve wash:", error);
}
};
const retrySelfServeData = async () => {
if (isSelfServeRetrying.value) {
return;
@@ -589,7 +743,13 @@ onMounted(async () => {
applyEffectiveCustomerNumberInput(customerNumberInput.value);
await fetchCustomerVehicles();
await fetchVehicleTypes();
restoreProgress();
const restoredProgress = restoreProgress();
if (!restoredProgress?.washInProgress) {
const restoreDelayMs = restoredProgress ? 1600 : 0;
window.setTimeout(() => {
restoreServerActiveWash();
}, restoreDelayMs);
}
});
onUnmounted(() => {
+68
View File
@@ -191,6 +191,74 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
const requests = captureSelfServeGatewayRequests(page);
await mockApi(page, {
authenticated: true,
permissions: ["user"],
sessionData: {
customer_number: 12345679,
},
selfServe: {
inProgressByLaneId: {
9: {
lane_id: 9,
in_progress: true,
session: {
id: 704,
lane_id: 9,
department_id: 2,
reg: "CD67890",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: true,
wash_started_at: "2026-04-28 10:15:00",
status: "MACHINE_STARTED",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "CD67890", type: 2 },
},
},
summaryBySessionId: {
704: {
session: {
id: 704,
lane_id: 9,
reg: "CD67890",
status: "MACHINE_STARTED",
allowed: true,
vehicle_type_id: 2,
},
lane: { id: 9, name: "9", department: 2 },
questions: [],
conditions: [],
rules: [],
tasks: [],
events: [{ id: 7041, type: "MACHINE_STARTED", created_at: "2026-04-28T10:15:00.000Z" }],
},
},
},
});
await primeSession(page, {
token: "self-serve-active-server-wash-token",
permissions: ["user"],
});
await page.goto("/user/wash/start");
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
expect(requests.commands).toHaveLength(0);
const accessCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_ACCESS_GATE");
await page.getByTestId("self-serve-nav-open-property-access-gate").click();
const accessCommandRequest = await accessCommandRequestPromise;
expect(accessCommandRequest.postDataJSON?.()).toMatchObject({
lane_id: 9,
command: "OPEN_PROPERTY_ACCESS_GATE",
});
});
test("superuser can force a department and clear the override", async ({ page }) => {
await mockApi(page, {
authenticated: true,
+22
View File
@@ -99,6 +99,10 @@ function mergeFixture(base, overrides = {}) {
...(base.laneAllowedServicesByLane || {}),
...(overrides.laneAllowedServicesByLane || {}),
},
inProgressByLaneId: {
...(base.inProgressByLaneId || {}),
...(overrides.inProgressByLaneId || {}),
},
relayStatuses: {
...(base.relayStatuses || {}),
...(overrides.relayStatuses || {}),
@@ -1010,6 +1014,7 @@ function createSelfServeFixture(overrides = {}) {
laneAllowedServices: ["MACHINE"],
laneAllowedServiceResponse: null,
laneAllowedServiceResponses: null,
inProgressByLaneId: {},
allowedServiceRequests: [],
answerRequests: [],
commandResponse: { success: true },
@@ -6113,6 +6118,23 @@ export async function mockApi(page, options = {}) {
return;
}
if (pathname.endsWith("/modules/self-serve/lane/wash/in-progress") && method === "GET") {
const laneId = parsedUrl.searchParams.get("lane_id");
const details = selfServe.inProgressByLaneId?.[String(laneId || "")] || null;
await route.fulfill(
json({
data: details || {
lane_id: Number(laneId || 0),
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
})
);
return;
}
if (pathname.endsWith("/department/selfserve/tasks/attachments") && method === "GET") {
const taskId = parsedUrl.searchParams.get("id");
await route.fulfill(
+80 -2
View File
@@ -81,8 +81,8 @@ vi.mock("@/components/session/token/SessionUser.vue", () => ({
},
canAccessSuperUser: () => true,
request: vi.fn(async (...args) => {
mocks.sessionRequest(...args);
return { status: 200, data: { data: {} } };
const response = await mocks.sessionRequest(...args);
return response ?? { status: 200, data: { data: {} } };
}),
functions: {
contact: {
@@ -337,6 +337,7 @@ describe("MyWashStart", () => {
mocks.setShowFooterInContent.mockClear();
mocks.addVehicle.mockClear();
mocks.sessionRequest.mockClear();
mocks.sessionRequest.mockResolvedValue(undefined);
mocks.openPropertyAccessGate.mockClear();
mocks.openPropertyExitGate.mockClear();
});
@@ -577,4 +578,81 @@ describe("MyWashStart", () => {
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(7);
expect(mocks.openPropertyExitGate).toHaveBeenCalledWith(7);
});
it("restores an active wash from the server when local progress is missing", async () => {
mocks.nearestDepartment.value = {
id: 6,
name: "Roskilde",
address: "Industrivej 45, 4000 Roskilde",
self_serve_enabled: true,
lanes: [{ id: 7, name: "7", status: "AVAILABLE", products: [2], machine_available: true }],
};
const odense = {
id: 2,
name: "Odense",
address: "Beta 2",
self_serve_enabled: true,
lanes: [{ id: 9, name: "9", status: "OCCUPIED", products: [2], machine_available: true }],
};
mocks.guestDepartments.value = [mocks.nearestDepartment.value, odense];
mocks.sessionRequest.mockImplementation(async (path, method, payload) => {
if (path === "/modules/self-serve/lane/wash/in-progress" && method === "GET" && payload?.lane_id === 9) {
return {
data: {
data: {
lane_id: 9,
in_progress: true,
session: {
id: 704,
reg: "CD67890",
customer_number: 12345679,
vehicle_type_id: 2,
machine_relay_enabled: true,
wash_started_at: "2026-04-28 10:15:00",
},
customer: { customer_number: 12345679 },
vehicle: { reg: "CD67890", type: 2 },
},
},
};
}
return {
data: {
data: {
lane_id: payload?.lane_id,
in_progress: false,
session: null,
customer: null,
vehicle: null,
},
},
};
});
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
await new Promise((resolve) => setTimeout(resolve, 0));
await flushPromises();
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 7,
});
expect(mocks.sessionRequest).toHaveBeenCalledWith("/modules/self-serve/lane/wash/in-progress", "GET", {
lane_id: 9,
});
expect(mocks.fetchWashSummary).toHaveBeenCalledWith({ session_id: 704, vehicle_type: 2 }, false);
expect(mocks.startElapsedTimer).toHaveBeenCalled();
expect(mocks.saveProgress).toHaveBeenCalledWith("serverActiveWash");
const accessGateButton = wrapper.get('[data-testid="self-serve-nav-open-property-access-gate"]');
expect(accessGateButton.attributes("style") || "").not.toContain("display: none");
await accessGateButton.trigger("click");
expect(mocks.openPropertyAccessGate).toHaveBeenCalledWith(9);
});
});