import { test } from "node:test"; import assert from "node:assert/strict"; import TinyLex from "./tinylex.js"; /** @returns {(v: unknown) => boolean} */ const validator = (/** @type {any} */ def) => { const schema = new TinyLex() .add({ lexicon: 1, id: "test.x", defs: { main: def } }) .schema("test.x"); return (v) => !schema["~standard"].validate(v).issues; }; // --------------------------------------------------------------------------- // datetime format // --------------------------------------------------------------------------- const dt = validator({ type: "string", format: "datetime" }); test("datetime: accepts valid RFC 3339", () => { assert(dt("2025-01-01T00:00:00Z")); assert(dt("2025-01-01T00:00:00.123Z")); assert(dt("2025-01-01T00:00:00.123456789Z")); assert(dt("2025-01-01T00:00:00+05:00")); assert(dt("2025-01-01T00:00:00-08:00")); assert(dt("2024-02-29T00:00:00Z")); }); test("datetime: rejects malformed strings", () => { assert(!dt("")); assert(!dt("2025-01-01")); assert(!dt("2025-01-01 00:00:00Z")); assert(!dt("2025-01-01T00:00:00")); assert(!dt("2025/01/01T00:00:00Z")); }); test("datetime: rejects out-of-range date components", () => { assert(!dt("2025-13-01T00:00:00Z")); assert(!dt("2025-00-01T00:00:00Z")); assert(!dt("2025-01-32T00:00:00Z")); assert(!dt("2025-01-00T00:00:00Z")); }); test("datetime: rejects out-of-range times", () => { assert(!dt("2025-01-01T25:00:00Z")); assert(!dt("2025-01-01T00:60:00Z")); assert(!dt("2025-01-01T00:00:60Z")); }); test("datetime: rejects -00:00 timezone", () => { assert(!dt("2025-01-01T00:00:00-00:00")); }); // --------------------------------------------------------------------------- // other formats // --------------------------------------------------------------------------- test("handle format", () => { const v = validator({ type: "string", format: "handle" }); assert(v("alice.bsky.social")); assert(v("a.b")); assert(v("foo-bar.example.com")); assert(!v("alice")); assert(!v("-alice.bsky.social")); assert(!v("alice-.bsky.social")); assert(!v("alice..bsky.social")); assert(!v("a".repeat(254) + ".com")); }); test("did format", () => { const v = validator({ type: "string", format: "did" }); assert(v("did:plc:abc123")); assert(v("did:web:example.com")); assert(!v("plc:abc")); assert(!v("did:plc:")); }); test("at-identifier format", () => { const v = validator({ type: "string", format: "at-identifier" }); assert(v("did:plc:abc123")); assert(v("alice.bsky.social")); assert(!v("not_valid")); }); // --------------------------------------------------------------------------- // type dispatch — one happy + sad path per type // --------------------------------------------------------------------------- test("null", () => { const v = validator({ type: "null" }); assert(v(null)); assert(!v(undefined)); assert(!v(0)); }); test("boolean", () => { const v = validator({ type: "boolean" }); assert(v(true) && v(false)); assert(!v("true")); const c = validator({ type: "boolean", const: true }); assert(c(true) && !c(false)); const e = validator({ type: "boolean", enum: [true] }); assert(e(true) && !e(false)); }); test("integer", () => { const v = validator({ type: "integer" }); assert(v(0) && v(-5) && v(42)); assert(!v(1.5) && !v("1") && !v(null)); const r = validator({ type: "integer", minimum: 0, maximum: 10 }); assert(r(0) && r(10)); assert(!r(-1) && !r(11)); const c = validator({ type: "integer", const: 7 }); assert(c(7) && !c(8)); const e = validator({ type: "integer", enum: [1, 2, 3] }); assert(e(2) && !e(4)); }); test("string", () => { const v = validator({ type: "string" }); assert(v("") && v("hello")); assert(!v(123)); const l = validator({ type: "string", minLength: 2, maxLength: 4 }); assert(l("ab") && l("abcd")); assert(!l("a") && !l("abcde")); const c = validator({ type: "string", const: "x" }); assert(c("x") && !c("y")); const e = validator({ type: "string", enum: ["a", "b"] }); assert(e("a") && !e("c")); }); test("bytes", () => { const v = validator({ type: "bytes" }); assert(v({ $bytes: "aGVsbG8=" })); assert(!v({ $bytes: "not!base64!" })); assert(!v({ $bytes: 123 })); assert(!v(null)); }); test("cid-link", () => { const v = validator({ type: "cid-link" }); assert(v({ $link: "bafyabc123" })); assert(!v({ $link: "!!invalid" })); assert(!v({})); }); test("blob", () => { const v = validator({ type: "blob" }); assert(v({ $type: "blob", mimeType: "image/png", size: 100, ref: { $link: "bafy123" } })); assert(!v({ $type: "blob" })); assert(!v(null)); const accept = validator({ type: "blob", accept: ["image/*"] }); assert(accept({ $type: "blob", mimeType: "image/png", size: 1, ref: { $link: "bafy1" } })); assert(!accept({ $type: "blob", mimeType: "text/plain", size: 1, ref: { $link: "bafy1" } })); const maxSize = validator({ type: "blob", maxSize: 100 }); assert(maxSize({ $type: "blob", mimeType: "x/y", size: 50, ref: { $link: "bafy1" } })); assert(!maxSize({ $type: "blob", mimeType: "x/y", size: 200, ref: { $link: "bafy1" } })); }); test("array", () => { const v = validator({ type: "array", items: { type: "integer" } }); assert(v([]) && v([1, 2, 3])); assert(!v([1, "two"]) && !v("not array")); const l = validator({ type: "array", items: { type: "string" }, minLength: 1, maxLength: 2 }); assert(l(["a"]) && l(["a", "b"])); assert(!l([]) && !l(["a", "b", "c"])); }); test("object", () => { const v = validator({ type: "object", required: ["x"], properties: { x: { type: "integer" }, y: { type: "string" } }, }); assert(v({ x: 1 })); assert(v({ x: 1, y: "hi" })); assert(!v({})); assert(!v({ x: "not int" })); assert(!v(null)); assert(!v([])); const n = validator({ type: "object", required: ["x"], nullable: ["x"], properties: { x: { type: "integer" } }, }); assert(n({ x: null })); const nn = validator({ type: "object", properties: { x: { type: "integer" } } }); assert(!nn({ x: null })); }); test("params", () => { const v = validator({ type: "params", required: ["limit"], properties: { limit: { type: "integer" }, cursor: { type: "string" } }, }); assert(v({ limit: 10 })); assert(!v({})); assert(!v({ limit: "ten" })); }); test("unknown type", () => { const v = validator({ type: "unknown" }); assert(v({ anything: "goes" })); assert(!v(null)); assert(!v([])); assert(!v({ $link: "x" })); assert(!v({ $bytes: "x" })); }); test("record: requires $type matching the lexicon NSID", () => { const v = validator({ type: "record", record: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, }); assert(v({ $type: "test.x", x: 1 })); assert(v({ $type: "test.x#main", x: 1 })); assert(!v({ x: 1 })); assert(!v({ $type: "test.other", x: 1 })); assert(!v({ $type: "test.x" })); assert(!v(null)); assert(!v([])); }); test("token has no data representation", () => { const v = validator({ type: "token" }); assert(!v(null) && !v("anything")); }); test("unknown schema type fails gracefully", () => { const v = validator({ type: "not-a-real-type" }); assert(!v("anything")); }); // --------------------------------------------------------------------------- // ref + union (need a real catalog) // --------------------------------------------------------------------------- test("ref resolves through catalog", () => { const catalog = new TinyLex().add({ lexicon: 1, id: "test.ref", defs: { main: { type: "object", required: ["n"], properties: { n: { type: "ref", ref: "#count" } } }, count: { type: "integer", minimum: 0 }, }, }); const schema = catalog.schema("test.ref"); assert(!schema["~standard"].validate({ n: 5 }).issues); assert(schema["~standard"].validate({ n: -1 }).issues); }); test("union: open accepts unknown $type", () => { const catalog = new TinyLex().add({ lexicon: 1, id: "test.u", defs: { main: { type: "union", refs: ["#a"] }, a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, }, }); const schema = catalog.schema("test.u"); assert(!schema["~standard"].validate({ $type: "test.u#a", x: 1 }).issues); assert(!schema["~standard"].validate({ $type: "com.other#thing" }).issues); assert(schema["~standard"].validate({ $type: "test.u#a", x: "bad" }).issues); }); test("union: closed rejects unknown $type", () => { const catalog = new TinyLex().add({ lexicon: 1, id: "test.cu", defs: { main: { type: "union", closed: true, refs: ["#a"] }, a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, }, }); const schema = catalog.schema("test.cu"); assert(!schema["~standard"].validate({ $type: "test.cu#a", x: 1 }).issues); assert(schema["~standard"].validate({ $type: "com.other#thing" }).issues); });