1import { describe, expect, it } from "vitest";
2
3import {
4 createViewSchema,
5 databasePropertySchema,
6 filterNodeSchema,
7 normalizeSelectOptions,
8 optionIdForName,
9 propertyConfigSchema,
10 recordQuerySchema,
11} from "./database.dto.js";
12import type { FilterNode } from "./database.dto.js";
13
14describe("propertyConfigSchema (per-type configs)", () => {
15 it("accepts select with options", () => {
16 const r = propertyConfigSchema.safeParse({
17 type: "select",
18 config: { options: [{ id: "o1", name: "In Progress", color: "blue" }] },
19 });
20 expect(r.success).toBe(true);
21 });
22
23 it("accepts status with options", () => {
24 const r = propertyConfigSchema.safeParse({
25 type: "status",
26 config: { options: [{ id: "o1", name: "Done", color: "green" }] },
27 });
28 expect(r.success).toBe(true);
29 });
30
31 it("rejects select without options", () => {
32 expect(propertyConfigSchema.safeParse({ type: "select", config: {} }).success).toBe(false);
33 });
34
35 it("accepts formula with expression", () => {
36 expect(
37 propertyConfigSchema.safeParse({
38 type: "formula",
39 config: { expression: 'if(prop("Status") == "Done", 100, 0)' },
40 }).success,
41 ).toBe(true);
42 });
43
44 it("rejects formula without expression", () => {
45 expect(propertyConfigSchema.safeParse({ type: "formula", config: {} }).success).toBe(false);
46 });
47
48 it("accepts button with actions", () => {
49 const r = propertyConfigSchema.safeParse({
50 type: "button",
51 config: {
52 label: "Start",
53 actions: [{ type: "set_property", config: { propertyId: "p1", value: "Active" } }],
54 },
55 });
56 expect(r.success).toBe(true);
57 });
58
59 it("rejects button with empty actions", () => {
60 expect(
61 propertyConfigSchema.safeParse({ type: "button", config: { actions: [] } }).success,
62 ).toBe(false);
63 });
64
65 it("accepts number format/prefix/suffix", () => {
66 expect(
67 propertyConfigSchema.safeParse({
68 type: "number",
69 config: { format: "dollar", prefix: "$", suffix: "", decimals: 2 },
70 }).success,
71 ).toBe(true);
72 });
73
74 it("accepts relation with relationId", () => {
75 expect(
76 propertyConfigSchema.safeParse({ type: "relation", config: { relationId: "rel1" } }).success,
77 ).toBe(true);
78 });
79
80 it("accepts date with format", () => {
81 expect(
82 propertyConfigSchema.safeParse({ type: "date", config: { format: "yyyy-mm-dd" } }).success,
83 ).toBe(true);
84 });
85
86 it("title requires empty config", () => {
87 expect(propertyConfigSchema.safeParse({ type: "title", config: {} }).success).toBe(true);
88 expect(propertyConfigSchema.safeParse({ type: "title", config: { x: 1 } }).success).toBe(false);
89 });
90});
91
92describe("databasePropertySchema", () => {
93 it("validates config against the property type", () => {
94
95 const bad = databasePropertySchema.safeParse({
96 name: "Budget",
97 type: "number",
98 config: { options: [{ id: "o1", name: "x", color: "blue" }] },
99 });
100 expect(bad.success).toBe(false);
101
102 const good = databasePropertySchema.safeParse({
103 name: "Budget",
104 type: "number",
105 config: { format: "dollar" },
106 });
107 expect(good.success).toBe(true);
108 });
109});
110
111describe("filterNodeSchema (recursive AND/OR/NOT)", () => {
112 it("parses a leaf condition", () => {
113 const r = filterNodeSchema.safeParse({
114 propertyId: "status",
115 operator: "=",
116 value: "In Progress",
117 });
118 expect(r.success).toBe(true);
119 });
120
121 it("rejects a leaf with a non-empty operator missing value", () => {
122 const r = filterNodeSchema.safeParse({ propertyId: "status", operator: "=" });
123 expect(r.success).toBe(false);
124 });
125
126 it("allows is_empty without a value", () => {
127 const r = filterNodeSchema.safeParse({ propertyId: "owner", operator: "is_empty" });
128 expect(r.success).toBe(true);
129 });
130
131 it("parses nested AND/OR/NOT", () => {
132 const tree: FilterNode = {
133 op: "and",
134 conditions: [
135 {
136 op: "or",
137 conditions: [
138 { propertyId: "status", operator: "=", value: "In Progress" },
139 { propertyId: "status", operator: "=", value: "Review" },
140 ],
141 },
142 { op: "not", conditions: [{ propertyId: "archived", operator: "=", value: true }] },
143 ],
144 };
145 const r = filterNodeSchema.safeParse(tree);
146 expect(r.success).toBe(true);
147 });
148
149 it("rejects a group with no conditions", () => {
150 expect(filterNodeSchema.safeParse({ op: "and", conditions: [] }).success).toBe(false);
151 });
152
153 it("rejects an invalid operator", () => {
154 expect(filterNodeSchema.safeParse({ propertyId: "a", operator: "~~~", value: 1 }).success).toBe(
155 false,
156 );
157 });
158
159 it("rejects a node that is neither group nor leaf", () => {
160 expect(filterNodeSchema.safeParse({ foo: "bar" }).success).toBe(false);
161 });
162});
163
164describe("recordQuerySchema", () => {
165 it("defaults pageSize to 50 and caps at 200", () => {
166 expect(recordQuerySchema.parse({}).pageSize).toBe(50);
167 expect(recordQuerySchema.parse({ pageSize: 200 }).pageSize).toBe(200);
168 expect(recordQuerySchema.safeParse({ pageSize: 201 }).success).toBe(false);
169 expect(recordQuerySchema.safeParse({ pageSize: 0 }).success).toBe(false);
170 });
171
172 it("accepts filter + sort + group", () => {
173 const r = recordQuerySchema.safeParse({
174 filter: { op: "and", conditions: [{ propertyId: "p", operator: ">", value: 5 }] },
175 sort: [{ propertyId: "p", direction: "desc" }],
176 group: [{ propertyId: "q" }],
177 });
178 expect(r.success).toBe(true);
179 });
180});
181
182describe("optionIdForName (deterministic option-id generator — single source)", () => {
183 it("is deterministic and slug-safe", () => {
184 expect(optionIdForName("To Do")).toBe("opt-to-do");
185 expect(optionIdForName("In Progress")).toBe("opt-in-progress");
186 expect(optionIdForName("Done")).toBe("opt-done");
187
188 expect(optionIdForName("To Do")).toBe(optionIdForName("to-do"));
189 expect(optionIdForName("In Progress")).toBe(optionIdForName("IN_PROGRESS"));
190
191 expect(optionIdForName("Àccent")).toBe(optionIdForName("accent"));
192
193 expect(optionIdForName(" ")).toMatch(/^opt-\d+$/);
194 expect(optionIdForName(" ")).toBe(optionIdForName(" "));
195 });
196});
197
198describe("normalizeSelectOptions (option-id backfill — single source)", () => {
199 it("assigns deterministic ids to id-less options", () => {
200 const out = normalizeSelectOptions({
201 options: [
202 { name: "To Do", color: "default" },
203 { name: "In Progress", color: "blue" },
204 { name: "Done", color: "green" },
205 ],
206 }) as { options: { id: string; name: string; color: string }[] };
207 expect(out.options.map((o) => o.id)).toEqual([
208 optionIdForName("To Do"),
209 optionIdForName("In Progress"),
210 optionIdForName("Done"),
211 ]);
212
213 expect(out.options[0]).toEqual({
214 name: "To Do",
215 color: "default",
216 id: optionIdForName("To Do"),
217 });
218 });
219
220 it("is idempotent: an already-conformant config is returned by reference", () => {
221 const cfg = { options: [{ id: "opt-x", name: "X", color: "blue" }] };
222 expect(normalizeSelectOptions(cfg)).toBe(cfg);
223 });
224
225 it("returns null/undefined/option-less configs unchanged (by reference)", () => {
226 expect(normalizeSelectOptions(null)).toBe(null);
227 expect(normalizeSelectOptions(undefined)).toBe(undefined);
228 const empty = {};
229 expect(normalizeSelectOptions(empty)).toBe(empty);
230 const noOptions = { format: "dollar" };
231 expect(normalizeSelectOptions(noOptions)).toBe(noOptions);
232 });
233
234 it("reuses an existing option's id by NAME (incl. a custom id) so stored values stay resolvable", () => {
235 const existing = [{ id: "custom-id-1", name: "Done", color: "green" }];
236 const out = normalizeSelectOptions(
237 { options: [{ name: "Done", color: "green" }] },
238 existing,
239 ) as { options: { id: string }[] };
240 expect(out.options[0]?.id).toBe("custom-id-1");
241 });
242
243 it("leaves already-id'd options untouched even when a custom id exists", () => {
244 const cfg = { options: [{ id: "custom-id-1", name: "Done", color: "green" }] };
245 expect(normalizeSelectOptions(cfg, [{ id: "opt-done", name: "Done", color: "green" }])).toBe(cfg);
246 });
247
248 it("mixes: new options get deterministic ids, re-listed existing ones reuse theirs", () => {
249 const out = normalizeSelectOptions(
250 {
251 options: [
252 { name: "Done", color: "green" },
253 { name: "Blocked", color: "orange" },
254 ],
255 },
256 [{ id: "custom-done", name: "Done", color: "green" }],
257 ) as { options: { id: string }[] };
258 expect(out.options[0]?.id).toBe("custom-done");
259 expect(out.options[1]?.id).toBe(optionIdForName("Blocked"));
260 });
261
262 it("does not mutate the input config (a new object is returned on change)", () => {
263 const cfg = { options: [{ name: "To Do", color: "default" }] };
264 const out = normalizeSelectOptions(cfg) as { options: { id: string }[] };
265 expect(out.options[0]?.id).toBe("opt-to-do");
266 expect((cfg.options[0] as { id?: string } | undefined)?.id).toBeUndefined();
267 });
268});
269
270describe("createViewSchema", () => {
271 it("accepts a table view with config", () => {
272 const r = createViewSchema.safeParse({
273 name: "All Tasks",
274 type: "table",
275 config: { visiblePropertyIds: ["a", "b"], columnWidths: { a: 200 }, density: "default" },
276 });
277 expect(r.success).toBe(true);
278 });
279
280 it("accepts all 9 view types", () => {
281 for (const t of [
282 "table",
283 "list",
284 "board",
285 "gallery",
286 "calendar",
287 "timeline",
288 "chart",
289 "dashboard",
290 "form",
291 ]) {
292 expect(createViewSchema.safeParse({ name: "v", type: t }).success).toBe(true);
293 }
294 });
295
296 it("rejects unknown view type", () => {
297 expect(createViewSchema.safeParse({ name: "v", type: "kanban" }).success).toBe(false);
298 });
299});
300