1
2
10import { describe, expect, it } from "vitest";
11import {
12 FORM_ACCENTS,
13 FORM_CONFIG_SCHEMA,
14 FORM_QUESTION_TYPES,
15 QUESTION_PROPERTY_COMPATIBILITY,
16 DB_PROPERTY_TYPES,
17 compatiblePropertyTypes,
18 isCompatibleMapping,
19 toPublicSafeValidation,
20 validateQuestionValidation
21} from "./forms.js";
22
23
24
25
26
27describe("QUESTION_PROPERTY_COMPATIBILITY", () => {
28 it("spec matrix — every cell (TASK-0106 §1)", () => {
29 expect(QUESTION_PROPERTY_COMPATIBILITY.SHORT_TEXT).toEqual(
30 expect.arrayContaining(["TITLE", "TEXT", "RICH_TEXT", "EMAIL", "PHONE", "URL"])
31 );
32 expect(QUESTION_PROPERTY_COMPATIBILITY.LONG_TEXT).toEqual(expect.arrayContaining(["RICH_TEXT", "TEXT"]));
33 expect(QUESTION_PROPERTY_COMPATIBILITY.NUMBER).toEqual(["NUMBER"]);
34 expect(QUESTION_PROPERTY_COMPATIBILITY.EMAIL).toEqual(["EMAIL"]);
35 expect(QUESTION_PROPERTY_COMPATIBILITY.DATE).toEqual(["DATE"]);
36 expect(QUESTION_PROPERTY_COMPATIBILITY.CHECKBOX).toEqual(["CHECKBOX"]);
37 expect(QUESTION_PROPERTY_COMPATIBILITY.SINGLE_SELECT).toEqual(expect.arrayContaining(["SELECT", "STATUS"]));
38 expect(QUESTION_PROPERTY_COMPATIBILITY.MULTI_SELECT).toEqual(["MULTI_SELECT"]);
39 expect(QUESTION_PROPERTY_COMPATIBILITY.RATING).toEqual(["NUMBER"]);
40 expect(QUESTION_PROPERTY_COMPATIBILITY.FILE).toEqual(["FILES"]);
41 });
42
43 it("every entry is a known PropertyType", () => {
44 for (const t of FORM_QUESTION_TYPES) {
45 for (const p of compatiblePropertyTypes(t)) {
46 expect(DB_PROPERTY_TYPES).toContain(p);
47 }
48 }
49 });
50
51 it("isCompatibleMapping — positive + negative cells", () => {
52 expect(isCompatibleMapping("SHORT_TEXT", "TEXT")).toBe(true);
53 expect(isCompatibleMapping("SHORT_TEXT", "NUMBER")).toBe(false);
54 expect(isCompatibleMapping("SINGLE_SELECT", "STATUS")).toBe(true);
55 expect(isCompatibleMapping("SINGLE_SELECT", "MULTI_SELECT")).toBe(false);
56 expect(isCompatibleMapping("FILE", "FILES")).toBe(true);
57 expect(isCompatibleMapping("FILE", "TEXT")).toBe(false);
58 expect(isCompatibleMapping("RATING", "NUMBER")).toBe(true);
59 expect(isCompatibleMapping("RATING", "SELECT")).toBe(false);
60 });
61});
62
63
64
65
66
67describe("validateQuestionValidation", () => {
68 it("null/undefined is always valid (no config)", async () => {
69 for (const t of FORM_QUESTION_TYPES) {
70 await expect(validateQuestionValidation(t, null)).resolves.toEqual({ valid: true });
71 await expect(validateQuestionValidation(t, undefined)).resolves.toEqual({ valid: true });
72 }
73 });
74
75 it("SHORT_TEXT/LONG_TEXT — valid config accepted", async () => {
76 await expect(
77 validateQuestionValidation("SHORT_TEXT", { minLength: 1, maxLength: 200, pattern: "^[a-z]+$" })
78 ).resolves.toEqual({ valid: true });
79 await expect(validateQuestionValidation("LONG_TEXT", {})).resolves.toEqual({ valid: true });
80 });
81
82 it("SHORT_TEXT — rejects min > max", async () => {
83 await expect(validateQuestionValidation("SHORT_TEXT", { minLength: 10, maxLength: 5 })).resolves.toMatchObject({
84 valid: false,
85 issue: "min_gt_max"
86 });
87 });
88
89 it("SHORT_TEXT — rejects pattern > 200 chars", async () => {
90 await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "a".repeat(201) })).resolves.toMatchObject({
91 valid: false,
92 issue: "too_big"
93 });
94 });
95
96 it("SHORT_TEXT — rejects unknown keys (strict)", async () => {
97 await expect(validateQuestionValidation("SHORT_TEXT", { minLength: 1, bogus: true })).resolves.toMatchObject({
98 valid: false,
99 issue: "unrecognized_keys"
100 });
101 });
102
103 it("NUMBER — valid + min > max rejected + integer flag", async () => {
104 await expect(validateQuestionValidation("NUMBER", { min: 0, max: 100, integer: true })).resolves.toEqual({
105 valid: true
106 });
107 await expect(validateQuestionValidation("NUMBER", { min: 50, max: 10 })).resolves.toMatchObject({
108 valid: false,
109 issue: "min_gt_max"
110 });
111 });
112
113 it("EMAIL — only the empty config is valid", async () => {
114 await expect(validateQuestionValidation("EMAIL", {})).resolves.toEqual({ valid: true });
115 await expect(validateQuestionValidation("EMAIL", { minLength: 1 })).resolves.toMatchObject({
116 valid: false,
117 issue: "unrecognized_keys"
118 });
119 });
120
121 it("DATE — valid + bad ISO + minDate > maxDate rejected", async () => {
122 await expect(
123 validateQuestionValidation("DATE", { minDate: "2026-01-01", maxDate: "2026-12-31" })
124 ).resolves.toEqual({ valid: true });
125 await expect(validateQuestionValidation("DATE", { minDate: "2026/01/01" })).resolves.toMatchObject({
126 valid: false,
127 issue: "invalid_string"
128 });
129 await expect(validateQuestionValidation("DATE", { minDate: "2026-05-01", maxDate: "2026-01-01" })).resolves.toMatchObject({
130 valid: false,
131 issue: "min_gt_max"
132 });
133 });
134
135 it("CHECKBOX — only the empty config is valid", async () => {
136 await expect(validateQuestionValidation("CHECKBOX", {})).resolves.toEqual({ valid: true });
137 await expect(validateQuestionValidation("CHECKBOX", { min: 1 })).resolves.toMatchObject({
138 valid: false,
139 issue: "unrecognized_keys"
140 });
141 });
142
143 it("SINGLE_SELECT/MULTI_SELECT — options rules", async () => {
144 await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["a", "b"] })).resolves.toEqual({ valid: true });
145 await expect(validateQuestionValidation("SINGLE_SELECT", { options: [] })).resolves.toMatchObject({ valid: false });
146 await expect(validateQuestionValidation("SINGLE_SELECT", { options: [""] })).resolves.toMatchObject({
147 valid: false,
148 issue: "too_small"
149 });
150 await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["a", "a"] })).resolves.toMatchObject({
151 valid: false,
152 issue: "duplicate_option"
153 });
154 await expect(
155 validateQuestionValidation("MULTI_SELECT", { options: Array.from({ length: 51 }, (_, i) => `o${i}`) })
156 ).resolves.toMatchObject({ valid: false, issue: "too_big" });
157 await expect(
158 validateQuestionValidation("MULTI_SELECT", { options: Array.from({ length: 50 }, (_, i) => `o${i}`) })
159 ).resolves.toEqual({ valid: true });
160 await expect(validateQuestionValidation("SINGLE_SELECT", { options: ["x".repeat(51)] })).resolves.toMatchObject({
161 valid: false,
162 issue: "too_big"
163 });
164 });
165
166 it("RATING — max bounds 5..10, default ≤ max", async () => {
167 await expect(validateQuestionValidation("RATING", { max: 5 })).resolves.toEqual({ valid: true });
168 await expect(validateQuestionValidation("RATING", { max: 10, default: 10 })).resolves.toEqual({ valid: true });
169 await expect(validateQuestionValidation("RATING", { max: 4 })).resolves.toMatchObject({
170 valid: false,
171 issue: "too_small"
172 });
173 await expect(validateQuestionValidation("RATING", { max: 11 })).resolves.toMatchObject({
174 valid: false,
175 issue: "too_big"
176 });
177 await expect(validateQuestionValidation("RATING", { max: 5, default: 7 })).resolves.toMatchObject({
178 valid: false,
179 issue: "min_gt_max"
180 });
181 });
182
183 it("FILE — maxFiles 1..5 + maxBytes", async () => {
184 await expect(validateQuestionValidation("FILE", { maxFiles: 5, maxBytes: 1048576 })).resolves.toEqual({
185 valid: true
186 });
187 await expect(validateQuestionValidation("FILE", { maxFiles: 0 })).resolves.toMatchObject({
188 valid: false,
189 issue: "too_small"
190 });
191 await expect(validateQuestionValidation("FILE", { maxFiles: 6 })).resolves.toMatchObject({
192 valid: false,
193 issue: "too_big"
194 });
195 await expect(validateQuestionValidation("FILE", { maxBytes: 0 })).resolves.toMatchObject({
196 valid: false,
197 issue: "too_small"
198 });
199 });
200
201 it("ReDoS probe — catastrophic backtracking rejected (worker hard-kill), linear passes", async () => {
202
203
204 await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "^(a+)+$" })).resolves.toMatchObject({
205 valid: false,
206 issue: "PATTERN_TOO_COMPLEX"
207 });
208 await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "^[a-z]+$" })).resolves.toEqual({ valid: true });
209 }, 15_000);
210
211 it("invalid regex source rejected", async () => {
212 await expect(validateQuestionValidation("SHORT_TEXT", { pattern: "(" })).resolves.toMatchObject({
213 valid: false,
214 issue: "invalid_pattern"
215 });
216 });
217});
218
219
220
221
222
223describe("FORM_CONFIG_SCHEMA", () => {
224 it("accepts the documented shape", () => {
225 const r = FORM_CONFIG_SCHEMA.safeParse({
226 icon: "📝",
227 intro: "Tell us about **yourself**",
228 accent: FORM_ACCENTS[1],
229 successMessage: "Thanks!",
230 closedMessage: "This form is closed.",
231 poweredBy: false,
232 recaptcha: { enabled: false, siteKey: "abc" }
233 });
234 expect(r.success).toBe(true);
235 });
236
237 it("rejects a non-preset accent", () => {
238 expect(FORM_CONFIG_SCHEMA.safeParse({ accent: "#123456" }).success).toBe(false);
239 });
240
241 it("enforces length caps (intro 2000, messages 500)", () => {
242 expect(FORM_CONFIG_SCHEMA.safeParse({ intro: "x".repeat(2001) }).success).toBe(false);
243 expect(FORM_CONFIG_SCHEMA.safeParse({ successMessage: "x".repeat(501) }).success).toBe(false);
244 expect(FORM_CONFIG_SCHEMA.safeParse({ intro: "x".repeat(2000) }).success).toBe(true);
245 });
246
247 it("rejects unknown keys (strict)", () => {
248 expect(FORM_CONFIG_SCHEMA.safeParse({ icon: "x", hacker: true }).success).toBe(false);
249 });
250});
251
252
253
254
255
256describe("toPublicSafeValidation", () => {
257 it("strips pattern + non-numeric noise, keeps the numeric/options subset", () => {
258 const out = toPublicSafeValidation({
259 minLength: 2,
260 maxLength: 50,
261 pattern: "TOP-SECRET",
262 min: 0,
263 max: 100,
264 options: ["a", "b", 7, "c"],
265 maxFiles: 3,
266 minDate: "2026-01-01"
267 });
268 expect(out).toEqual({
269 minLength: 2,
270 maxLength: 50,
271 min: 0,
272 max: 100,
273 options: ["a", "b", "c"],
274 maxFiles: 3
275 });
276 expect(JSON.stringify(out)).not.toContain("TOP-SECRET");
277 expect("minDate" in out).toBe(false);
278 });
279
280 it("tolerates corrupt/null configs", () => {
281 expect(toPublicSafeValidation(null)).toEqual({});
282 expect(toPublicSafeValidation("nope")).toEqual({});
283 expect(toPublicSafeValidation({ options: "nope" })).toEqual({});
284 });
285});
286