1import { assert, describe, it } from "@effect/vitest"
2import { DateTime, Effect, Option, Result, SchemaGetter, SchemaIssue } from "effect"
3import { assertSome, deepStrictEqual } from "../utils/assert.ts"
4
5const formatIssue = SchemaIssue.makeFormatterDefault()
6
7function makeAsserts<T, E>(getter: SchemaGetter.Getter<T, E>) {
8 return async (input: E, expected: T) => {
9 const r = await Effect.runPromise(
10 SchemaGetter.run(getter, Option.some(input), {}).pipe(
11 Effect.mapError(formatIssue),
12 Effect.result
13 )
14 )
15 deepStrictEqual(r, Result.succeed(Option.some(expected)))
16 }
17}
18
19describe("SchemaGetter", () => {
20 it.effect("forbiddenEncoding", () =>
21 Effect.gen(function*() {
22 const getter: SchemaGetter.Getter<string, number> = SchemaGetter.forbiddenEncoding
23 const issue = yield* SchemaGetter.run(getter, Option.some(1), {}).pipe(Effect.flip)
24
25 assert.strictEqual(issue._tag, "Forbidden")
26 assert.strictEqual(formatIssue(issue), "Encoding is not supported")
27 }))
28
29 it.effect("stringifyJson fails when JSON.stringify returns undefined", () =>
30 SchemaGetter.run(SchemaGetter.stringifyJson(), Option.some(undefined), {}).pipe(
31 Effect.flip,
32 Effect.map((issue) => assert.strictEqual(issue._tag, "InvalidValue"))
33 ))
34
35 it.effect("map", () =>
36 Effect.gen(function*() {
37 const getter = SchemaGetter.map(SchemaGetter.succeed(1), (t) => t + 1)
38 const result = yield* SchemaGetter.run(Option.some(1), {})(getter)
39 assertSome(result, 2)
40 }))
41
42 it("map preserves the specialized execution mode", () => {
43 assert.strictEqual(SchemaGetter.map(SchemaGetter.passthrough<number>(), String)._tag, "Transform")
44 assert.strictEqual(SchemaGetter.map(SchemaGetter.transform(Number), String)._tag, "Transform")
45 assert.strictEqual(
46 SchemaGetter.map(
47 SchemaGetter.transformOptional((input: Option.Option<string>) => Option.map(input, Number)),
48 String
49 )._tag,
50 "TransformOptional"
51 )
52 assert.strictEqual(
53 SchemaGetter.map(SchemaGetter.transformEffect((input: string) => Effect.succeed(Number(input))), String)._tag,
54 "TransformEffect"
55 )
56 assert.strictEqual(
57 SchemaGetter.map(
58 SchemaGetter.transformOptionalEffect((input: Option.Option<string>) =>
59 Effect.succeed(Option.map(input, Number))
60 ),
61 String
62 )._tag,
63 "TransformOptionalEffect"
64 )
65 })
66
67 it.effect("compose", () =>
68 Effect.gen(function*() {
69 const first = SchemaGetter.transform<number, string>(Number)
70 const second = SchemaGetter.transform((value: number) => value * 2)
71 const composed = SchemaGetter.compose(first, second)
72
73 assert.strictEqual(composed._tag, "Transform")
74 assertSome(yield* SchemaGetter.run(composed, Option.some("2"), {}), 4)
75 assert.strictEqual(SchemaGetter.compose(SchemaGetter.passthrough<string>(), first), first)
76 assert.strictEqual(SchemaGetter.compose(first, SchemaGetter.passthrough<number>()), first)
77 }))
78
79 it("compose preserves the specialized execution mode", () => {
80 const transform = SchemaGetter.transform<number, string>(Number)
81 const transformNumber = SchemaGetter.transform((value: number) => value + 1)
82 const transformOptional = SchemaGetter.transformOptional<number, number>(Option.map((value) => value + 1))
83 const transformEffect = SchemaGetter.transformEffect((value: number) => Effect.succeed(value + 1))
84 const transformOptionalEffect = SchemaGetter.transformOptionalEffect((input: Option.Option<number>) =>
85 Effect.succeed(Option.map(input, (value) => value + 1))
86 )
87
88 assert.strictEqual(SchemaGetter.compose(transform, transformOptional)._tag, "TransformOptional")
89 assert.strictEqual(SchemaGetter.compose(transform, transformEffect)._tag, "TransformEffect")
90 assert.strictEqual(SchemaGetter.compose(transformOptional, transformNumber)._tag, "TransformOptional")
91 assert.strictEqual(SchemaGetter.compose(transformEffect, transformNumber)._tag, "TransformEffect")
92 assert.strictEqual(SchemaGetter.compose(transformEffect, transformEffect)._tag, "TransformEffect")
93 assert.strictEqual(SchemaGetter.compose(transformOptional, transformEffect)._tag, "TransformOptionalEffect")
94 assert.strictEqual(SchemaGetter.compose(transformEffect, transformOptional)._tag, "TransformOptionalEffect")
95 assert.strictEqual(SchemaGetter.compose(transformOptionalEffect, transformNumber)._tag, "TransformOptionalEffect")
96 })
97
98 it("dateTimeUtcFromInput", async () => {
99 const decoding = makeAsserts(SchemaGetter.dateTimeUtcFromInput<string>())
100 await decoding("2024-01-01 01:00:00", DateTime.makeUnsafe("2024-01-01T01:00:00.000Z"))
101 await decoding("2020-02-01T11:17:00+1100", DateTime.makeUnsafe("2020-02-01T00:17:00.000Z"))
102 // should support strings with explicit GMT zone
103 await decoding("Tue, 27 Jan 2026 17:14:06 GMT", DateTime.makeUnsafe("2026-01-27T17:14:06.000Z"))
104 })
105
106 describe("makeTreeRecord", () => {
107 it("preserves array-valued leaves at duplicate paths", () => {
108 deepStrictEqual(
109 SchemaGetter.makeTreeRecord([
110 ["permissions", ["read"]],
111 ["permissions", ["write"]],
112 ["permissions", ["share"]]
113 ]),
114 { permissions: [["read"], ["write"], ["share"]] }
115 )
116 })
117
118 it("replaces conflicting leaf values with containers", () => {
119 deepStrictEqual(
120 SchemaGetter.makeTreeRecord([
121 ["a", "x"],
122 ["a[b]", "y"]
123 ]),
124 { a: { b: "y" } }
125 )
126 deepStrictEqual(
127 SchemaGetter.makeTreeRecord([
128 ["a", "x"],
129 ["a[0]", "y"]
130 ]),
131 { a: ["y"] }
132 )
133 deepStrictEqual(
134 SchemaGetter.makeTreeRecord([
135 ["a", Object.freeze({ value: "x" })],
136 ["a[b]", { value: "y" }]
137 ]),
138 { a: { b: { value: "y" } } }
139 )
140 })
141
142 it("replaces conflicting object and array containers", () => {
143 deepStrictEqual(
144 SchemaGetter.makeTreeRecord([
145 ["a[b]", "x"],
146 ["a[0]", "y"]
147 ]),
148 { a: ["y"] }
149 )
150 deepStrictEqual(
151 SchemaGetter.makeTreeRecord([
152 ["a[0]", "x"],
153 ["a[b]", "y"]
154 ]),
155 { a: { b: "y" } }
156 )
157 })
158
159 it("reinitializes own undefined values before descending", () => {
160 deepStrictEqual(
161 SchemaGetter.makeTreeRecord([
162 ["a", undefined],
163 ["a[b]", 1]
164 ]),
165 { a: { b: 1 } }
166 )
167 })
168
169 it("reinitializes own undefined values at numeric indexes", () => {
170 deepStrictEqual(
171 SchemaGetter.makeTreeRecord([
172 ["a[0]", undefined],
173 ["a[0][b]", 1]
174 ]),
175 { a: [{ b: 1 }] }
176 )
177 })
178
179 it("preserves invalid array index segments as object keys", () => {
180 assert.deepStrictEqual(
181 SchemaGetter.makeTreeRecord([
182 ["leading[01]", "a"],
183 ["overflow[4294967295]", "b"]
184 ]),
185 {
186 leading: { "01": "a" },
187 overflow: { "4294967295": "b" }
188 }
189 )
190 })
191 })
192
193 describe("decodeFormData / encodeFormData", () => {
194 const decoding = makeAsserts(SchemaGetter.decodeFormData())
195 const encoding = makeAsserts(SchemaGetter.encodeFormData())
196
197 it("should support multiple values for the same key", async () => {
198 const formData = new FormData()
199 formData.append("a", "1")
200 formData.append("a", "2")
201 const object = {
202 a: ["1", "2"]
203 }
204 await decoding(formData, object)
205 await encoding(object, formData)
206 })
207
208 it("should handle top level empty keys", async () => {
209 const formData = new FormData()
210 formData.append("", "value")
211 const object = { "": "value" }
212 await decoding(formData, object)
213 await encoding(object, formData)
214 })
215
216 it("decodes simple top-level keys", async () => {
217 const formData = new FormData()
218 formData.append("a", "1")
219 formData.append("b", "two")
220 const object = {
221 a: "1",
222 b: "two"
223 }
224 await decoding(formData, object)
225 await encoding(object, formData)
226 })
227
228 it("decodes nested objects via bracket notation", async () => {
229 const formData = new FormData()
230 formData.append("user[name]", "John")
231 formData.append("user[email]", "john@example.com")
232 const object = {
233 user: {
234 name: "John",
235 email: "john@example.com"
236 }
237 }
238 await decoding(formData, object)
239 await encoding(object, formData)
240 })
241
242 it("decodes nested objects via dot notation", async () => {
243 const formData = new FormData()
244 formData.append("user.name", "John")
245 formData.append("user.email", "john@example.com")
246 const object = {
247 user: {
248 name: "John",
249 email: "john@example.com"
250 }
251 }
252 await decoding(formData, object)
253 })
254
255 it("decodes mixed dot + bracket notation", async () => {
256 const formData = new FormData()
257 formData.append("user.address[city]", "Milan")
258 formData.append("user.address[zip]", "20100")
259 const object = {
260 user: {
261 address: {
262 city: "Milan",
263 zip: "20100"
264 }
265 }
266 }
267 await decoding(formData, object)
268 })
269
270 it("decodes arrays with numeric indices", async () => {
271 const formData = new FormData()
272 formData.append("items[0]", "item1")
273 formData.append("items[1]", "item2")
274 const object = {
275 items: ["item1", "item2"]
276 }
277 await decoding(formData, object)
278
279 {
280 const formData = new FormData()
281 formData.append("items", "item1")
282 formData.append("items", "item2")
283 await encoding(object, formData)
284 }
285 })
286
287 it("decodes arrays with numeric indices and nested objects", async () => {
288 const formData = new FormData()
289 formData.append("items[0][id]", "a")
290 formData.append("items[0][name]", "Item A")
291 formData.append("items[1][id]", "b")
292 formData.append("items[1][name]", "Item B")
293 const object = {
294 items: [
295 { id: "a", name: "Item A" },
296 { id: "b", name: "Item B" }
297 ]
298 }
299 await decoding(formData, object)
300 await encoding(object, formData)
301 })
302
303 it("decodes arrays with [] (append)", async () => {
304 const formData = new FormData()
305 formData.append("tags[]", "a")
306 formData.append("tags[]", "b")
307 formData.append("tags[]", "c")
308 const object = {
309 tags: ["a", "b", "c"]
310 }
311 await decoding(formData, object)
312
313 {
314 const formData = new FormData()
315 formData.append("tags", "a")
316 formData.append("tags", "b")
317 formData.append("tags", "c")
318 await encoding(object, formData)
319 }
320 })
321
322 it("decodes arrays with [] and nested objects", async () => {
323 const formData = new FormData()
324 formData.append("items[][id]", "x")
325 formData.append("items[][id]", "y")
326 const object = {
327 items: [
328 { id: "x" },
329 { id: "y" }
330 ]
331 }
332 await decoding(formData, object)
333 })
334
335 it("decodes mixed indexed and append arrays under the same key", async () => {
336 const formData = new FormData()
337 formData.append("items[0]", "a")
338 formData.append("items[]", "b")
339 formData.append("items[]", "c")
340 const object = {
341 items: ["a", "b", "c"]
342 }
343 // Implementation detail: first write at index 0, then pushes at 1 and 2
344 await decoding(formData, object)
345 })
346
347 it("decodes nested objects inside appended array elements", async () => {
348 const formData = new FormData()
349 formData.append("users[][name]", "John")
350 formData.append("users[][name]", "Alice")
351 const object = {
352 users: [
353 { name: "John" },
354 { name: "Alice" }
355 ]
356 }
357 await decoding(formData, object)
358 })
359
360 it("decodes complex mixed structure", async () => {
361 const formData = new FormData()
362 formData.append("user[name]", "John")
363 formData.append("user[address][city]", "Milan")
364 formData.append("user[address][zip]", "20100")
365 formData.append("orders[0][id]", "o1")
366 formData.append("orders[0][total]", "10")
367 formData.append("orders[1][id]", "o2")
368 formData.append("orders[1][total]", "20")
369 formData.append("tags[0]", "a")
370 formData.append("tags[1]", "b")
371 const object = {
372 user: {
373 name: "John",
374 address: {
375 city: "Milan",
376 zip: "20100"
377 }
378 },
379 orders: [
380 { id: "o1", total: "10" },
381 { id: "o2", total: "20" }
382 ],
383 tags: ["a", "b"]
384 }
385 await decoding(formData, object)
386 })
387
388 it("stores __proto__ paths as own properties", async () => {
389 const pollutedKey = "__effectSchemaPolluted"
390 Reflect.deleteProperty(Object.prototype, pollutedKey)
391 try {
392 const formData = new FormData()
393 formData.append(`__proto__[${pollutedKey}]`, "yes")
394 await decoding(formData, {
395 ["__proto__"]: {
396 [pollutedKey]: "yes"
397 }
398 })
399 assert.isFalse(Object.hasOwn(Object.prototype, pollutedKey))
400 } finally {
401 Reflect.deleteProperty(Object.prototype, pollutedKey)
402 }
403 })
404 })
405
406 describe("decodeURLSearchParams / encodeURLSearchParams", () => {
407 const decoding = makeAsserts(SchemaGetter.decodeURLSearchParams())
408 const encoding = makeAsserts(SchemaGetter.encodeURLSearchParams())
409
410 it("should support multiple values for the same key", async () => {
411 const urlSearchParams = new URLSearchParams("a=1&a=2")
412 const object = {
413 a: ["1", "2"]
414 }
415 await decoding(urlSearchParams, object)
416 await encoding(object, urlSearchParams)
417 })
418
419 it("should handle top level empty keys", async () => {
420 const urlSearchParams = new URLSearchParams("=value")
421 const object = { "": "value" }
422 await decoding(urlSearchParams, object)
423 await encoding(object, urlSearchParams)
424 })
425
426 it("decodes simple top-level keys", async () => {
427 const urlSearchParams = new URLSearchParams("a=1&b=two")
428 const object = {
429 a: "1",
430 b: "two"
431 }
432 await decoding(urlSearchParams, object)
433 await encoding(object, urlSearchParams)
434 })
435
436 it("decodes nested objects via bracket notation", async () => {
437 const urlSearchParams = new URLSearchParams("user[name]=John&user[email]=john@example.com")
438 const object = {
439 user: {
440 name: "John",
441 email: "john@example.com"
442 }
443 }
444 await decoding(urlSearchParams, object)
445 await encoding(object, urlSearchParams)
446 })
447
448 it("decodes nested objects via dot notation", async () => {
449 const urlSearchParams = new URLSearchParams("user.name=John&user.email=john@example.com")
450 const object = {
451 user: {
452 name: "John",
453 email: "john@example.com"
454 }
455 }
456 await decoding(urlSearchParams, object)
457 })
458
459 it("decodes mixed dot + bracket notation", async () => {
460 const urlSearchParams = new URLSearchParams("user.address[city]=Milan&user.address[zip]=20100")
461 const object = {
462 user: {
463 address: {
464 city: "Milan",
465 zip: "20100"
466 }
467 }
468 }
469 await decoding(urlSearchParams, object)
470 })
471
472 it("decodes arrays with numeric indices", async () => {
473 const urlSearchParams = new URLSearchParams("items[0]=item1&items[1]=item2")
474 const object = {
475 items: ["item1", "item2"]
476 }
477 await decoding(urlSearchParams, object)
478
479 {
480 const urlSearchParams = new URLSearchParams("items=item1&items=item2")
481 await encoding(object, urlSearchParams)
482 }
483 })
484
485 it("decodes arrays with numeric indices and nested objects", async () => {
486 const urlSearchParams = new URLSearchParams(
487 "items[0][id]=a&items[0][name]=Item A&items[1][id]=b&items[1][name]=Item B"
488 )
489 const object = {
490 items: [
491 { id: "a", name: "Item A" },
492 { id: "b", name: "Item B" }
493 ]
494 }
495 await decoding(urlSearchParams, object)
496 await encoding(object, urlSearchParams)
497 })
498
499 it("decodes arrays with [] (append)", async () => {
500 const urlSearchParams = new URLSearchParams("tags[]=a&tags[]=b&tags[]=c")
501 const object = {
502 tags: ["a", "b", "c"]
503 }
504 await decoding(urlSearchParams, object)
505
506 {
507 const urlSearchParams = new URLSearchParams("tags=a&tags=b&tags=c")
508 await encoding(object, urlSearchParams)
509 }
510 })
511
512 it("decodes arrays with [] and nested objects", async () => {
513 const urlSearchParams = new URLSearchParams("items[][id]=x&items[][id]=y")
514 const object = {
515 items: [{ id: "x" }, { id: "y" }]
516 }
517 await decoding(urlSearchParams, object)
518 })
519
520 it("decodes mixed indexed and append arrays under the same key", async () => {
521 const urlSearchParams = new URLSearchParams("items[0]=a&items[]=b&items[]=c")
522 const object = {
523 items: ["a", "b", "c"]
524 }
525 // Implementation detail: first write at index 0, then pushes at 1 and 2
526 await decoding(urlSearchParams, object)
527 })
528
529 it("decodes nested objects inside appended array elements", async () => {
530 const urlSearchParams = new URLSearchParams("users[][name]=John&users[][name]=Alice")
531 const object = {
532 users: [
533 { name: "John" },
534 { name: "Alice" }
535 ]
536 }
537 await decoding(urlSearchParams, object)
538 })
539
540 it("decodes complex mixed structure", async () => {
541 const urlSearchParams = new URLSearchParams(
542 "user[name]=John&user.address[city]=Milan&user.address[zip]=20100&orders[0][id]=o1&orders[0][total]=10&orders[1][id]=o2&orders[1][total]=20&tags[0]=a&tags[1]=b"
543 )
544 const object = {
545 user: {
546 name: "John",
547 address: {
548 city: "Milan",
549 zip: "20100"
550 }
551 },
552 orders: [
553 { id: "o1", total: "10" },
554 { id: "o2", total: "20" }
555 ],
556 tags: ["a", "b"]
557 }
558 await decoding(urlSearchParams, object)
559 })
560
561 it("does not traverse inherited constructor paths", async () => {
562 const pollutedKey = "__effectSchemaPolluted"
563 Reflect.deleteProperty(Object.prototype, pollutedKey)
564 try {
565 const urlSearchParams = new URLSearchParams(`constructor[prototype][${pollutedKey}]=yes`)
566 await decoding(urlSearchParams, {
567 constructor: {
568 prototype: {
569 [pollutedKey]: "yes"
570 }
571 }
572 })
573 assert.isFalse(Object.hasOwn(Object.prototype, pollutedKey))
574 } finally {
575 Reflect.deleteProperty(Object.prototype, pollutedKey)
576 }
577 })
578 })
579})