Files
stack/apps/web/src/hooks/useChatOverlay.test.ts
Jason Woltje f64ca3871d
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline was successful
fix(web): Address review findings for M4-LLM integration
- Sanitize user-facing error messages (no raw API/DB errors)
- Remove dead try/catch from Chat.tsx handleSendMessage
- Add onError callback for persistence errors in useChat
- Add console.error logging to loadConversation
- Guard minimize/toggleMinimize against closed overlay state
- Improve error dedup bucketing for non-DOMException errors
- Add tests: non-Error throws, updateConversation failure,
  minimize/toggleMinimize guards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 20:25:03 -06:00

474 lines
13 KiB
TypeScript

/**
* @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<string, string> = {};
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);
});
});
});