1
26
27process.env.AI_PROVIDER = "mock";
28process.env.SEARCH_VECTOR_DIMENSION = "8";
29
30
31process.env.AI_MOCK_DELTA_DELAY_MS = "25";
32
33import { afterAll, beforeAll, describe, expect, it } from "vitest";
34import { createTestApp, type TestAppHandle } from "./helpers/api";
35import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
36
37
38
39
40const { resetWorkspaceCache } = await import("@factory/api/src/modules/ai/gateway-provider");
41
42let app: TestAppHandle;
43
44beforeAll(async () => {
45 app = await createTestApp();
46}, 180_000);
47afterAll(async () => {
48 await app?.close();
49});
50
51
52
53
54
55type SseEvent = {
56 type: "delta" | "tool_call" | "usage" | "error" | "done";
57 text?: string;
58 id?: string;
59 name?: string;
60 arguments?: Record<string, unknown>;
61 tokensIn?: number;
62 tokensOut?: number;
63 code?: string;
64 message?: string;
65 retryable?: boolean;
66 retryAfterSeconds?: number;
67 assistantMessageId?: string;
68 userMessageId?: string;
69 reason?: string;
70};
71
72
73async function streamWriting(
74 accessToken: string,
75 workspaceId: string,
76 body: Record<string, unknown>,
77): Promise<{ status: number; events: SseEvent[]; contentType: string }> {
78 const res = await fetch(`${app.baseUrl}/api/v1/workspaces/${workspaceId}/ai/writing`, {
79 method: "POST",
80 headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
81 body: JSON.stringify(body),
82 });
83 const contentType = res.headers.get("content-type") ?? "";
84 const events: SseEvent[] = [];
85 if (!res.body) return { status: res.status, events, contentType };
86 const reader = res.body.getReader();
87 const decoder = new TextDecoder();
88 let buf = "";
89 for (;;) {
90 const chunk = await reader.read();
91 if (chunk.done) break;
92 buf += decoder.decode(chunk.value, { stream: true });
93 let idx: number;
94 while ((idx = buf.indexOf("\n\n")) !== -1) {
95 const frame = buf.slice(0, idx);
96 buf = buf.slice(idx + 2);
97 for (const line of frame.split("\n")) {
98 if (!line.startsWith("data:")) continue;
99 events.push(JSON.parse(line.slice(5).trim()) as SseEvent);
100 }
101 }
102 }
103 return { status: res.status, events, contentType };
104}
105
106const json = async (res: Response): Promise<Record<string, unknown>> =>
107 (await res.json().catch(() => ({}))) as Record<string, unknown>;
108
109
111async function createPageWithBlocks(
112 accessToken: string,
113 workspaceId: string,
114 title: string,
115 texts: string[],
116): Promise<{ pageId: string; blockIds: string[] }> {
117 const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
118 headers: authHeaders(accessToken),
119 body: { title },
120 });
121 expect(pageRes.status).toBe(201);
122 const page = (await pageRes.json()) as { id: string };
123 let prevId: string | undefined;
124 const blockIds: string[] = [];
125 for (const text of texts) {
126 const r = await app.request("POST", `/api/v1/pages/${page.id}/blocks`, {
127 headers: authHeaders(accessToken),
128 body: { type: "PARAGRAPH", content: { text }, ...(prevId ? { afterBlockId: prevId } : {}) },
129 });
130 expect(r.status).toBe(201);
131 const b = (await r.json()) as { id: string };
132 blockIds.push(b.id);
133 prevId = b.id;
134 }
135 return { pageId: page.id, blockIds };
136}
137
138
139async function freshWorkspace(name: string) {
140 const owner = await createUser(app.request, { name });
141 const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken!), `${name} WS`);
142 return { owner, workspaceId, h: authHeaders(owner.accessToken!) };
143}
144
145
146async function suggestionFrom(events: SseEvent[]) {
147 const done = events.find((e) => e.type === "done");
148 expect(done?.reason).toBe("ok");
149 const suggestionId = done!.assistantMessageId!;
150 const row = await app.prisma.aiMessage.findUniqueOrThrow({ where: { id: suggestionId } });
151 return { done, suggestionId, row };
152}
153
154
155
156
157
158describe("TASK-0123 writing — SSE happy path", () => {
159 it("summarize 3 blocks → suggestion; apply → all 3 updated as NORMAL block updates (audit + versions + positions unchanged)", async () => {
160 const { owner, workspaceId, h } = await freshWorkspace("Writing Owner");
161 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Doc", [
162 "The first paragraph talks about the launch plan and its milestones in detail.",
163 "The second paragraph explains the budget, the staffing model, and the risks ahead.",
164 "The third paragraph summarizes the decision criteria the team agreed to follow.",
165 ]);
166
167 const out = await streamWriting(owner.accessToken!, workspaceId, {
168 action: "summarize",
169 pageId,
170 blockIds,
171 });
172 expect(out.status).toBe(200);
173 expect(out.contentType).toContain("text/event-stream");
174 const types = out.events.map((e) => e.type);
175 expect(types[types.length - 1]).toBe("done");
176 expect(types).toContain("tool_call");
177 expect(types).toContain("usage");
178
179 const tool = out.events.find((e) => e.type === "tool_call")!;
180 expect(tool.name).toBe("generateBlocks");
181 const suggested = (tool.arguments as { blocks: Array<{ blockId: string; newText: string }> }).blocks;
182 expect(suggested.map((b) => b.blockId)).toEqual(blockIds);
183 for (const b of suggested) expect(b.newText).toMatch(/^Summarized: /);
184
185 const usage = out.events.find((e) => e.type === "usage")!;
186 expect(usage.tokensIn).toBeGreaterThan(0);
187 expect(usage.tokensOut).toBeGreaterThan(0);
188
189
190 const { suggestionId, row } = await suggestionFrom(out.events);
191 expect(row.role).toBe("assistant");
192 const meta = row.metadata as { feature: string; action: string };
193 expect(meta.feature).toBe("writing");
194 expect(meta.action).toBe("summarize");
195 expect(row.tokensIn).toBe(usage.tokensIn);
196 expect(row.tokensOut).toBe(usage.tokensOut);
197 expect(row.model).toBeTruthy();
198 const suggestion = row.toolResults as { blocks: Array<{ blockId: string; newText: string; version: number; content: { text: string }; text: string }>; droppedIds: string[] };
199 expect(suggestion.droppedIds).toEqual([]);
200 expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds);
201
202 expect(suggestion.blocks[0]!.text).toBe("The first paragraph talks about the launch plan and its milestones in detail.");
203 expect(suggestion.blocks[0]!.content.text).toBe("The first paragraph talks about the launch plan and its milestones in detail.");
204
205
206 const listRes = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, { headers: h });
207 const list = (await json(listRes)) as { data: Array<{ id: string; title: string }> };
208 expect(list.data.map((c) => c.title)).not.toContain("writing:summarize");
209
210
211 const before = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } });
212 const positionsBefore = before.map((b) => b.position);
213 const versionsBefore = before.map((b) => b.version);
214
215
216 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
217 headers: h,
218 body: { suggestionId },
219 });
220 expect(applyRes.status).toBe(200);
221 const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string }> };
222 expect(apply.results).toHaveLength(3);
223 for (const r of apply.results) expect(r.status).toBe("applied");
224
225
226 const after = await app.prisma.block.findMany({ where: { id: { in: blockIds } }, orderBy: { position: "asc" } });
227 expect(after.map((b) => b.position)).toEqual(positionsBefore);
228 expect(after.map((b) => b.version)).toEqual(versionsBefore.map((v) => v + 1));
229 const byId = new Map(blockIds.map((id, i) => [id, after[i]!]));
230 for (const b of suggested) {
231 const block = byId.get(b.blockId)!;
232 expect((block.content as { text: string }).text).toBe(b.newText);
233 expect(block.updatedBy).toBe(owner.userId);
234 }
235
236
237 const audit = await app.prisma.auditLog.findMany({
238 where: { workspaceId, action: { in: ["block.updated", "ai.writing_applied"] } },
239 });
240 expect(audit.filter((a) => a.action === "block.updated")).toHaveLength(3);
241 const applied = audit.find((a) => a.action === "ai.writing_applied");
242 expect(applied).toBeTruthy();
243 expect((applied!.metadata as { applied: number; failed: number }).applied).toBe(3);
244 });
245
246 it("translate with a language: the language rides into the prompt + the suggestion row", async () => {
247 const { owner, workspaceId, h } = await freshWorkspace("Translate Owner");
248 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Intl", [
249 "Hello there, this is a friendly greeting for the international reader.",
250 ]);
251 const out = await streamWriting(owner.accessToken!, workspaceId, {
252 action: "translate",
253 language: "Spanish",
254 pageId,
255 blockIds,
256 });
257 const tool = out.events.find((e) => e.type === "tool_call")!;
258 const suggested = (tool.arguments as { blocks: Array<{ newText: string }> }).blocks;
259 expect(suggested[0]!.newText).toBe("Translated to Spanish: Hello there, this is a friendly greeting for the international reader.");
260 const { row } = await suggestionFrom(out.events);
261 const meta = row.metadata as { feature: string; action: string; language: string };
262 expect(meta).toEqual({ feature: "writing", action: "translate", language: "Spanish" });
263
264 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
265 headers: h,
266 body: { suggestionId: row.id },
267 });
268 expect(applyRes.status).toBe(200);
269 });
270});
271
272
273
274
275
276describe("TASK-0123 writing — pre-stream validation", () => {
277 it(">50 blocks → 422 too_many_blocks (the spec's canonical code, not 400)", async () => {
278 const { owner, workspaceId, h } = await freshWorkspace("Cap Owner");
279 const tooMany = Array.from({ length: 51 }, (_, i) => `fake-block-id-${i}`);
280 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
281 headers: h,
282 body: { action: "improve", pageId: "page-x", blockIds: tooMany },
283 });
284 expect(res.status).toBe(422);
285 const body = await json(res);
286 expect(body.error.code).toBe("VALIDATION_ERROR");
287 const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason;
288 expect(reason).toBe("too_many_blocks");
289 });
290
291 it("bad action → 400 VALIDATION_ERROR (zod enum)", async () => {
292 const { owner, workspaceId, h } = await freshWorkspace("Enum Owner");
293 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
294 headers: h,
295 body: { action: "definitely-not-an-action", pageId: "page-x", blockIds: ["b1"] },
296 });
297 expect(res.status).toBe(400);
298 });
299
300 it("selected text over 100k chars → 413 PAYLOAD_TOO_LARGE (before any SSE byte)", async () => {
301 const { owner, workspaceId, h } = await freshWorkspace("Big Owner");
302 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Big", [
303 "z".repeat(100_001),
304 ]);
305 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
306 headers: h,
307 body: { action: "rewrite", pageId, blockIds },
308 });
309 expect(res.status).toBe(413);
310 const body = await json(res);
311 expect(body.error.code).toBe("PAYLOAD_TOO_LARGE");
312 });
313
314 it("no editable blocks (all missing) → 422 no_editable_blocks", async () => {
315 const { owner, workspaceId, h } = await freshWorkspace("Empty Owner");
316 const { pageId } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Empty", ["some text here"]);
317 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
318 headers: h,
319 body: { action: "rewrite", pageId, blockIds: ["never-existed-1", "never-existed-2"] },
320 });
321 expect(res.status).toBe(422);
322 const body = await json(res);
323 const reason = (body.error.details as Array<{ reason?: string }>)?.[0]?.reason;
324 expect(reason).toBe("no_editable_blocks");
325 });
326});
327
328
329
330
331
332describe("TASK-0123 writing — per-page permission granularity", () => {
333 it("a view-only member can SUGGEST; apply fails per block with FORBIDDEN {reason:'requires_EDIT'}", async () => {
334 const { owner, workspaceId, h } = await freshWorkspace("Perm Owner");
335 const guest = await createUser(app.request, { name: "View Guest" });
336 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
337 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Shared", [
338 "Shared document paragraph one with some meaningful text inside it.",
339 ]);
340
341
342 const out = await streamWriting(guest.accessToken!, workspaceId, {
343 action: "improve",
344 pageId,
345 blockIds,
346 });
347 expect(out.status).toBe(200);
348 const tool = out.events.find((e) => e.type === "tool_call")!;
349 expect((tool.arguments as { blocks: unknown[] }).blocks).toHaveLength(1);
350 const { row } = await suggestionFrom(out.events);
351
352
353 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
354 headers: authHeaders(guest.accessToken!),
355 body: { suggestionId: row.id },
356 });
357 expect(applyRes.status).toBe(200);
358 const apply = (await json(applyRes)) as { results: Array<{ blockId: string; status: string; code: string }> };
359 expect(apply.results[0]).toMatchObject({ status: "failed", code: "FORBIDDEN" });
360
361
362 const block = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } });
363 expect((block.content as { text: string }).text).toBe("Shared document paragraph one with some meaningful text inside it.");
364 });
365
366 it("a page-level EDIT grant lets the same member apply (grant beats role)", async () => {
367 const { owner, workspaceId, h } = await freshWorkspace("Grant Owner");
368 const guest = await createUser(app.request, { name: "Edit Grant Guest" });
369 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
370 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Grant Page", [
371 "Grant document paragraph with a sentence the guest should be able to improve.",
372 ]);
373
374
375
376
377
378 await app.prisma.permission.create({
379 data: {
380 workspaceId,
381 granteeType: "user",
382 granteeId: guest.userId,
383 resourceType: "page",
384 resourceId: pageId,
385 level: "EDIT",
386 },
387 });
388
389 const out = await streamWriting(guest.accessToken!, workspaceId, { action: "improve", pageId, blockIds });
390 expect(out.status).toBe(200);
391 const { row } = await suggestionFrom(out.events);
392 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
393 headers: authHeaders(guest.accessToken!),
394 body: { suggestionId: row.id },
395 });
396 expect(applyRes.status).toBe(200);
397 const apply = (await json(applyRes)) as { results: Array<{ status: string }> };
398 expect(apply.results[0]!.status).toBe("applied");
399 });
400
401 it("apply of a foreign user's suggestion → 404 (existence hidden)", async () => {
402 const a = await freshWorkspace("A");
403 const b = await freshWorkspace("B");
404 const { pageId, blockIds } = await createPageWithBlocks(a.owner.accessToken!, a.workspaceId, "A", ["Something to summarize here"]);
405 const out = await streamWriting(a.owner.accessToken!, a.workspaceId, { action: "summarize", pageId, blockIds });
406 const { row } = await suggestionFrom(out.events);
407 const res = await app.request("POST", `/api/v1/workspaces/${b.workspaceId}/ai/writing/apply`, {
408 headers: b.h,
409 body: { suggestionId: row.id },
410 });
411 expect(res.status).toBe(404);
412 });
413});
414
415
416
417
418
419describe("TASK-0123 writing — tool-call validation", () => {
420 it("a forged blockId from the model is DROPPED + noted (never applied)", async () => {
421 const { owner, workspaceId, h } = await freshWorkspace("Forged Owner");
422 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Forge", [
423 "Forging test paragraph one with a full sentence for the mock to transform.",
424 "Forging test paragraph two with another full sentence for the mock.",
425 ]);
426
427 const armRes = await app.request("POST", `/internal/workspaces/${workspaceId}/ai/mock-unknown-id`, {
428 headers: { Authorization: `Bearer ${app.internalServiceToken}` },
429 body: { unknownId: "FORGED_BLOCK_ID" },
430 });
431 expect(armRes.status).toBe(200);
432
433 const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds });
434 const tool = out.events.find((e) => e.type === "tool_call")!;
435
436 const raw = (tool.arguments as { blocks: Array<{ blockId: string }> }).blocks;
437 expect(raw.map((b) => b.blockId)).toContain("FORGED_BLOCK_ID");
438
439 const { row } = await suggestionFrom(out.events);
440 const suggestion = row.toolResults as { blocks: Array<{ blockId: string }>; droppedIds: string[] };
441
442 expect(suggestion.droppedIds).toEqual(["FORGED_BLOCK_ID"]);
443 expect(suggestion.blocks.map((b) => b.blockId)).toEqual(blockIds);
444 expect((row.metadata as { droppedIds: string[] }).droppedIds).toEqual(["FORGED_BLOCK_ID"]);
445
446
447 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
448 headers: h,
449 body: { suggestionId: row.id },
450 });
451 expect(applyRes.status).toBe(200);
452 const apply = (await json(applyRes)) as { results: Array<{ status: string }> };
453 expect(apply.results).toHaveLength(2);
454 for (const r of apply.results) expect(r.status).toBe("applied");
455 const forged = await app.prisma.block.findUnique({ where: { id: "FORGED_BLOCK_ID" } });
456 expect(forged).toBeNull();
457 });
458});
459
460
461
462
463
464describe("TASK-0123 writing — version conflicts (rebase)", () => {
465 it("a concurrent edit bumps the version → apply fails that block with 409 + actualVersion, the rest apply", async () => {
466 const { owner, workspaceId, h } = await freshWorkspace("Rebase Owner");
467 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Race", [
468 "Racing paragraph one that someone else will edit between suggest and apply.",
469 "Racing paragraph two that stays untouched so it can still be applied.",
470 ]);
471 const out = await streamWriting(owner.accessToken!, workspaceId, { action: "improve", pageId, blockIds });
472 const { row } = await suggestionFrom(out.events);
473 const versionAtSuggest = blockIds.map((id) =>
474 app.prisma.block.findUniqueOrThrow({ where: { id }, select: { version: true } }),
475 );
476 const v1 = (await Promise.all(versionAtSuggest))[0]!.version;
477
478
479 const patchRes = await app.request("PATCH", `/api/v1/blocks/${blockIds[0]}`, {
480 headers: h,
481 body: { content: { text: "Concurrently edited by another user." } },
482 });
483 expect([200, 201]).toContain(patchRes.status);
484
485 const applyRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
486 headers: h,
487 body: { suggestionId: row.id },
488 });
489 expect(applyRes.status).toBe(200);
490 const apply = (await json(applyRes)) as {
491 results: Array<{ blockId: string; status: string; code?: string; actualVersion?: number }>;
492 };
493 const failed = apply.results.find((r) => r.blockId === blockIds[0]!)!;
494 expect(failed).toMatchObject({ status: "failed", code: "CONFLICT" });
495 expect(failed.actualVersion).toBe(v1 + 1);
496
497 expect(apply.results.find((r) => r.blockId === blockIds[1]!)!.status).toBe("applied");
498
499 const b1 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[0]! } });
500 expect((b1.content as { text: string }).text).toBe("Concurrently edited by another user.");
501 });
502});
503
504
505
506
507
508describe("TASK-0123 writing — budget + apply validation", () => {
509 it("an exhausted budget → 402 AI_BUDGET_EXCEEDED (pre-stream, no suggestion persisted)", async () => {
510 const { owner, workspaceId, h } = await freshWorkspace("Budget Owner");
511 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Budget", [
512 "Budget test paragraph with a sentence long enough for the mock to count.",
513 ]);
514
515 await app.prisma.workspace.update({
516 where: { id: workspaceId },
517 data: { usageLimits: { aiMonthlyTokens: 1 } },
518 });
519 resetWorkspaceCache();
520
521 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing`, {
522 headers: h,
523 body: { action: "improve", pageId, blockIds },
524 });
525 expect(res.status).toBe(402);
526 const body = await json(res);
527 expect(body.error.code).toBe("AI_BUDGET_EXCEEDED");
528
529 const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "writing:improve" } });
530 expect(convs).toHaveLength(0);
531
532 await app.prisma.workspace.update({
533 where: { id: workspaceId },
534 data: { usageLimits: {} },
535 });
536 resetWorkspaceCache();
537 });
538
539 it("apply rejects smuggled blockIds (422 unknown_block_id) and accepts a SUBSET (accept-one)", async () => {
540 const { owner, workspaceId, h } = await freshWorkspace("Subset Owner");
541 const { pageId, blockIds } = await createPageWithBlocks(owner.accessToken!, workspaceId, "Subset", [
542 "Subset paragraph one that will be accepted through the subset path.",
543 "Subset paragraph two that the owner decides to keep as-is.",
544 ]);
545 const out = await streamWriting(owner.accessToken!, workspaceId, { action: "rewrite", pageId, blockIds });
546 const { row } = await suggestionFrom(out.events);
547
548
549 const smuggle = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
550 headers: h,
551 body: { suggestionId: row.id, blockIds: [blockIds[0]!, "smuggled-id"] },
552 });
553 expect(smuggle.status).toBe(422);
554 const smuggleBody = await json(smuggle);
555 const reason = (smuggleBody.error.details as Array<{ reason?: string }>)?.[0]?.reason;
556 expect(reason).toBe("unknown_block_id");
557
558
559 const subset = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/writing/apply`, {
560 headers: h,
561 body: { suggestionId: row.id, blockIds: [blockIds[0]!] },
562 });
563 expect(subset.status).toBe(200);
564 const apply = (await json(subset)) as { results: Array<{ blockId: string; status: string }> };
565 expect(apply.results).toHaveLength(1);
566 expect(apply.results[0]!.status).toBe("applied");
567
568 const b2 = await app.prisma.block.findUniqueOrThrow({ where: { id: blockIds[1]! } });
569 expect((b2.content as { text: string }).text).toBe("Subset paragraph two that the owner decides to keep as-is.");
570 });
571});
572