/** * @file useChat.test.ts * @description Tests for the useChat hook that manages chat state and LLM interactions */ import { renderHook, act } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest"; import { useChat, type Message } from "./useChat"; import * as chatApi from "@/lib/api/chat"; import * as ideasApi from "@/lib/api/ideas"; import type { Idea } from "@/lib/api/ideas"; import type { ChatResponse } from "@/lib/api/chat"; // Mock the API modules - use importOriginal to preserve types/enums vi.mock("@/lib/api/chat", () => ({ sendChatMessage: vi.fn(), })); vi.mock("@/lib/api/ideas", async (importOriginal) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports const actual = await importOriginal(); return { ...actual, createConversation: vi.fn(), updateConversation: vi.fn(), getIdea: vi.fn(), }; }); const mockSendChatMessage = chatApi.sendChatMessage as MockedFunction< typeof chatApi.sendChatMessage >; const mockCreateConversation = ideasApi.createConversation as MockedFunction< typeof ideasApi.createConversation >; const mockGetIdea = ideasApi.getIdea as MockedFunction; /** * Creates a mock ChatResponse */ function createMockChatResponse(content: string, model = "llama3.2"): ChatResponse { return { message: { role: "assistant" as const, content }, model, done: true, promptEvalCount: 10, evalCount: 5, }; } /** * Creates a mock Idea */ function createMockIdea(id: string, title: string, content: string): Idea { return { id, workspaceId: "workspace-1", title, content, status: "CAPTURED", priority: "medium", tags: ["chat"], metadata: { conversationType: "chat" }, creatorId: "user-1", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } as Idea; } describe("useChat", () => { beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); }); describe("initial state", () => { it("should initialize with welcome message", () => { const { result } = renderHook(() => useChat()); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.role).toBe("assistant"); expect(result.current.messages[0]?.id).toBe("welcome"); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); expect(result.current.conversationId).toBeNull(); }); }); describe("sendMessage", () => { it("should add user message and assistant response", async () => { mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Hello there!")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); const { result } = renderHook(() => useChat()); await act(async () => { await result.current.sendMessage("Hello"); }); expect(result.current.messages).toHaveLength(3); // welcome + user + assistant expect(result.current.messages[1]?.role).toBe("user"); expect(result.current.messages[1]?.content).toBe("Hello"); expect(result.current.messages[2]?.role).toBe("assistant"); expect(result.current.messages[2]?.content).toBe("Hello there!"); }); it("should not send empty messages", async () => { const { result } = renderHook(() => useChat()); await act(async () => { await result.current.sendMessage(""); await result.current.sendMessage(" "); }); expect(mockSendChatMessage).not.toHaveBeenCalled(); expect(result.current.messages).toHaveLength(1); // only welcome }); it("should not send while loading", async () => { let resolveFirst: ((value: ChatResponse) => void) | undefined; const firstPromise = new Promise((resolve) => { resolveFirst = resolve; }); mockSendChatMessage.mockReturnValueOnce(firstPromise); const { result } = renderHook(() => useChat()); // Start first message act(() => { void result.current.sendMessage("First"); }); expect(result.current.isLoading).toBe(true); // Try to send second while loading await act(async () => { await result.current.sendMessage("Second"); }); // Should only have one call expect(mockSendChatMessage).toHaveBeenCalledTimes(1); // Cleanup - resolve the pending promise mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); await act(async () => { if (resolveFirst) { resolveFirst(createMockChatResponse("Response")); } // Allow promise to settle await Promise.resolve(); }); }); it("should handle API errors gracefully", async () => { mockSendChatMessage.mockRejectedValueOnce(new Error("API Error")); const onError = vi.fn(); const { result } = renderHook(() => useChat({ onError })); await act(async () => { await result.current.sendMessage("Hello"); }); expect(result.current.error).toBe("API Error"); expect(onError).toHaveBeenCalledWith(expect.any(Error)); // Should have welcome + user + error message expect(result.current.messages).toHaveLength(3); expect(result.current.messages[2]?.content).toContain("Error: API Error"); }); }); describe("rapid sends - stale closure prevention", () => { it("should not lose messages on rapid sequential sends", async () => { // This test verifies that functional state updates prevent message loss // when multiple messages are sent in quick succession let callCount = 0; mockSendChatMessage.mockImplementation(async (): Promise => { callCount++; // Small delay to simulate network await Promise.resolve(); return createMockChatResponse(`Response ${String(callCount)}`); }); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); const { result } = renderHook(() => useChat()); // Send first message await act(async () => { await result.current.sendMessage("Message 1"); }); // Verify first message cycle complete expect(result.current.messages).toHaveLength(3); // welcome + user1 + assistant1 // Send second message await act(async () => { await result.current.sendMessage("Message 2"); }); // Verify all messages are present (no data loss) expect(result.current.messages).toHaveLength(5); // welcome + user1 + assistant1 + user2 + assistant2 // Verify message order and content const userMessages = result.current.messages.filter((m) => m.role === "user"); expect(userMessages).toHaveLength(2); expect(userMessages[0]?.content).toBe("Message 1"); expect(userMessages[1]?.content).toBe("Message 2"); }); it("should use functional updates for all message state changes", async () => { // This test verifies that the implementation uses functional updates // by checking that messages accumulate correctly mockSendChatMessage.mockResolvedValue(createMockChatResponse("Response")); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); const { result } = renderHook(() => useChat()); // Track message count after each operation const messageCounts: number[] = []; await act(async () => { await result.current.sendMessage("Test 1"); }); messageCounts.push(result.current.messages.length); await act(async () => { await result.current.sendMessage("Test 2"); }); messageCounts.push(result.current.messages.length); await act(async () => { await result.current.sendMessage("Test 3"); }); messageCounts.push(result.current.messages.length); // Should accumulate: 3, 5, 7 (welcome + pairs of user/assistant) expect(messageCounts).toEqual([3, 5, 7]); // Verify final state has all messages expect(result.current.messages).toHaveLength(7); const userMessages = result.current.messages.filter((m) => m.role === "user"); expect(userMessages).toHaveLength(3); }); it("should maintain correct message order with ref-based state tracking", async () => { // This test verifies that messagesRef is properly synchronized const responses = ["First response", "Second response", "Third response"]; let responseIndex = 0; mockSendChatMessage.mockImplementation((): Promise => { const response = responses[responseIndex++]; return Promise.resolve(createMockChatResponse(response ?? "")); }); mockCreateConversation.mockResolvedValue(createMockIdea("conv-1", "Test", "")); const { result } = renderHook(() => useChat()); await act(async () => { await result.current.sendMessage("Query 1"); }); await act(async () => { await result.current.sendMessage("Query 2"); }); await act(async () => { await result.current.sendMessage("Query 3"); }); // Verify messages are in correct order const messages = result.current.messages; expect(messages[0]?.id).toBe("welcome"); expect(messages[1]?.content).toBe("Query 1"); expect(messages[2]?.content).toBe("First response"); expect(messages[3]?.content).toBe("Query 2"); expect(messages[4]?.content).toBe("Second response"); expect(messages[5]?.content).toBe("Query 3"); expect(messages[6]?.content).toBe("Third response"); }); }); describe("loadConversation", () => { it("should load conversation from backend", async () => { const savedMessages: Message[] = [ { id: "msg-1", role: "user", content: "Saved message", createdAt: new Date().toISOString(), }, { id: "msg-2", role: "assistant", content: "Saved response", createdAt: new Date().toISOString(), }, ]; mockGetIdea.mockResolvedValueOnce( createMockIdea("conv-123", "My Conversation", JSON.stringify(savedMessages)) ); const { result } = renderHook(() => useChat()); await act(async () => { await result.current.loadConversation("conv-123"); }); expect(result.current.messages).toHaveLength(2); expect(result.current.messages[0]?.content).toBe("Saved message"); expect(result.current.conversationId).toBe("conv-123"); expect(result.current.conversationTitle).toBe("My Conversation"); }); }); describe("startNewConversation", () => { it("should reset to initial state", async () => { mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response")); mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); const { result } = renderHook(() => useChat()); // Send a message to have some state await act(async () => { await result.current.sendMessage("Hello"); }); expect(result.current.messages.length).toBeGreaterThan(1); // Start new conversation act(() => { result.current.startNewConversation(); }); expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0]?.id).toBe("welcome"); expect(result.current.conversationId).toBeNull(); expect(result.current.conversationTitle).toBeNull(); }); }); describe("clearError", () => { it("should clear error state", async () => { mockSendChatMessage.mockRejectedValueOnce(new Error("Test error")); const { result } = renderHook(() => useChat()); await act(async () => { await result.current.sendMessage("Hello"); }); expect(result.current.error).toBe("Test error"); act(() => { result.current.clearError(); }); expect(result.current.error).toBeNull(); }); }); });