Files
pleno-vue/tests/unit/use-self-serve-logic.spec.js
T

1461 lines
49 KiB
JavaScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
previewAllowed: vi.fn(),
washSummary: vi.fn(),
add: vi.fn(),
getAll: vi.fn(),
deleteCondition: vi.fn(),
attachmentsList: vi.fn(),
attachmentsDownload: vi.fn(),
request: vi.fn(),
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
request: mocks.request,
objects: {
self_serve_vehicle_conditions: {
get: {
previewAllowed: mocks.previewAllowed,
washSummary: mocks.washSummary,
all: mocks.getAll,
},
add: mocks.add,
delete: mocks.deleteCondition,
},
self_serve_tasks: {
attachments: {
list: mocks.attachmentsList,
download: mocks.attachmentsDownload,
},
},
},
},
}));
import { useSelfServeLogic } from "@/composables/useSelfServeLogic.js";
describe("useSelfServeLogic", () => {
beforeEach(() => {
mocks.previewAllowed.mockReset();
mocks.washSummary.mockReset();
mocks.add.mockReset();
mocks.getAll.mockReset();
mocks.deleteCondition.mockReset();
mocks.attachmentsList.mockReset();
mocks.attachmentsDownload.mockReset();
mocks.request.mockReset();
vi.unstubAllGlobals();
});
const makeRule = ({ id = 1, conditionId = 100, objectType = "question", objectId = 1, type = "IS_TRUE" } = {}) => ({
id,
condition_id: conditionId,
object_type: objectType,
object_id: objectId,
type,
name: `${type}-${id}`,
});
const applyEvaluationState = (logic, { answers = {}, rules = [], tasks = [], conditions = [] } = {}) => {
logic.answers.value = answers;
logic.rules.value = rules;
logic.tasks.value = tasks;
logic.conditions.value = conditions;
};
describe("condition evaluation engine", () => {
it.each([
["IS_TRUE", true, true],
["IS_TRUE", false, false],
["IS_TRUE", null, false],
["IS_TRUE", undefined, false],
["IS_FALSE", false, true],
["IS_FALSE", true, false],
["IS_FALSE", null, false],
["IS_FALSE", undefined, false],
["IS_TRUE_OR_NOT_SET", true, true],
["IS_TRUE_OR_NOT_SET", false, false],
["IS_TRUE_OR_NOT_SET", null, true],
["IS_TRUE_OR_NOT_SET", undefined, true],
["IS_FALSE_OR_NOT_SET", false, true],
["IS_FALSE_OR_NOT_SET", true, false],
["IS_FALSE_OR_NOT_SET", null, true],
["IS_FALSE_OR_NOT_SET", undefined, true],
["IS_SET", true, true],
["IS_SET", false, true],
["IS_SET", null, false],
["IS_SET", undefined, false],
["UNKNOWN_RULE", true, false],
])("evaluates %s for %s answers", (type, answerValue, expected) => {
const logic = useSelfServeLogic();
const answers = {};
if (answerValue !== undefined) {
answers[1] = answerValue;
}
const rule = makeRule({ type });
applyEvaluationState(logic, {
answers,
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(expected);
expect(logic.evaluateCondition(100)).toBe(expected);
});
it("treats an empty condition id as satisfied", () => {
const logic = useSelfServeLogic();
expect(logic.evaluateCondition(null)).toBe(true);
expect(logic.evaluateCondition(0)).toBe(true);
});
it("rejects conditions with no rules when evaluated directly", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
conditions: [{ id: 100, name: "No rules" }],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(999)).toBe(false);
});
it("evaluates nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(
logic.evaluateRule(makeRule({ conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }))
).toBe(true);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("detects direct condition cycles", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 100, type: "IS_TRUE" })],
});
expect(logic.evaluateCondition(100)).toBe(false);
});
it("detects cycles across nested condition references", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectType: "condition", objectId: 100, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(200)).toBe(false);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when the nested condition is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns true for IS_TRUE_OR_ANY_TRUE when any nested rule is true", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 200, objectId: 2, type: "IS_FALSE" }),
],
});
expect(logic.evaluateCondition(200)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(true);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when direct question answer is not true", () => {
const logic = useSelfServeLogic();
const rule = makeRule({ type: "IS_TRUE_OR_ANY_TRUE" });
applyEvaluationState(logic, {
answers: { 1: false },
rules: [rule],
});
expect(logic.evaluateRule(rule)).toBe(false);
expect(logic.evaluateCondition(100)).toBe(false);
});
it("returns false for IS_TRUE_OR_ANY_TRUE when nested rules are all false or missing", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true },
rules: [
makeRule({ id: 1, conditionId: 100, objectType: "condition", objectId: 200, type: "IS_TRUE_OR_ANY_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 1, type: "IS_FALSE" }),
makeRule({ id: 3, conditionId: 300, objectType: "condition", objectId: 999, type: "IS_TRUE_OR_ANY_TRUE" }),
],
});
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.evaluateCondition(300)).toBe(false);
});
it("keeps tasks with no condition or missing condition rules active by default", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
tasks: [
{ id: 1, task_id: 1, task: "No condition", order_priority: 3, condition_id: null },
{ id: 2, task_id: 2, task: "Zero condition", order_priority: 2, condition_id: 0 },
{ id: 3, task_id: 3, task: "Missing rules", order_priority: 1, condition_id: 999 },
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2, 1]);
expect(logic.isTaskActive(999)).toBe(false);
});
it("filters and sorts active tasks based on evaluated conditions", () => {
const logic = useSelfServeLogic();
applyEvaluationState(logic, {
answers: { 1: true, 2: false, 3: null },
rules: [
makeRule({ id: 1, conditionId: 100, objectId: 1, type: "IS_TRUE" }),
makeRule({ id: 2, conditionId: 200, objectId: 2, type: "IS_TRUE" }),
makeRule({ id: 3, conditionId: 300, objectId: 3, type: "IS_TRUE_OR_NOT_SET" }),
],
tasks: [
{
id: 1,
task_id: 1,
task: "Hidden false condition",
order_priority: 1,
condition_id: 200,
services: ["HIDDEN"],
},
{
id: 2,
task_id: 2,
task: "Active null condition",
order_priority: 3,
condition_id: 300,
services: ["OPTIONAL"],
},
{
id: 3,
task_id: 3,
task: "Active true condition",
order_priority: 2,
condition_id: 100,
services: ["MACHINE"],
},
],
});
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([3, 2]);
expect(logic.isTaskActive(1)).toBe(false);
expect(logic.isTaskActive(2)).toBe(true);
expect(logic.activeTaskServices.value).toEqual(["MACHINE", "OPTIONAL"]);
});
});
it("uses task attachments supplied by preview and summary data", async () => {
const taskAttachments = [
{ id: 201, content: { other: "manual.pdf" }, download_link: "https://cdn.example.test/manual.pdf" },
{ id: 202, content: { other: "photo.jpg" }, download_link: "https://cdn.example.test/photo.jpg" },
];
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Machine wash?", answer: null, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"], attachments: taskAttachments }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "IN_PROGRESS" },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"], attachments: taskAttachments }],
events: [{ id: 1, type: "STARTED", created_at: "2026-01-01T00:00:00.000Z" }],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "AB12345", 9);
expect(mocks.washSummary).toHaveBeenCalledWith({ session_id: 91 });
expect(logic.allowed.value).toBe(true);
expect(logic.machineAvailable.value).toBe(true);
expect(logic.answers.value[1]).toBe(true);
expect(logic.events.value).toHaveLength(1);
expect(mocks.attachmentsList).not.toHaveBeenCalled();
expect(mocks.attachmentsDownload).not.toHaveBeenCalled();
expect(logic.tasks.value[0].attachments[1]).toMatchObject({
id: 202,
download_link: "https://cdn.example.test/photo.jpg",
});
});
it("clears stale wash summary machine availability when a fresh preview has no session", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "READY_FOR_MACHINE_START" },
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
})
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValueOnce({
machine_available: false,
session: { id: 91, status: "READY_FOR_MACHINE_START" },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
events: [],
allowed_services: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.machineAvailable.value).toBe(false);
await logic.fetchSelfServeData(1, 9, 7, "cd12345");
expect(logic.summary.value).toBeNull();
expect(logic.machineAvailable.value).toBe(true);
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
});
it("does not clear server-provided allowed services when task ids are temporarily empty", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
const response = await logic.updateLaneAllowedServices(7);
expect(mocks.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
success: true,
data: {
lane_id: 7,
allowed_services: ["MACHINE", "PROGRAM_PICKER"],
skipped_empty_task_sync: true,
},
});
expect(logic.allowedServices.value).toEqual(["MACHINE", "PROGRAM_PICKER"]);
expect(logic.machineAvailable.value).toBe(true);
});
it("does not post empty task ids to the lane allowed-services endpoint", async () => {
const logic = useSelfServeLogic();
const response = await logic.updateLaneAllowedServices(7);
expect(mocks.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
success: true,
data: {
lane_id: 7,
allowed_services: [],
skipped_empty_task_sync: true,
},
});
expect(logic.allowedServices.value).toEqual([]);
});
it("allows a forced preview refresh for an already successful request key", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
})
.mockResolvedValueOnce({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [],
tasks: [{ id: 6, task: "Pick program", order_priority: 1, services: ["PROGRAM_PICKER"] }],
conditions: [],
rules: [],
allowed_services: ["PROGRAM_PICKER"],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.previewAllowed).toHaveBeenCalledTimes(1);
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
await logic.fetchSelfServeData(1, 9, 7, "ab12345", { force: true });
expect(mocks.previewAllowed).toHaveBeenCalledTimes(2);
expect(logic.tasks.value.map((task) => task.id)).toEqual([6]);
expect(logic.allowedServices.value).toEqual(["PROGRAM_PICKER"]);
});
it("publishes preview task ids before attachment hydration finishes", async () => {
let resolveAttachments;
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: null,
questions: [{ id: 1, question: "Machine wash?", answer: true, order_priority: 1 }],
tasks: [{ id: 5, task: "Prepare", order_priority: 1, services: ["MACHINE"] }],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.attachmentsList.mockReturnValue(
new Promise((resolve) => {
resolveAttachments = resolve;
})
);
mocks.request.mockResolvedValue({
data: { success: true, data: { allowed_services: ["MACHINE"] } },
});
const logic = useSelfServeLogic();
const fetchPromise = logic.fetchSelfServeData(1, 9, 7, "ab12345");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
await logic.updateLaneAllowedServices(7);
expect(mocks.request).toHaveBeenCalledWith("/modules/self-serve/lane/services/allowed", "post", {
lane_id: 7,
task_ids: [5],
});
resolveAttachments([]);
await fetchPromise;
});
it("syncs only the caller-selected task ids when manual wash hides machine tasks", async () => {
mocks.request.mockResolvedValue({
data: { success: true, data: { allowed_services: ["GATE"] } },
});
const logic = useSelfServeLogic();
logic.tasks.value = [
{ id: 5, task_id: 5, task: "Pick program", order_priority: 1, services: ["MACHINE", "PROGRAM_PICKER"] },
{ id: 6, task_id: 6, task: "Manual bay prep", order_priority: 2, services: ["GATE"] },
];
await logic.updateLaneAllowedServices(7, { taskIds: [6] });
expect(mocks.request).toHaveBeenCalledWith("/modules/self-serve/lane/services/allowed", "post", {
lane_id: 7,
task_ids: [6],
});
expect(logic.allowedServices.value).toEqual(["GATE"]);
});
it("does not fall back to machine task ids when the caller-selected task list is empty", async () => {
const logic = useSelfServeLogic();
logic.tasks.value = [
{ id: 5, task_id: 5, task: "Pick program", order_priority: 1, services: ["MACHINE", "PROGRAM_PICKER"] },
];
const response = await logic.updateLaneAllowedServices(7, { taskIds: [] });
expect(mocks.request).not.toHaveBeenCalled();
expect(response).toMatchObject({
success: true,
data: {
lane_id: 7,
skipped_empty_task_sync: true,
},
});
});
it("preserves preview task ids when wash summary omits task ids", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "READY_FOR_MACHINE_START" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{
id: 802001,
task: "Vælg program #2 på dial",
condition_id: 702001,
order_priority: 10,
services: ["MACHINE"],
},
{
id: 802006,
task: "Tryk knap #6 (tagbørste program)",
condition_id: 702003,
order_priority: 36,
services: ["MACHINE"],
},
],
conditions: [],
rules: [],
allowed_services: ["MACHINE"],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "READY_FOR_MACHINE_START" },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [
{ task: "Vælg program #2 på dial", condition_id: 702001, order_priority: 10, services: ["MACHINE"] },
{ task: "Tryk knap #6 (tagbørste program)", condition_id: 702003, order_priority: 36, services: ["MACHINE"] },
],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.tasks.value.map((task) => task.id)).toEqual([802001, 802006]);
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([802001, 802006]);
});
it("does not infer machine permission from summary tasks when allowed services are explicitly empty", async () => {
const machineButtonTask = {
id: 5,
task: "Press reset",
order_priority: 1,
services: ["MACHINE"],
buttons: ["reset"],
dynamic_images_vehicle_type: 2,
};
mocks.previewAllowed.mockResolvedValue({
allowed: false,
machine_available: true,
lane: { id: 7, name: "Lane 7" },
session: { id: 91, status: "MACHINE_NOT_ALLOWED", allowed: false },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [machineButtonTask],
conditions: [],
rules: [],
allowed_services: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "MACHINE_NOT_ALLOWED", allowed: false },
lane: { id: 7, name: "Lane 7" },
questions: [{ id: 1, question: "Machine wash?", answer: false, order_priority: 1 }],
tasks: [machineButtonTask],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.allowed.value).toBe(false);
expect(logic.allowedServices.value).toEqual([]);
expect(logic.activeTaskServices.value).toEqual([]);
expect(logic.isServiceAllowed("MACHINE")).toBe(false);
expect(logic.tasks.value.map((task) => task.id)).toEqual([5]);
});
it("opens an existing task attachment link without requesting a new download link", async () => {
const openSpy = vi.fn();
vi.stubGlobal("window", { open: openSpy });
const logic = useSelfServeLogic();
await logic.downloadAttachment(5, 202, {
id: 202,
download_link: "https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
content: { other: "photo.jpg" },
});
expect(mocks.attachmentsDownload).not.toHaveBeenCalled();
expect(openSpy).toHaveBeenCalledWith(
"https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
"_blank",
"noopener"
);
});
it("does not open untrusted embedded task attachment links", async () => {
const openSpy = vi.fn();
vi.stubGlobal("window", { open: openSpy });
mocks.attachmentsDownload.mockResolvedValue({ download_link: "javascript:alert(document.domain)" });
const logic = useSelfServeLogic();
await logic.downloadAttachment(5, 202, {
id: 202,
download_link: "data:text/html,<script>alert(document.domain)</script>",
content: { other: "photo.jpg" },
});
expect(mocks.attachmentsDownload).toHaveBeenCalledWith(5, 202);
expect(openSpy).not.toHaveBeenCalled();
});
it("falls back to lane/reg summary when session summary does not include questions", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 91, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Machine wash?", answer: null, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary
.mockResolvedValueOnce({
session: { id: 91, status: "IN_PROGRESS" },
events: [],
})
.mockResolvedValueOnce({
session: { id: 91, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Machine wash?", answer: null, order_priority: 1 }],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 9 });
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]);
});
it("keeps preview questions visible when no summary session exists yet", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: null,
questions: [
{ id: 1, question: "Machine wash?", answer: null, order_priority: 1 },
{ id: 2, question: "Doors closed?", answer: null, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(mocks.washSummary).not.toHaveBeenCalled();
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
});
it("keeps the newest task list when overlapping preview requests resolve out of order", async () => {
const createDeferred = () => {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return { promise, resolve };
};
const firstPreview = createDeferred();
const secondPreview = createDeferred();
mocks.previewAllowed
.mockImplementationOnce(() => firstPreview.promise)
.mockImplementationOnce(() => secondPreview.promise);
mocks.attachmentsList.mockResolvedValue({ data: [] });
const logic = useSelfServeLogic();
const firstFetch = logic.fetchSelfServeData(1, null, 7, "ab12345");
const secondFetch = logic.fetchSelfServeData(1, 9, 7, "ab12345");
secondPreview.resolve({
allowed: true,
session: null,
questions: [],
tasks: [
{ id: 1001, task: "Task A", order_priority: 1 },
{ id: 1002, task: "Task B", order_priority: 2 },
],
conditions: [],
rules: [],
allowed_services: [],
});
await secondFetch;
expect(logic.tasks.value.map((task) => task.id)).toEqual([1001, 1002]);
firstPreview.resolve({
allowed: true,
session: null,
questions: [],
tasks: [{ id: 1001, task: "Task A", order_priority: 1 }],
conditions: [],
rules: [],
allowed_services: [],
});
await firstFetch;
expect(logic.tasks.value.map((task) => task.id)).toEqual([1001, 1002]);
});
it("exposes preview errors for retry UI", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mocks.previewAllowed.mockRejectedValue({
response: {
data: {
data: {
message: "Edge gateway command timed out",
},
},
},
});
const logic = useSelfServeLogic();
const result = await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(result).toBeNull();
expect(logic.loading.value).toBe(false);
expect(logic.error.value).toBe("Edge gateway command timed out");
consoleErrorSpy.mockRestore();
});
it("surfaces edge gateway failures when updating lane allowed services", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mocks.request
.mockResolvedValueOnce({
data: {
success: false,
data: {
message: "Edge gateway command timed out",
},
},
})
.mockResolvedValueOnce({
data: {
success: true,
data: {
allowed_services: ["GATE"],
},
},
});
const logic = useSelfServeLogic();
logic.tasks.value = [{ id: 5, task_id: 5, task: "Machine step", order_priority: 1, services: ["MACHINE"] }];
await expect(logic.updateLaneAllowedServices(7)).rejects.toThrow("Edge gateway command timed out");
expect(mocks.request).toHaveBeenNthCalledWith(1, "/modules/self-serve/lane/services/allowed", "post", {
lane_id: 7,
task_ids: [5],
});
expect(logic.error.value).toBe("Edge gateway command timed out");
expect(logic.allowedServices.value).toEqual(["MACHINE"]);
await logic.updateLaneAllowedServices(7);
expect(logic.error.value).toBeNull();
expect(logic.allowedServices.value).toEqual(["GATE"]);
consoleErrorSpy.mockRestore();
});
it("evaluates conditional rules for task activation", () => {
const logic = useSelfServeLogic();
logic.questions.value = [
{ id: 1, question: "Question 1", order_priority: 1, condition_id: null },
{ id: 2, question: "Question 2", order_priority: 2, condition_id: 100 },
];
logic.conditions.value = [{ id: 100, name: "Question 1 yes" }];
logic.rules.value = [
{ id: 501, condition_id: 100, object_type: "question", object_id: 1, type: "IS_TRUE", name: "Q1 true" },
];
logic.tasks.value = [
{ id: 8, task: "Machine step", order_priority: 1, condition_id: 100, services: ["MACHINE"] },
{ id: 9, task: "Always visible", order_priority: 2, condition_id: null, services: [] },
];
logic.answers.value = { 1: false };
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([]);
expect(logic.evaluateCondition(100)).toBe(false);
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([9]);
logic.answerQuestion(1, true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([]);
expect(logic.evaluateCondition(100)).toBe(true);
expect(logic.activeTasks.value.map((task) => task.id)).toEqual([8, 9]);
expect(logic.isServiceAllowed("MACHINE")).toBe(true);
});
it("does not show any questions before summary visibility is available", () => {
const logic = useSelfServeLogic();
logic.questions.value = [
{ id: 1, question: "Always visible", order_priority: 1, condition_id: null },
{ id: 2, question: "Included conditional", order_priority: 2, condition_id: 100 },
];
logic.rules.value = [];
logic.answers.value = { 1: true };
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([]);
});
it("synchronizes answers and only keeps questions listed in summary response", async () => {
mocks.previewAllowed.mockResolvedValueOnce({
allowed: true,
session: { id: 44, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 3, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValueOnce({
session: { id: 44, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 3, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 44, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Doors closed?", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 44, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Doors closed?", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 44, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Doors closed?", answer: true, order_priority: 1 }],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(3, null, 7, "cd67890");
await logic.syncVehicleAnswer({
departmentId: 3,
laneId: 7,
customerNumber: 12345,
reg: "cd67890",
questionId: 2,
value: true,
});
expect(mocks.add).toHaveBeenCalledWith(3, 7, 12345, "CD67890", 2, true, {
activate_machine: false,
sync_relay_state: false,
});
expect(mocks.previewAllowed).toHaveBeenCalledWith(7, "CD67890");
expect(logic.answers.value[2]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]);
});
it("ignores stale answer sync responses that resolve after a newer mutation", async () => {
const defer = () => {
let resolve;
let reject;
const promise = new Promise((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
};
const staleSave = defer();
const freshSave = defer();
mocks.add.mockImplementationOnce(() => staleSave.promise).mockImplementationOnce(() => freshSave.promise);
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: null,
questions: [{ id: 2, question: "Doors closed?", answer: false, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
const logic = useSelfServeLogic();
const stalePromise = logic.syncVehicleAnswer({
departmentId: 3,
laneId: 7,
customerNumber: 12345,
reg: "cd67890",
questionId: 2,
value: true,
});
const freshPromise = logic.syncVehicleAnswer({
departmentId: 3,
laneId: 7,
customerNumber: 12345,
reg: "cd67890",
questionId: 2,
value: false,
});
freshSave.resolve({
data: {
data: {
selfserve: {
session: null,
questions: [{ id: 2, question: "Doors closed?", answer: false, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
await freshPromise;
expect(logic.answers.value[2]).toBe(false);
staleSave.resolve({
data: {
data: {
selfserve: {
session: null,
questions: [{ id: 2, question: "Doors closed?", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
await stalePromise;
expect(logic.answers.value[2]).toBe(false);
});
it("preserves the selected vehicle-type override when refreshing after answer sync", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 66, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 66, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 66, status: "IN_PROGRESS" },
questions: [{ id: 1, question: "Primary question", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(1, 7, "AB12345", 9);
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(2, 7, "AB12345", 9);
expect(mocks.add).toHaveBeenCalledWith(1, 7, 12345, "AB12345", 1, true, {
activate_machine: false,
sync_relay_state: false,
vehicle_type: 9,
});
});
it("uses the resolved auto vehicle type for lane/reg summary fallback", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 91, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 91, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(logic.resolvedVehicleTypeId.value).toBe(5);
expect(mocks.washSummary).toHaveBeenNthCalledWith(1, { session_id: 91 });
expect(mocks.washSummary).toHaveBeenNthCalledWith(2, { lane_id: 7, reg: "AB12345", vehicle_type: 5 });
});
it("reuses the resolved auto vehicle type when refreshing after answer sync", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [{ id: 1, question: "Primary question", answer: null, order_priority: 1 }],
tasks: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 66, status: "IN_PROGRESS", vehicle_type_id: 5 },
questions: [{ id: 1, question: "Primary question", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
},
},
},
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(1, 7, "AB12345");
expect(mocks.previewAllowed).toHaveBeenNthCalledWith(2, 7, "AB12345", 5);
});
it("keeps answered questions visible when summary returns no currently-visible question list", async () => {
mocks.previewAllowed.mockResolvedValue({
allowed: false,
session: { id: 99, status: "MACHINE_NOT_ALLOWED" },
questions: [{ id: 1, question: "Primary question", answer: true, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 99, status: "MACHINE_NOT_ALLOWED" },
questions: [],
tasks: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, 9, 7, "ab12345");
expect(logic.answers.value[1]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1]);
});
it("hides condition-dependent questions again when the parent answer no longer satisfies the condition", async () => {
const baseConditions = [{ id: 100, name: "Primary answer is yes" }];
const baseRules = [
{ id: 501, condition_id: 100, object_type: "question", object_id: 2, type: "IS_TRUE", name: "Q2 yes" },
];
mocks.previewAllowed.mockResolvedValueOnce({
allowed: true,
session: { id: 55, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
});
mocks.washSummary
.mockResolvedValueOnce({
session: { id: 55, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
events: [],
})
.mockResolvedValueOnce({
session: { id: 55, status: "IN_PROGRESS" },
questions: [
{ id: 2, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 3, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 55, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Primary question", answer: false, order_priority: 1 }],
conditions: [],
rules: [],
tasks: [],
},
},
},
});
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 55, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Primary question", answer: false, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 55, status: "IN_PROGRESS" },
questions: [{ id: 2, question: "Primary question", answer: false, order_priority: 1 }],
tasks: [],
conditions: [],
rules: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2, 3]);
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 2,
value: false,
});
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([2]);
});
it("keeps condition-dependent questions visible in stable order when the condition remains true", async () => {
const baseConditions = [{ id: 100, name: "Primary answer is yes" }];
const baseRules = [
{ id: 501, condition_id: 100, object_type: "question", object_id: 1, type: "IS_TRUE", name: "Q1 yes" },
];
mocks.previewAllowed.mockResolvedValueOnce({
allowed: true,
session: { id: 77, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: false, order_priority: 1, condition_id: null },
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
});
mocks.washSummary.mockResolvedValueOnce({
session: { id: 77, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: false, order_priority: 1, condition_id: null },
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: baseConditions,
rules: baseRules,
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 77, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
conditions: [],
rules: [],
tasks: [],
},
},
},
});
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 77, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 77, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Primary question", answer: true, order_priority: 1, condition_id: null },
{ id: 2, question: "Conditional follow-up", answer: null, order_priority: 2, condition_id: 100 },
],
tasks: [],
conditions: [],
rules: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(logic.answers.value[1]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
});
it("keeps visible question order stable when answer refreshes return the same questions reordered", async () => {
const initialQuestions = [
{ id: 1, question: "Primary question", answer: null, order_priority: 1, condition_id: null },
{ id: 2, question: "Follow-up question", answer: null, order_priority: 2, condition_id: null },
];
const refreshedQuestions = [{ ...initialQuestions[1] }, { ...initialQuestions[0], answer: true }];
mocks.previewAllowed.mockResolvedValueOnce({
allowed: true,
session: { id: 78, status: "IN_PROGRESS" },
questions: initialQuestions,
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValueOnce({
session: { id: 78, status: "IN_PROGRESS" },
questions: initialQuestions,
tasks: [],
conditions: [],
rules: [],
events: [],
});
mocks.add.mockResolvedValue({
data: {
data: {
selfserve: {
session: { id: 78, status: "IN_PROGRESS" },
questions: refreshedQuestions,
conditions: [],
rules: [],
tasks: [],
},
},
},
});
mocks.previewAllowed.mockResolvedValue({
allowed: true,
session: { id: 78, status: "IN_PROGRESS" },
questions: refreshedQuestions,
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary.mockResolvedValue({
session: { id: 78, status: "IN_PROGRESS" },
questions: refreshedQuestions,
tasks: [],
conditions: [],
rules: [],
events: [],
});
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(1, null, 7, "ab12345");
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
await logic.syncVehicleAnswer({
departmentId: 1,
laneId: 7,
customerNumber: 12345,
reg: "ab12345",
questionId: 1,
value: true,
});
expect(logic.answers.value[1]).toBe(true);
expect(logic.visibleQuestions.value.map((question) => question.id)).toEqual([1, 2]);
});
it("clears persisted answers for a lane/reg and refreshes preview state", async () => {
mocks.previewAllowed
.mockResolvedValueOnce({
allowed: true,
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: true, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: false, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
})
.mockResolvedValueOnce({
allowed: true,
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
conditions: [],
rules: [],
});
mocks.washSummary
.mockResolvedValueOnce({
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: true, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: false, order_priority: 2 },
],
tasks: [],
events: [],
})
.mockResolvedValueOnce({
session: { id: 88, status: "IN_PROGRESS" },
questions: [
{ id: 1, question: "Doors closed?", answer: null, order_priority: 1 },
{ id: 2, question: "Windows closed?", answer: null, order_priority: 2 },
],
tasks: [],
events: [],
});
mocks.getAll.mockResolvedValue([
{ id: 701, question: 1 },
{ id: 702, question: 2 },
]);
mocks.deleteCondition.mockResolvedValue({ data: { message: "Condition deleted" } });
const logic = useSelfServeLogic();
await logic.fetchSelfServeData(3, null, 7, "ab12345");
expect(logic.answers.value[1]).toBe(true);
expect(logic.answers.value[2]).toBe(false);
const result = await logic.clearVehicleAnswers({
departmentId: 3,
laneId: 7,
reg: "ab12345",
});
expect(result).toEqual({ deletedCount: 2 });
expect(mocks.getAll).toHaveBeenCalledWith({
lane: 7,
reg: "AB12345",
department: 3,
});
expect(mocks.deleteCondition).toHaveBeenCalledTimes(2);
expect(mocks.deleteCondition).toHaveBeenNthCalledWith(1, 701);
expect(mocks.deleteCondition).toHaveBeenNthCalledWith(2, 702);
expect(logic.answers.value[1]).toBe(null);
expect(logic.answers.value[2]).toBe(null);
expect(logic.allVisibleQuestionsAnswered.value).toBe(false);
});
});