IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

useRebootRecovery.test.tsx

Open raw
/* @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();
  });
});
bytes
7,690
lines
247
Type
tsx
Latest commit
16 October 2026