/** * @file Chat.test.tsx * @description Tests for Chat component error handling in imperative handle methods */ import { createRef } from "react"; import { render, fireEvent, waitFor } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest"; import { Chat, type ChatRef } from "./Chat"; import * as useChatModule from "@/hooks/useChat"; import * as useWebSocketModule from "@/hooks/useWebSocket"; import * as authModule from "@/lib/auth/auth-context"; import * as orchestratorModule from "@/hooks/useOrchestratorCommands"; // Mock scrollIntoView (not available in JSDOM) Element.prototype.scrollIntoView = vi.fn(); // Mock dependencies vi.mock("@/lib/auth/auth-context", () => ({ useAuth: vi.fn(), })); vi.mock("@/hooks/useChat", () => ({ useChat: vi.fn(), })); vi.mock("@/hooks/useWebSocket", () => ({ useWebSocket: vi.fn(), })); vi.mock("./MessageList", () => ({ MessageList: (): React.ReactElement =>
, })); vi.mock("./ChatInput", () => ({ ChatInput: ({ onSend, }: { onSend: (content: string) => Promise; disabled: boolean; inputRef: React.RefObject; }): React.ReactElement => ( <> ), DEFAULT_TEMPERATURE: 0.7, DEFAULT_MAX_TOKENS: 4096, DEFAULT_MODEL: "llama3.2", AVAILABLE_MODELS: [ { id: "llama3.2", label: "Llama 3.2" }, { id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" }, { id: "gpt-4o", label: "GPT-4o" }, { id: "deepseek-r1", label: "DeepSeek R1" }, ], })); vi.mock("@/hooks/useOrchestratorCommands", () => ({ useOrchestratorCommands: vi.fn(), })); const mockUseAuth = authModule.useAuth as MockedFunction; const mockUseChat = useChatModule.useChat as MockedFunction; const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction< typeof useWebSocketModule.useWebSocket >; const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction< typeof orchestratorModule.useOrchestratorCommands >; function createMockUseChatReturn( overrides: Partial = {} ): useChatModule.UseChatReturn { return { messages: [ { id: "welcome", role: "assistant", content: "Hello!", createdAt: new Date().toISOString(), }, ], isLoading: false, isStreaming: false, error: null, conversationId: null, conversationTitle: null, sendMessage: vi.fn().mockResolvedValue(undefined), abortStream: vi.fn(), loadConversation: vi.fn().mockResolvedValue(undefined), startNewConversation: vi.fn(), setMessages: vi.fn(), clearError: vi.fn(), ...overrides, }; } describe("Chat", () => { let consoleSpy: ReturnType; beforeEach(() => { vi.clearAllMocks(); consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); mockUseAuth.mockReturnValue({ user: { id: "user-1", name: "Test User", email: "test@test.com" }, isAuthenticated: true, isLoading: false, login: vi.fn(), logout: vi.fn(), } as unknown as ReturnType); mockUseWebSocket.mockReturnValue({ isConnected: true, socket: null, connectionError: null, }); // Default: no commands intercepted mockUseOrchestratorCommands.mockReturnValue({ isCommand: vi.fn().mockReturnValue(false), executeCommand: vi.fn().mockResolvedValue(null), }); }); afterEach(() => { consoleSpy.mockRestore(); }); describe("loadConversation via ref", () => { it("should delegate to useChat.loadConversation", async () => { const mockLoadConversation = vi.fn().mockResolvedValue(undefined); mockUseChat.mockReturnValue( createMockUseChatReturn({ loadConversation: mockLoadConversation }) ); const ref = createRef(); render(); await ref.current?.loadConversation("conv-123"); expect(mockLoadConversation).toHaveBeenCalledWith("conv-123"); }); it("should not re-throw when useChat.loadConversation handles errors internally", async () => { // useChat.loadConversation handles errors internally (sets error state, logs, calls onError) // and does NOT re-throw, so the imperative handle should resolve cleanly const mockLoadConversation = vi.fn().mockResolvedValue(undefined); mockUseChat.mockReturnValue( createMockUseChatReturn({ loadConversation: mockLoadConversation }) ); const ref = createRef(); render(); // Should resolve without throwing await expect(ref.current?.loadConversation("conv-123")).resolves.toBeUndefined(); }); }); describe("sendMessage delegation", () => { it("should delegate to useChat.sendMessage", async () => { const mockSendMessage = vi.fn().mockResolvedValue(undefined); mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage })); const ref = createRef(); const { getByTestId } = render(); const sendButton = getByTestId("chat-input"); sendButton.click(); await vi.waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith("test message"); }); }); }); describe("orchestrator command routing", () => { it("routes command messages through orchestrator instead of LLM", async () => { const mockSendMessage = vi.fn().mockResolvedValue(undefined); const mockSetMessages = vi.fn(); const mockExecuteCommand = vi.fn().mockResolvedValue({ id: "orch-123", role: "assistant" as const, content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |", createdAt: new Date().toISOString(), }); mockUseChat.mockReturnValue( createMockUseChatReturn({ sendMessage: mockSendMessage, setMessages: mockSetMessages, }) ); mockUseOrchestratorCommands.mockReturnValue({ isCommand: (content: string) => content.trim().startsWith("/"), executeCommand: mockExecuteCommand, }); const { getByTestId } = render(); const commandButton = getByTestId("chat-input-command"); fireEvent.click(commandButton); await waitFor(() => { // executeCommand should have been called with the slash command expect(mockExecuteCommand).toHaveBeenCalledWith("/status"); }); // sendMessage should NOT have been called expect(mockSendMessage).not.toHaveBeenCalled(); // setMessages should have been called to add user and assistant messages await waitFor(() => { expect(mockSetMessages).toHaveBeenCalledTimes(2); }); }); it("does not call orchestrator for regular messages", async () => { const mockSendMessage = vi.fn().mockResolvedValue(undefined); const mockExecuteCommand = vi.fn().mockResolvedValue(null); mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage })); mockUseOrchestratorCommands.mockReturnValue({ isCommand: vi.fn().mockReturnValue(false), executeCommand: mockExecuteCommand, }); const { getByTestId } = render(); fireEvent.click(getByTestId("chat-input")); await waitFor(() => { expect(mockSendMessage).toHaveBeenCalledWith("test message"); }); expect(mockExecuteCommand).not.toHaveBeenCalled(); }); it("still adds user message to chat for commands", async () => { const mockSetMessages = vi.fn(); const mockExecuteCommand = vi.fn().mockResolvedValue({ id: "orch-456", role: "assistant" as const, content: "Help content", createdAt: new Date().toISOString(), }); mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages })); mockUseOrchestratorCommands.mockReturnValue({ isCommand: (content: string) => content.trim().startsWith("/"), executeCommand: mockExecuteCommand, }); const { getByTestId } = render(); fireEvent.click(getByTestId("chat-input-command")); await waitFor(() => { expect(mockSetMessages).toHaveBeenCalled(); }); // First setMessages call should add the user message const firstCall = mockSetMessages.mock.calls[0]; if (!firstCall) throw new Error("Expected setMessages to have been called"); const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[]; const result = updater([]); expect(result).toHaveLength(1); expect(result[0]).toMatchObject({ role: "user", content: "/status", }); }); }); });