1
35
36process.env.AI_PROVIDER = "mock";
37process.env.SEARCH_VECTOR_DIMENSION = "8";
38
39process.env.AI_MOCK_DELTA_DELAY_MS = "25";
40
41import { afterAll, beforeAll, describe, expect, it } from "vitest";
42import { createTestApp, type TestAppHandle } from "./helpers/api";
43import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed";
44
45
46
47
48
49const { resetWorkspaceCache, clearMockDbInvalidStructure } = await import(
50 "@factory/api/src/modules/ai/gateway-provider"
51);
52
53let app: TestAppHandle;
54
55beforeAll(async () => {
56 app = await createTestApp();
57}, 180_000);
58afterAll(async () => {
59 await app?.close();
60});
61
62
63
64
65
66
67const PROPERTY_TYPE_ENUM = [
68 "TITLE", "TEXT", "RICH_TEXT", "NUMBER", "SELECT", "MULTI_SELECT", "STATUS",
69 "DATE", "DATE_RANGE", "PERSON", "EMAIL", "PHONE", "URL", "CHECKBOX", "FILES",
70 "CREATED_TIME", "CREATED_BY", "LAST_EDITED_TIME", "LAST_EDITED_BY",
71 "RELATION", "ROLLUP", "FORMULA", "BUTTON", "UNIQUE_ID",
72] as const;
73
74
75
76
77
78type SseEvent = {
79 type: "delta" | "tool_call" | "usage" | "error" | "done";
80 text?: string;
81 id?: string;
82 name?: string;
83 arguments?: Record<string, unknown>;
84 tokensIn?: number;
85 tokensOut?: number;
86 code?: string;
87 message?: string;
88 reason?: string;
89 assistantMessageId?: string;
90 userMessageId?: string;
91 databaseId?: string;
92 propertyIds?: string[];
93 recordIds?: string[];
94 ignoredColumns?: string[];
95 formulaId?: string;
96 expression?: string;
97 returnType?: string;
98 samples?: Array<{ recordId: string; value: unknown; error?: string }>;
99};
100
101
102async function streamDb(
103 path: string,
104 accessToken: string,
105 body: Record<string, unknown>,
106): Promise<{ status: number; events: SseEvent[]; contentType: string }> {
107 const res = await fetch(`${app.baseUrl}${path}`, {
108 method: "POST",
109 headers: { "Content-Type": "application/json", Authorization: `Bearer ${accessToken}` },
110 body: JSON.stringify(body),
111 });
112 const contentType = res.headers.get("content-type") ?? "";
113 const events: SseEvent[] = [];
114 if (!res.body) return { status: res.status, events, contentType };
115 const reader = res.body.getReader();
116 const decoder = new TextDecoder();
117 let buf = "";
118 for (;;) {
119 const chunk = await reader.read();
120 if (chunk.done) break;
121 buf += decoder.decode(chunk.value, { stream: true });
122 let idx: number;
123 while ((idx = buf.indexOf("\n\n")) !== -1) {
124 const frame = buf.slice(0, idx);
125 buf = buf.slice(idx + 2);
126 for (const line of frame.split("\n")) {
127 if (!line.startsWith("data:")) continue;
128 events.push(JSON.parse(line.slice(5).trim()) as SseEvent);
129 }
130 }
131 }
132 return { status: res.status, events, contentType };
133}
134
135const json = async (res: Response): Promise<Record<string, unknown>> =>
136 (await res.json().catch(() => ({}))) as Record<string, unknown>;
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 createDb(token: string, workspaceId: string, title: string): Promise<string> {
147 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
148 headers: authHeaders(token),
149 body: { title, type: "database" },
150 });
151 expect([200, 201]).toContain(res.status);
152 const body = (await res.json()) as { id: string; databaseId?: string };
153 expect(body.databaseId).toBeTruthy();
154 return body.databaseId!;
155}
156
157
158async function addProperty(
159 token: string,
160 databaseId: string,
161 def: { name: string; type: string; config?: unknown },
162): Promise<{ id: string; name: string; type: string }> {
163 const res = await app.request("POST", `/api/v1/databases/${databaseId}/properties`, {
164 headers: authHeaders(token),
165 body: { name: def.name, type: def.type, config: def.config ?? {} },
166 });
167 expect([200, 201]).toContain(res.status);
168 return (await res.json()) as { id: string; name: string; type: string };
169}
170
171
172async function addRecord(
173 token: string,
174 databaseId: string,
175 def: { title?: string; properties?: Record<string, unknown> },
176): Promise<Record<string, unknown>> {
177 const res = await app.request("POST", `/api/v1/databases/${databaseId}/records`, {
178 headers: authHeaders(token),
179 body: { title: def.title, properties: def.properties ?? {}, links: {} },
180 });
181 expect([200, 201]).toContain(res.status);
182 return (await res.json()) as Record<string, unknown>;
183}
184
185async function listProperties(token: string, databaseId: string): Promise<Array<Record<string, unknown>>> {
186 const res = await app.request("GET", `/api/v1/databases/${databaseId}/properties`, { headers: authHeaders(token) });
187 expect(res.status).toBe(200);
188 return ((await json(res)) as { data: Array<Record<string, unknown>> }).data;
189}
190
191async function getDb(token: string, databaseId: string): Promise<Record<string, unknown>> {
192 const res = await app.request("GET", `/api/v1/databases/${databaseId}`, { headers: authHeaders(token) });
193 expect(res.status).toBe(200);
194 return json(res);
195}
196
197async function listRecords(token: string, databaseId: string): Promise<Array<Record<string, unknown>>> {
198 const res = await app.request("GET", `/api/v1/databases/${databaseId}/records`, { headers: authHeaders(token) });
199 expect(res.status).toBe(200);
200 return ((await json(res)) as { data: Array<Record<string, unknown>> }).data;
201}
202
203
204async function armDbInvalid(workspaceId: string, action: "generate" | "extract" | "formula"): Promise<void> {
205 const res = await app.request("POST", `/internal/workspaces/${workspaceId}/ai/mock-db-invalid`, {
206 headers: { Authorization: `Bearer ${app.internalServiceToken}` },
207 body: { action },
208 });
209 expect(res.status).toBe(200);
210 const body = (await json(res)) as { armed: boolean; action: string };
211 expect(body.armed).toBe(true);
212 expect(body.action).toBe(action);
213}
214
215
216async function costRow(assistantMessageId: string) {
217 return app.prisma.aiMessage.findUniqueOrThrow({ where: { id: assistantMessageId } });
218}
219
220
221async function assertHiddenConvsAbsent(token: string, workspaceId: string, titles: string[]): Promise<void> {
222 const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/ai/conversations`, {
223 headers: authHeaders(token),
224 });
225 expect(res.status).toBe(200);
226 const list = (await json(res)) as { data: Array<{ title: string }> };
227 const shown = new Set(list.data.map((c) => c.title));
228 for (const t of titles) expect(shown.has(t), `hidden conversation "${t}" must not be listed`).toBe(false);
229}
230
231
232const EXTRACT_TABLE = [
233 "| Name | Score | State | Notes |",
234 "| --- | --- | --- | --- |",
235 "| Alice | 42 | Open | first note |",
236 "| Bob | 7 | Closed | second note |",
237 "| Carol | 99 | Open | third note |",
238].join("\n");
239
240
241
242
243
244describe("TASK-0124 generate — happy path", () => {
245 it("a prompt returns a valid database (enum props, page-backed records, cost row, hidden conv)", async () => {
246 const { owner, workspaceId } = await freshWorkspace("Gen Owner");
247
248 const out = await streamDb(`/api/v1/workspaces/${workspaceId}/ai/databases/generate`, owner.accessToken!, {
249 prompt: "a bug tracker",
250 });
251 expect(out.status).toBe(200);
252 expect(out.contentType).toContain("text/event-stream");
253
254 const tool = out.events.find((e) => e.type === "tool_call");
255 expect(tool?.name).toBe("createDatabase");
256 const usage = out.events.find((e) => e.type === "usage");
257 expect(usage).toBeTruthy();
258 const done = out.events.find((e) => e.type === "done");
259 expect(done?.reason).toBe("ok");
260 const databaseId = done!.databaseId!;
261 expect(done!.propertyIds).toHaveLength(4);
262 expect(done!.recordIds).toHaveLength(2);
263
264
265
266 const db = await getDb(owner.accessToken!, databaseId);
267 const props = db.properties as Array<Record<string, unknown>>;
268 const names = props.map((p) => String(p.name));
269
270 for (const n of ["Title", "Status", "Name", "State", "Due", "Done?"]) expect(names).toContain(n);
271 for (const p of props) expect(PROPERTY_TYPE_ENUM).toContain(p.type as never);
272
273
274 const state = props.find((p) => p.name === "State")!;
275 const stateOptions = (state.config as { options: Array<{ id: string; name: string }> }).options;
276 expect(stateOptions.map((o) => o.name).sort()).toEqual(["Done", "To Do"]);
277 expect(stateOptions.map((o) => o.id).sort()).toEqual(["opt-done", "opt-to-do"]);
278
279
280 const records = await listRecords(owner.accessToken!, databaseId);
281 expect(records).toHaveLength(2);
282 for (const r of records) expect(r.pageId).toBeTruthy();
283 const nameId = props.find((p) => p.name === "Name")!.id as string;
284 const dueId = props.find((p) => p.name === "Due")!.id as string;
285 const doneId = props.find((p) => p.name === "Done?")!.id as string;
286 const rec0 = records[0]!;
287 expect(rec0.title).toBe("First item");
288 expect(rec0.properties![nameId]).toMatchObject({ text: "First item" });
289 expect(rec0.properties![state.id]).toEqual({ optionId: "opt-to-do" });
290 expect(rec0.properties![dueId]).toMatchObject({ start: "2026-01-01" });
291 expect(rec0.properties![doneId]).toEqual({ value: false });
292 expect(records[1]!.properties![state.id]).toEqual({ optionId: "opt-done" });
293 expect(records[1]!.properties![doneId]).toEqual({ value: true });
294
295
296 const audit = await app.prisma.auditLog.findFirst({
297 where: { workspaceId, action: "ai.database_generated", resourceId: databaseId },
298 });
299 expect(audit).toBeTruthy();
300 const row = await costRow(done!.assistantMessageId!);
301 expect(row.role).toBe("assistant");
302 expect((row.metadata as { feature: string }).feature).toBe("db.generate");
303 expect(row.tokensIn).toBeGreaterThan(0);
304 expect(row.tokensOut).toBeGreaterThan(0);
305 expect(row.model).toBeTruthy();
306
307
308 await assertHiddenConvsAbsent(owner.accessToken!, workspaceId, ["db:generate"]);
309 });
310});
311
312describe("TASK-0124 generate — permissions", () => {
313 it("a GUEST (VIEW) gets 403 FORBIDDEN pre-stream and nothing is created", async () => {
314 const { workspaceId } = await freshWorkspace("GenPerm Owner");
315 const guest = await createUser(app.request, { name: "Gen Guest" });
316 await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST");
317
318 const before = await app.prisma.database.count({ where: { workspaceId } });
319 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/databases/generate`, {
320 headers: authHeaders(guest.accessToken!),
321 body: { prompt: "a bug tracker" },
322 });
323 expect(res.status).toBe(403);
324 const body = await json(res);
325 expect(body.error.code).toBe("FORBIDDEN");
326
327 const after = await app.prisma.database.count({ where: { workspaceId } });
328 expect(after).toBe(before);
329
330 const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "db:generate" } });
331 expect(convs).toHaveLength(0);
332 });
333});
334
335describe("TASK-0124 generate — hostile structure", () => {
336 it("a non-generatable type → retry-once → in-band ai_invalid_structure, no orphan Database", async () => {
337 const { owner, workspaceId } = await freshWorkspace("GenHostile Owner");
338 await armDbInvalid(workspaceId, "generate");
339
340 const out = await streamDb(`/api/v1/workspaces/${workspaceId}/ai/databases/generate`, owner.accessToken!, {
341 prompt: "a bug tracker",
342 });
343 expect(out.status).toBe(200);
344 const err = out.events.find((e) => e.type === "error");
345 expect(err).toBeTruthy();
346 expect(err!.reason).toBe("ai_invalid_structure");
347 const done = out.events.find((e) => e.type === "done");
348 expect(done?.reason).toBe("error");
349
350
351 const dbs = await app.prisma.database.count({ where: { workspaceId } });
352 expect(dbs).toBe(0);
353
354
355 const row = await costRow(done!.assistantMessageId!);
356 expect((row.metadata as { feature: string; error: string }).feature).toBe("db.generate");
357 expect((row.metadata as { error: string }).error).toBe("ai_invalid_structure");
358 expect(row.content).toMatch(/^error:/);
359 });
360});
361
362
363
364
365
366async function makeExtractDb(token: string, workspaceId: string) {
367 const databaseId = await createDb(token, workspaceId, "Extract DB");
368 const name = await addProperty(token, databaseId, { name: "Name", type: "TEXT" });
369 const score = await addProperty(token, databaseId, { name: "Score", type: "NUMBER" });
370 const state = await addProperty(token, databaseId, {
371 name: "State",
372 type: "SELECT",
373 config: { options: [{ name: "Open" }, { name: "Closed" }] },
374 });
375 return { databaseId, name, score, state };
376}
377
378describe("TASK-0124 extract — happy path", () => {
379 it("a 3-row markdown table with an unmatched column → 3 records, per-type values, ignoredColumns", async () => {
380 const { owner, workspaceId } = await freshWorkspace("Extract Owner");
381 const { databaseId, name, score, state } = await makeExtractDb(owner.accessToken!, workspaceId);
382
383 const out = await streamDb(`/api/v1/workspaces/${workspaceId}/ai/databases/extract`, owner.accessToken!, {
384 databaseId,
385 text: EXTRACT_TABLE,
386 });
387 expect(out.status).toBe(200);
388 const tool = out.events.find((e) => e.type === "tool_call");
389 expect(tool?.name).toBe("insertRecords");
390 const done = out.events.find((e) => e.type === "done");
391 expect(done?.reason).toBe("ok");
392 expect(done!.recordIds).toHaveLength(3);
393 expect(done!.ignoredColumns).toEqual(["Notes"]);
394
395
396 const records = await listRecords(owner.accessToken!, databaseId);
397 expect(records).toHaveLength(3);
398 const r0 = records[0]!;
399 expect(r0.properties![name.id]).toMatchObject({ text: "Alice" });
400 expect(r0.properties![score.id]).toEqual({ value: 42 });
401
402 expect(r0.properties![state.id]).toEqual({ optionId: "opt-open" });
403 const r1 = records[1]!;
404 expect(r1.properties![score.id]).toEqual({ value: 7 });
405 expect(r1.properties![state.id]).toEqual({ optionId: "opt-closed" });
406
407
408 const audit = await app.prisma.auditLog.findFirst({
409 where: { workspaceId, action: "ai.records_extracted", resourceId: databaseId },
410 });
411 expect(audit).toBeTruthy();
412 const row = await costRow(done!.assistantMessageId!);
413 expect((row.metadata as { feature: string }).feature).toBe("db.extract");
414 expect(row.tokensIn).toBeGreaterThan(0);
415
416 await assertHiddenConvsAbsent(owner.accessToken!, workspaceId, ["db:extract"]);
417 });
418});
419
420describe("TASK-0124 extract — preview (dry-run)", () => {
421 it("returns the mapping with no write and no LLM", async () => {
422 const { owner, workspaceId } = await freshWorkspace("Preview Owner");
423 const { databaseId, name, score, state } = await makeExtractDb(owner.accessToken!, workspaceId);
424 const before = await app.prisma.databaseRecord.count({ where: { databaseId } });
425
426 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/databases/extract/preview`, {
427 headers: authHeaders(owner.accessToken!),
428 body: { databaseId, text: EXTRACT_TABLE },
429 });
430 expect(res.status).toBe(200);
431 const body = (await json(res)) as {
432 databaseId: string;
433 isTable: boolean;
434 columns: string[];
435 mapped: Array<{ column: string; propertyId: string }>;
436 ignored: string[];
437 rowCount: number;
438 };
439 expect(body.databaseId).toBe(databaseId);
440 expect(body.isTable).toBe(true);
441 expect(body.columns).toEqual(["Name", "Score", "State", "Notes"]);
442 expect(body.rowCount).toBe(3);
443 expect(body.ignored).toEqual(["Notes"]);
444 expect(body.mapped).toEqual([
445 { column: "Name", propertyId: name.id },
446 { column: "Score", propertyId: score.id },
447 { column: "State", propertyId: state.id },
448 ]);
449
450
451 const after = await app.prisma.databaseRecord.count({ where: { databaseId } });
452 expect(after).toBe(before);
453 const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "db:extract" } });
454 expect(convs).toHaveLength(0);
455 });
456});
457
458
459
460
461
462describe("TASK-0124 formula — happy path", () => {
463 it("a description → engine-validated expression saved to Formula (AST + returnType), FORMULA prop, evaluated samples", async () => {
464 const { owner, workspaceId } = await freshWorkspace("Formula Owner");
465 const databaseId = await createDb(owner.accessToken!, workspaceId, "Formula DB");
466
467
468 const recIds: string[] = [];
469 for (const title of ["Done", "Open", "Done"]) {
470 const r = await addRecord(owner.accessToken!, databaseId, { title });
471 recIds.push(String(r.id));
472 }
473
474 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/databases/formulas`, {
475 headers: authHeaders(owner.accessToken!),
476 body: { databaseId, description: "score a row 100 when it is done", name: "Points" },
477 });
478 expect(res.status).toBe(200);
479 const body = (await json(res)) as {
480 formulaId: string;
481 propertyId: string;
482 expression: string;
483 returnType: string;
484 samples: Array<{ recordId: string; value: unknown; error?: string }>;
485 tokensIn: number;
486 tokensOut: number;
487 };
488 expect(body.expression).toBe('if(prop("Title") == "Done", 100, 0)');
489 expect(body.returnType).toBe("number");
490 expect(body.samples).toHaveLength(3);
491
492 expect(body.samples.map((s) => s.value)).toEqual([100, 0, 100]);
493 for (const s of body.samples) expect(s.error).toBeUndefined();
494
495
496
497 const formula = await app.prisma.formula.findUniqueOrThrow({ where: { id: body.formulaId } });
498 expect(formula.expression).toBe(body.expression);
499 expect(formula.returnType).toBe("number");
500 const ast = formula.ast as Record<string, unknown>;
501 expect(ast).toBeTruthy();
502 expect(ast.raw).toBeUndefined();
503 const titleProp = (await listProperties(owner.accessToken!, databaseId)).find((p) => p.name === "Title")!;
504 expect(formula.dependencies).toEqual([titleProp.id]);
505
506
507 const formulaProp = await app.prisma.databaseProperty.findFirst({
508 where: { databaseId, name: "Points", type: "FORMULA" },
509 });
510 expect(formulaProp).toBeTruthy();
511 expect(body.propertyId).toBe(formulaProp!.id);
512
513
514 const aiAudit = await app.prisma.auditLog.findFirst({
515 where: { workspaceId, action: "ai.formula_generated" },
516 });
517 expect(aiAudit).toBeTruthy();
518 const created = await app.prisma.auditLog.findFirst({
519 where: { workspaceId, action: "database.formula_created", resourceId: body.formulaId },
520 });
521 expect(created).toBeTruthy();
522
523
524
525 const conv = await app.prisma.aiConversation.findFirst({
526 where: { workspaceId, title: "db:formula" },
527 orderBy: { createdAt: "desc" },
528 });
529 expect(conv).toBeTruthy();
530 const row = await app.prisma.aiMessage.findFirst({
531 where: { conversationId: conv!.id, role: "assistant" },
532 orderBy: { createdAt: "desc" },
533 });
534 expect(row).toBeTruthy();
535 expect((row!.metadata as { feature: string }).feature).toBe("db.formula");
536
537 await assertHiddenConvsAbsent(owner.accessToken!, workspaceId, ["db:formula"]);
538 void recIds;
539 });
540});
541
542describe("TASK-0124 formula — hostile", () => {
543 it("an unparseable expression → retry-once → 422 formula_invalid, no Formula row", async () => {
544 const { owner, workspaceId } = await freshWorkspace("FormulaHostile Owner");
545 const databaseId = await createDb(owner.accessToken!, workspaceId, "FormulaHostile DB");
546 await addRecord(owner.accessToken!, databaseId, { title: "a row" });
547 await armDbInvalid(workspaceId, "formula");
548
549 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/databases/formulas`, {
550 headers: authHeaders(owner.accessToken!),
551 body: { databaseId, description: "something impossible" },
552 });
553 expect(res.status).toBe(422);
554 const body = (await json(res)) as { error: { code: string; details?: Array<{ reason?: string; parseError?: string }> } };
555 expect(body.error.code).toBe("UNPROCESSABLE_ENTITY");
556 expect(body.error.details?.[0]?.reason).toBe("formula_invalid");
557 expect(body.error.details?.[0]?.parseError).toBeTruthy();
558
559
560 const count = await app.prisma.formula.count({ where: { databaseId } });
561 expect(count).toBe(0);
562
563 const prop = await app.prisma.databaseProperty.findFirst({ where: { databaseId, type: "FORMULA" } });
564 expect(prop).toBeNull();
565 });
566});
567
568
569
570
571
572describe("TASK-0124 budget", () => {
573 it("an exhausted budget → 402 AI_BUDGET_EXCEEDED (pre-stream, no db:generate conv)", async () => {
574 const { owner, workspaceId, h } = await freshWorkspace("BudgetDB Owner");
575 await app.prisma.workspace.update({
576 where: { id: workspaceId },
577 data: { usageLimits: { aiMonthlyTokens: 1 } },
578 });
579 resetWorkspaceCache();
580
581 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/ai/databases/generate`, {
582 headers: h,
583 body: { prompt: "a bug tracker" },
584 });
585 expect(res.status).toBe(402);
586 const body = await json(res);
587 expect(body.error.code).toBe("AI_BUDGET_EXCEEDED");
588 const convs = await app.prisma.aiConversation.findMany({ where: { workspaceId, title: "db:generate" } });
589 expect(convs).toHaveLength(0);
590
591
592 await app.prisma.workspace.update({ where: { id: workspaceId }, data: { usageLimits: {} } });
593 resetWorkspaceCache();
594 });
595});
596
597