From 8484e060d7a25506ef9e9ff8f571603931f9525f Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 4 Mar 2026 18:14:14 -0600 Subject: [PATCH] test(web): update useChat tests for streaming-only implementation --- apps/web/src/hooks/useChat.test.ts | 249 +++-------------------------- 1 file changed, 21 insertions(+), 228 deletions(-) diff --git a/apps/web/src/hooks/useChat.test.ts b/apps/web/src/hooks/useChat.test.ts index 3c37b56..fcb3d72 100644 --- a/apps/web/src/hooks/useChat.test.ts +++ b/apps/web/src/hooks/useChat.test.ts @@ -9,7 +9,6 @@ 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", () => ({ @@ -37,24 +36,8 @@ const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction< const mockCreateConversation = ideasApi.createConversation as MockedFunction< typeof ideasApi.createConversation >; -const mockUpdateConversation = ideasApi.updateConversation as MockedFunction< - typeof ideasApi.updateConversation ->; 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 */ @@ -76,9 +59,9 @@ function createMockIdea(id: string, title: string, content: string): Idea { /** * Configure streamChatMessage to immediately fail, - * triggering the fallback to sendChatMessage. + * without using a non-streaming fallback. */ -function makeStreamFail(): void { +function makeStreamFail(error: Error = new Error("Streaming not available")): void { mockStreamChatMessage.mockImplementation( ( _request, @@ -88,7 +71,7 @@ function makeStreamFail(): void { _signal?: AbortSignal ): void => { // Call synchronously so the Promise rejects immediately - onError(new Error("Streaming not available")); + onError(error); } ); } @@ -155,24 +138,7 @@ describe("useChat", () => { }); }); - describe("sendMessage (fallback path when streaming fails)", () => { - it("should add user message and assistant response via fallback", 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!"); - }); - + describe("sendMessage (streaming failure path)", () => { it("should not send empty messages", async () => { const { result } = renderHook(() => useChat()); @@ -186,22 +152,19 @@ describe("useChat", () => { expect(result.current.messages).toHaveLength(1); // only welcome }); - it("should handle API errors gracefully", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); + it("should handle streaming errors gracefully", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce(new Error("API Error")); + makeStreamFail(new Error("Streaming not available")); - const onError = vi.fn(); - const { result } = renderHook(() => useChat({ onError })); + const { result } = renderHook(() => useChat()); await act(async () => { await result.current.sendMessage("Hello"); }); - expect(result.current.error).toBe("Unable to send message. Please try again."); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); + // Streaming fails, no fallback, placeholder is removed + expect(result.current.error).toContain("Chat error:"); + expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant) }); }); @@ -588,9 +551,8 @@ describe("useChat", () => { describe("clearError", () => { it("should clear error state", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce(new Error("Test error")); + makeStreamFail(new Error("Test error")); const { result } = renderHook(() => useChat()); @@ -598,7 +560,7 @@ describe("useChat", () => { await result.current.sendMessage("Hello"); }); - expect(result.current.error).toBe("Unable to send message. Please try again."); + expect(result.current.error).toContain("Chat error:"); act(() => { result.current.clearError(); @@ -608,87 +570,14 @@ describe("useChat", () => { }); }); - describe("error context logging", () => { - it("should log comprehensive error context when sendMessage fails", async () => { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce(new Error("LLM timeout")); - - const { result } = renderHook(() => useChat({ model: "llama3.2" })); - - await act(async () => { - await result.current.sendMessage("Hello world"); - }); - - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to send chat message", - expect.objectContaining({ - errorType: "LLM_ERROR", - messageLength: 11, - messagePreview: "Hello world", - model: "llama3.2", - timestamp: expect.any(String) as string, - }) - ); - }); - - it("should truncate long message previews to 50 characters", async () => { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce(new Error("Failed")); - - const longMessage = "A".repeat(100); - const { result } = renderHook(() => useChat()); - - await act(async () => { - await result.current.sendMessage(longMessage); - }); - - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to send chat message", - expect.objectContaining({ - messagePreview: "A".repeat(50), - messageLength: 100, - }) - ); - }); - - it("should include message count in error context", async () => { - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - - // First successful message via streaming - makeStreamSucceed(["OK"]); - mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); - - const { result } = renderHook(() => useChat()); - - await act(async () => { - await result.current.sendMessage("First"); - }); - - // Second message: streaming fails, fallback fails - makeStreamFail(); - mockSendChatMessage.mockRejectedValueOnce(new Error("Fail")); - - await act(async () => { - await result.current.sendMessage("Second"); - }); - - expect(consoleSpy).toHaveBeenCalledWith( - "Failed to send chat message", - expect.objectContaining({ - messageCount: expect.any(Number) as number, - }) - ); - }); - }); + // Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type + // was removed in commit 44da50d when guest fallback mode was removed. + // The implementation now uses simple console.warn for streaming failures. describe("LLM vs persistence error separation", () => { - it("should show LLM error and add error message to chat when API fails", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); + it("should show streaming error when stream fails", async () => { vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available")); + makeStreamFail(new Error("Streaming not available")); const { result } = renderHook(() => useChat()); @@ -696,9 +585,9 @@ describe("useChat", () => { await result.current.sendMessage("Hello"); }); - expect(result.current.error).toBe("Unable to send message. Please try again."); - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); + // Streaming fails, placeholder is removed, error is set + expect(result.current.error).toContain("Chat error:"); + expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant) }); it("should keep assistant message visible when save fails (streaming path)", async () => { @@ -717,27 +606,10 @@ describe("useChat", () => { expect(result.current.error).toContain("Message sent but failed to save"); }); - it("should keep assistant message visible when save fails (fallback path)", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Great answer!")); - mockCreateConversation.mockRejectedValueOnce(new Error("Database connection lost")); - - const { result } = renderHook(() => useChat()); - - await act(async () => { - await result.current.sendMessage("Hello"); - }); - - expect(result.current.messages).toHaveLength(3); - expect(result.current.messages[2]?.content).toBe("Great answer!"); - expect(result.current.error).toContain("Message sent but failed to save"); - }); - it("should log with PERSISTENCE_ERROR type when save fails", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response")); + makeStreamSucceed(["Response"]); mockCreateConversation.mockRejectedValueOnce(new Error("DB error")); const { result } = renderHook(() => useChat()); @@ -765,53 +637,6 @@ describe("useChat", () => { expect(llmErrorCalls).toHaveLength(0); }); - it("should use different user-facing messages for LLM vs save errors", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - - // LLM error path (streaming fails + fallback fails) - mockSendChatMessage.mockRejectedValueOnce(new Error("Timeout")); - const { result: result1 } = renderHook(() => useChat()); - - await act(async () => { - await result1.current.sendMessage("Test"); - }); - - const llmError = result1.current.error; - - // Save error path (streaming succeeds, save fails) - makeStreamSucceed(["OK"]); - mockCreateConversation.mockRejectedValueOnce(new Error("DB down")); - const { result: result2 } = renderHook(() => useChat()); - - await act(async () => { - await result2.current.sendMessage("Test"); - }); - - const saveError = result2.current.error; - - expect(llmError).toBe("Unable to send message. Please try again."); - expect(saveError).toContain("Message sent but failed to save"); - expect(llmError).not.toEqual(saveError); - }); - - it("should handle non-Error throws from LLM API", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - mockSendChatMessage.mockRejectedValueOnce("string error"); - - const onError = vi.fn(); - const { result } = renderHook(() => useChat({ onError })); - - await act(async () => { - await result.current.sendMessage("Hello"); - }); - - expect(result.current.error).toBe("Unable to send message. Please try again."); - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); - }); - it("should handle non-Error throws from persistence layer", async () => { vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined); @@ -829,37 +654,5 @@ describe("useChat", () => { expect(result.current.error).toBe("Message sent but failed to save. Please try again."); expect(onError).toHaveBeenCalledWith(expect.any(Error)); }); - - it("should handle updateConversation failure for existing conversations", async () => { - vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(console, "warn").mockImplementation(() => undefined); - - // First message via fallback - mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("First response")); - mockCreateConversation.mockResolvedValueOnce(createMockIdea("conv-1", "Test", "")); - - const { result } = renderHook(() => useChat()); - - await act(async () => { - await result.current.sendMessage("First"); - }); - - expect(result.current.conversationId).toBe("conv-1"); - - // Second message via fallback, updateConversation fails - makeStreamFail(); - mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Second response")); - mockUpdateConversation.mockRejectedValueOnce(new Error("Connection reset")); - - await act(async () => { - await result.current.sendMessage("Second"); - }); - - const assistantMessages = result.current.messages.filter( - (m) => m.role === "assistant" && m.id !== "welcome" - ); - expect(assistantMessages[assistantMessages.length - 1]?.content).toBe("Second response"); - expect(result.current.error).toBe("Message sent but failed to save. Please try again."); - }); }); });