Add department self-serve management with API integration and tests:

- Introduced `DepartmentModulesDisplay` and `DepartmentModulesSetup` components for self-serve enablement management.
- Implemented `getDepartmentSelfServeEnabled` and `setDepartmentSelfServeEnabled` helpers.
- Added unit tests for self-serve status fetching, updating, and UI synchronization.
- Enhanced connectivity and relay logic with in-progress wash session handling and detailed displays.
This commit is contained in:
Jeppe Bundgaard
2026-03-25 14:06:22 +01:00
parent d03b7d86bf
commit caec066c6c
12 changed files with 790 additions and 19 deletions
@@ -5,12 +5,18 @@ import { authenticatedRequest } from "@/components/session/authenticatedRequest.
import {BSwitch} from "buefy";
import {useToast} from 'vue-toast-notification';
import {useI18n} from 'vue-i18n';
import {
getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled,
} from "@/composables/departmentSelfServeEnabled.js";
const router = useRouter();
const toast = useToast();
const { t } = useI18n();
const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const bookingsCount = ref(0);
const modules = computed(() => [
@@ -98,19 +104,24 @@ const modules = computed(() => [
count: 0,
hasSwitch: true,
switchValue: selfServeEnabled,
switchDisabled: isLoadingSelfServeEnabled.value || isSavingSelfServeEnabled.value,
onToggle: async (newValue) => {
const departmentId = getDepartmentId();
if (!departmentId) return;
// Get the current self-serve status, and set it to the opposite of the new value
const currentStatus = selfServeEnabled.value;
selfServeEnabled.value = newValue;
isSavingSelfServeEnabled.value = true;
try {
await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}&enabled=${newValue}`, 'PUT');
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId, newValue);
toast.success(newValue ? t('admin.department_modules.self_wash.enabled') : t('admin.department_modules.self_wash.disabled'));
} catch (error) {
console.error("Failed to update self-serve status", error);
// Revert switch value on error
selfServeEnabled.value = currentStatus;
toast.error(t('admin.department_modules.self_wash.update_error'));
} finally {
isSavingSelfServeEnabled.value = false;
}
}
}
@@ -179,11 +190,13 @@ const getSelfServeStatus = async () => {
const departmentId = getDepartmentId();
if (!departmentId) return;
isLoadingSelfServeEnabled.value = true;
try {
const response = await authenticatedRequest(`/departments/self-serve/enabled?id=${departmentId}`, 'GET');
selfServeEnabled.value = response.data.data.enabled;
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId);
} catch (error) {
console.error("Failed to fetch self-serve status", error);
} finally {
isLoadingSelfServeEnabled.value = false;
}
};
@@ -217,7 +230,13 @@ setInterval(() => {
<div class="is-pulled-right card-header-title pr-0">
<span class="button is-small is-dark" @click="module.onClickCount" v-if="module.count > 0">{{ module.count }}</span>
<span class="button is-small is-outlined" v-else-if="module?.hasSwitch" @click.stop>
<b-switch size="is-small" type="is-link" :model-value="module.switchValue" @update:model-value="module.onToggle"/>
<b-switch
size="is-small"
type="is-link"
:model-value="Boolean(module?.switchValue?.value ?? module?.switchValue)"
:disabled="module?.switchDisabled"
@update:model-value="module.onToggle"
/>
</span>
</div>
</header>
@@ -232,4 +251,4 @@ setInterval(() => {
<style scoped>
</style>
</style>
@@ -0,0 +1,74 @@
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
const normalizeDepartmentId = (departmentId) => {
const parsedDepartmentId = Number.parseInt(String(departmentId), 10);
if (!Number.isInteger(parsedDepartmentId) || parsedDepartmentId <= 0) {
throw new Error(`Invalid department id: ${departmentId}`);
}
return parsedDepartmentId;
};
const normalizeEnabledValue = (value) => {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value > 0;
}
if (typeof value === "string") {
const normalizedValue = value.trim().toLowerCase();
if (["true", "1", "yes", "on", "enabled"].includes(normalizedValue)) {
return true;
}
if (["false", "0", "no", "off", "disabled", ""].includes(normalizedValue)) {
return false;
}
}
return Boolean(value);
};
const extractEnabledValue = (response) => {
const payload = response?.data?.data ?? response?.data ?? response;
if (!payload || typeof payload !== "object") {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(payload, "enabled")) {
return payload.enabled;
}
return undefined;
};
export const getDepartmentSelfServeEnabled = async (departmentId) => {
const normalizedDepartmentId = normalizeDepartmentId(departmentId);
const response = await authenticatedRequest(
`/departments/self-serve/enabled?id=${normalizedDepartmentId}`,
"GET"
);
return normalizeEnabledValue(extractEnabledValue(response));
};
export const setDepartmentSelfServeEnabled = async (departmentId, enabled) => {
const normalizedDepartmentId = normalizeDepartmentId(departmentId);
const normalizedEnabled = normalizeEnabledValue(enabled);
const response = await authenticatedRequest(
`/departments/self-serve/enabled?id=${normalizedDepartmentId}&enabled=${normalizedEnabled ? "true" : "false"}`,
"PUT"
);
const responseEnabled = extractEnabledValue(response);
return responseEnabled === undefined
? normalizedEnabled
: normalizeEnabledValue(responseEnabled);
};
export default {
getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled,
};
@@ -12,6 +12,10 @@ import SuperUserDashboardDepartmentModulesNavigation
from "@/views/dashboards/superUserDashboard/department/modules/SuperUserDashboardDepartmentModulesNavigation.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
import {
getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled,
} from "@/composables/departmentSelfServeEnabled.js";
// Get the department from the route
const router = useRouter();
@@ -21,6 +25,10 @@ const departmentVariables = ref([]);
const workfeedDepartmentOptions = ref([{ value: "", label: "No Workfeed department" }]);
const isLoadingWorkfeedDepartments = ref(false);
const workfeedDepartmentOptionsError = ref("");
const selfServeEnabled = ref(false);
const isLoadingSelfServeEnabled = ref(false);
const isSavingSelfServeEnabled = ref(false);
const selfServeEnabledError = ref("");
const getDepartmentVariables = async () => {
await SessionUser.request(
@@ -129,8 +137,40 @@ const setWorkfeedDepartmentId = async (selectedDepartmentId) => {
});
};
const loadSelfServeEnabled = async () => {
isLoadingSelfServeEnabled.value = true;
selfServeEnabledError.value = "";
try {
selfServeEnabled.value = await getDepartmentSelfServeEnabled(departmentId.value);
} catch (error) {
console.log(error);
selfServeEnabledError.value = "Unable to load self-serve status.";
selfServeEnabled.value = false;
} finally {
isLoadingSelfServeEnabled.value = false;
}
};
const updateSelfServeEnabled = async (enabled) => {
const previousValue = selfServeEnabled.value;
selfServeEnabled.value = Boolean(enabled);
isSavingSelfServeEnabled.value = true;
selfServeEnabledError.value = "";
try {
selfServeEnabled.value = await setDepartmentSelfServeEnabled(departmentId.value, enabled);
} catch (error) {
console.log(error);
selfServeEnabled.value = previousValue;
selfServeEnabledError.value = "Unable to update self-serve status.";
} finally {
isSavingSelfServeEnabled.value = false;
}
};
getDepartmentVariables();
getWorkfeedDepartmentOptions();
loadSelfServeEnabled();
</script>
<template>
@@ -217,6 +257,25 @@ getWorkfeedDepartmentOptions();
></ConfigurationSwitch>
</template>
</ConfigurationCategory>
<ConfigurationCategory
module="Department setup"
title="Moduler"
icon="fas fa-soap"
subtitle="Selvvask"
>
<template #default>
<ConfigurationSwitch
title="Selvvask"
description="Aktiver selvvask i denne afdeling."
:value="selfServeEnabled"
:disabled="isLoadingSelfServeEnabled || isSavingSelfServeEnabled"
:on-switch="updateSelfServeEnabled"
></ConfigurationSwitch>
<p class="help is-danger" v-if="selfServeEnabledError">
{{ selfServeEnabledError }}
</p>
</template>
</ConfigurationCategory>
<ConfigurationCategory
module="Workfeed"
title="Moduler"
@@ -152,6 +152,15 @@ export const lane = {
RESERVE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RESERVE", payload),
RELEASE: (laneId: number, payload: LaneCommandPayload = {}) => executeLaneCommand(laneId, "RELEASE", payload),
},
wash: {
inProgress: (laneId: number) => SessionUser.request(
"/modules/self-serve/lane/wash/in-progress",
"GET",
{
lane_id: normalizeLaneId(laneId),
}
),
},
gate: {
open: (laneId: number, gate: LaneGate) => SessionUser.request(
"/modules/self-serve/lane/gate/open",
@@ -29,6 +29,13 @@ type ForceEnableOptions = {
duration?: number | null;
licensePlate?: string | null;
};
type InProgressWashDetails = {
lane_id: number;
in_progress: boolean;
session: Record<string, unknown> | null;
customer: Record<string, unknown> | null;
vehicle: Record<string, unknown> | null;
};
const DEFAULT_POLL_INTERVAL_MS = 7000;
@@ -37,6 +44,8 @@ const laneRelayLoading = reactive<Record<number, RelayLoadingMap>>({});
const laneCommandLoading = reactive<Record<number, LaneCommandLoadingState>>({});
const laneErrors = reactive<Record<number, string | null>>({});
const laneLastUpdatedAt = reactive<Record<number, number | null>>({});
const laneInProgressDetails = reactive<Record<number, InProgressWashDetails | null>>({});
const laneInProgressLoading = reactive<Record<number, boolean>>({});
const lanePollSubscribers = reactive<Record<number, number>>({});
const lanePollIntervals = new Map<number, ReturnType<typeof setInterval>>();
@@ -84,6 +93,12 @@ const ensureLaneState = (laneId: number): void => {
if (!Object.prototype.hasOwnProperty.call(laneLastUpdatedAt, laneId)) {
laneLastUpdatedAt[laneId] = null;
}
if (!Object.prototype.hasOwnProperty.call(laneInProgressDetails, laneId)) {
laneInProgressDetails[laneId] = null;
}
if (!Object.prototype.hasOwnProperty.call(laneInProgressLoading, laneId)) {
laneInProgressLoading[laneId] = false;
}
if (!Object.prototype.hasOwnProperty.call(lanePollSubscribers, laneId)) {
lanePollSubscribers[laneId] = 0;
}
@@ -191,6 +206,40 @@ const updateLaneLastUpdatedAt = (laneId: number): void => {
laneLastUpdatedAt[laneId] = Date.now();
};
const normalizeInProgressDetails = (
laneId: number,
payload: unknown
): InProgressWashDetails => {
if (!payload || typeof payload !== "object") {
return {
lane_id: laneId,
in_progress: false,
session: null,
customer: null,
vehicle: null,
};
}
const source = payload as Record<string, unknown>;
const session = source.session && typeof source.session === "object"
? source.session as Record<string, unknown>
: null;
const customer = source.customer && typeof source.customer === "object"
? source.customer as Record<string, unknown>
: null;
const vehicle = source.vehicle && typeof source.vehicle === "object"
? source.vehicle as Record<string, unknown>
: null;
return {
lane_id: Number.isFinite(Number(source.lane_id)) ? Number(source.lane_id) : laneId,
in_progress: Boolean(source.in_progress),
session,
customer,
vehicle,
};
};
export const parseRelayStatus = (status: RelayStatus | null): RelayDisplayStatus => {
if (!status) {
return "MAINTENANCE";
@@ -304,6 +353,32 @@ export const setRelayState = async (
}
};
export const fetchInProgressDetails = async (
laneId: number
): Promise<InProgressWashDetails> => {
const normalizedLaneId = toLaneId(laneId);
ensureLaneState(normalizedLaneId);
laneInProgressLoading[normalizedLaneId] = true;
try {
const response = await lane.wash.inProgress(normalizedLaneId);
const details = normalizeInProgressDetails(
normalizedLaneId,
extractPayload(response)
);
laneInProgressDetails[normalizedLaneId] = details;
laneErrors[normalizedLaneId] = null;
updateLaneLastUpdatedAt(normalizedLaneId);
return details;
} catch (error) {
laneErrors[normalizedLaneId] = parseErrorMessage(error);
throw error;
} finally {
laneInProgressLoading[normalizedLaneId] = false;
}
};
type CommandLoadingKey = keyof LaneCommandLoadingState;
const runLaneAction = async <T>(
@@ -394,13 +469,17 @@ export const startPolling = async (
}
const pollInterval = Math.max(1000, Math.round(intervalMs));
const refreshLane = async () => {
await Promise.allSettled([
fetchAllRelayStatuses(normalizedLaneId),
fetchInProgressDetails(normalizedLaneId),
]);
};
await fetchAllRelayStatuses(normalizedLaneId).catch(() => {
// Polling should continue even if one fetch iteration fails.
});
await refreshLane();
const intervalRef = setInterval(() => {
fetchAllRelayStatuses(normalizedLaneId).catch(() => {
refreshLane().catch(() => {
// Keep polling resilient to temporary backend/network failures.
});
}, pollInterval);
@@ -439,6 +518,8 @@ export const useMachineConnectivity = (laneId: number) => {
const commandLoading = computed(() => laneCommandLoading[normalizedLaneId]);
const error = computed(() => laneErrors[normalizedLaneId]);
const lastUpdatedAt = computed(() => laneLastUpdatedAt[normalizedLaneId]);
const inProgressDetails = computed(() => laneInProgressDetails[normalizedLaneId]);
const inProgressLoading = computed(() => laneInProgressLoading[normalizedLaneId]);
const isPolling = computed(() => lanePollIntervals.has(normalizedLaneId));
return {
@@ -449,9 +530,12 @@ export const useMachineConnectivity = (laneId: number) => {
commandLoading,
error,
lastUpdatedAt,
inProgressDetails,
inProgressLoading,
isPolling,
fetchRelayStatus: (relay: RelayKind) => fetchRelayStatus(normalizedLaneId, relay),
fetchAllRelayStatuses: () => fetchAllRelayStatuses(normalizedLaneId),
fetchInProgressDetails: () => fetchInProgressDetails(normalizedLaneId),
toggleRelay: (relay: RelayKind, on: boolean) => setRelayState(normalizedLaneId, relay, on),
executeLaneCommand: (command: LaneCommand, payload: LaneCommandPayload = {}) => executeLaneCommand(normalizedLaneId, command, payload),
stopWash: () => stopWash(normalizedLaneId),
@@ -474,11 +558,14 @@ export const machineConnectivityStore = {
laneCommandLoading,
laneErrors,
laneLastUpdatedAt,
laneInProgressDetails,
laneInProgressLoading,
lanePollSubscribers,
},
functions: {
fetchRelayStatus,
fetchAllRelayStatuses,
fetchInProgressDetails,
setRelayState,
executeLaneCommand,
forceEnableMachine,
@@ -508,6 +595,8 @@ export const __resetMachineConnectivityStoreForTests = (): void => {
resetReactiveObject(laneCommandLoading as unknown as Record<string, unknown>);
resetReactiveObject(laneErrors as unknown as Record<string, unknown>);
resetReactiveObject(laneLastUpdatedAt as unknown as Record<string, unknown>);
resetReactiveObject(laneInProgressDetails as unknown as Record<string, unknown>);
resetReactiveObject(laneInProgressLoading as unknown as Record<string, unknown>);
resetReactiveObject(lanePollSubscribers as unknown as Record<string, unknown>);
};
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineProps, onMounted, onUnmounted } from "vue";
import { computed, onMounted, onUnmounted } from "vue";
import SelfServeMachineStatus
from "@/views/dashboards/superUserDashboard/selfserve/displays/machine/SelfServeMachineStatus.vue";
import {
@@ -10,7 +10,6 @@ import {
type RelayKind,
} from "@/views/dashboards/superUserDashboard/selfserve/components/SelfServeMachineConnectivity.vue";
import type { Machine } from "@/views/dashboards/superUserDashboard/selfserve/types/MachineType.vue";
import {BSwitch} from "buefy";
const props = defineProps<{ machine: Machine }>();
@@ -20,6 +19,8 @@ const statuses = computed(() => connectivity.statuses.value);
const loading = computed(() => connectivity.loading.value);
const commandLoading = computed(() => connectivity.commandLoading.value);
const error = computed(() => connectivity.error.value);
const inProgressDetails = computed(() => connectivity.inProgressDetails.value);
const inProgressLoading = computed(() => connectivity.inProgressLoading.value);
const normalizeRelayId = (value: unknown): string | null => {
if (typeof value !== "string") {
@@ -39,9 +40,40 @@ const usesSharedGateRelay = computed(() => (
const hasGateLoading = computed(() => (
commandLoading.value.openEntranceGate || commandLoading.value.openExitGate
));
const combinedGateLoading = computed(() => (
commandLoading.value.openEntranceGate || commandLoading.value.openExitGate
));
const normalizeDisplayValue = (value: unknown): string | null => {
if (typeof value === "string") {
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return null;
};
const customerDisplay = computed(() => {
if (!inProgressDetails.value?.in_progress) {
return "Unknown";
}
return normalizeDisplayValue(inProgressDetails.value.customer?.display_name)
?? normalizeDisplayValue(inProgressDetails.value.customer?.customer_number)
?? normalizeDisplayValue(inProgressDetails.value.session?.customer_number)
?? "Unknown";
});
const vehicleDisplay = computed(() => {
if (!inProgressDetails.value?.in_progress) {
return "Unknown";
}
return normalizeDisplayValue(inProgressDetails.value.vehicle?.reg)
?? normalizeDisplayValue(inProgressDetails.value.session?.reg)
?? "Unknown";
});
const toggleRelay = async (relay: RelayKind, on: boolean) => {
try {
@@ -139,6 +171,27 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="divider">
<p class="divider-text">Wash In Progress</p>
</div>
<div class="box is-shadowless p-3 mb-3">
<template v-if="inProgressLoading">
<p>Loading in-progress details...</p>
</template>
<template v-else-if="inProgressDetails?.in_progress">
<p>
<strong>Customer:</strong>
{{ customerDisplay }}
</p>
<p>
<strong>Vehicle:</strong>
{{ vehicleDisplay }}
</p>
</template>
<template v-else>
<p>No wash in progress.</p>
</template>
</div>
<div class="divider">
<p class="divider-text">Gate Controls</p>
</div>
@@ -146,11 +199,11 @@ onUnmounted(() => {
<div class="column is-full">
<button
class="button is-primary is-small is-fullwidth"
:class="{ 'is-loading': combinedGateLoading }"
:class="{ 'is-loading': hasGateLoading }"
:disabled="hasGateLoading"
@click="openSharedGate"
>
Open gate
Open Gate
</button>
</div>
</div>
@@ -0,0 +1,205 @@
// @vitest-environment jsdom
import { defineComponent, h } from "vue";
import { mount } from "@vue/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticatedRequest: vi.fn(),
toastSuccess: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("vue-router", async () => {
const { ref } = await import("vue");
const currentRoute = ref({
params: {
departmentId: "12",
},
});
const push = vi.fn();
return {
useRouter: () => ({
currentRoute,
push,
}),
__routeRef: currentRoute,
__pushMock: push,
};
});
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
vi.mock("vue-toast-notification", () => ({
useToast: () => ({
success: mocks.toastSuccess,
error: mocks.toastError,
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key) => key,
}),
}));
vi.mock("buefy", () => ({
BSwitch: defineComponent({
name: "MockSwitch",
props: {
modelValue: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ["update:model-value"],
setup(props, { emit }) {
return () => h("button", {
"data-testid": "self-serve-switch",
"data-state": props.modelValue ? "on" : "off",
"disabled": props.disabled,
onClick: () => emit("update:model-value", !props.modelValue),
});
},
}),
}));
import DepartmentModulesDisplay from "@/components/displays/department/moduleNavigation/DepartmentModulesDisplay.vue";
import { __routeRef } from "vue-router";
const flushMicrotasks = async () => {
await Promise.resolve();
await Promise.resolve();
};
describe("DepartmentModulesDisplay self-serve management", () => {
let setIntervalSpy;
let consoleErrorSpy;
beforeEach(() => {
mocks.authenticatedRequest.mockReset();
mocks.toastSuccess.mockReset();
mocks.toastError.mockReset();
__routeRef.value = { params: { departmentId: "12" } };
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1);
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
return {
data: {
data: {
message: 0,
},
},
};
}
if (url === "/departments/self-serve/enabled?id=12" && method === "GET") {
return {
data: {
data: {
enabled: false,
},
},
};
}
if (url === "/departments/self-serve/enabled?id=12&enabled=true" && method === "PUT") {
return {
data: {
data: {
enabled: true,
},
},
};
}
if (url === "/departments/self-serve/enabled?id=12&enabled=false" && method === "PUT") {
return {
data: {
data: {
enabled: false,
},
},
};
}
throw new Error(`Unexpected request: ${method} ${url}`);
});
});
afterEach(() => {
setIntervalSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
it("loads the current self-serve status on mount", async () => {
const wrapper = mount(DepartmentModulesDisplay);
await flushMicrotasks();
expect(mocks.authenticatedRequest).toHaveBeenCalledWith("/departments/self-serve/enabled?id=12", "GET");
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("off");
});
it("updates self-serve status and keeps UI in sync on success", async () => {
const wrapper = mount(DepartmentModulesDisplay);
await flushMicrotasks();
await wrapper.get("[data-testid='self-serve-switch']").trigger("click");
await flushMicrotasks();
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
"/departments/self-serve/enabled?id=12&enabled=true",
"PUT"
);
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("on");
expect(mocks.toastSuccess).toHaveBeenCalledWith("admin.department_modules.self_wash.enabled");
});
it("reverts self-serve status and shows an error when update fails", async () => {
mocks.authenticatedRequest.mockImplementation(async (url, method) => {
if (String(url).startsWith("/admin/bookings/department/count?department_id=12")) {
return {
data: {
data: {
message: 0,
},
},
};
}
if (url === "/departments/self-serve/enabled?id=12" && method === "GET") {
return {
data: {
data: {
enabled: false,
},
},
};
}
if (url === "/departments/self-serve/enabled?id=12&enabled=true" && method === "PUT") {
throw new Error("save failed");
}
throw new Error(`Unexpected request: ${method} ${url}`);
});
const wrapper = mount(DepartmentModulesDisplay);
await flushMicrotasks();
await wrapper.get("[data-testid='self-serve-switch']").trigger("click");
await flushMicrotasks();
expect(wrapper.get("[data-testid='self-serve-switch']").attributes("data-state")).toBe("off");
expect(mocks.toastError).toHaveBeenCalledWith("admin.department_modules.self_wash.update_error");
});
});
@@ -0,0 +1,26 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(
process.cwd(),
"src/views/dashboards/superUserDashboard/department/modules/DepartmentModulesSetup.vue"
),
"utf8"
);
describe("DepartmentModulesSetup self-serve management regression", () => {
it("loads and updates department self-serve status through shared helpers", () => {
expect(source).toContain("getDepartmentSelfServeEnabled");
expect(source).toContain("setDepartmentSelfServeEnabled");
expect(source).toContain("loadSelfServeEnabled()");
expect(source).toContain("updateSelfServeEnabled");
});
it("renders a dedicated self-serve module switch in setup UI", () => {
expect(source).toContain("subtitle=\"Selvvask\"");
expect(source).toContain("title=\"Selvvask\"");
expect(source).toContain("Aktiver selvvask i denne afdeling.");
});
});
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
authenticatedRequest: vi.fn(),
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: mocks.authenticatedRequest,
}));
import {
getDepartmentSelfServeEnabled,
setDepartmentSelfServeEnabled,
} from "@/composables/departmentSelfServeEnabled.js";
describe("department self-serve enabled management", () => {
beforeEach(() => {
mocks.authenticatedRequest.mockReset();
});
it("gets self-serve enabled status for a department", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
data: {
enabled: "true",
},
},
});
const enabled = await getDepartmentSelfServeEnabled("12");
expect(enabled).toBe(true);
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
"/departments/self-serve/enabled?id=12",
"GET"
);
});
it("normalizes false-like enabled responses", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
enabled: 0,
},
});
const enabled = await getDepartmentSelfServeEnabled(12);
expect(enabled).toBe(false);
});
it("updates self-serve enabled status for a department", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
message: "updated",
},
});
const enabled = await setDepartmentSelfServeEnabled(22, true);
expect(enabled).toBe(true);
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
"/departments/self-serve/enabled?id=22&enabled=true",
"PUT"
);
});
it("uses response enabled state after update when backend returns it", async () => {
mocks.authenticatedRequest.mockResolvedValue({
data: {
data: {
enabled: "false",
},
},
});
const enabled = await setDepartmentSelfServeEnabled(22, true);
expect(enabled).toBe(false);
expect(mocks.authenticatedRequest).toHaveBeenCalledWith(
"/departments/self-serve/enabled?id=22&enabled=true",
"PUT"
);
});
it("rejects invalid department ids", async () => {
await expect(getDepartmentSelfServeEnabled(0)).rejects.toThrow("Invalid department id");
await expect(setDepartmentSelfServeEnabled("abc", true)).rejects.toThrow("Invalid department id");
expect(mocks.authenticatedRequest).not.toHaveBeenCalled();
});
});
@@ -218,6 +218,18 @@ describe("self-serve connectivity relay api wrappers", () => {
);
});
it("fetches in-progress wash details for a lane", async () => {
await lane.wash.inProgress("13");
expect(mocks.request).toHaveBeenCalledWith(
"/modules/self-serve/lane/wash/in-progress",
"GET",
{
lane_id: 13,
}
);
});
it("rejects invalid lane ids for relay and lane commands", async () => {
await expect(relays.get.status.all(0)).rejects.toThrow("Invalid lane id");
expect(() => lane.command.START(0)).toThrow("Invalid lane id");
@@ -33,6 +33,8 @@ const setEndpoints = {
CLEANER: "/modules/self-serve/lane/relay/machine_cleaner/set",
};
const inProgressEndpoint = "/modules/self-serve/lane/wash/in-progress";
const buildRelayStatus = (
laneId,
relay,
@@ -49,6 +51,22 @@ const buildRelayStatus = (
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;
@@ -197,11 +215,61 @@ describe("self-serve machine connectivity store", () => {
);
});
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}`);
@@ -216,7 +284,7 @@ describe("self-serve machine connectivity store", () => {
await vi.advanceTimersByTimeAsync(2200);
const statusCallCountBeforeStop = mocks.request.mock.calls.length;
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(9);
expect(statusCallCountBeforeStop).toBeGreaterThanOrEqual(12);
connectivity.stopPolling();
expect(connectivity.isPolling.value).toBe(false);
@@ -283,6 +351,10 @@ describe("self-serve machine connectivity store", () => {
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}`);
@@ -31,6 +31,8 @@ const buildConnectivity = () => ({
openExitGate: false,
}),
error: ref(null),
inProgressDetails: ref(null),
inProgressLoading: ref(false),
toggleRelay: mocks.toggleRelay,
openGate: mocks.openGate,
fetchAllRelayStatuses: mocks.fetchAllRelayStatuses,
@@ -137,4 +139,65 @@ describe("SelfServeMachineRelayControls gate controls", () => {
expect(mocks.openGate).toHaveBeenNthCalledWith(1, "ENTRANCE");
expect(mocks.openGate).toHaveBeenNthCalledWith(2, "EXIT");
});
it("shows no in-progress wash details when lane is idle", async () => {
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 3,
name: "Lane 3",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.text()).toContain("No wash in progress.");
});
it("renders customer and vehicle details when wash is in progress", async () => {
mocks.useMachineConnectivity.mockImplementation(() => ({
...buildConnectivity(),
inProgressDetails: ref({
lane_id: 4,
in_progress: true,
session: { reg: "AB12345", customer_number: 1001 },
customer: { display_name: "John Doe", customer_number: 1001 },
vehicle: { reg: "AB12345" },
}),
}));
const wrapper = mount(SelfServeMachineRelayControls, {
props: {
machine: {
id: 4,
name: "Lane 4",
status: "ONLINE",
relay_in_id: "in",
relay_out_id: "out",
},
},
global: {
stubs: {
SelfServeMachineStatus: { template: "<div />" },
BSwitch: { template: "<div />" },
},
},
});
await flushMicrotasks();
expect(wrapper.text()).toContain("Customer:");
expect(wrapper.text()).toContain("John Doe");
expect(wrapper.text()).toContain("Vehicle:");
expect(wrapper.text()).toContain("AB12345");
});
});