Add production wash start test matrix

This commit is contained in:
Jeppe B
2026-06-02 13:42:22 +02:00
parent 5170139a32
commit 3960daa556
6 changed files with 513 additions and 1 deletions
@@ -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. |
+53 -1
View File
@@ -1144,7 +1144,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 +1512,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,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");
});
});
@@ -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);
});
});
@@ -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();
});
});