Files
pleno-vue/tests/unit/self-serve-machine-connectivity.spec.js
T

363 lines
13 KiB
JavaScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
request: vi.fn(),
parseErrorMessage: vi.fn((error) => error?.message || String(error || "Unknown error")),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
functions: {
parseErrorMessage: mocks.parseErrorMessage,
},
},
}));
import {
__resetMachineConnectivityStoreForTests,
parseRelayStatus,
relayKinds,
useMachineConnectivity,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
const statusEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/status",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/status",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/status",
};
const setEndpoints = {
MACHINE: "/modules/self-serve/lane/relay/machine/set",
PROGRAM_PICKER: "/modules/self-serve/lane/relay/machine_program_picker/set",
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/set",
};
const inProgressEndpoint = "/modules/self-serve/lane/wash/in-progress";
const buildRelayStatus = (
laneId,
relay,
{ relayId = `${relay.toLowerCase()}-${laneId}`, online = true, on = false } = {}
) => ({
lane_id: laneId,
relay,
relay_id: relayId,
online,
on,
});
const buildInProgressDetails = (
laneId,
{ inProgress = false, session = null, customer = null, vehicle = null } = {}
) => ({
lane_id: laneId,
in_progress: inProgress,
session,
customer,
vehicle,
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("self-serve machine connectivity store", () => {
beforeEach(() => {
vi.useRealTimers();
mocks.request.mockReset();
mocks.parseErrorMessage.mockReset();
mocks.parseErrorMessage.mockImplementation((error) => error?.message || String(error || "Unknown error"));
__resetMachineConnectivityStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
__resetMachineConnectivityStoreForTests();
});
it("hydrates all relay statuses for a lane via shared store", async () => {
const laneId = 7;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === statusEndpoints.MACHINE) {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: true }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.PROGRAM_PICKER, { on: false }) } };
}
if (endpoint === statusEndpoints.CLEANER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER, { online: false, on: false }) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE).toMatchObject({ lane_id: laneId, on: true, online: true });
expect(connectivity.statuses.value.PROGRAM_PICKER).toMatchObject({ lane_id: laneId, on: false, online: true });
expect(connectivity.statuses.value.CLEANER).toMatchObject({ lane_id: laneId, on: false, online: false });
expect(connectivity.error.value).toBeNull();
});
it("toggles a relay and refreshes relay status when set response is envelope-only", async () => {
const laneId = 11;
let machineOn = false;
mocks.request.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === setEndpoints.MACHINE && method === "POST") {
machineOn = Boolean(payload?.on);
return { data: { success: true } };
}
if (endpoint === statusEndpoints.MACHINE && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: machineOn }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.PROGRAM_PICKER) } };
}
if (endpoint === statusEndpoints.CLEANER && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE?.on).toBe(false);
await connectivity.toggleRelay(relayKinds.MACHINE, true);
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
});
it("runs force and command actions through shared connectivity helpers", async () => {
const laneId = 15;
mocks.request.mockResolvedValue({ data: { success: true } });
const connectivity = useMachineConnectivity(laneId);
await connectivity.forceEnableMachine({ duration: 120, licensePlate: "ab12345" });
await connectivity.forceDisableMachine("cd67890");
await connectivity.forceStopLane({ sessionId: 91, bill: true, reason: "clear app runtime" });
await connectivity.stopWash();
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/lane/force/machine/enable", "POST", {
lane_id: laneId,
duration: 120,
license_plate: "AB12345",
});
expect(mocks.request).toHaveBeenNthCalledWith(2, "/modules/self-serve/lane/force/machine/disable", "POST", {
lane_id: laneId,
license_plate: "CD67890",
});
expect(mocks.request).toHaveBeenNthCalledWith(3, "/modules/self-serve/lane/force/stop", "POST", {
lane_id: laneId,
session_id: 91,
bill: true,
reason: "clear app runtime",
});
expect(mocks.request).toHaveBeenNthCalledWith(4, "/modules/self-serve/lane/command", "POST", {
lane_id: laneId,
command: "STOP",
});
});
it("runs entrance/exit gate actions through shared connectivity helpers", async () => {
const laneId = 18;
mocks.request.mockResolvedValue({ data: { success: true } });
const connectivity = useMachineConnectivity(laneId);
await connectivity.openEntranceGate();
await connectivity.openExitGate();
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/lane/gate/open", "POST", {
lane_id: laneId,
gate: "ENTRANCE",
});
expect(mocks.request).toHaveBeenNthCalledWith(2, "/modules/self-serve/lane/gate/open", "POST", {
lane_id: laneId,
gate: "EXIT",
});
});
it("fetches in-progress wash details through shared connectivity helpers", async () => {
const laneId = 19;
mocks.request.mockResolvedValue({
data: {
data: buildInProgressDetails(laneId, {
inProgress: true,
session: { id: 55, reg: "AB12345", customer_number: 9001 },
customer: { id: 2, customer_number: 9001, display_name: "Jane Doe" },
vehicle: { id: 3, reg: "AB12345" },
}),
},
});
const connectivity = useMachineConnectivity(laneId);
const details = await connectivity.fetchInProgressDetails();
expect(mocks.request).toHaveBeenCalledWith(inProgressEndpoint, "GET", { lane_id: laneId });
expect(details).toMatchObject({
lane_id: laneId,
in_progress: true,
customer: { display_name: "Jane Doe" },
vehicle: { reg: "AB12345" },
});
expect(connectivity.inProgressDetails.value).toMatchObject({
lane_id: laneId,
in_progress: true,
});
expect(connectivity.inProgressLoading.value).toBe(false);
});
it("sets lane error when in-progress details request fails", async () => {
const laneId = 20;
mocks.request.mockRejectedValue(new Error("in-progress unavailable"));
const connectivity = useMachineConnectivity(laneId);
await expect(connectivity.fetchInProgressDetails()).rejects.toThrow("in-progress unavailable");
expect(connectivity.error.value).toContain("in-progress unavailable");
expect(connectivity.inProgressLoading.value).toBe(false);
});
it("starts and stops polling without leaking background status requests", async () => {
vi.useFakeTimers();
const laneId = 4;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === inProgressEndpoint) {
return { data: { data: buildInProgressDetails(laneId) } };
}
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
if (!relay) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return { data: { data: buildRelayStatus(laneId, relay, { on: false }) } };
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.startPolling(1000);
expect(connectivity.isPolling.value).toBe(true);
await vi.advanceTimersByTimeAsync(2200);
const statusCallCountBeforeStop = mocks.request.mock.calls.length;
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(12);
connectivity.stopPolling();
expect(connectivity.isPolling.value).toBe(false);
await vi.advanceTimersByTimeAsync(2200);
expect(mocks.request.mock.calls.length).toBe(statusCallCountBeforeStop);
});
it("keeps successful relay statuses and surfaces an error when one relay fetch fails", async () => {
const laneId = 22;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === statusEndpoints.MACHINE) {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: true }) } };
}
if (endpoint === statusEndpoints.PROGRAM_PICKER) {
throw new Error("program picker unavailable");
}
if (endpoint === statusEndpoints.CLEANER) {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER, { on: false }) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
expect(connectivity.statuses.value.CLEANER?.on).toBe(false);
expect(connectivity.statuses.value.PROGRAM_PICKER).toBeNull();
expect(connectivity.error.value).toContain("program picker unavailable");
});
it("throws and stores lane error when all relay status requests fail", async () => {
const laneId = 24;
mocks.request.mockRejectedValue(new Error("all relays offline"));
const connectivity = useMachineConnectivity(laneId);
await expect(connectivity.fetchAllRelayStatuses()).rejects.toThrow("all relays offline");
expect(connectivity.error.value).toContain("all relays offline");
});
it("tracks command loading while lane commands are in-flight and resets after completion", async () => {
const laneId = 28;
const deferred = createDeferred();
mocks.request.mockReturnValue(deferred.promise);
const connectivity = useMachineConnectivity(laneId);
const stopPromise = connectivity.openEntranceGate();
expect(connectivity.commandLoading.value.openEntranceGate).toBe(true);
deferred.resolve({ data: { success: true } });
await stopPromise;
expect(connectivity.commandLoading.value.openEntranceGate).toBe(false);
expect(connectivity.error.value).toBeNull();
});
it("keeps polling active until all subscribers for the same lane have unsubscribed", async () => {
vi.useFakeTimers();
const laneId = 31;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === inProgressEndpoint) {
return { data: { data: buildInProgressDetails(laneId) } };
}
const relay = Object.entries(statusEndpoints).find(([, value]) => value === endpoint)?.[0];
if (!relay) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return { data: { data: buildRelayStatus(laneId, relay) } };
});
const first = useMachineConnectivity(laneId);
const second = useMachineConnectivity(laneId);
await first.startPolling(1000);
await second.startPolling(1000);
const callsAfterStart = mocks.request.mock.calls.length;
first.stopPolling();
expect(first.isPolling.value).toBe(true);
await vi.advanceTimersByTimeAsync(1100);
expect(mocks.request.mock.calls.length).toBeGreaterThan(callsAfterStart);
second.stopPolling();
expect(first.isPolling.value).toBe(false);
const callsAfterFinalStop = mocks.request.mock.calls.length;
await vi.advanceTimersByTimeAsync(1100);
expect(mocks.request.mock.calls.length).toBe(callsAfterFinalStop);
});
it("maps relay status payloads to display labels", () => {
expect(parseRelayStatus(null)).toBe("MAINTENANCE");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: false }))).toBe("OFFLINE");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: true, on: true }))).toBe("ON");
expect(parseRelayStatus(buildRelayStatus(1, relayKinds.MACHINE, { online: true, on: false }))).toBe("OFF");
});
});