/** * TASK-0123 — the writing client (`sendWriting` / `writingApi`) over SSE. * * Verifies the client's contract against a mocked `fetch`: * - a successful stream resolves to the parsed suggestion (blockId+newText * from the `generateBlocks` tool_call, suggestionId = done.assistantMessageId) * with `droppedIds` computed for emitted-but-unrequested ids; * - a pre-stream 4xx/5xx throws `AiPreStreamError` carrying the code; * - an aborted fetch resolves with `reason: "aborted"` (no throw). * * @vitest-environment node (no DOM; the config default jsdom isn't installed). */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/lib/auth-store", () => ({ authStore: { get accessToken() { return "tok-123"; } }, useAuthStore: { getState: () => ({}) } })); vi.mock("@/lib/constants", () => ({ API_BASE: "http://x/api/v1" })); const { sendWriting, AiPreStreamError, WRITING_ACTIONS, WRITING_LANGUAGES } = await import("@/lib/ai"); function streamFrom(parts: string[]): ReadableStream { const enc = new TextEncoder(); return new ReadableStream({ start(controller) { for (const p of parts) controller.enqueue(enc.encode(p)); controller.close(); } }); } interface FetchCall { url: string; init: { body: string; headers: Record; method?: string }; } /** Read the first mocked fetch call as a typed `{url, init}` pair. */ function firstFetchCall(): FetchCall { const fn = globalThis.fetch as unknown as { mock: { calls: unknown[][] } }; const args = fn.mock.calls[0] as [string, RequestInit]; return { url: args[0], init: { body: args[1].body as string, headers: args[1].headers as Record, method: args[1].method } }; } function writingStream(blocks: Array<{ blockId: string; newText: string }>, assistantMessageId: string) { return [ `data: ${JSON.stringify({ type: "tool_call", id: "c1", name: "generateBlocks", arguments: { blocks } })}\n\n`, `data: ${JSON.stringify({ type: "usage", tokensIn: 40, tokensOut: 12 })}\n\n`, `data: ${JSON.stringify({ type: "done", assistantMessageId, reason: "ok" })}\n\n` ]; } describe("sendWriting", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); }); afterEach(() => vi.unstubAllGlobals()); it("resolves to the parsed suggestion (tool_call blocks + suggestionId)", async () => { (globalThis.fetch as unknown as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, body: streamFrom(writingStream([ { blockId: "b1", newText: "Summarized: one" }, { blockId: "b2", newText: "Summarized: two" } ], "sugg-1")) }); const res = await sendWriting({ workspaceId: "ws", pageId: "p1", blockIds: ["b1", "b2"], action: "summarize" }); expect(res.reason).toBe("ok"); expect(res.suggestionId).toBe("sugg-1"); expect(res.suggestion?.action).toBe("summarize"); expect(res.suggestion?.blockIds).toEqual(["b1", "b2"]); expect(res.suggestion?.blocks).toEqual([ { blockId: "b1", newText: "Summarized: one" }, { blockId: "b2", newText: "Summarized: two" } ]); expect(res.suggestion?.droppedIds).toEqual([]); expect(res.usage).toEqual({ tokensIn: 40, tokensOut: 12 }); // The request body carried action + pageId + blockIds (no language for summarize). const { url, init } = firstFetchCall(); expect(url).toBe("http://x/api/v1/workspaces/ws/ai/writing"); expect(JSON.parse(init.body)).toMatchObject({ action: "summarize", pageId: "p1", blockIds: ["b1", "b2"] }); expect(JSON.parse(init.body).language).toBeUndefined(); expect(init.headers.Authorization).toBe("Bearer tok-123"); }); it("rides the language for translate and computes droppedIds for smuggled ids", async () => { (globalThis.fetch as unknown as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, body: streamFrom(writingStream([ { blockId: "b1", newText: "Translated to es: hola" }, { blockId: "FORGED", newText: "should be dropped" } ], "sugg-2")) }); const res = await sendWriting({ workspaceId: "ws", pageId: "p1", blockIds: ["b1"], action: "translate", language: "es" }); expect(res.suggestion?.language).toBe("es"); expect(res.suggestion?.droppedIds).toEqual(["FORGED"]); expect(JSON.parse(firstFetchCall().init.body)).toMatchObject({ action: "translate", language: "es" }); }); it("throws AiPreStreamError for a pre-stream 422", async () => { (globalThis.fetch as unknown as ReturnType).mockResolvedValueOnce({ ok: false, status: 422, json: async () => ({ error: { code: "UNPROCESSABLE_ENTITY", message: "too many blocks", details: [{ reason: "too_many_blocks" }] } }) }); await expect( sendWriting({ workspaceId: "ws", pageId: "p1", blockIds: ["b1"], action: "improve" }) ).rejects.toMatchObject({ name: "AiPreStreamError", code: "UNPROCESSABLE_ENTITY" }); }); it("resolves with reason=aborted when the reader is aborted mid-stream", async () => { // A live stream that rejects with AbortError on read (what a real // fetch's body does when the AbortController fires mid-response). const abortingStream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: "delta", text: "x" })}\n\n`)); }, pull() { const err = new Error("aborted"); err.name = "AbortError"; return Promise.reject(err); } }); (globalThis.fetch as unknown as ReturnType).mockResolvedValueOnce({ ok: true, status: 200, body: abortingStream }); const controller = new AbortController(); const res = await sendWriting({ workspaceId: "ws", pageId: "p1", blockIds: ["b1"], action: "improve", signal: controller.signal }); expect(res.reason).toBe("aborted"); }); it("exposes the four actions and seven fixed languages", () => { expect(WRITING_ACTIONS).toEqual(["summarize", "rewrite", "improve", "translate"]); expect(WRITING_LANGUAGES.map((l) => l.id)).toEqual(["en", "es", "de", "fr", "ja", "pt", "zh"]); }); });