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
Some checks failed
ci/woodpecker/push/ci Pipeline failed
This commit was merged in pull request #674.
This commit is contained in:
@@ -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<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
|
||||
*/
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user