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 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.");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user