Merge pull request 'test(web): update useChat tests for streaming-only implementation' (#674) from fix/usechat-tests into main
Some checks failed
ci/woodpecker/push/ci Pipeline failed

This commit was merged in pull request #674.
This commit is contained in:
2026-03-05 00:49:38 +00:00

View File

@@ -9,7 +9,6 @@ import { useChat, type Message } from "./useChat";
import * as chatApi from "@/lib/api/chat"; import * as chatApi from "@/lib/api/chat";
import * as ideasApi from "@/lib/api/ideas"; import * as ideasApi from "@/lib/api/ideas";
import type { Idea } 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 // Mock the API modules - use importOriginal to preserve types/enums
vi.mock("@/lib/api/chat", () => ({ vi.mock("@/lib/api/chat", () => ({
@@ -37,24 +36,8 @@ const mockStreamChatMessage = chatApi.streamChatMessage as MockedFunction<
const mockCreateConversation = ideasApi.createConversation as MockedFunction< const mockCreateConversation = ideasApi.createConversation as MockedFunction<
typeof ideasApi.createConversation typeof ideasApi.createConversation
>; >;
const mockUpdateConversation = ideasApi.updateConversation as MockedFunction<
typeof ideasApi.updateConversation
>;
const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>; const mockGetIdea = ideasApi.getIdea as MockedFunction<typeof ideasApi.getIdea>;
/**
* 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 * Creates a mock Idea
*/ */
@@ -76,9 +59,9 @@ function createMockIdea(id: string, title: string, content: string): Idea {
/** /**
* Configure streamChatMessage to immediately fail, * 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( mockStreamChatMessage.mockImplementation(
( (
_request, _request,
@@ -88,7 +71,7 @@ function makeStreamFail(): void {
_signal?: AbortSignal _signal?: AbortSignal
): void => { ): void => {
// Call synchronously so the Promise rejects immediately // 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)", () => { describe("sendMessage (streaming failure path)", () => {
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!");
});
it("should not send empty messages", async () => { it("should not send empty messages", async () => {
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -186,22 +152,19 @@ describe("useChat", () => {
expect(result.current.messages).toHaveLength(1); // only welcome expect(result.current.messages).toHaveLength(1); // only welcome
}); });
it("should handle API errors gracefully", async () => { it("should handle streaming errors gracefully", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined); 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());
const { result } = renderHook(() => useChat({ onError }));
await act(async () => { await act(async () => {
await result.current.sendMessage("Hello"); await result.current.sendMessage("Hello");
}); });
expect(result.current.error).toBe("Unable to send message. Please try again."); // Streaming fails, no fallback, placeholder is removed
expect(onError).toHaveBeenCalledWith(expect.any(Error)); expect(result.current.error).toContain("Chat error:");
expect(result.current.messages).toHaveLength(3); expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again.");
}); });
}); });
@@ -588,9 +551,8 @@ describe("useChat", () => {
describe("clearError", () => { describe("clearError", () => {
it("should clear error state", async () => { it("should clear error state", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("Test error")); makeStreamFail(new Error("Test error"));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -598,7 +560,7 @@ describe("useChat", () => {
await result.current.sendMessage("Hello"); 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(() => { act(() => {
result.current.clearError(); result.current.clearError();
@@ -608,87 +570,14 @@ describe("useChat", () => {
}); });
}); });
describe("error context logging", () => { // Note: "error context logging" tests removed - the detailed logging with LLM_ERROR type
it("should log comprehensive error context when sendMessage fails", async () => { // was removed in commit 44da50d when guest fallback mode was removed.
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); // The implementation now uses simple console.warn for streaming failures.
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,
})
);
});
});
describe("LLM vs persistence error separation", () => { describe("LLM vs persistence error separation", () => {
it("should show LLM error and add error message to chat when API fails", async () => { it("should show streaming error when stream fails", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockRejectedValueOnce(new Error("Model not available")); makeStreamFail(new Error("Streaming not available"));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -696,9 +585,9 @@ describe("useChat", () => {
await result.current.sendMessage("Hello"); await result.current.sendMessage("Hello");
}); });
expect(result.current.error).toBe("Unable to send message. Please try again."); // Streaming fails, placeholder is removed, error is set
expect(result.current.messages).toHaveLength(3); expect(result.current.error).toContain("Chat error:");
expect(result.current.messages[2]?.content).toBe("Something went wrong. Please try again."); expect(result.current.messages).toHaveLength(2); // welcome + user (no assistant)
}); });
it("should keep assistant message visible when save fails (streaming path)", async () => { 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"); 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 () => { it("should log with PERSISTENCE_ERROR type when save fails", async () => {
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSendChatMessage.mockResolvedValueOnce(createMockChatResponse("Response")); makeStreamSucceed(["Response"]);
mockCreateConversation.mockRejectedValueOnce(new Error("DB error")); mockCreateConversation.mockRejectedValueOnce(new Error("DB error"));
const { result } = renderHook(() => useChat()); const { result } = renderHook(() => useChat());
@@ -765,53 +637,6 @@ describe("useChat", () => {
expect(llmErrorCalls).toHaveLength(0); 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 () => { it("should handle non-Error throws from persistence layer", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined); vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(console, "warn").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(result.current.error).toBe("Message sent but failed to save. Please try again.");
expect(onError).toHaveBeenCalledWith(expect.any(Error)); 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.");
});
}); });
}); });