import * as l from "../mod.ts"; import { assert, equal, throws } from "./assert.ts"; const range = { start: l.integerWith({ minimum: 0 }), end: l.integerWith({ minimum: 0 }) }; const Facet = l.union("blue.cerulea.app.facet", { "#mention": l.object({ ...range, did: l.did }), "#link": l.object({ ...range, uri: l.uri }), }); const Post = l.record("blue.cerulea.app.post", { key: "tid" }, { text: l.string, facets: l.array(Facet), createdAt: l.optional(l.datetime), }); const post = { $type: "blue.cerulea.app.post", text: "🙂 hi", facets: [{ $type: "blue.cerulea.app.facet#mention", start: 3, end: 5, did: "did:plc:alice" }], }; const cid = "bafkreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; Deno.test("records and discriminated variants validate without mutating data", () => { const check = l.compile(Post); const input = structuredClone(post); const result = check(input); assert(result.success); assert(result.value === input); equal(input, post); assert(check({ ...post, extra: { future: true } }).success); assert(!check({ ...post, $type: "blue.cerulea.app.other" }).success); assert(!check({ text: "", facets: [] }).success); }); Deno.test("record builder adds the discriminator without mutating fields", () => { const fields = { text: "hello", facets: [] }; const built = Post.build(fields); equal(built, { ...fields, $type: Post.id }); assert(!Object.hasOwn(fields, "$type")); assert(Post.validate(built).success); throws(() => Post.build({ ...fields, $type: Post.id } as never), "$type"); const described = l.withDescription(Post, "A post."); assert(described.build(fields).$type === Post.id); assert(described.validate(built).success); }); Deno.test("builders expand short union variants and nested records throughout the value", () => { const Embed = l.union("blue.cerulea.app.embed", { "#facets": l.object({ facets: l.array(Facet) }), "#post": l.object({ post: Post }), }); const Holder = l.record("blue.cerulea.app.holder", { key: "tid" }, { embeds: l.array(Embed), pin: l.optional(l.nullable(l.named("blue.cerulea.app.defs#pin", l.object({ facet: Facet })))), }); const stored = Post.validate(post); assert(stored.success); const link = { start: 0, end: 1, uri: "https://example.com" }; const fields = { embeds: [ { $type: "#facets", facets: [{ $type: "#link", ...link }] }, { $type: "#post", post: { text: "", facets: [{ $type: "#link", ...link }] } }, { $type: "blue.cerulea.app.embed#post", post: stored.value }, ], pin: { facet: { $type: "#link", ...link } }, } as const; const original = structuredClone(fields); const built = Holder.build(fields); equal(fields, original); const facet = { $type: "blue.cerulea.app.facet#link", ...link }; equal(built, { $type: Holder.id, embeds: [ { $type: "blue.cerulea.app.embed#facets", facets: [facet] }, { $type: "blue.cerulea.app.embed#post", post: { $type: Post.id, text: "", facets: [facet] } }, { $type: "blue.cerulea.app.embed#post", post }, ], pin: { facet }, }); assert(Holder.validate(built).success); equal(Holder.build({ embeds: [], pin: null }), { $type: Holder.id, embeds: [], pin: null }); equal(Facet.build({ $type: "#link", ...link }), facet); }); Deno.test("builders reject discriminators they cannot expand", () => { throws(() => Facet.build({ $type: "#other" } as never), "Unknown union variant #other at $type"); throws(() => Post.build({ text: "", facets: [{ $type: "#other" }] } as never), "facets.0.$type"); const anonymous = l.union({ "blue.cerulea.app.facet#link": l.object({}) }); throws(() => anonymous.build({ $type: "#link" } as never), "namespaced"); const Holder = l.record("blue.cerulea.app.holder", { key: "tid" }, { post: Post }); throws( () => Holder.build({ post: { ...post, $type: "blue.cerulea.app.other" } } as never), "Expected blue.cerulea.app.post at post.$type", ); const open = l.union("blue.cerulea.app.open", { "#known": l.object({ facets: l.array(Facet) }), }, { closed: false }); const future = { $type: "other.example.defs#future", facets: [{ $type: "#link" }] }; equal(open.build(future), future); throws(() => open.build({ $type: "#future" }), "Unknown union variant"); }); Deno.test("schema validation is bound to each schema, including derived descriptions", () => { const text = l.stringWith({ maxLength: 3 }); const described = l.withDescription(text, "A short string."); assert(described.validate("abc").success); assert(!described.validate("abcd").success); assert(!text.validate(42).success); assert(described.description === "A short string."); assert(text.description === undefined); assert(l.params({ limit: l.integer }).validate({ limit: 3 }).success); }); Deno.test("union validation selects only the declared discriminator", () => { const check = l.compile(Facet); assert(check(post.facets[0]).success); for ( const value of [ { start: 0, end: 1, did: "did:plc:alice" }, { $type: "blue.cerulea.app.facet#unknown", start: 0, end: 1 }, { $type: "blue.cerulea.app.facet#mention", start: 0, end: 1, uri: "https://example.com" }, { $type: "blue.cerulea.app.facet#link", start: 0.5, end: 1, uri: "https://example.com" }, ] ) assert(!check(value).success); const bad = l.compile(Post)({ ...post, facets: [{ ...post.facets[0], did: 42 }] }); assert(!bad.success); equal(bad.issues.map((i) => i.path), [["facets", 0, "did"]]); }); Deno.test("Lexicon output declares variants and uses refs, not explicit $type fields", () => { equal(l.toLexicons(Post), [ { lexicon: 1, id: "blue.cerulea.app.facet", defs: { link: { type: "object", properties: { start: { type: "integer", minimum: 0 }, end: { type: "integer", minimum: 0 }, uri: { type: "string", format: "uri" }, }, required: ["end", "start", "uri"], }, mention: { type: "object", properties: { start: { type: "integer", minimum: 0 }, end: { type: "integer", minimum: 0 }, did: { type: "string", format: "did" }, }, required: ["did", "end", "start"], }, }, }, { lexicon: 1, id: "blue.cerulea.app.post", defs: { main: { type: "record", key: "tid", record: { type: "object", properties: { text: { type: "string" }, facets: { type: "array", items: { type: "union", refs: ["blue.cerulea.app.facet#link", "blue.cerulea.app.facet#mention"], closed: true, }, }, createdAt: { type: "string", format: "datetime" }, }, required: ["facets", "text"], }, }, }, }, ]); }); Deno.test("optional and nullable are independent and undefined means absent", () => { const schema = l.named( "blue.cerulea.app.defs#fields", l.object({ required: l.string, optional: l.optional(l.string), nullable: l.nullable(l.string), both: l.optional(l.nullable(l.string)), }), ); const check = l.compile(schema); assert(check({ required: "", nullable: null }).success); assert(check({ required: "", nullable: "", optional: "", both: null }).success); assert(!check({ required: "" }).success); assert(!check({ required: "", nullable: null, optional: null }).success); assert(check({ required: "", nullable: null, optional: undefined, both: undefined }).success); assert(!check({ required: "", nullable: undefined }).success); const def = l.toLexicons(schema)[0]!.defs.fields!; equal(def.required, ["nullable", "required"]); equal(def.nullable, ["both", "nullable"]); }); Deno.test("open unions permit well-formed unknown variants but still validate known ones", () => { const schema = l.union("blue.cerulea.app.open", { "#known": l.object({ text: l.string }), }, { closed: false }); const check = l.compile(schema); assert(check({ $type: "other.example.defs#future", value: [true, null, 3] }).success); assert(!check({ $type: "blue.cerulea.app.open#known", text: 3 }).success); assert(!check({ $type: "future", text: "" }).success); assert(check({ $type: "other.example.defs#future", value: 0.5 }).success); assert( l.compile(l.union("blue.cerulea.app.empty", {}, { closed: false }))({ $type: "other.example.defs#future", }).success, ); }); Deno.test("named definitions are collected transitively, deduplicated, and ordered", () => { const Ref = l.named("blue.cerulea.app.defs#strongRef", l.object({ uri: l.atUri, cid: l.cid })); const Reply = l.named("blue.cerulea.app.defs#reply", l.object({ root: Ref, parent: Ref })); const A = l.record("blue.cerulea.app.a", { key: "tid" }, { reply: Reply }); const B = l.record("blue.cerulea.app.b", { key: "literal:self" }, { pinned: Ref, record: A }); const docs = l.toLexicons(A, B, Ref, Ref); equal(docs, l.toLexicons(B, A)); equal(docs.map((d) => d.id), [ "blue.cerulea.app.a", "blue.cerulea.app.b", "blue.cerulea.app.defs", ]); equal(Object.keys(docs[2]!.defs), ["reply", "strongRef"]); equal(docs[2]!.defs.reply, { type: "object", properties: { root: { type: "ref", ref: "blue.cerulea.app.defs#strongRef" }, parent: { type: "ref", ref: "blue.cerulea.app.defs#strongRef" }, }, required: ["parent", "root"], }); }); Deno.test("conflicting definitions fail rather than overwrite each other", () => { const a = l.named("blue.cerulea.app.defs#item", l.object({ a: l.string })); const same = l.named("blue.cerulea.app.defs#item", l.object({ a: l.string })); const b = l.named("blue.cerulea.app.defs#item", l.object({ b: l.integer })); equal(l.toLexicons(a, same), l.toLexicons(a)); throws(() => l.toLexicons(a, b), "Conflicting definition"); }); Deno.test("unrepresentable and invalid definitions are rejected", () => { throws(() => l.toLexicons(l.object({})), "roots"); throws( () => l.toLexicons(l.record("blue.cerulea.app.post", { key: "tid" }, { nested: l.object({}) })), "Nested objects", ); throws( () => l.toLexicons(l.named("blue.cerulea.app.defs#matrix", l.array(l.array(l.integer)))), "Nested arrays", ); throws(() => l.named("blue.cerulea.app.defs#union", Facet), "concrete definitions"); throws(() => l.object({ $type: l.string }), "$type"); throws(() => l.union("blue.cerulea.app.facet", {}), "empty"); throws(() => l.union("bad", { "#x": l.object({}) }), "namespace"); throws(() => l.union("blue.cerulea.app.facet", { "#main": l.object({}) }), "#main"); throws(() => l.union("blue.cerulea.app.facet", { "#bad-name": l.object({}) }), "definition ID"); throws(() => l.named("blue.cerulea.app.defs#main", l.string), "#main"); throws(() => l.record("blue.cerulea.app.post", { key: "literal:.." }, {}), "record key"); throws(() => l.stringWith({ minLength: 4, maxLength: 3 }), "Minimum"); throws(() => l.integerWith({ minimum: 0.5 }), "safe integers"); throws(() => l.array(l.string, { maxLength: -1 }), ">= 0"); throws(() => l.literal(NaN), "safe integers"); throws(() => l.blob({ accept: ["image/p*"] }), "MIME pattern"); }); Deno.test("string limits count UTF-8 bytes and graphemes, not UTF-16 indices", () => { const check = l.compile(l.stringWith({ maxLength: 4, maxGraphemes: 1 })); assert(check("🙂").success); assert(check("e\u0301").success); assert(!check("🙂a").success); assert(!check("ab").success); assert(!check("\ud800").success); }); Deno.test("integer, literal, enum, array and byte constraints", () => { const check = l.compile(l.integerWith({ minimum: -1, maximum: 1 })); for (const v of [-1, 0, 1]) assert(check(v).success); for (const v of [2, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, "0"]) { assert(!check(v).success); } assert(l.compile(l.literal(false))(false).success); assert(!l.compile(l.literal(false))(true).success); assert(l.compile(l.enumValues("one", "two"))("two").success); assert(!l.compile(l.enumValues("one", "two"))("three").success); assert(l.compile(l.array(l.boolean, { minLength: 1, maxLength: 2 }))([false]).success); assert(!l.compile(l.array(l.boolean, { minLength: 1 }))([]).success); assert(!l.compile(l.array(l.boolean, { maxLength: 1 }))([true, false]).success); assert(!l.compile(l.array(l.boolean))(new Array(1)).success); assert(l.compile(l.bytesWith({ minLength: 1, maxLength: 2 }))({ $bytes: "AQI=" }).success); assert(!l.compile(l.bytesWith({ maxLength: 1 }))({ $bytes: "AQI=" }).success); assert(!l.compile(l.bytes)([1]).success); }); Deno.test("blob refs enforce CID, size, and MIME constraints", () => { const check = l.compile(l.blob({ accept: ["image/*"], maxSize: 100 })); const value = { $type: "blob", ref: { $link: cid }, mimeType: "image/png", size: 100 }; assert(check(value).success); for ( const change of [ { size: 101 }, { size: -1 }, { size: 0 }, { size: 1.5 }, { mimeType: "video/mp4" }, { ref: { $link: "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } }, { mimeType: "image/*" }, { ref: { $link: "not a CID" } }, { $type: "other" }, ] ) assert(!check({ ...value, ...change }).success); const def = l.toLexicons( l.named("blue.cerulea.app.defs#image", l.blob({ accept: ["image/*"], maxSize: 100 })), )[0]!.defs.image; equal(def, { type: "blob", accept: ["image/*"], maxSize: 100 }); }); Deno.test("extra fields are accepted whatever their value, for forward compatibility", () => { const check = l.compile(l.object({ text: l.string })); const cyclic: Record = { text: "" }; cyclic.extra = cyclic; assert(check(cyclic).success); for ( const extra of [undefined, 0.1, new Date(), { $bytes: false }, { $link: "x" }, { $type: "blob", }] ) assert(check({ text: "", extra }).success); assert(!check(Object.create({ text: "inherited" })).success); assert(check(Object.assign(Object.create(null), { text: "" })).success); }); Deno.test("prototype-looking property names are ordinary data", () => { const properties = { ["__proto__"]: l.string, constructor: l.integer }; const schema = l.named("blue.cerulea.app.defs#safe", l.object(properties)); const value = JSON.parse('{"__proto__":"ok","constructor":1}'); assert(l.compile(schema)(value).success); const doc = JSON.parse(JSON.stringify(l.toLexicons(schema)))[0]; equal(doc.defs.safe.properties.__proto__, { type: "string" }); });