/** * @file useChatOverlay.test.ts * @description Tests for the useChatOverlay hook that manages chat overlay state */ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { useChatOverlay } from "./useChatOverlay"; // Mock localStorage const localStorageMock = ((): Storage => { let store: Record = {}; return { getItem: (key: string): string | null => store[key] ?? null, setItem: (key: string, value: string): void => { store[key] = value; }, removeItem: (key: string): void => { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete store[key]; }, clear: (): void => { store = {}; }, get length(): number { return Object.keys(store).length; }, key: (index: number): string | null => { const keys = Object.keys(store); return keys[index] ?? null; }, }; })(); Object.defineProperty(window, "localStorage", { value: localStorageMock, }); describe("useChatOverlay", () => { beforeEach(() => { localStorageMock.clear(); vi.clearAllMocks(); }); describe("initial state", () => { it("should initialize with closed and not minimized state", () => { const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should restore state from localStorage if available", () => { localStorageMock.setItem( "chatOverlayState", JSON.stringify({ isOpen: true, isMinimized: true }) ); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(true); expect(result.current.isMinimized).toBe(true); }); it("should handle invalid localStorage data gracefully", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); localStorageMock.setItem("chatOverlayState", "invalid json"); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should fall back to defaults when localStorage has wrong shape", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); // Valid JSON but wrong shape localStorageMock.setItem("chatOverlayState", JSON.stringify({ isOpen: "yes", wrong: true })); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should fall back to defaults when localStorage has null value parsed", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); localStorageMock.setItem("chatOverlayState", "null"); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should fall back to defaults when localStorage has array instead of object", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); localStorageMock.setItem("chatOverlayState", JSON.stringify([true, false])); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); }); describe("open", () => { it("should open the chat overlay", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); expect(result.current.isOpen).toBe(true); expect(result.current.isMinimized).toBe(false); }); it("should persist state to localStorage when opening", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as { isOpen: boolean; isMinimized: boolean; }; expect(stored.isOpen).toBe(true); expect(stored.isMinimized).toBe(false); }); }); describe("close", () => { it("should close the chat overlay", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); act(() => { result.current.close(); }); expect(result.current.isOpen).toBe(false); }); it("should persist state to localStorage when closing", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); act(() => { result.current.close(); }); const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as { isOpen: boolean; isMinimized: boolean; }; expect(stored.isOpen).toBe(false); }); }); describe("minimize", () => { it("should minimize the chat overlay", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); act(() => { result.current.minimize(); }); expect(result.current.isOpen).toBe(true); expect(result.current.isMinimized).toBe(true); }); it("should persist minimized state to localStorage", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); act(() => { result.current.minimize(); }); const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as { isOpen: boolean; isMinimized: boolean; }; expect(stored.isMinimized).toBe(true); }); }); describe("expand", () => { it("should expand the minimized chat overlay", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); result.current.minimize(); }); act(() => { result.current.expand(); }); expect(result.current.isMinimized).toBe(false); }); it("should persist expanded state to localStorage", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); result.current.minimize(); }); act(() => { result.current.expand(); }); const stored = JSON.parse(localStorageMock.getItem("chatOverlayState") ?? "{}") as { isOpen: boolean; isMinimized: boolean; }; expect(stored.isMinimized).toBe(false); }); }); describe("toggle", () => { it("should toggle the chat overlay open state", () => { const { result } = renderHook(() => useChatOverlay()); // Initially closed expect(result.current.isOpen).toBe(false); // Toggle to open act(() => { result.current.toggle(); }); expect(result.current.isOpen).toBe(true); // Toggle to close act(() => { result.current.toggle(); }); expect(result.current.isOpen).toBe(false); }); it("should reset minimized state when toggling closed", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); result.current.minimize(); }); expect(result.current.isMinimized).toBe(true); act(() => { result.current.toggle(); }); // Should close and reset minimized (invariant: cannot be minimized when closed) expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should preserve minimized state when toggling open", () => { const { result } = renderHook(() => useChatOverlay()); // Start closed expect(result.current.isOpen).toBe(false); act(() => { result.current.toggle(); }); // Should open and not be minimized expect(result.current.isOpen).toBe(true); expect(result.current.isMinimized).toBe(false); }); }); describe("toggleMinimize", () => { it("should toggle the minimize state", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); }); // Initially not minimized expect(result.current.isMinimized).toBe(false); // Toggle to minimized act(() => { result.current.toggleMinimize(); }); expect(result.current.isMinimized).toBe(true); // Toggle back to expanded act(() => { result.current.toggleMinimize(); }); expect(result.current.isMinimized).toBe(false); }); }); describe("state invariant enforcement", () => { it("should reject invalid localStorage state: closed AND minimized", () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); // This violates the invariant: cannot be minimized when closed localStorageMock.setItem( "chatOverlayState", JSON.stringify({ isOpen: false, isMinimized: true }) ); const { result } = renderHook(() => useChatOverlay()); // Should fall back to defaults since invariant is violated expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should accept valid state: open and minimized", () => { localStorageMock.setItem( "chatOverlayState", JSON.stringify({ isOpen: true, isMinimized: true }) ); const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(true); expect(result.current.isMinimized).toBe(true); }); it("should reset isMinimized when closing via close()", () => { const { result } = renderHook(() => useChatOverlay()); act(() => { result.current.open(); result.current.minimize(); }); expect(result.current.isMinimized).toBe(true); act(() => { result.current.close(); }); expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); }); describe("minimize/toggleMinimize guards", () => { it("should not allow minimize when overlay is closed", () => { const { result } = renderHook(() => useChatOverlay()); // Overlay starts closed expect(result.current.isOpen).toBe(false); act(() => { result.current.minimize(); }); // Should remain unchanged - cannot minimize when closed expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); it("should not allow toggleMinimize when overlay is closed", () => { const { result } = renderHook(() => useChatOverlay()); expect(result.current.isOpen).toBe(false); act(() => { result.current.toggleMinimize(); }); // Should remain unchanged - cannot toggle minimize when closed expect(result.current.isOpen).toBe(false); expect(result.current.isMinimized).toBe(false); }); }); describe("storage error handling", () => { it("should call onStorageError when localStorage save fails", () => { const onStorageError = vi.fn(); const quotaError = new DOMException("Storage full", "QuotaExceededError"); const { result } = renderHook(() => useChatOverlay({ onStorageError })); // Make setItem throw vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { throw quotaError; }); vi.spyOn(console, "warn").mockImplementation(() => undefined); act(() => { result.current.open(); }); expect(onStorageError).toHaveBeenCalledWith( "Storage is full. Chat state may not persist across page refreshes." ); }); it("should show appropriate message for SecurityError", () => { const onStorageError = vi.fn(); const securityError = new DOMException("Blocked", "SecurityError"); const { result } = renderHook(() => useChatOverlay({ onStorageError })); vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { throw securityError; }); vi.spyOn(console, "warn").mockImplementation(() => undefined); act(() => { result.current.open(); }); expect(onStorageError).toHaveBeenCalledWith( "Storage is unavailable in this browser mode. Chat state will not persist." ); }); it("should only notify once per error type", () => { const onStorageError = vi.fn(); const quotaError = new DOMException("Storage full", "QuotaExceededError"); // Set up spy BEFORE rendering so all saves (including initial) throw vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { throw quotaError; }); vi.spyOn(console, "warn").mockImplementation(() => undefined); const { result } = renderHook(() => useChatOverlay({ onStorageError })); // Initial render triggers a save which throws → first notification // Multiple state changes that trigger more saves act(() => { result.current.open(); }); act(() => { result.current.minimize(); }); act(() => { result.current.expand(); }); // Should only have been called once for QuotaExceededError despite multiple failures expect(onStorageError).toHaveBeenCalledTimes(1); }); }); });