homeio
PublicForked from doctor-io/homeio
useSettingsBackend.test.tsx
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { queryKeys } from "@/lib/shared/query-keys";
import { createTestQueryClient, createWrapper } from "@/test/query-client-wrapper";
const { toastSuccessMock, toastInfoMock, toastErrorMock } = vi.hoisted(() => ({
toastSuccessMock: vi.fn(),
toastInfoMock: vi.fn(),
toastErrorMock: vi.fn(),
}));
const mockUseCurrentUser = vi.fn();
const mockUseSystemMetrics = vi.fn();
const mockUseNetworkStatus = vi.fn();
const mockUseWifiNetworks = vi.fn();
const mockUseLocalFolderShares = vi.fn();
const mockUseNetworkShares = vi.fn();
const mockUseDockerStatsSnapshot = vi.fn();
vi.mock("@/hooks/useCurrentUser", () => ({
useCurrentUser: () => mockUseCurrentUser(),
}));
vi.mock("@/modules/system/hooks/useSystemMetrics", () => ({
useSystemMetrics: () => mockUseSystemMetrics(),
}));
vi.mock("@/modules/system/hooks/useNetworkStatus", () => ({
useNetworkStatus: () => mockUseNetworkStatus(),
}));
vi.mock("@/modules/system/hooks/useWifiNetworks", () => ({
useWifiNetworks: () => mockUseWifiNetworks(),
}));
vi.mock("@/modules/files/hooks/useLocalFolderShares", () => ({
useLocalFolderShares: () => mockUseLocalFolderShares(),
}));
vi.mock("@/modules/files/hooks/useNetworkShares", () => ({
useNetworkShares: () => mockUseNetworkShares(),
}));
vi.mock("@/modules/system/hooks/useDockerStats", () => ({
useDockerStatsSnapshot: () => mockUseDockerStatsSnapshot(),
}));
vi.mock("sonner", () => ({
toast: {
success: toastSuccessMock,
info: toastInfoMock,
error: toastErrorMock,
},
}));
import { useSettingsBackend } from "@/modules/settings/hooks/useSettingsBackend";
function mockOkJson<T>(data: T) {
return { ok: true, json: async () => ({ data }) };
}
type FetchHandler = (
url: string,
init?: RequestInit,
) => ReturnType<typeof mockOkJson> | null | undefined;
function createFetchMock(actionHandler?: FetchHandler) {
return vi.fn().mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const override = actionHandler?.(url, init);
if (override != null) return override;
if (url === "/api/v1/system/preferences") {
return mockOkJson({ hostname: "home-node", timezone: "Europe/Paris" });
}
if (url === "/api/v1/system/power/schedule") {
return mockOkJson({ enabled: false, frequency: "weekly", dayOfWeek: "sunday", time: "03:00" });
}
if (url === "/api/v1/system/power/capabilities") {
return mockOkJson({ reboot: true, shutdown: true, factoryReset: true, scheduledReboot: true });
}
if (url === "/api/v1/system/security") {
return mockOkJson({
firewallEnabled: true,
firewallIncomingPolicy: "deny",
firewallOutgoingPolicy: "allow",
fail2banEnabled: true,
fail2banMaxRetries: 5,
fail2banBanDurationSeconds: 3600,
});
}
if (url === "/api/v1/system/updates") {
return mockOkJson({
currentVersion: "0.1.74",
latestVersion: "0.1.75",
updateAvailable: true,
checkedAt: "2026-03-08T10:00:00.000Z",
});
}
if (url === "/api/v1/system/backups") {
return mockOkJson({
settings: { enabled: false, frequency: "weekly", dayOfWeek: "sunday", time: "03:00", retentionCount: 7 },
backups: [],
backupRoot: "/DATA/Backups/homeio",
});
}
return { ok: true, json: async () => ({ data: [] }) };
});
}
function setDefaultMocks() {
mockUseCurrentUser.mockReturnValue({
data: {
id: "u1",
username: "ahmed",
},
isLoading: false,
error: null,
});
mockUseSystemMetrics.mockReturnValue({
data: {
timestamp: "2026-02-27T10:00:00.000Z",
hostname: "home-node",
platform: "linux",
uptimeSeconds: 90061,
cpu: {
oneMinute: 0.3,
fiveMinute: 0.2,
fifteenMinute: 0.1,
normalizedPercent: 32,
},
memory: {
totalBytes: 8 * 1024 * 1024 * 1024,
freeBytes: 3 * 1024 * 1024 * 1024,
usedBytes: 5 * 1024 * 1024 * 1024,
usedPercent: 62.5,
},
temperature: {
mainCelsius: 47.2,
maxCelsius: 49,
coresCelsius: [46.5, 47.8],
},
battery: {
hasBattery: false,
isCharging: false,
percent: null,
timeRemainingMinutes: null,
acConnected: null,
manufacturer: null,
cycleCount: null,
designedCapacityWh: null,
maxCapacityWh: null,
designToMaxCapacityPercent: null,
},
wifi: {
connected: true,
iface: "wlan0",
ssid: "HomeNet",
bssid: "11:22:33:44",
signalPercent: 78,
txRateMbps: 150,
downloadMbps: 30,
uploadMbps: 10,
ipv4: "192.168.1.20",
ipv6: null,
availableNetworks: [],
},
process: {
pid: 123,
uptimeSeconds: 7200,
nodeVersion: "v22.17.0",
},
storage: {
mountPath: "/DATA",
totalBytes: 4 * 1024 * 1024 * 1024 * 1024,
availableBytes: 2200 * 1024 * 1024 * 1024,
usedBytes: 1800 * 1024 * 1024 * 1024,
usedPercent: 45,
},
},
isLoading: false,
error: null,
});
mockUseNetworkStatus.mockReturnValue({
data: {
connected: true,
iface: "wlan0",
ssid: "HomeNet",
ipv4: "192.168.1.20",
signalPercent: 78,
},
isLoading: false,
error: null,
});
mockUseWifiNetworks.mockReturnValue({
data: [
{
ssid: "HomeNet",
bssid: "11:22:33:44",
signalPercent: 80,
channel: 6,
frequencyMhz: 2437,
security: "wpa2",
},
{
ssid: "GuestNet",
bssid: "aa:bb:cc:dd",
signalPercent: 42,
channel: 11,
frequencyMhz: 2462,
security: "wpa2",
},
],
isLoading: false,
error: null,
});
mockUseDockerStatsSnapshot.mockReturnValue({
stats: [
{
id: "container123456789",
name: "plex",
state: "running",
cpuPercent: 3.4,
memoryUsed: 512 * 1024 * 1024,
memoryLimit: 2 * 1024 * 1024 * 1024,
memoryPercent: 25,
networkRx: 10,
networkTx: 15,
blockRead: 1,
blockWrite: 2,
},
],
isLoading: false,
error: null,
});
mockUseLocalFolderShares.mockReturnValue({
data: [
{
id: "local-1",
shareName: "Media",
sourcePath: "Media",
sharedPath: "Shared/Media",
isMounted: true,
isExported: true,
},
],
isLoading: false,
error: null,
});
mockUseNetworkShares.mockReturnValue({
data: [
{
id: "network-1",
host: "nas.local",
share: "Public",
username: "guest",
mountPath: "Network/nas.local/Public",
isMounted: true,
},
],
isLoading: false,
error: null,
});
}
describe("useSettingsBackend", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
setDefaultMocks();
vi.stubGlobal(
"fetch",
createFetchMock((url) => {
if (url === "/api/v1/system/backups") {
return mockOkJson({
settings: { enabled: false, frequency: "weekly", dayOfWeek: "sunday", time: "03:00", retentionCount: 7 },
backups: [
{
id: "backup-1",
createdAt: "2026-03-08T09:00:00.000Z",
sizeBytes: 1024,
appVersion: "0.1.72",
hostname: "home-node",
backupPath: "/DATA/Backups/homeio/backup-1.tar.gz",
dbDumpIncluded: true,
dataRootIncluded: true,
stacksRootIncluded: true,
status: "completed",
},
],
backupRoot: "/DATA/Backups/homeio",
});
}
return null;
}),
);
});
it("maps live settings data and capability flags", async () => {
const client = createTestQueryClient();
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
expect(result.current.general.hostname).toBe("home-node");
expect(result.current.general.uptime).toContain("1 day");
expect(result.current.network.iface).toBe("wlan0");
expect(result.current.network.topSsids).toEqual(["HomeNet", "GuestNet"]);
expect(result.current.storage.mountPath).toBe("/DATA");
expect(result.current.storage.shares).toHaveLength(2);
expect(result.current.docker.containers).toHaveLength(1);
expect(result.current.power.reboot.isPending).toBe(false);
await waitFor(() => {
expect(result.current.power.reboot.available).toBe(true);
expect(result.current.power.shutdown.available).toBe(true);
expect(result.current.power.factoryReset.available).toBe(true);
});
await waitFor(() => {
expect(result.current.generalPreferences.timezone).toBe("Europe/Paris");
});
expect(result.current.generalPreferences.hostname).toBe("home-node");
await waitFor(() => {
expect(result.current.security.firewall.enabled).toBe(true);
});
expect(result.current.security.fail2ban.maxRetries).toBe(5);
expect(result.current.backup.backups).toHaveLength(1);
expect(result.current.backup.backupRoot).toBe("/DATA/Backups/homeio");
expect(result.current.power.scheduledReboot.time).toBe("03:00");
expect(result.current.capabilities.general.hostname.disabled).toBe(false);
await waitFor(() => {
expect(result.current.updates.currentVersion).toBe("0.1.74");
expect(result.current.updates.latestVersion).toBe("0.1.75");
expect(result.current.updates.updateAvailable).toBe(true);
});
expect(result.current.capabilities.updates.checkForUpdates.disabled).toBe(false);
expect(result.current.capabilities.saveBySection.security).toBe(true);
});
it("marks sections unavailable when source hooks fail", () => {
mockUseSystemMetrics.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("metrics down"),
});
mockUseNetworkStatus.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("network down"),
});
mockUseDockerStatsSnapshot.mockReturnValue({
stats: [],
isLoading: false,
error: "docker down",
});
const client = createTestQueryClient();
vi.stubGlobal(
"fetch",
createFetchMock((url) => {
if (url === "/api/v1/system/updates") {
return { ok: false, json: async () => ({ error: "updates down" }) };
}
return null;
}),
);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
expect(result.current.general.unavailable).toBe(true);
expect(result.current.network.unavailable).toBe(true);
expect(result.current.docker.unavailable).toBe(true);
return waitFor(() => {
expect(result.current.updates.unavailable).toBe(true);
});
});
it("triggers Homeio update check mutation and invalidates system updates", async () => {
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/updates/check" && init?.method === "POST") {
return mockOkJson({ currentVersion: "0.1.74", latestVersion: "0.1.75", updateAvailable: true, checkedAt: "2026-03-08T10:00:00.000Z" });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.checkForUpdates();
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/updates/check", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemUpdates,
});
expect(toastSuccessMock).toHaveBeenCalledWith("Homeio 0.1.75 is available.");
});
it("persists update recovery state and redirects to the dedicated updating screen", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/updates") {
return mockOkJson({ currentVersion: "0.1.79", latestVersion: "0.1.80", updateAvailable: true, checkedAt: "2026-03-11T10:00:00.000Z" });
}
if (url === "/api/v1/system/updates/apply" && init?.method === "POST") {
return mockOkJson({ action: "update", accepted: true });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await expect(result.current.actions.applySystemUpdate()).resolves.toBeUndefined();
});
expect(
JSON.parse(localStorage.getItem("system.power.action.v1") ?? "{}"),
).toMatchObject({
action: "update",
requestDispatchedAt: expect.any(String),
});
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/system/updates/apply",
{
method: "POST",
},
);
});
it("keeps update recovery active after redirecting to the dedicated updating screen", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/updates") {
return mockOkJson({ currentVersion: "0.1.81", latestVersion: "0.1.82", updateAvailable: true, checkedAt: "2026-03-11T21:49:04.000Z" });
}
if (url === "/api/v1/system/updates/apply" && init?.method === "POST") {
return mockOkJson({ action: "update", accepted: true });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await expect(result.current.actions.applySystemUpdate()).resolves.toBeUndefined();
});
expect(
JSON.parse(localStorage.getItem("system.power.action.v1") ?? "{}"),
).toMatchObject({
action: "update",
});
expect(result.current.updates.isRecoveryActive).toBe(true);
});
it("surfaces update scheduling failures without entering recovery", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/updates") {
return mockOkJson({ currentVersion: "0.1.81", latestVersion: "0.1.82", updateAvailable: true, checkedAt: "2026-03-11T21:49:04.000Z" });
}
if (url === "/api/v1/system/updates/apply" && init?.method === "POST") {
return { ok: false, status: 500, json: async () => ({ error: "Update runner failed" }) };
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await expect(result.current.actions.applySystemUpdate()).resolves.toBeUndefined();
});
expect(localStorage.getItem("system.power.action.v1")).toBeNull();
await waitFor(() => {
expect(result.current.updates.isRecoveryActive).toBe(false);
expect(result.current.updates.applyError).toBe("Update runner failed");
});
});
it("suppresses Homeio update fetches while update recovery is active", async () => {
localStorage.setItem(
"system.power.action.v1",
JSON.stringify({
action: "update",
startedAt: new Date().toISOString(),
}),
);
const fetchMock = createFetchMock();
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.updates.isRecoveryActive).toBe(true);
});
expect(fetchMock).not.toHaveBeenCalledWith("/api/v1/system/updates", expect.anything());
expect(result.current.updates.unavailable).toBe(false);
expect(result.current.updates.checkError).toBeNull();
});
it("triggers reboot mutation and persists reboot recovery state", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/power/reboot" && init?.method === "POST") {
return mockOkJson({ action: "reboot", accepted: true });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.rebootNow();
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/power/reboot", {
method: "POST",
});
expect(
JSON.parse(localStorage.getItem("system.power.action.v1") ?? "{}"),
).toMatchObject({
action: "reboot",
});
});
it("saves scheduled reboot configuration", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/power/schedule" && init?.method === "PUT") {
return mockOkJson(JSON.parse(String(init.body)));
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.saveScheduledReboot({
enabled: true,
frequency: "daily",
dayOfWeek: "sunday",
time: "04:00",
});
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/power/schedule", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled: true,
frequency: "daily",
dayOfWeek: "sunday",
time: "04:00",
}),
});
});
it("saves general preferences and invalidates system queries", async () => {
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/preferences" && init?.method === "PUT") {
return mockOkJson({ hostname: "homeio-box", timezone: "Europe/Berlin" });
}
if (url === "/api/v1/system/security" && init?.method === "PUT") {
return mockOkJson(JSON.parse(String(init.body)));
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.saveGeneralPreferences({
hostname: "homeio-box",
timezone: "Europe/Berlin",
});
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/preferences", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
hostname: "homeio-box",
timezone: "Europe/Berlin",
}),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemPreferences,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemMetrics,
});
});
it("saves security settings and invalidates the security query", async () => {
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/security" && init?.method === "PUT") {
return mockOkJson(JSON.parse(String(init.body)));
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.saveSecuritySettings({
firewallEnabled: false,
firewallIncomingPolicy: "reject",
firewallOutgoingPolicy: "deny",
fail2banEnabled: false,
fail2banMaxRetries: 7,
fail2banBanDurationSeconds: 7200,
});
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/security", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
firewallEnabled: false,
firewallIncomingPolicy: "reject",
firewallOutgoingPolicy: "deny",
fail2banEnabled: false,
fail2banMaxRetries: 7,
fail2banBanDurationSeconds: 7200,
}),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemSecurity,
});
});
it("prunes docker images and volumes and invalidates docker queries", async () => {
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/docker/prune/images" && init?.method === "POST") {
return mockOkJson({ command: "images", output: "Deleted Images:\nsha256:deadbeef" });
}
if (url === "/api/v1/docker/prune/volumes" && init?.method === "POST") {
return mockOkJson({ command: "volumes", output: "Deleted Volumes:\nold-volume" });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.pruneDockerImages();
await result.current.actions.pruneDockerVolumes();
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/docker/prune/images", {
method: "POST",
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/docker/prune/volumes", {
method: "POST",
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.dockerInfo,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemMetrics,
});
});
it("saves backup settings and invalidates backup queries", async () => {
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/backups/settings" && init?.method === "PUT") {
return mockOkJson(JSON.parse(String(init.body)));
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.saveBackupSettings({
enabled: true,
frequency: "daily",
dayOfWeek: "monday",
time: "02:30",
retentionCount: 14,
});
});
expect(fetchMock).toHaveBeenCalledWith("/api/v1/system/backups/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
enabled: true,
frequency: "daily",
dayOfWeek: "monday",
time: "02:30",
retentionCount: 14,
}),
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemBackups,
});
});
it("persists restore recovery state when a backup restore starts", async () => {
const client = createTestQueryClient();
const fetchMock = createFetchMock((url, init) => {
if (url === "/api/v1/system/backups/backup-1/restore" && init?.method === "POST") {
return mockOkJson({ action: "restore", accepted: true, backupId: "backup-1" });
}
return null;
});
vi.stubGlobal("fetch", fetchMock);
const { result } = renderHook(() => useSettingsBackend(), {
wrapper: createWrapper(client),
});
await act(async () => {
await result.current.actions.restoreBackup("backup-1");
});
expect(
JSON.parse(localStorage.getItem("system.power.action.v1") ?? "{}"),
).toMatchObject({
action: "restore",
});
});
});