1
25import { describe, expect, it, vi, beforeEach } from "vitest";
26
27import {
28 validateTools,
29 PROPERTY_TYPES,
30 GENERATABLE_PROPERTY_TYPES,
31 NON_GENERATABLE_PROPERTY_TYPES,
32 optionIdForName,
33 normalizePropertyType,
34 parseMarkdownTable,
35 mapColumnsToProperties,
36 DB_GEN_MAX_PROPERTIES,
37 DB_GEN_MAX_SAMPLE_RECORDS,
38 DB_EXTRACT_MAX_RECORDS,
39} from "@factory/ai";
40import {
41 validateCreateDatabase,
42 validateInsertRecords,
43 prepareAiValue,
44 previewExtractMapping,
45 CREATE_DATABASE_TOOL,
46 INSERT_RECORDS_TOOL,
47 GENERATE_FORMULA_TOOL,
48 renderExtractPrompt,
49 renderFormulaPrompt,
50 renderGeneratePrompt,
51 DB_GEN_SYSTEM_PROMPT,
52 type DbPropMeta,
53} from "../db-prompts.js";
54import { DatabaseAiService } from "../database-ai.service.js";
55
56vi.mock("../../../lib/authorize.js", async (importOriginal) => {
57 const actual = await importOriginal<typeof import("../../../lib/authorize.js")>();
58 return { ...actual, assertCan: vi.fn(async () => undefined) };
59});
60
61const WS = "ws1";
62const USER = "u1";
63
64
65
66
67describe("validateCreateDatabase (generate)", () => {
68 it("normalizes a clean call: options get deterministic ids, sample records keyed by NAME", () => {
69 const r = validateCreateDatabase({
70 title: "Tasks",
71 description: "a task tracker",
72 properties: [
73 { name: "Priority", type: "select", options: [{ name: "High" }, { name: "Low" }] },
74 { name: "Due", type: "date" },
75 { name: "Done", type: "checkbox" },
76 ],
77 sampleRecords: [{ properties: { Title: "Ship it", Priority: "High", Due: "2026-01-01", Done: "yes" } }],
78 });
79 expect(r.ok).toBe(true);
80 if (!r.ok) return;
81 expect(r.value.title).toBe("Tasks");
82 const priority = r.value.properties.find((p) => p.name === "Priority")!;
83 expect(priority.type).toBe("SELECT");
84 expect(priority.options!.map((o) => o.id)).toEqual([optionIdForName("High"), optionIdForName("Low")]);
85 expect(priority.optionNameToId.get("high")).toBe(optionIdForName("High"));
86 const rec = r.value.sampleRecords[0]!;
87 expect(rec.Title).toBe("Ship it");
88 expect(rec.Priority).toBe(optionIdForName("High"));
89 expect(rec.Due).toBe("2026-01-01");
90 expect(rec.Done).toBe(true);
91 });
92
93 it("rejects a non-generatable type (relation) with a reason", () => {
94 const r = validateCreateDatabase({ title: "X", properties: [{ name: "Link", type: "relation" }] });
95 expect(r.ok).toBe(false);
96 if (r.ok) return;
97 expect(r.reason).toBe("ai_invalid_structure");
98 expect(r.feedback).toMatch(/RELATION/i);
99 });
100
101 it("rejects re-declaring the seeded Title / Status", () => {
102 expect(validateCreateDatabase({ title: "X", properties: [{ name: "Title", type: "text" }] }).ok).toBe(false);
103 expect(validateCreateDatabase({ title: "X", properties: [{ name: "Status", type: "select", options: [{ name: "A" }] }] }).ok).toBe(false);
104 });
105
106 it("rejects a duplicate property name (case-insensitive)", () => {
107 const r = validateCreateDatabase({ title: "X", properties: [{ name: "Due", type: "date" }, { name: "due", type: "text" }] });
108 expect(r.ok).toBe(false);
109 if (!r.ok) expect(r.feedback).toMatch(/duplicate/i);
110 });
111
112 it("rejects a select without options", () => {
113 expect(validateCreateDatabase({ title: "X", properties: [{ name: "P", type: "select" }] }).ok).toBe(false);
114 });
115
116 it("rejects a sample value that is wrong for the property type", () => {
117 const r = validateCreateDatabase({
118 title: "X",
119 properties: [{ name: "Due", type: "date" }],
120 sampleRecords: [{ properties: { Due: true } }],
121 });
122 expect(r.ok).toBe(false);
123 if (!r.ok) expect(r.feedback).toMatch(/not valid for type/i);
124 });
125
126 it("enforces the ≤30 property and ≤10 sample-record caps (schema-level)", () => {
127 const props = Array.from({ length: DB_GEN_MAX_PROPERTIES + 1 }, (_, i) => ({ name: `p${i}`, type: "text" }));
128 expect(validateCreateDatabase({ title: "X", properties: props }).ok).toBe(false);
129 const recs = Array.from({ length: DB_GEN_MAX_SAMPLE_RECORDS + 1 }, (_, i) => {
130 const key = "c" + i;
131 const props: Record<string, unknown> = {};
132 props[key] = i;
133 return { properties: props };
134 });
135 expect(validateCreateDatabase({ title: "X", properties: [{ name: "a", type: "text" }], sampleRecords: recs }).ok).toBe(false);
136 });
137
138 it("normalizes common LLM type synonyms (multiselect, boolean, …)", () => {
139 const r = validateCreateDatabase({
140 title: "X",
141 properties: [
142 { name: "Tags", type: "multiSelect", options: [{ name: "a" }] },
143 { name: "Flag", type: "boolean" },
144 ],
145 });
146 expect(r.ok).toBe(true);
147 if (!r.ok) return;
148 expect(r.value.properties[0]!.type).toBe("MULTI_SELECT");
149 expect(r.value.properties[1]!.type).toBe("CHECKBOX");
150 });
151});
152
153
154
155
156const PROPS: DbPropMeta[] = [
157 { id: "prop_title", name: "Title", type: "TITLE", isTitle: true },
158 { id: "prop_name", name: "Name", type: "TEXT" },
159 { id: "prop_due", name: "Due", type: "DATE" },
160 { id: "prop_state", name: "State", type: "SELECT", config: { options: [{ id: "opt-a", name: "A" }, { id: "opt-b", name: "B" }] } },
161];
162
163describe("validateInsertRecords (extract)", () => {
164 it("maps propertyId-keyed records; title → __title__; select name → id; checkbox → bool", () => {
165 const r = validateInsertRecords(
166 {
167 records: [
168 { properties: { prop_title: "Row 1", prop_name: "n1", prop_due: "2026-01-01", prop_state: "A" } },
169 { properties: { prop_name: "n2" } },
170 ],
171 ignoredColumns: ["Notes"],
172 },
173 PROPS,
174 );
175 expect(r.ok).toBe(true);
176 if (!r.ok) return;
177 expect(r.value.records[0]!.properties.__title__).toBe("Row 1");
178 expect(r.value.records[0]!.properties.prop_state).toBe("opt-a");
179 expect(r.value.records[0]!.properties.prop_due).toBe("2026-01-01");
180 expect(r.value.records[1]!.properties.prop_name).toBe("n2");
181 expect(r.value.ignoredColumns).toEqual(["Notes"]);
182 });
183
184 it("rejects an unknown propertyId (the model never invents ids)", () => {
185 const r = validateInsertRecords({ records: [{ properties: { does_not_exist: "x" } }] }, PROPS);
186 expect(r.ok).toBe(false);
187 if (!r.ok) expect(r.feedback).toMatch(/unknown propertyId/i);
188 });
189
190 it("rejects a value that is wrong for the property type (a bool for a date)", () => {
191 const r = validateInsertRecords({ records: [{ properties: { prop_due: true } }] }, PROPS);
192 expect(r.ok).toBe(false);
193 if (!r.ok) expect(r.feedback).toMatch(/invalid/i);
194 });
195
196 it("rejects > 200 records (the InsertRecordsSchema caps at 200)", () => {
197 const records = Array.from({ length: DB_EXTRACT_MAX_RECORDS + 1 }, () => ({ properties: { prop_name: "x" } }));
198 const r = validateInsertRecords({ records }, PROPS);
199 expect(r.ok).toBe(false);
200 if (r.ok) return;
201
202
203 expect(r.reason).toBe("ai_invalid_structure");
204 expect(r.feedback).toMatch(/200/);
205 });
206
207 it("rejects a malformed shape (records missing `properties`)", () => {
208 const r = validateInsertRecords({ records: [{}] }, PROPS);
209 expect(r.ok).toBe(false);
210 });
211});
212
213
214
215
216describe("prepareAiValue (per-type coercion)", () => {
217 const selectMap = new Map([["a", "opt-a"], ["b", "opt-b"]]);
218 it("SELECT/STATUS: option name → id (unknown name kept as-is)", () => {
219 expect(prepareAiValue("A", "SELECT", selectMap)).toBe("opt-a");
220 expect(prepareAiValue("B", "STATUS", selectMap)).toBe("opt-b");
221 expect(prepareAiValue("Nope", "SELECT", selectMap)).toBe("Nope");
222 expect(prepareAiValue(42, "SELECT", selectMap)).toBeUndefined();
223 });
224 it("MULTI_SELECT: comma/;-split → ids", () => {
225 expect(prepareAiValue("a, b", "MULTI_SELECT", selectMap)).toEqual(["opt-a", "opt-b"]);
226 expect(prepareAiValue(["a", "b"], "MULTI_SELECT", selectMap)).toEqual(["opt-a", "opt-b"]);
227 expect(prepareAiValue("nope", "MULTI_SELECT", selectMap)).toEqual(["nope"]);
228 });
229 it("CHECKBOX: yes/1/on/x/✓ → true, no/0/off → false, bool passthrough", () => {
230 expect(prepareAiValue("yes", "CHECKBOX", selectMap)).toBe(true);
231 expect(prepareAiValue("1", "CHECKBOX", selectMap)).toBe(true);
232 expect(prepareAiValue("✓", "CHECKBOX", selectMap)).toBe(true);
233 expect(prepareAiValue("no", "CHECKBOX", selectMap)).toBe(false);
234 expect(prepareAiValue("0", "CHECKBOX", selectMap)).toBe(false);
235 expect(prepareAiValue(true, "CHECKBOX", selectMap)).toBe(true);
236 expect(prepareAiValue("maybe", "CHECKBOX", selectMap)).toBeUndefined();
237 });
238 it("NUMBER: number passthrough, numeric string → number, junk → undefined", () => {
239 expect(prepareAiValue(3.14, "NUMBER", selectMap)).toBe(3.14);
240 expect(prepareAiValue("42", "NUMBER", selectMap)).toBe(42);
241 expect(prepareAiValue("abc", "NUMBER", selectMap)).toBeUndefined();
242 });
243 it("DATE/DATE_RANGE: ISO string or {start}", () => {
244 expect(prepareAiValue("2026-01-01", "DATE", selectMap)).toBe("2026-01-01");
245 expect(prepareAiValue({ start: "2026-01-01" }, "DATE_RANGE", selectMap)).toEqual({ start: "2026-01-01" });
246 expect(prepareAiValue(123, "DATE", selectMap)).toBeUndefined();
247 });
248 it("PERSON: array or single id; TEXT/RICH_TEXT: string (number → string)", () => {
249 expect(prepareAiValue(["u1", "u2"], "PERSON", selectMap)).toEqual(["u1", "u2"]);
250 expect(prepareAiValue("u1", "PERSON", selectMap)).toEqual(["u1"]);
251 expect(prepareAiValue("hi", "TEXT", selectMap)).toBe("hi");
252 expect(prepareAiValue(7, "RICH_TEXT", selectMap)).toBe("7");
253 });
254 it("empty/null → undefined (the cell is omitted)", () => {
255 expect(prepareAiValue("", "TEXT", selectMap)).toBeUndefined();
256 expect(prepareAiValue(null, "NUMBER", selectMap)).toBeUndefined();
257 expect(prepareAiValue(undefined, "CHECKBOX", selectMap)).toBeUndefined();
258 });
259});
260
261
262
263
264describe("parseMarkdownTable (deterministic parser)", () => {
265 it("parses a standard table (header + separator + rows)", () => {
266 const t = parseMarkdownTable("| Name | Due |\n| --- | --- |\n| a | 2026-01-01 |\n| b | 2026-01-02 |");
267 expect(t.isTable).toBe(true);
268 expect(t.columns).toEqual(["Name", "Due"]);
269 expect(t.rows).toEqual([
270 { Name: "a", Due: "2026-01-01" },
271 { Name: "b", Due: "2026-01-02" },
272 ]);
273 });
274
275 it("handles escaped pipes (\\|) inside a cell", () => {
276 const t = parseMarkdownTable("| A |\n| --- |\n| x\\|y |");
277 expect(t.columns).toEqual(["A"]);
278 expect(t.rows[0]!.A).toBe("x|y");
279 });
280
281 it("fills empty header cells with a synthetic column_i name", () => {
282 const t = parseMarkdownTable("| | Name |\n| --- | --- |\n| 1 | a |");
283 expect(t.columns).toEqual(["column_1", "Name"]);
284 expect(t.rows[0]!.column_1).toBe("1");
285 });
286
287 it("returns a non-table for prose / a single row / no pipes", () => {
288 expect(parseMarkdownTable("just some words").isTable).toBe(false);
289 expect(parseMarkdownTable("| only one |").isTable).toBe(false);
290 expect(parseMarkdownTable("").isTable).toBe(false);
291 });
292
293 it("skips the separator row and aligns missing cells to ''", () => {
294 const t = parseMarkdownTable("| A | B |\n| --- | --- |\n| only-a |");
295 expect(t.columns).toEqual(["A", "B"]);
296 expect(t.rows).toEqual([{ A: "only-a", B: "" }]);
297 });
298});
299
300describe("mapColumnsToProperties (name match)", () => {
301 it("matches case-insensitively (whitespace-normalized) and reports ignored columns", () => {
302 const { mapped, ignored } = mapColumnsToProperties(
303 ["name", " Due ", "Notes"],
304 [
305 { id: "p1", name: "Name" },
306 { id: "p2", name: "Due" },
307 ],
308 );
309 expect(mapped).toEqual([
310 { column: "name", propertyId: "p1" },
311 { column: " Due ", propertyId: "p2" },
312 ]);
313 expect(ignored).toEqual(["Notes"]);
314 });
315});
316
317describe("previewExtractMapping (the dry-run mapping)", () => {
318 it("combines parse + map + rowCount (no write)", () => {
319 const m = previewExtractMapping("| Name | Unknown |\n| --- | --- |\n| a | z |", PROPS);
320 expect(m.isTable).toBe(true);
321 expect(m.rowCount).toBe(1);
322 expect(m.columns).toEqual(["Name", "Unknown"]);
323 expect(m.mapped).toEqual([{ column: "Name", propertyId: "prop_name" }]);
324 expect(m.ignored).toEqual(["Unknown"]);
325 });
326});
327
328
329
330
331describe("tool specs + allowlist", () => {
332 it("the three tool specs pass the @factory/ai STRICT tool validator", () => {
333
334 for (const tool of [CREATE_DATABASE_TOOL, INSERT_RECORDS_TOOL, GENERATE_FORMULA_TOOL]) {
335 const v = validateTools([tool as never]);
336 expect(v.ok).toBe(true);
337 }
338 });
339
340 it("the allowlist is exhaustive: GENERATABLE ∪ NON_GENERATABLE = the 24 real types, disjoint", () => {
341 const gen = new Set(GENERATABLE_PROPERTY_TYPES as readonly string[]);
342 const nonGen = new Set(Object.keys(NON_GENERATABLE_PROPERTY_TYPES));
343 const all = new Set(PROPERTY_TYPES as readonly string[]);
344 expect(all.size).toBe(24);
345
346 for (const t of all) expect(gen.has(t) || nonGen.has(t)).toBe(true);
347
348 for (const t of gen) expect(nonGen.has(t)).toBe(false);
349
350 for (const t of gen) expect(all.has(t)).toBe(true);
351 for (const t of nonGen) expect(all.has(t)).toBe(true);
352
353 expect((PROPERTY_TYPES as readonly string[]).includes("EXTRACTION")).toBe(false);
354 });
355
356 it("normalizePropertyType: exact enum, synonyms, and null for unknown", () => {
357 expect(normalizePropertyType("SELECT")).toBe("SELECT");
358 expect(normalizePropertyType("multiSelect")).toBe("MULTI_SELECT");
359 expect(normalizePropertyType("date range")).toBe("DATE_RANGE");
360 expect(normalizePropertyType("bogus-type")).toBeNull();
361 expect(normalizePropertyType(42)).toBeNull();
362 });
363
364 it("optionIdForName is deterministic + slug-safe", () => {
365 expect(optionIdForName("To Do")).toBe(optionIdForName("to-do"));
366 expect(optionIdForName("Àccent")).toBe(optionIdForName("accent"));
367 expect(optionIdForName(" ")).toMatch(/^opt-\d+$/);
368 });
369
370 it("the system prompts frame the pasted/described text as UNTRUSTED + pin the tool contract", () => {
371 expect(DB_GEN_SYSTEM_PROMPT).toMatch(/EXACTLY ONCE/i);
372 expect(DB_GEN_SYSTEM_PROMPT).toMatch(/untrusted/i);
373 expect(renderGeneratePrompt("a db")).toContain("a db");
374 });
375
376 it("renderExtractPrompt lists [PROP <id>] lines + wraps the text in <<<PASTE…PASTE>>>", () => {
377 const p = renderExtractPrompt({ databaseTitle: "T", properties: PROPS.filter((x) => !x.isTitle), text: "hello" });
378 expect(p).toContain("[PROP prop_name] Name (TEXT)");
379 expect(p).toContain("<<<PASTE\nhello\nPASTE>>>");
380
381 expect(p).not.toContain("prop_title");
382 });
383
384 it("renderFormulaPrompt lists [PROP <name>] with LOWERCASE engine types", () => {
385 const p = renderFormulaPrompt({ properties: [{ name: "State", type: "SELECT" }, { name: "Due", type: "DATE" }], description: "is done?" });
386 expect(p).toContain('[PROP State] select');
387 expect(p).toContain('[PROP Due] date');
388 expect(p).toContain("is done?");
389 });
390});
391
392
393
394
395describe("retry-once contract (DatabaseAiService)", () => {
396 function makeRes() {
397 const written: string[] = [];
398 return {
399 written,
400 writableEnded: false,
401 status() { return this as never; },
402 setHeader() { return this as never; },
403 flushHeaders() { },
404 socket: { setNoDelay() {} },
405 on() { return this as never; },
406 off() { return this as never; },
407 write(s: string) { written.push(s); return true; },
408 end() { (this as { writableEnded: boolean }).writableEnded = true; return this as never; },
409 };
410 }
411
412
413 function makeService(gatewayChat: (...a: any[]) => AsyncIterable<unknown>) {
414 const prisma = {
415 workspace: { findUnique: async () => ({ settings: {}, usageLimits: {}, billingPlan: "free" }) },
416 aiConversation: { create: async () => ({ id: "conv1" }) },
417 aiMessage: { create: async () => ({ id: "msg1" }) },
418 database: { findFirst: async () => ({ id: "db1", title: "T", workspaceId: WS }) },
419 databaseProperty: { findMany: async () => PROPS.map(({ id, name, type, isTitle, config }) => ({ id, name, type, isTitle, config })) },
420 formula: { create: async () => ({ id: "f1", propertyId: "prop_f" }) },
421 };
422 const gateway = {
423 resolve: () => ({ chatModel: "mock" }),
424 chat: gatewayChat as never,
425 };
426 const writes = { page: 0, prop: 0, record: 0, formula: 0 };
427 const service = new DatabaseAiService(
428 prisma as never,
429 gateway as never,
430 { guard: { check: async () => undefined } } as never,
431 { check: async () => ({ allowed: true }) } as never,
432 { createProperty: async () => { writes.prop++; return { id: "p_new" }; }, createFormula: async () => { writes.formula++; return { id: "f1", propertyId: "prop_f" }; } } as never,
433 { createRecord: async () => { writes.record++; return { id: "r1" }; } } as never,
434 { create: async () => { writes.page++; return { id: "pg1", databaseId: "db1", blockIds: [] }; } } as never,
435 { audit: async () => undefined } as never,
436 );
437 return { service, writes };
438 }
439
440 it("an invalid createDatabase call → EXACTLY a second chat (with feedback) and NO partial write", async () => {
441 const calls: Array<{ user: string }> = [];
442
443
444 const hostileArgs = { title: "X", properties: [{ name: "Broken", type: "relation" }], sampleRecords: [] };
445
446 const chat = async function* (params: any) {
447 const user = [...params.messages].reverse().find((m: { role: string; content: string }) => m.role === "user")!.content;
448 calls.push({ user });
449 yield { toolCallDelta: { id: "t0", name: "createDatabase", arguments: hostileArgs }, done: false };
450 yield { usage: { in: 1, out: 1, total: 2 }, finishReason: "tool_calls", done: true };
451 };
452 const { service, writes } = makeService(chat);
453 const res = makeRes();
454 let threw: unknown;
455 await service.generate(WS, USER, { prompt: "make a db" }, { auth: { userId: USER } } as never, res as never).catch((e) => (threw = e));
456
457
458 expect(calls.length).toBe(2);
459 expect(calls[0]!.user).not.toContain("INVALID");
460 expect(calls[1]!.user).toContain("INVALID");
461 expect(writes.page).toBe(0);
462 expect(writes.prop).toBe(0);
463 expect(writes.record).toBe(0);
464
465 expect(res.written.some((s) => s.includes("ai_invalid_structure"))).toBe(true);
466 expect(res.written.some((s) => s.includes('"reason":"error"'))).toBe(true);
467 void threw;
468 });
469
470 it("a valid createDatabase call → ONE chat, and the writes happen", async () => {
471 const goodArgs = {
472 title: "Tasks",
473 properties: [{ name: "Due", type: "date" }],
474 sampleRecords: [{ properties: { Title: "Row", Due: "2026-01-01" } }],
475 };
476 const calls = 0;
477 const chat = async function* () {
478 calls + 0;
479 yield { toolCallDelta: { id: "t0", name: "createDatabase", arguments: goodArgs }, done: false };
480 yield { usage: { in: 1, out: 1, total: 2 }, finishReason: "tool_calls", done: true };
481 };
482 const { service, writes } = makeService(chat);
483 const res = makeRes();
484 await service.generate(WS, USER, { prompt: "make a db" }, { auth: { userId: USER } } as never, res as never);
485 expect(writes.page).toBe(1);
486 expect(writes.prop).toBe(1);
487 expect(writes.record).toBe(1);
488 expect(res.written.some((s) => s.includes('"reason":"ok"'))).toBe(true);
489 expect(res.written.some((s) => s.includes("db1"))).toBe(true);
490 });
491});
492