All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
538 lines
16 KiB
TypeScript
538 lines
16 KiB
TypeScript
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<Socket>;
|
|
let eventHandlers: Record<string, (data: unknown) => 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<typeof vi.fn>).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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<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 } }
|
|
);
|
|
|
|
// 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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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();
|
|
});
|
|
});
|
|
});
|