import { describe, expect, it } from "vitest"; import { createViewSchema, databasePropertySchema, filterNodeSchema, normalizeSelectOptions, optionIdForName, propertyConfigSchema, recordQuerySchema, } from "./database.dto.js"; import type { FilterNode } from "./database.dto.js"; describe("propertyConfigSchema (per-type configs)", () => { it("accepts select with options", () => { const r = propertyConfigSchema.safeParse({ type: "select", config: { options: [{ id: "o1", name: "In Progress", color: "blue" }] }, }); expect(r.success).toBe(true); }); it("accepts status with options", () => { const r = propertyConfigSchema.safeParse({ type: "status", config: { options: [{ id: "o1", name: "Done", color: "green" }] }, }); expect(r.success).toBe(true); }); it("rejects select without options", () => { expect(propertyConfigSchema.safeParse({ type: "select", config: {} }).success).toBe(false); }); it("accepts formula with expression", () => { expect( propertyConfigSchema.safeParse({ type: "formula", config: { expression: 'if(prop("Status") == "Done", 100, 0)' }, }).success, ).toBe(true); }); it("rejects formula without expression", () => { expect(propertyConfigSchema.safeParse({ type: "formula", config: {} }).success).toBe(false); }); it("accepts button with actions", () => { const r = propertyConfigSchema.safeParse({ type: "button", config: { label: "Start", actions: [{ type: "set_property", config: { propertyId: "p1", value: "Active" } }], }, }); expect(r.success).toBe(true); }); it("rejects button with empty actions", () => { expect( propertyConfigSchema.safeParse({ type: "button", config: { actions: [] } }).success, ).toBe(false); }); it("accepts number format/prefix/suffix", () => { expect( propertyConfigSchema.safeParse({ type: "number", config: { format: "dollar", prefix: "$", suffix: "", decimals: 2 }, }).success, ).toBe(true); }); it("accepts relation with relationId", () => { expect( propertyConfigSchema.safeParse({ type: "relation", config: { relationId: "rel1" } }).success, ).toBe(true); }); it("accepts date with format", () => { expect( propertyConfigSchema.safeParse({ type: "date", config: { format: "yyyy-mm-dd" } }).success, ).toBe(true); }); it("title requires empty config", () => { expect(propertyConfigSchema.safeParse({ type: "title", config: {} }).success).toBe(true); expect(propertyConfigSchema.safeParse({ type: "title", config: { x: 1 } }).success).toBe(false); }); }); describe("databasePropertySchema", () => { it("validates config against the property type", () => { // number property given a select config → invalid const bad = databasePropertySchema.safeParse({ name: "Budget", type: "number", config: { options: [{ id: "o1", name: "x", color: "blue" }] }, }); expect(bad.success).toBe(false); const good = databasePropertySchema.safeParse({ name: "Budget", type: "number", config: { format: "dollar" }, }); expect(good.success).toBe(true); }); }); describe("filterNodeSchema (recursive AND/OR/NOT)", () => { it("parses a leaf condition", () => { const r = filterNodeSchema.safeParse({ propertyId: "status", operator: "=", value: "In Progress", }); expect(r.success).toBe(true); }); it("rejects a leaf with a non-empty operator missing value", () => { const r = filterNodeSchema.safeParse({ propertyId: "status", operator: "=" }); expect(r.success).toBe(false); }); it("allows is_empty without a value", () => { const r = filterNodeSchema.safeParse({ propertyId: "owner", operator: "is_empty" }); expect(r.success).toBe(true); }); it("parses nested AND/OR/NOT", () => { const tree: FilterNode = { op: "and", conditions: [ { op: "or", conditions: [ { propertyId: "status", operator: "=", value: "In Progress" }, { propertyId: "status", operator: "=", value: "Review" }, ], }, { op: "not", conditions: [{ propertyId: "archived", operator: "=", value: true }] }, ], }; const r = filterNodeSchema.safeParse(tree); expect(r.success).toBe(true); }); it("rejects a group with no conditions", () => { expect(filterNodeSchema.safeParse({ op: "and", conditions: [] }).success).toBe(false); }); it("rejects an invalid operator", () => { expect(filterNodeSchema.safeParse({ propertyId: "a", operator: "~~~", value: 1 }).success).toBe( false, ); }); it("rejects a node that is neither group nor leaf", () => { expect(filterNodeSchema.safeParse({ foo: "bar" }).success).toBe(false); }); }); describe("recordQuerySchema", () => { it("defaults pageSize to 50 and caps at 200", () => { expect(recordQuerySchema.parse({}).pageSize).toBe(50); expect(recordQuerySchema.parse({ pageSize: 200 }).pageSize).toBe(200); expect(recordQuerySchema.safeParse({ pageSize: 201 }).success).toBe(false); expect(recordQuerySchema.safeParse({ pageSize: 0 }).success).toBe(false); }); it("accepts filter + sort + group", () => { const r = recordQuerySchema.safeParse({ filter: { op: "and", conditions: [{ propertyId: "p", operator: ">", value: 5 }] }, sort: [{ propertyId: "p", direction: "desc" }], group: [{ propertyId: "q" }], }); expect(r.success).toBe(true); }); }); describe("optionIdForName (deterministic option-id generator — single source)", () => { it("is deterministic and slug-safe", () => { expect(optionIdForName("To Do")).toBe("opt-to-do"); expect(optionIdForName("In Progress")).toBe("opt-in-progress"); expect(optionIdForName("Done")).toBe("opt-done"); // case-insensitive expect(optionIdForName("To Do")).toBe(optionIdForName("to-do")); expect(optionIdForName("In Progress")).toBe(optionIdForName("IN_PROGRESS")); // diacritics are stripped (NFKD) expect(optionIdForName("Àccent")).toBe(optionIdForName("accent")); // a name with no surviving slug falls back to a length-derived id expect(optionIdForName(" ")).toMatch(/^opt-\d+$/); expect(optionIdForName(" ")).toBe(optionIdForName(" ")); }); }); describe("normalizeSelectOptions (option-id backfill — single source)", () => { it("assigns deterministic ids to id-less options", () => { const out = normalizeSelectOptions({ options: [ { name: "To Do", color: "default" }, { name: "In Progress", color: "blue" }, { name: "Done", color: "green" }, ], }) as { options: { id: string; name: string; color: string }[] }; expect(out.options.map((o) => o.id)).toEqual([ optionIdForName("To Do"), optionIdForName("In Progress"), optionIdForName("Done"), ]); // existing fields are preserved expect(out.options[0]).toEqual({ name: "To Do", color: "default", id: optionIdForName("To Do"), }); }); it("is idempotent: an already-conformant config is returned by reference", () => { const cfg = { options: [{ id: "opt-x", name: "X", color: "blue" }] }; expect(normalizeSelectOptions(cfg)).toBe(cfg); }); it("returns null/undefined/option-less configs unchanged (by reference)", () => { expect(normalizeSelectOptions(null)).toBe(null); expect(normalizeSelectOptions(undefined)).toBe(undefined); const empty = {}; expect(normalizeSelectOptions(empty)).toBe(empty); const noOptions = { format: "dollar" }; expect(normalizeSelectOptions(noOptions)).toBe(noOptions); }); it("reuses an existing option's id by NAME (incl. a custom id) so stored values stay resolvable", () => { const existing = [{ id: "custom-id-1", name: "Done", color: "green" }]; const out = normalizeSelectOptions( { options: [{ name: "Done", color: "green" }] }, existing, ) as { options: { id: string }[] }; expect(out.options[0]?.id).toBe("custom-id-1"); }); it("leaves already-id'd options untouched even when a custom id exists", () => { const cfg = { options: [{ id: "custom-id-1", name: "Done", color: "green" }] }; expect(normalizeSelectOptions(cfg, [{ id: "opt-done", name: "Done", color: "green" }])).toBe(cfg); }); it("mixes: new options get deterministic ids, re-listed existing ones reuse theirs", () => { const out = normalizeSelectOptions( { options: [ { name: "Done", color: "green" }, // existing (custom id) { name: "Blocked", color: "orange" }, // genuinely new ], }, [{ id: "custom-done", name: "Done", color: "green" }], ) as { options: { id: string }[] }; expect(out.options[0]?.id).toBe("custom-done"); expect(out.options[1]?.id).toBe(optionIdForName("Blocked")); }); it("does not mutate the input config (a new object is returned on change)", () => { const cfg = { options: [{ name: "To Do", color: "default" }] }; const out = normalizeSelectOptions(cfg) as { options: { id: string }[] }; expect(out.options[0]?.id).toBe("opt-to-do"); expect((cfg.options[0] as { id?: string } | undefined)?.id).toBeUndefined(); }); }); describe("createViewSchema", () => { it("accepts a table view with config", () => { const r = createViewSchema.safeParse({ name: "All Tasks", type: "table", config: { visiblePropertyIds: ["a", "b"], columnWidths: { a: 200 }, density: "default" }, }); expect(r.success).toBe(true); }); it("accepts all 9 view types", () => { for (const t of [ "table", "list", "board", "gallery", "calendar", "timeline", "chart", "dashboard", "form", ]) { expect(createViewSchema.safeParse({ name: "v", type: t }).success).toBe(true); } }); it("rejects unknown view type", () => { expect(createViewSchema.safeParse({ name: "v", type: "kanban" }).success).toBe(false); }); });