homeio
PublikFork dari doctor-io/homeio
useRebootRecovery.test.tsx
/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
POWER_ACTION_COMPLETION_STORAGE_KEY,
POWER_ACTION_STATE_CHANGED_EVENT,
readPersistedPowerActionCompletion,
writePersistedPowerActionState,
} from "@/lib/desktop/reboot-state";
import { queryKeys } from "@/lib/shared/query-keys";
import { useRebootRecovery } from "@/modules/shell/hooks/useRebootRecovery";
import { createTestQueryClient, createWrapper } from "@/test/query-client-wrapper";
const { reloadBrowserWindowMock } = vi.hoisted(() => ({
reloadBrowserWindowMock: vi.fn(),
}));
vi.mock("@/lib/desktop/browser-reload", () => ({
reloadBrowserWindow: reloadBrowserWindowMock,
}));
describe("useRebootRecovery", () => {
beforeEach(() => {
vi.useRealTimers();
localStorage.clear();
vi.restoreAllMocks();
reloadBrowserWindowMock.mockReset();
});
it("recovers from persisted reboot state and clears it after health/auth succeed", async () => {
writePersistedPowerActionState(localStorage, {
action: "reboot",
startedAt: new Date(Date.now() - 10_000).toISOString(),
});
const fetchMock = vi
.fn()
.mockRejectedValueOnce(new Error("down"))
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
expect(result.current.isActive).toBe(true);
});
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(3);
}, { timeout: 4_000 });
await waitFor(() => {
expect(result.current.isActive).toBe(false);
}, { timeout: 4_000 });
expect(localStorage.getItem("system.power.action.v1")).toBeNull();
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.currentUser,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemMetrics,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.installedApps,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.powerSchedule,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemBackups,
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: queryKeys.systemUpdates,
});
});
it("clears reboot state on 401 after service recovery", async () => {
writePersistedPowerActionState(localStorage, {
action: "factory-reset",
startedAt: new Date(Date.now() - 20_000).toISOString(),
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: false, status: 401 });
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
});
await waitFor(() => {
expect(result.current.isActive).toBe(false);
}, { timeout: 4_000 });
expect(localStorage.getItem("system.power.action.v1")).toBeNull();
});
it("keeps the restore overlay active until health and auth recover", async () => {
writePersistedPowerActionState(localStorage, {
action: "restore",
startedAt: new Date(Date.now() - 10_000).toISOString(),
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
expect(result.current.action).toBe("restore");
});
await waitFor(() => {
expect(result.current.isActive).toBe(false);
}, { timeout: 4_000 });
expect(localStorage.getItem("system.power.action.v1")).toBeNull();
});
it("keeps the update overlay active until health and auth recover", async () => {
writePersistedPowerActionState(localStorage, {
action: "update",
startedAt: new Date(Date.now() - 20_000).toISOString(),
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
expect(result.current.action).toBe("update");
});
await waitFor(() => {
expect(result.current.isActive).toBe(false);
}, { timeout: 4_000 });
expect(localStorage.getItem("system.power.action.v1")).toBeNull();
expect(readPersistedPowerActionCompletion(localStorage)).toMatchObject({
action: "update",
});
expect(reloadBrowserWindowMock).toHaveBeenCalledTimes(1);
});
it("does not clear update recovery before the service actually goes away", async () => {
writePersistedPowerActionState(localStorage, {
action: "update",
startedAt: new Date().toISOString(),
});
const fetchMock = vi
.fn()
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockRejectedValueOnce(new Error("gateway down"))
.mockResolvedValueOnce({ ok: true, status: 200 })
.mockResolvedValueOnce({ ok: true, status: 200 });
vi.stubGlobal("fetch", fetchMock);
const client = createTestQueryClient();
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
expect(result.current.isActive).toBe(true);
expect(result.current.action).toBe("update");
});
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.current.isActive).toBe(true);
});
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(4);
expect(result.current.isActive).toBe(false);
}, { timeout: 8_000 });
expect(reloadBrowserWindowMock).toHaveBeenCalledTimes(1);
});
it("reacts immediately when a power action is written in the same tab", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("still rebooting")));
const client = createTestQueryClient();
const { result } = renderHook(() => useRebootRecovery(), {
wrapper: createWrapper(client),
});
await waitFor(() => {
expect(result.current.isHydrated).toBe(true);
expect(result.current.isActive).toBe(false);
});
act(() => {
writePersistedPowerActionState(localStorage, {
action: "update",
startedAt: new Date().toISOString(),
});
window.dispatchEvent(new Event(POWER_ACTION_STATE_CHANGED_EVENT));
});
await waitFor(() => {
expect(result.current.isActive).toBe(true);
expect(result.current.action).toBe("update");
});
expect(localStorage.getItem(POWER_ACTION_COMPLETION_STORAGE_KEY)).toBeNull();
});
});