442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
import { assert } from "@effect/vitest"
|
|
import { DateTime, Effect, Option, Result, SchemaGetter } from "effect"
|
|
import { describe, it } from "vitest"
|
|
import { assertSome, deepStrictEqual } from "../utils/assert.ts"
|
|
|
|
function makeAsserts<T, E>(getter: SchemaGetter.Getter<T, E>) {
|
|
return async (input: E, expected: T) => {
|
|
const r = await Effect.runPromise(
|
|
getter.run(Option.some(input), {}).pipe(
|
|
Effect.mapError((issue) => issue.toString()),
|
|
Effect.result
|
|
)
|
|
)
|
|
deepStrictEqual(r, Result.succeed(Option.some(expected)))
|
|
}
|
|
}
|
|
|
|
describe("SchemaGetter", () => {
|
|
it("map", () => {
|
|
const getter = SchemaGetter.succeed(1).map((t) => t + 1)
|
|
const result = Effect.runSync(getter.run(Option.some(1), {}))
|
|
assertSome(result, 2)
|
|
})
|
|
|
|
it("dateTimeUtcFromInput", async () => {
|
|
const decoding = makeAsserts(SchemaGetter.dateTimeUtcFromInput<string>())
|
|
await decoding("2024-01-01 01:00:00", DateTime.makeUnsafe("2024-01-01T01:00:00.000Z"))
|
|
await decoding("2020-02-01T11:17:00+1100", DateTime.makeUnsafe("2020-02-01T00:17:00.000Z"))
|
|
// should support strings with explicit GMT zone
|
|
await decoding("Tue, 27 Jan 2026 17:14:06 GMT", DateTime.makeUnsafe("2026-01-27T17:14:06.000Z"))
|
|
})
|
|
|
|
describe("makeTreeRecord", () => {
|
|
it("reinitializes own undefined values before descending", () => {
|
|
deepStrictEqual(
|
|
SchemaGetter.makeTreeRecord([
|
|
["a", undefined],
|
|
["a[b]", 1]
|
|
]),
|
|
{ a: { b: 1 } }
|
|
)
|
|
})
|
|
|
|
it("reinitializes own undefined values at numeric indexes", () => {
|
|
deepStrictEqual(
|
|
SchemaGetter.makeTreeRecord([
|
|
["a[0]", undefined],
|
|
["a[0][b]", 1]
|
|
]),
|
|
{ a: [{ b: 1 }] }
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("decodeFormData / encodeFormData", () => {
|
|
const decoding = makeAsserts(SchemaGetter.decodeFormData())
|
|
const encoding = makeAsserts(SchemaGetter.encodeFormData())
|
|
|
|
it("should support multiple values for the same key", async () => {
|
|
const formData = new FormData()
|
|
formData.append("a", "1")
|
|
formData.append("a", "2")
|
|
const object = {
|
|
a: ["1", "2"]
|
|
}
|
|
await decoding(formData, object)
|
|
await encoding(object, formData)
|
|
})
|
|
|
|
it("should handle top level empty keys", async () => {
|
|
const formData = new FormData()
|
|
formData.append("", "value")
|
|
const object = { "": "value" }
|
|
await decoding(formData, object)
|
|
await encoding(object, formData)
|
|
})
|
|
|
|
it("decodes simple top-level keys", async () => {
|
|
const formData = new FormData()
|
|
formData.append("a", "1")
|
|
formData.append("b", "two")
|
|
const object = {
|
|
a: "1",
|
|
b: "two"
|
|
}
|
|
await decoding(formData, object)
|
|
await encoding(object, formData)
|
|
})
|
|
|
|
it("decodes nested objects via bracket notation", async () => {
|
|
const formData = new FormData()
|
|
formData.append("user[name]", "John")
|
|
formData.append("user[email]", "john@example.com")
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
email: "john@example.com"
|
|
}
|
|
}
|
|
await decoding(formData, object)
|
|
await encoding(object, formData)
|
|
})
|
|
|
|
it("decodes nested objects via dot notation", async () => {
|
|
const formData = new FormData()
|
|
formData.append("user.name", "John")
|
|
formData.append("user.email", "john@example.com")
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
email: "john@example.com"
|
|
}
|
|
}
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("decodes mixed dot + bracket notation", async () => {
|
|
const formData = new FormData()
|
|
formData.append("user.address[city]", "Milan")
|
|
formData.append("user.address[zip]", "20100")
|
|
const object = {
|
|
user: {
|
|
address: {
|
|
city: "Milan",
|
|
zip: "20100"
|
|
}
|
|
}
|
|
}
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("decodes arrays with numeric indices", async () => {
|
|
const formData = new FormData()
|
|
formData.append("items[0]", "item1")
|
|
formData.append("items[1]", "item2")
|
|
const object = {
|
|
items: ["item1", "item2"]
|
|
}
|
|
await decoding(formData, object)
|
|
|
|
{
|
|
const formData = new FormData()
|
|
formData.append("items", "item1")
|
|
formData.append("items", "item2")
|
|
await encoding(object, formData)
|
|
}
|
|
})
|
|
|
|
it("decodes arrays with numeric indices and nested objects", async () => {
|
|
const formData = new FormData()
|
|
formData.append("items[0][id]", "a")
|
|
formData.append("items[0][name]", "Item A")
|
|
formData.append("items[1][id]", "b")
|
|
formData.append("items[1][name]", "Item B")
|
|
const object = {
|
|
items: [
|
|
{ id: "a", name: "Item A" },
|
|
{ id: "b", name: "Item B" }
|
|
]
|
|
}
|
|
await decoding(formData, object)
|
|
await encoding(object, formData)
|
|
})
|
|
|
|
it("decodes arrays with [] (append)", async () => {
|
|
const formData = new FormData()
|
|
formData.append("tags[]", "a")
|
|
formData.append("tags[]", "b")
|
|
formData.append("tags[]", "c")
|
|
const object = {
|
|
tags: ["a", "b", "c"]
|
|
}
|
|
await decoding(formData, object)
|
|
|
|
{
|
|
const formData = new FormData()
|
|
formData.append("tags", "a")
|
|
formData.append("tags", "b")
|
|
formData.append("tags", "c")
|
|
await encoding(object, formData)
|
|
}
|
|
})
|
|
|
|
it("decodes arrays with [] and nested objects", async () => {
|
|
const formData = new FormData()
|
|
formData.append("items[][id]", "x")
|
|
formData.append("items[][id]", "y")
|
|
const object = {
|
|
items: [
|
|
{ id: "x" },
|
|
{ id: "y" }
|
|
]
|
|
}
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("decodes mixed indexed and append arrays under the same key", async () => {
|
|
const formData = new FormData()
|
|
formData.append("items[0]", "a")
|
|
formData.append("items[]", "b")
|
|
formData.append("items[]", "c")
|
|
const object = {
|
|
items: ["a", "b", "c"]
|
|
}
|
|
// Implementation detail: first write at index 0, then pushes at 1 and 2
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("decodes nested objects inside appended array elements", async () => {
|
|
const formData = new FormData()
|
|
formData.append("users[][name]", "John")
|
|
formData.append("users[][name]", "Alice")
|
|
const object = {
|
|
users: [
|
|
{ name: "John" },
|
|
{ name: "Alice" }
|
|
]
|
|
}
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("decodes complex mixed structure", async () => {
|
|
const formData = new FormData()
|
|
formData.append("user[name]", "John")
|
|
formData.append("user[address][city]", "Milan")
|
|
formData.append("user[address][zip]", "20100")
|
|
formData.append("orders[0][id]", "o1")
|
|
formData.append("orders[0][total]", "10")
|
|
formData.append("orders[1][id]", "o2")
|
|
formData.append("orders[1][total]", "20")
|
|
formData.append("tags[0]", "a")
|
|
formData.append("tags[1]", "b")
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
address: {
|
|
city: "Milan",
|
|
zip: "20100"
|
|
}
|
|
},
|
|
orders: [
|
|
{ id: "o1", total: "10" },
|
|
{ id: "o2", total: "20" }
|
|
],
|
|
tags: ["a", "b"]
|
|
}
|
|
await decoding(formData, object)
|
|
})
|
|
|
|
it("stores __proto__ paths as own properties", async () => {
|
|
const pollutedKey = "__effectSchemaPolluted"
|
|
Reflect.deleteProperty(Object.prototype, pollutedKey)
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append(`__proto__[${pollutedKey}]`, "yes")
|
|
await decoding(formData, {
|
|
["__proto__"]: {
|
|
[pollutedKey]: "yes"
|
|
}
|
|
})
|
|
assert.isFalse(Object.hasOwn(Object.prototype, pollutedKey))
|
|
} finally {
|
|
Reflect.deleteProperty(Object.prototype, pollutedKey)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe("decodeURLSearchParams / encodeURLSearchParams", () => {
|
|
const decoding = makeAsserts(SchemaGetter.decodeURLSearchParams())
|
|
const encoding = makeAsserts(SchemaGetter.encodeURLSearchParams())
|
|
|
|
it("should support multiple values for the same key", async () => {
|
|
const urlSearchParams = new URLSearchParams("a=1&a=2")
|
|
const object = {
|
|
a: ["1", "2"]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
await encoding(object, urlSearchParams)
|
|
})
|
|
|
|
it("should handle top level empty keys", async () => {
|
|
const urlSearchParams = new URLSearchParams("=value")
|
|
const object = { "": "value" }
|
|
await decoding(urlSearchParams, object)
|
|
await encoding(object, urlSearchParams)
|
|
})
|
|
|
|
it("decodes simple top-level keys", async () => {
|
|
const urlSearchParams = new URLSearchParams("a=1&b=two")
|
|
const object = {
|
|
a: "1",
|
|
b: "two"
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
await encoding(object, urlSearchParams)
|
|
})
|
|
|
|
it("decodes nested objects via bracket notation", async () => {
|
|
const urlSearchParams = new URLSearchParams("user[name]=John&user[email]=john@example.com")
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
email: "john@example.com"
|
|
}
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
await encoding(object, urlSearchParams)
|
|
})
|
|
|
|
it("decodes nested objects via dot notation", async () => {
|
|
const urlSearchParams = new URLSearchParams("user.name=John&user.email=john@example.com")
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
email: "john@example.com"
|
|
}
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("decodes mixed dot + bracket notation", async () => {
|
|
const urlSearchParams = new URLSearchParams("user.address[city]=Milan&user.address[zip]=20100")
|
|
const object = {
|
|
user: {
|
|
address: {
|
|
city: "Milan",
|
|
zip: "20100"
|
|
}
|
|
}
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("decodes arrays with numeric indices", async () => {
|
|
const urlSearchParams = new URLSearchParams("items[0]=item1&items[1]=item2")
|
|
const object = {
|
|
items: ["item1", "item2"]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
|
|
{
|
|
const urlSearchParams = new URLSearchParams("items=item1&items=item2")
|
|
await encoding(object, urlSearchParams)
|
|
}
|
|
})
|
|
|
|
it("decodes arrays with numeric indices and nested objects", async () => {
|
|
const urlSearchParams = new URLSearchParams(
|
|
"items[0][id]=a&items[0][name]=Item A&items[1][id]=b&items[1][name]=Item B"
|
|
)
|
|
const object = {
|
|
items: [
|
|
{ id: "a", name: "Item A" },
|
|
{ id: "b", name: "Item B" }
|
|
]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
await encoding(object, urlSearchParams)
|
|
})
|
|
|
|
it("decodes arrays with [] (append)", async () => {
|
|
const urlSearchParams = new URLSearchParams("tags[]=a&tags[]=b&tags[]=c")
|
|
const object = {
|
|
tags: ["a", "b", "c"]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
|
|
{
|
|
const urlSearchParams = new URLSearchParams("tags=a&tags=b&tags=c")
|
|
await encoding(object, urlSearchParams)
|
|
}
|
|
})
|
|
|
|
it("decodes arrays with [] and nested objects", async () => {
|
|
const urlSearchParams = new URLSearchParams("items[][id]=x&items[][id]=y")
|
|
const object = {
|
|
items: [{ id: "x" }, { id: "y" }]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("decodes mixed indexed and append arrays under the same key", async () => {
|
|
const urlSearchParams = new URLSearchParams("items[0]=a&items[]=b&items[]=c")
|
|
const object = {
|
|
items: ["a", "b", "c"]
|
|
}
|
|
// Implementation detail: first write at index 0, then pushes at 1 and 2
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("decodes nested objects inside appended array elements", async () => {
|
|
const urlSearchParams = new URLSearchParams("users[][name]=John&users[][name]=Alice")
|
|
const object = {
|
|
users: [
|
|
{ name: "John" },
|
|
{ name: "Alice" }
|
|
]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("decodes complex mixed structure", async () => {
|
|
const urlSearchParams = new URLSearchParams(
|
|
"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"
|
|
)
|
|
const object = {
|
|
user: {
|
|
name: "John",
|
|
address: {
|
|
city: "Milan",
|
|
zip: "20100"
|
|
}
|
|
},
|
|
orders: [
|
|
{ id: "o1", total: "10" },
|
|
{ id: "o2", total: "20" }
|
|
],
|
|
tags: ["a", "b"]
|
|
}
|
|
await decoding(urlSearchParams, object)
|
|
})
|
|
|
|
it("does not traverse inherited constructor paths", async () => {
|
|
const pollutedKey = "__effectSchemaPolluted"
|
|
Reflect.deleteProperty(Object.prototype, pollutedKey)
|
|
try {
|
|
const urlSearchParams = new URLSearchParams(`constructor[prototype][${pollutedKey}]=yes`)
|
|
await decoding(urlSearchParams, {
|
|
constructor: {
|
|
prototype: {
|
|
[pollutedKey]: "yes"
|
|
}
|
|
}
|
|
})
|
|
assert.isFalse(Object.hasOwn(Object.prototype, pollutedKey))
|
|
} finally {
|
|
Reflect.deleteProperty(Object.prototype, pollutedKey)
|
|
}
|
|
})
|
|
})
|
|
})
|