import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, act, waitFor } from "@testing-library/react"; import { useWebSocket } from "./useWebSocket"; import type { Socket } from "socket.io-client"; import { io } from "socket.io-client"; // Mock socket.io-client vi.mock("socket.io-client"); describe("useWebSocket", (): void => { let mockSocket: Partial; let eventHandlers: Record void>; beforeEach((): void => { eventHandlers = {}; mockSocket = { on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { eventHandlers[event] = handler; return mockSocket; }) as unknown as Socket["on"], off: vi.fn((event?: string) => { if (event && Object.hasOwn(eventHandlers, event)) { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete eventHandlers[event]; } return mockSocket; }) as unknown as Socket["off"], connect: vi.fn(), disconnect: vi.fn(), connected: false, }; (io as unknown as ReturnType).mockReturnValue(mockSocket); }); afterEach((): void => { vi.clearAllMocks(); }); it("should connect to WebSocket server on mount", (): void => { const workspaceId = "workspace-123"; const token = "auth-token"; renderHook(() => useWebSocket(workspaceId, token)); expect(io).toHaveBeenCalledWith(expect.any(String), { auth: { token }, query: { workspaceId }, withCredentials: true, }); }); it("should disconnect on unmount", (): void => { const { unmount } = renderHook(() => useWebSocket("workspace-123", "token")); unmount(); expect(mockSocket.disconnect).toHaveBeenCalled(); }); it("should update connection status on connect event", async (): Promise => { mockSocket.connected = false; const { result } = renderHook(() => useWebSocket("workspace-123", "token")); expect(result.current.isConnected).toBe(false); act(() => { mockSocket.connected = true; eventHandlers.connect?.(undefined); }); await waitFor(() => { expect(result.current.isConnected).toBe(true); }); }); it("should update connection status on disconnect event", async (): Promise => { mockSocket.connected = true; const { result } = renderHook(() => useWebSocket("workspace-123", "token")); act(() => { eventHandlers.connect?.(undefined); }); await waitFor(() => { expect(result.current.isConnected).toBe(true); }); act(() => { mockSocket.connected = false; eventHandlers.disconnect?.(undefined); }); await waitFor(() => { expect(result.current.isConnected).toBe(false); }); }); it("should handle task:created events", async (): Promise => { const onTaskCreated = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onTaskCreated })); const task = { id: "task-1", title: "New Task" }; act(() => { eventHandlers["task:created"]?.(task); }); await waitFor(() => { expect(onTaskCreated).toHaveBeenCalledWith(task); }); }); it("should handle task:updated events", async (): Promise => { const onTaskUpdated = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onTaskUpdated })); const task = { id: "task-1", title: "Updated Task" }; act(() => { eventHandlers["task:updated"]?.(task); }); await waitFor(() => { expect(onTaskUpdated).toHaveBeenCalledWith(task); }); }); it("should handle task:deleted events", async (): Promise => { const onTaskDeleted = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onTaskDeleted })); const payload = { id: "task-1" }; act(() => { eventHandlers["task:deleted"]?.(payload); }); await waitFor(() => { expect(onTaskDeleted).toHaveBeenCalledWith(payload); }); }); it("should handle event:created events", async (): Promise => { const onEventCreated = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onEventCreated })); const event = { id: "event-1", title: "New Event" }; act(() => { eventHandlers["event:created"]?.(event); }); await waitFor(() => { expect(onEventCreated).toHaveBeenCalledWith(event); }); }); it("should handle event:updated events", async (): Promise => { const onEventUpdated = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onEventUpdated })); const event = { id: "event-1", title: "Updated Event" }; act(() => { eventHandlers["event:updated"]?.(event); }); await waitFor(() => { expect(onEventUpdated).toHaveBeenCalledWith(event); }); }); it("should handle event:deleted events", async (): Promise => { const onEventDeleted = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onEventDeleted })); const payload = { id: "event-1" }; act(() => { eventHandlers["event:deleted"]?.(payload); }); await waitFor(() => { expect(onEventDeleted).toHaveBeenCalledWith(payload); }); }); it("should handle project:updated events", async (): Promise => { const onProjectUpdated = vi.fn(); renderHook(() => useWebSocket("workspace-123", "token", { onProjectUpdated })); const project = { id: "project-1", name: "Updated Project" }; act(() => { eventHandlers["project:updated"]?.(project); }); await waitFor(() => { expect(onProjectUpdated).toHaveBeenCalledWith(project); }); }); it("should reconnect with new workspace ID", (): void => { const { rerender } = renderHook( ({ workspaceId }: { workspaceId: string }) => useWebSocket(workspaceId, "token"), { initialProps: { workspaceId: "workspace-1" } } ); expect(io).toHaveBeenCalledTimes(1); rerender({ workspaceId: "workspace-2" }); expect(mockSocket.disconnect).toHaveBeenCalled(); expect(io).toHaveBeenCalledTimes(2); }); describe("stale closure prevention", (): void => { it("should NOT disconnect when callback functions change", (): void => { const onTaskCreated1 = vi.fn(); const onTaskCreated2 = vi.fn(); const { rerender } = renderHook( ({ onTaskCreated }: { onTaskCreated: (task: { id: string }) => void }) => useWebSocket("workspace-123", "token", { onTaskCreated }), { initialProps: { onTaskCreated: onTaskCreated1 } } ); expect(io).toHaveBeenCalledTimes(1); expect(mockSocket.disconnect).not.toHaveBeenCalled(); // Change the callback - this should NOT cause a reconnect rerender({ onTaskCreated: onTaskCreated2 }); // Socket should NOT have been disconnected or reconnected expect(mockSocket.disconnect).not.toHaveBeenCalled(); expect(io).toHaveBeenCalledTimes(1); }); it("should use the latest callback after callback change", async (): Promise => { const onTaskCreated1 = vi.fn(); const onTaskCreated2 = vi.fn(); const { rerender } = renderHook( ({ onTaskCreated }: { onTaskCreated: (task: { id: string }) => void }) => useWebSocket("workspace-123", "token", { onTaskCreated }), { initialProps: { onTaskCreated: onTaskCreated1 } } ); // Emit event with first callback const task1 = { id: "task-1" }; act(() => { eventHandlers["task:created"]?.(task1); }); await waitFor(() => { expect(onTaskCreated1).toHaveBeenCalledWith(task1); expect(onTaskCreated2).not.toHaveBeenCalled(); }); // Update to new callback rerender({ onTaskCreated: onTaskCreated2 }); // Emit another event - should use the new callback const task2 = { id: "task-2" }; act(() => { eventHandlers["task:created"]?.(task2); }); await waitFor(() => { expect(onTaskCreated2).toHaveBeenCalledWith(task2); // First callback should only have been called once (with task1) expect(onTaskCreated1).toHaveBeenCalledTimes(1); }); }); it("should NOT disconnect when multiple callbacks change simultaneously", (): void => { const callbacks1 = { onTaskCreated: vi.fn(), onTaskUpdated: vi.fn(), onEventCreated: vi.fn(), }; const callbacks2 = { onTaskCreated: vi.fn(), onTaskUpdated: vi.fn(), onEventCreated: vi.fn(), }; interface CallbackProps { onTaskCreated: (task: { id: string }) => void; onTaskUpdated: (task: { id: string }) => void; onEventCreated: (event: { id: string }) => void; } const { rerender } = renderHook( (props: CallbackProps) => useWebSocket("workspace-123", "token", props), { initialProps: callbacks1 } ); expect(io).toHaveBeenCalledTimes(1); // Change all callbacks at once - should NOT cause reconnect rerender(callbacks2); expect(mockSocket.disconnect).not.toHaveBeenCalled(); expect(io).toHaveBeenCalledTimes(1); }); it("should handle callback being removed without reconnect", (): void => { const onTaskCreated = vi.fn(); interface CallbackProps { onTaskCreated?: (task: { id: string }) => void; } const { rerender } = renderHook( (props: CallbackProps) => useWebSocket("workspace-123", "token", props), { initialProps: { onTaskCreated } as CallbackProps } ); expect(io).toHaveBeenCalledTimes(1); // Remove callback - should NOT cause reconnect rerender({}); expect(mockSocket.disconnect).not.toHaveBeenCalled(); expect(io).toHaveBeenCalledTimes(1); }); it("should handle callback being added without reconnect", (): void => { const onTaskCreated = vi.fn(); interface CallbackProps { onTaskCreated?: (task: { id: string }) => void; } const { rerender } = renderHook( (props: CallbackProps) => useWebSocket("workspace-123", "token", props), { initialProps: {} as CallbackProps } ); expect(io).toHaveBeenCalledTimes(1); // Add callback - should NOT cause reconnect rerender({ onTaskCreated }); expect(mockSocket.disconnect).not.toHaveBeenCalled(); expect(io).toHaveBeenCalledTimes(1); }); }); it("should clean up all event listeners on unmount", (): void => { const { unmount } = renderHook(() => useWebSocket("workspace-123", "token", { onTaskCreated: vi.fn(), onTaskUpdated: vi.fn(), onTaskDeleted: vi.fn(), }) ); unmount(); expect(mockSocket.off).toHaveBeenCalledWith("connect", expect.any(Function)); expect(mockSocket.off).toHaveBeenCalledWith("disconnect", expect.any(Function)); expect(mockSocket.off).toHaveBeenCalledWith("task:created", expect.any(Function)); expect(mockSocket.off).toHaveBeenCalledWith("task:updated", expect.any(Function)); expect(mockSocket.off).toHaveBeenCalledWith("task:deleted", expect.any(Function)); }); describe("connect_error handling", (): void => { it("should handle connect_error events and expose error state", async (): Promise => { const { result } = renderHook(() => useWebSocket("workspace-123", "token")); expect(result.current.connectionError).toBeNull(); const error = new Error("Connection refused"); act(() => { eventHandlers.connect_error?.(error); }); await waitFor(() => { expect(result.current.connectionError).toEqual({ message: "Connection refused", type: "connect_error", description: "Failed to establish WebSocket connection", }); expect(result.current.isConnected).toBe(false); }); }); it("should handle connect_error with missing message", async (): Promise => { const { result } = renderHook(() => useWebSocket("workspace-123", "token")); const error = new Error(); act(() => { eventHandlers.connect_error?.(error); }); await waitFor(() => { expect(result.current.connectionError).toEqual({ message: "Connection failed", type: "connect_error", description: "Failed to establish WebSocket connection", }); }); }); it("should clear connection error on reconnect", async (): Promise => { const { result, rerender } = renderHook( ({ workspaceId }: { workspaceId: string }) => useWebSocket(workspaceId, "token"), { initialProps: { workspaceId: "workspace-1" } } ); // Simulate connect error act(() => { eventHandlers.connect_error?.(new Error("Connection failed")); }); await waitFor(() => { expect(result.current.connectionError).not.toBeNull(); }); // Rerender with new workspace to trigger reconnect rerender({ workspaceId: "workspace-2" }); // Connection error should be cleared when attempting new connection await waitFor(() => { expect(result.current.connectionError).toBeNull(); }); }); it("should register connect_error handler on socket", (): void => { renderHook(() => useWebSocket("workspace-123", "token")); expect(mockSocket.on).toHaveBeenCalledWith("connect_error", expect.any(Function)); }); it("should clean up connect_error handler on unmount", (): void => { const { unmount } = renderHook(() => useWebSocket("workspace-123", "token")); unmount(); expect(mockSocket.off).toHaveBeenCalledWith("connect_error", expect.any(Function)); }); }); describe("WSS enforcement", (): void => { afterEach((): void => { vi.unstubAllEnvs(); vi.resetModules(); }); it("should warn when using ws:// in production", async (): Promise => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.stubEnv("NODE_ENV", "production"); // Mock the config module to return insecure URL vi.doMock("@/lib/config", () => ({ API_BASE_URL: "http://insecure-server.com", })); // Re-import to get fresh module with mocked config const { useWebSocket: useWebSocketMocked } = await import("./useWebSocket"); renderHook(() => useWebSocketMocked("workspace-123", "token")); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("[Security Warning]")); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining("insecure protocol")); consoleWarnSpy.mockRestore(); }); it("should not warn when using https:// in production", async (): Promise => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.stubEnv("NODE_ENV", "production"); // Mock the config module to return secure URL vi.doMock("@/lib/config", () => ({ API_BASE_URL: "https://secure-server.com", })); // Re-import to get fresh module with mocked config const { useWebSocket: useWebSocketMocked } = await import("./useWebSocket"); renderHook(() => useWebSocketMocked("workspace-123", "token")); expect(consoleWarnSpy).not.toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); it("should not warn when using wss:// in production", async (): Promise => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.stubEnv("NODE_ENV", "production"); // Mock the config module to return secure WSS URL vi.doMock("@/lib/config", () => ({ API_BASE_URL: "wss://secure-server.com", })); // Re-import to get fresh module with mocked config const { useWebSocket: useWebSocketMocked } = await import("./useWebSocket"); renderHook(() => useWebSocketMocked("workspace-123", "token")); expect(consoleWarnSpy).not.toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); it("should not warn in development mode even with http://", async (): Promise => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.stubEnv("NODE_ENV", "development"); // Mock the config module to return insecure URL (but we're in dev mode) vi.doMock("@/lib/config", () => ({ API_BASE_URL: "http://localhost:3001", })); // Re-import to get fresh module with mocked config const { useWebSocket: useWebSocketMocked } = await import("./useWebSocket"); renderHook(() => useWebSocketMocked("workspace-123", "token")); expect(consoleWarnSpy).not.toHaveBeenCalled(); consoleWarnSpy.mockRestore(); }); }); });