nexus/ui/src/hooks/useStreamingChat.test.ts
Nexus Dev 0d876ced26 feat(22-01): add useStreamingChat hook, chat API stream method, and unit tests
- Add postMessageAndStream and savePartialMessage to chatApi (fetch ReadableStream for POST SSE)
- Create useStreamingChat hook with startStream, stop, streamingContent, isStreaming
- startTransition wraps token updates to avoid blocking user input (PERF-02)
- AbortController used for stop functionality (CHAT-12)
- stop() saves partial content with [stopped] suffix to DB
- Add @testing-library/react devDependency to enable renderHook testing
- 5 passing unit tests: token accumulation, lifecycle, stop/abort, error, null guard
2026-04-04 03:55:47 +00:00

170 lines
4.9 KiB
TypeScript

// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement } from "react";
import { useStreamingChat } from "./useStreamingChat";
import { chatApi } from "../api/chat";
// Mock chatApi
vi.mock("../api/chat", () => ({
chatApi: {
postMessageAndStream: vi.fn(),
savePartialMessage: vi.fn().mockResolvedValue({}),
postMessage: vi.fn().mockResolvedValue({}),
},
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) =>
createElement(QueryClientProvider, { client: queryClient }, children);
}
describe("useStreamingChat", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("accumulates tokens from onToken callbacks into streamingContent", async () => {
// Mock postMessageAndStream to capture callbacks and simulate tokens
let capturedCallbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
};
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
(_convId, _data, callbacks, _signal) => {
capturedCallbacks = callbacks;
return Promise.resolve();
},
);
const { result } = renderHook(
() => useStreamingChat("conv-1"),
{ wrapper: createWrapper() },
);
// Start stream
act(() => {
result.current.startStream("hello world");
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.streamingContent).toBe("");
// Simulate tokens arriving
act(() => {
capturedCallbacks!.onToken("Hello ");
});
expect(result.current.streamingContent).toBe("Hello ");
act(() => {
capturedCallbacks!.onToken("world!");
});
expect(result.current.streamingContent).toBe("Hello world!");
});
it("sets isStreaming=true when stream starts, false on done", async () => {
let capturedCallbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
};
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
(_convId, _data, callbacks, _signal) => {
capturedCallbacks = callbacks;
return Promise.resolve();
},
);
const { result } = renderHook(
() => useStreamingChat("conv-1"),
{ wrapper: createWrapper() },
);
expect(result.current.isStreaming).toBe(false);
act(() => {
result.current.startStream("test");
});
expect(result.current.isStreaming).toBe(true);
act(() => {
capturedCallbacks!.onDone("msg-1", "test response");
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.streamingContent).toBe("");
});
it("stop() aborts the controller and sets isStreaming=false", async () => {
let capturedSignal: AbortSignal | undefined;
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
(_convId, _data, _callbacks, signal) => {
capturedSignal = signal;
return Promise.resolve();
},
);
const { result } = renderHook(
() => useStreamingChat("conv-1"),
{ wrapper: createWrapper() },
);
act(() => {
result.current.startStream("test");
});
expect(result.current.isStreaming).toBe(true);
expect(capturedSignal?.aborted).toBe(false);
act(() => {
result.current.stop();
});
expect(result.current.isStreaming).toBe(false);
expect(capturedSignal?.aborted).toBe(true);
});
it("handles SSE error by setting isStreaming=false", async () => {
let capturedCallbacks: {
onToken: (token: string) => void;
onDone: (messageId: string, content: string) => void;
onError: (error: string) => void;
};
vi.mocked(chatApi.postMessageAndStream).mockImplementation(
(_convId, _data, callbacks, _signal) => {
capturedCallbacks = callbacks;
return Promise.resolve();
},
);
const { result } = renderHook(
() => useStreamingChat("conv-1"),
{ wrapper: createWrapper() },
);
act(() => {
result.current.startStream("test");
});
expect(result.current.isStreaming).toBe(true);
act(() => {
capturedCallbacks!.onError("Stream error");
});
expect(result.current.isStreaming).toBe(false);
});
it("does nothing when conversationId is null", () => {
const { result } = renderHook(
() => useStreamingChat(null),
{ wrapper: createWrapper() },
);
act(() => {
result.current.startStream("test");
});
expect(chatApi.postMessageAndStream).not.toHaveBeenCalled();
expect(result.current.isStreaming).toBe(false);
});
});