1
13import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
14
15vi.mock("@/lib/auth-store", () => ({
16 authStore: { get accessToken() { return "tok-123"; } },
17 useAuthStore: { getState: () => ({}) }
18}));
19vi.mock("@/lib/constants", () => ({ API_BASE: "http://x/api/v1" }));
20
21const { sendWriting, AiPreStreamError, WRITING_ACTIONS, WRITING_LANGUAGES } = await import("@/lib/ai");
22
23function streamFrom(parts: string[]): ReadableStream<Uint8Array> {
24 const enc = new TextEncoder();
25 return new ReadableStream({
26 start(controller) {
27 for (const p of parts) controller.enqueue(enc.encode(p));
28 controller.close();
29 }
30 });
31}
32
33interface FetchCall {
34 url: string;
35 init: { body: string; headers: Record<string, string>; method?: string };
36}
37
38function firstFetchCall(): FetchCall {
39 const fn = globalThis.fetch as unknown as { mock: { calls: unknown[][] } };
40 const args = fn.mock.calls[0] as [string, RequestInit];
41 return {
42 url: args[0],
43 init: {
44 body: args[1].body as string,
45 headers: args[1].headers as Record<string, string>,
46 method: args[1].method
47 }
48 };
49}
50
51function writingStream(blocks: Array<{ blockId: string; newText: string }>, assistantMessageId: string) {
52 return [
53 `data: ${JSON.stringify({ type: "tool_call", id: "c1", name: "generateBlocks", arguments: { blocks } })}\n\n`,
54 `data: ${JSON.stringify({ type: "usage", tokensIn: 40, tokensOut: 12 })}\n\n`,
55 `data: ${JSON.stringify({ type: "done", assistantMessageId, reason: "ok" })}\n\n`
56 ];
57}
58
59describe("sendWriting", () => {
60 beforeEach(() => {
61 vi.stubGlobal("fetch", vi.fn());
62 });
63 afterEach(() => vi.unstubAllGlobals());
64
65 it("resolves to the parsed suggestion (tool_call blocks + suggestionId)", async () => {
66 (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
67 ok: true,
68 status: 200,
69 body: streamFrom(writingStream([
70 { blockId: "b1", newText: "Summarized: one" },
71 { blockId: "b2", newText: "Summarized: two" }
72 ], "sugg-1"))
73 });
74
75 const res = await sendWriting({
76 workspaceId: "ws",
77 pageId: "p1",
78 blockIds: ["b1", "b2"],
79 action: "summarize"
80 });
81
82 expect(res.reason).toBe("ok");
83 expect(res.suggestionId).toBe("sugg-1");
84 expect(res.suggestion?.action).toBe("summarize");
85 expect(res.suggestion?.blockIds).toEqual(["b1", "b2"]);
86 expect(res.suggestion?.blocks).toEqual([
87 { blockId: "b1", newText: "Summarized: one" },
88 { blockId: "b2", newText: "Summarized: two" }
89 ]);
90 expect(res.suggestion?.droppedIds).toEqual([]);
91 expect(res.usage).toEqual({ tokensIn: 40, tokensOut: 12 });
92
93
94 const { url, init } = firstFetchCall();
95 expect(url).toBe("http://x/api/v1/workspaces/ws/ai/writing");
96 expect(JSON.parse(init.body)).toMatchObject({ action: "summarize", pageId: "p1", blockIds: ["b1", "b2"] });
97 expect(JSON.parse(init.body).language).toBeUndefined();
98 expect(init.headers.Authorization).toBe("Bearer tok-123");
99 });
100
101 it("rides the language for translate and computes droppedIds for smuggled ids", async () => {
102 (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
103 ok: true,
104 status: 200,
105 body: streamFrom(writingStream([
106 { blockId: "b1", newText: "Translated to es: hola" },
107 { blockId: "FORGED", newText: "should be dropped" }
108 ], "sugg-2"))
109 });
110
111 const res = await sendWriting({
112 workspaceId: "ws",
113 pageId: "p1",
114 blockIds: ["b1"],
115 action: "translate",
116 language: "es"
117 });
118
119 expect(res.suggestion?.language).toBe("es");
120 expect(res.suggestion?.droppedIds).toEqual(["FORGED"]);
121 expect(JSON.parse(firstFetchCall().init.body)).toMatchObject({
122 action: "translate",
123 language: "es"
124 });
125 });
126
127 it("throws AiPreStreamError for a pre-stream 422", async () => {
128 (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
129 ok: false,
130 status: 422,
131 json: async () => ({ error: { code: "UNPROCESSABLE_ENTITY", message: "too many blocks", details: [{ reason: "too_many_blocks" }] } })
132 });
133
134 await expect(
135 sendWriting({ workspaceId: "ws", pageId: "p1", blockIds: ["b1"], action: "improve" })
136 ).rejects.toMatchObject({ name: "AiPreStreamError", code: "UNPROCESSABLE_ENTITY" });
137 });
138
139 it("resolves with reason=aborted when the reader is aborted mid-stream", async () => {
140
141
142 const abortingStream = new ReadableStream<Uint8Array>({
143 start(controller) {
144 controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ type: "delta", text: "x" })}\n\n`));
145 },
146 pull() {
147 const err = new Error("aborted");
148 err.name = "AbortError";
149 return Promise.reject(err);
150 }
151 });
152 (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
153 ok: true,
154 status: 200,
155 body: abortingStream
156 });
157
158 const controller = new AbortController();
159 const res = await sendWriting({
160 workspaceId: "ws",
161 pageId: "p1",
162 blockIds: ["b1"],
163 action: "improve",
164 signal: controller.signal
165 });
166 expect(res.reason).toBe("aborted");
167 });
168
169 it("exposes the four actions and seven fixed languages", () => {
170 expect(WRITING_ACTIONS).toEqual(["summarize", "rewrite", "improve", "translate"]);
171 expect(WRITING_LANGUAGES.map((l) => l.id)).toEqual(["en", "es", "de", "fr", "ja", "pt", "zh"]);
172 });
173});
174