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

724 lines
25 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 inProgressEndpoint = "/modules/self-serve/lane/wash/in-progress";
const batchStatusEndpoint = "/modules/self-serve/lane/hardware/batch/status";
const batchSetEndpoint = "/modules/self-serve/lane/hardware/batch/set";
const machineStatusEndpoint = "/modules/self-serve/lane/relay/machine/status";
const programPickerStatusEndpoint = "/modules/self-serve/lane/relay/machine_program_picker/status";
const cleanerStatusEndpoint = "/modules/self-serve/lane/relay/machine_cleaner/status";
const machineSetEndpoint = "/modules/self-serve/lane/relay/machine/set";
const gateOpenEndpoint = "/modules/self-serve/lane/gate/open";
const activeBatchStoragePrefix = "truckwash.selfserve.hardwareBatch.";
const asyncBatchUnsupportedError = "Cloud Shelly transport does not support asynchronous relay batches";
const withLocalTransport = (payload) => ({ ...payload, transport: "local" });
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 buildBatch = (items, status = "COMPLETED") => ({
batch_id: "batch-test-id",
status,
items,
});
const buildBatchItem = (laneId, relay, options = {}) => ({
target: relay,
relay_id: options.relayId || `${relay.toLowerCase()}-${laneId}`,
ok: options.ok !== false,
error: options.error || null,
payload: buildRelayStatus(laneId, relay, options),
});
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const createMemoryStorage = () => {
const entries = new Map();
return {
get length() {
return entries.size;
},
key: (index) => Array.from(entries.keys())[index] || null,
getItem: (key) => entries.get(String(key)) ?? null,
setItem: (key, value) => {
entries.set(String(key), String(value));
},
removeItem: (key) => {
entries.delete(String(key));
},
clear: () => {
entries.clear();
},
};
};
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"));
globalThis.window = {
localStorage: createMemoryStorage(),
sessionStorage: createMemoryStorage(),
};
window.localStorage.clear();
__resetMachineConnectivityStoreForTests();
});
afterEach(() => {
vi.useRealTimers();
window.localStorage.clear();
delete globalThis.window;
__resetMachineConnectivityStoreForTests();
});
it("hydrates all relay statuses for a lane via shared store", async () => {
const laneId = 7;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === batchStatusEndpoint) {
return {
data: {
data: buildBatch([
buildBatchItem(laneId, relayKinds.MACHINE, { on: true }),
buildBatchItem(laneId, relayKinds.PROGRAM_PICKER, { on: false }),
buildBatchItem(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("normalizes Shelly-shaped relay status payloads from batch results", async () => {
const laneId = 8;
mocks.request.mockImplementation(async (endpoint) => {
if (endpoint === batchStatusEndpoint) {
return {
data: {
data: buildBatch([
{
target: relayKinds.MACHINE,
relay_id: "machine-relay",
ok: true,
payload: {
status: {
"switch:0": {
output: true,
},
},
},
},
{
target: relayKinds.PROGRAM_PICKER,
relay_id: "program-picker-relay",
ok: true,
payload: {
raw: {
output: 0,
},
},
},
{
target: relayKinds.CLEANER,
relay_id: "cleaner-relay",
ok: true,
payload: {
online: false,
status: {
"switch:0": {
output: true,
},
},
},
},
]),
},
};
}
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: true, online: false });
expect(connectivity.getDisplayStatus(relayKinds.MACHINE)).toBe("ON");
expect(connectivity.getDisplayStatus(relayKinds.PROGRAM_PICKER)).toBe("OFF");
expect(connectivity.getDisplayStatus(relayKinds.CLEANER)).toBe("OFFLINE");
});
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 === batchSetEndpoint && method === "POST") {
machineOn = Boolean(payload?.commands?.[0]?.on);
return {
data: {
data: {
acknowledged: true,
},
},
};
}
if (endpoint === batchStatusEndpoint && method === "POST") {
return {
data: {
data: buildBatch([
buildBatchItem(laneId, relayKinds.MACHINE, { on: machineOn }),
buildBatchItem(laneId, relayKinds.PROGRAM_PICKER),
buildBatchItem(laneId, relayKinds.CLEANER),
]),
},
};
}
if (endpoint === machineStatusEndpoint && method === "GET") {
return {
data: {
data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: machineOn }),
},
};
}
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);
expect(mocks.request).toHaveBeenCalledWith(machineStatusEndpoint, "GET", withLocalTransport({ lane_id: laneId }));
});
it("waits long enough for slow edge batches to complete", async () => {
vi.useFakeTimers();
const laneId = 12;
let pollCount = 0;
mocks.request.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === batchSetEndpoint && method === "POST") {
return {
data: {
data: buildBatch(
[buildBatchItem(laneId, relayKinds.MACHINE, { on: Boolean(payload?.commands?.[0]?.on) })],
"PENDING"
),
},
};
}
if (endpoint === "/modules/self-serve/lane/hardware/batch/batch-test-id" && method === "GET") {
pollCount += 1;
return {
data: {
data:
pollCount < 45
? buildBatch([buildBatchItem(laneId, relayKinds.MACHINE, { on: true })], "PENDING")
: buildBatch([buildBatchItem(laneId, relayKinds.MACHINE, { on: true })]),
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
const togglePromise = connectivity.toggleRelay(relayKinds.MACHINE, true);
await vi.advanceTimersByTimeAsync(12_000);
await togglePromise;
expect(pollCount).toBeGreaterThan(40);
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
expect(connectivity.error.value).toBeNull();
});
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();
await connectivity.executeLaneCommand("START", { customer_number: 42, license_plate: "AB12345" });
await connectivity.openOutsideEntranceGate();
await connectivity.openOutsideExitGate();
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/lane/force/machine/enable", "POST", {
lane_id: laneId,
duration: 120,
license_plate: "AB12345",
transport: "local",
});
expect(mocks.request).toHaveBeenNthCalledWith(2, "/modules/self-serve/lane/force/machine/disable", "POST", {
lane_id: laneId,
license_plate: "CD67890",
transport: "local",
});
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",
transport: "local",
});
expect(mocks.request).toHaveBeenNthCalledWith(5, "/modules/self-serve/lane/command", "POST", {
lane_id: laneId,
command: "START",
customer_number: 42,
license_plate: "AB12345",
transport: "local",
});
expect(mocks.request).toHaveBeenNthCalledWith(6, "/modules/self-serve/lane/command", "POST", {
lane_id: laneId,
command: "OPEN_PROPERTY_ACCESS_GATE",
transport: "local",
});
expect(mocks.request).toHaveBeenNthCalledWith(7, "/modules/self-serve/lane/command", "POST", {
lane_id: laneId,
command: "OPEN_PROPERTY_EXIT_GATE",
transport: "local",
});
});
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, batchSetEndpoint, "POST", {
lane_id: laneId,
commands: [{ target: "ENTRANCE", action: "OPEN" }],
transport: "local",
});
expect(mocks.request).toHaveBeenNthCalledWith(2, batchSetEndpoint, "POST", {
lane_id: laneId,
commands: [{ target: "EXIT", action: "OPEN" }],
transport: "local",
});
});
it("surfaces unknown gate command outcomes as operator errors", async () => {
const laneId = 21;
mocks.request.mockResolvedValue({
data: {
data: buildBatch(
[
{
target: "ENTRANCE",
relay_id: "entrance-relay",
ok: false,
outcome_unknown: true,
error: "Edge gateway command outcome is unknown after dispatch interruption",
},
],
"UNKNOWN_OUTCOME"
),
},
});
const connectivity = useMachineConnectivity(laneId);
await expect(connectivity.openEntranceGate()).rejects.toThrow("outcome is unknown");
expect(connectivity.error.value).toContain("outcome is unknown");
});
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) } };
}
if (endpoint !== batchStatusEndpoint) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return {
data: {
data: buildBatch([
buildBatchItem(laneId, relayKinds.MACHINE, { on: false }),
buildBatchItem(laneId, relayKinds.PROGRAM_PICKER, { on: false }),
buildBatchItem(laneId, relayKinds.CLEANER, { 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(6);
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 === batchStatusEndpoint) {
return {
data: {
data: buildBatch(
[
buildBatchItem(laneId, relayKinds.MACHINE, { on: true }),
buildBatchItem(laneId, relayKinds.PROGRAM_PICKER, { ok: false, error: "program picker unavailable" }),
buildBatchItem(laneId, relayKinds.CLEANER, { on: false }),
],
"PARTIAL"
),
},
};
}
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("falls back to individual relay status requests when async batches are unavailable", async () => {
const laneId = 23;
mocks.request.mockImplementation(async (endpoint, method) => {
if (endpoint === batchStatusEndpoint && method === "POST") {
throw new Error(asyncBatchUnsupportedError);
}
if (endpoint === machineStatusEndpoint && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: true }) } };
}
if (endpoint === programPickerStatusEndpoint && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.PROGRAM_PICKER, { on: false }) } };
}
if (endpoint === cleanerStatusEndpoint && method === "GET") {
return { data: { data: buildRelayStatus(laneId, relayKinds.CLEANER, { online: false }) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.fetchAllRelayStatuses();
expect(connectivity.statuses.value.MACHINE).toMatchObject({ on: true, online: true });
expect(connectivity.statuses.value.PROGRAM_PICKER).toMatchObject({ on: false, online: true });
expect(connectivity.statuses.value.CLEANER).toMatchObject({ on: false, online: false });
expect(connectivity.error.value).toBeNull();
expect(mocks.request).toHaveBeenCalledWith(batchStatusEndpoint, "POST", {
lane_id: laneId,
targets: ["MACHINE", "PROGRAM_PICKER", "CLEANER"],
transport: "local",
});
expect(mocks.request).toHaveBeenCalledWith(machineStatusEndpoint, "GET", withLocalTransport({ lane_id: laneId }));
expect(mocks.request).toHaveBeenCalledWith(
programPickerStatusEndpoint,
"GET",
withLocalTransport({ lane_id: laneId })
);
expect(mocks.request).toHaveBeenCalledWith(cleanerStatusEndpoint, "GET", withLocalTransport({ lane_id: laneId }));
});
it("falls back to individual relay set requests when async batches are unavailable", async () => {
const laneId = 25;
mocks.request.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === batchSetEndpoint && method === "POST") {
throw new Error(asyncBatchUnsupportedError);
}
if (endpoint === machineSetEndpoint && method === "POST") {
return {
data: {
data: buildRelayStatus(laneId, relayKinds.MACHINE, { on: Boolean(payload?.on) }),
},
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.toggleRelay(relayKinds.MACHINE, true);
expect(connectivity.statuses.value.MACHINE).toMatchObject({ on: true, online: true });
expect(connectivity.error.value).toBeNull();
expect(mocks.request).toHaveBeenCalledWith(batchSetEndpoint, "POST", {
lane_id: laneId,
commands: [{ target: "MACHINE", on: true }],
transport: "local",
});
expect(mocks.request).toHaveBeenCalledWith(machineSetEndpoint, "POST", {
lane_id: laneId,
on: true,
transport: "local",
});
});
it("falls back to the synchronous gate endpoint when async batches are unavailable", async () => {
const laneId = 27;
mocks.request.mockImplementation(async (endpoint, method, payload) => {
if (endpoint === batchSetEndpoint && method === "POST") {
throw new Error(asyncBatchUnsupportedError);
}
if (endpoint === gateOpenEndpoint && method === "POST") {
return { data: { data: { lane_id: payload?.lane_id, gate: payload?.gate, queued: false } } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await connectivity.openEntranceGate();
expect(connectivity.error.value).toBeNull();
expect(mocks.request).toHaveBeenCalledWith(batchSetEndpoint, "POST", {
lane_id: laneId,
commands: [{ target: "ENTRANCE", action: "OPEN" }],
transport: "local",
});
expect(mocks.request).toHaveBeenCalledWith(gateOpenEndpoint, "POST", {
lane_id: laneId,
gate: "ENTRANCE",
transport: "local",
});
});
it("resumes a remembered hardware batch for the lane", async () => {
const laneId = 26;
const batchId = "batch-resume-id";
window.localStorage.setItem(
`${activeBatchStoragePrefix}${batchId}`,
JSON.stringify({
batch_id: batchId,
lane_id: laneId,
kind: "STATUS",
status: "PENDING",
remembered_at: Date.now(),
})
);
mocks.request.mockImplementation(async (endpoint, method) => {
if (endpoint === `/modules/self-serve/lane/hardware/batch/${batchId}` && method === "GET") {
return { data: { data: buildBatch([buildBatchItem(laneId, relayKinds.MACHINE, { on: true })]) } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
});
const connectivity = useMachineConnectivity(laneId);
await vi.waitFor(() => {
expect(connectivity.statuses.value.MACHINE?.on).toBe(true);
});
expect(window.localStorage.getItem(`${activeBatchStoragePrefix}${batchId}`)).toBeNull();
});
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) } };
}
if (endpoint !== batchStatusEndpoint) {
throw new Error(`Unexpected endpoint: ${endpoint}`);
}
return {
data: {
data: buildBatch([
buildBatchItem(laneId, relayKinds.MACHINE),
buildBatchItem(laneId, relayKinds.PROGRAM_PICKER),
buildBatchItem(laneId, relayKinds.CLEANER),
]),
},
};
});
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");
});
});