Add runtime type validation after all JSON.parse calls in the web app to prevent runtime crashes from corrupted or tampered storage data. Creates a shared safeJsonParse utility with type guard functions for each data shape (Message[], ChatOverlayState, LayoutConfigRecord). All four affected callsites now validate parsed data and fall back to safe defaults on mismatch. Files changed: - apps/web/src/lib/utils/safe-json.ts (new utility) - apps/web/src/lib/utils/safe-json.test.ts (25 tests) - apps/web/src/hooks/useChat.ts (deserializeMessages) - apps/web/src/hooks/useChat.test.ts (3 new corruption tests) - apps/web/src/hooks/useChatOverlay.ts (loadState) - apps/web/src/hooks/useChatOverlay.test.ts (3 new corruption tests) - apps/web/src/components/chat/ConversationSidebar.tsx (ideaToConversation) - apps/web/src/lib/hooks/useLayout.ts (layout loading) Co-Authored-By: Claude Opus 4.6 <[email protected]>
309 lines
8.1 KiB
TypeScript
309 lines
8.1 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 not change minimized state when toggling", () => {
|
|
const { result } = renderHook(() => useChatOverlay());
|
|
|
|
act(() => {
|
|
result.current.open();
|
|
result.current.minimize();
|
|
});
|
|
|
|
expect(result.current.isMinimized).toBe(true);
|
|
|
|
act(() => {
|
|
result.current.toggle();
|
|
});
|
|
|
|
// Should close but keep minimized state for next open
|
|
expect(result.current.isOpen).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);
|
|
});
|
|
});
|
|
});
|