a tiny atproto lexicon validator
0

Configure Feed

Select the types of activity you want to include in your feed.

tinylex / test.js
9.0 kB 293 lines
1import { test } from "node:test"; 2import assert from "node:assert/strict"; 3import TinyLex from "./tinylex.js"; 4 5/** @returns {(v: unknown) => boolean} */ 6const validator = (/** @type {any} */ def) => { 7 const schema = new TinyLex() 8 .add({ lexicon: 1, id: "test.x", defs: { main: def } }) 9 .schema("test.x"); 10 return (v) => !schema["~standard"].validate(v).issues; 11}; 12 13// --------------------------------------------------------------------------- 14// datetime format 15// --------------------------------------------------------------------------- 16 17const dt = validator({ type: "string", format: "datetime" }); 18 19test("datetime: accepts valid RFC 3339", () => { 20 assert(dt("2025-01-01T00:00:00Z")); 21 assert(dt("2025-01-01T00:00:00.123Z")); 22 assert(dt("2025-01-01T00:00:00.123456789Z")); 23 assert(dt("2025-01-01T00:00:00+05:00")); 24 assert(dt("2025-01-01T00:00:00-08:00")); 25 assert(dt("2024-02-29T00:00:00Z")); 26}); 27 28test("datetime: rejects malformed strings", () => { 29 assert(!dt("")); 30 assert(!dt("2025-01-01")); 31 assert(!dt("2025-01-01 00:00:00Z")); 32 assert(!dt("2025-01-01T00:00:00")); 33 assert(!dt("2025/01/01T00:00:00Z")); 34}); 35 36test("datetime: rejects out-of-range date components", () => { 37 assert(!dt("2025-13-01T00:00:00Z")); 38 assert(!dt("2025-00-01T00:00:00Z")); 39 assert(!dt("2025-01-32T00:00:00Z")); 40 assert(!dt("2025-01-00T00:00:00Z")); 41}); 42 43test("datetime: rejects out-of-range times", () => { 44 assert(!dt("2025-01-01T25:00:00Z")); 45 assert(!dt("2025-01-01T00:60:00Z")); 46 assert(!dt("2025-01-01T00:00:60Z")); 47}); 48 49test("datetime: rejects -00:00 timezone", () => { 50 assert(!dt("2025-01-01T00:00:00-00:00")); 51}); 52 53// --------------------------------------------------------------------------- 54// other formats 55// --------------------------------------------------------------------------- 56 57test("handle format", () => { 58 const v = validator({ type: "string", format: "handle" }); 59 assert(v("alice.bsky.social")); 60 assert(v("a.b")); 61 assert(v("foo-bar.example.com")); 62 assert(!v("alice")); 63 assert(!v("-alice.bsky.social")); 64 assert(!v("alice-.bsky.social")); 65 assert(!v("alice..bsky.social")); 66 assert(!v("a".repeat(254) + ".com")); 67}); 68 69test("did format", () => { 70 const v = validator({ type: "string", format: "did" }); 71 assert(v("did:plc:abc123")); 72 assert(v("did:web:example.com")); 73 assert(!v("plc:abc")); 74 assert(!v("did:plc:")); 75}); 76 77test("at-identifier format", () => { 78 const v = validator({ type: "string", format: "at-identifier" }); 79 assert(v("did:plc:abc123")); 80 assert(v("alice.bsky.social")); 81 assert(!v("not_valid")); 82}); 83 84// --------------------------------------------------------------------------- 85// type dispatch — one happy + sad path per type 86// --------------------------------------------------------------------------- 87 88test("null", () => { 89 const v = validator({ type: "null" }); 90 assert(v(null)); 91 assert(!v(undefined)); 92 assert(!v(0)); 93}); 94 95test("boolean", () => { 96 const v = validator({ type: "boolean" }); 97 assert(v(true) && v(false)); 98 assert(!v("true")); 99 100 const c = validator({ type: "boolean", const: true }); 101 assert(c(true) && !c(false)); 102 103 const e = validator({ type: "boolean", enum: [true] }); 104 assert(e(true) && !e(false)); 105}); 106 107test("integer", () => { 108 const v = validator({ type: "integer" }); 109 assert(v(0) && v(-5) && v(42)); 110 assert(!v(1.5) && !v("1") && !v(null)); 111 112 const r = validator({ type: "integer", minimum: 0, maximum: 10 }); 113 assert(r(0) && r(10)); 114 assert(!r(-1) && !r(11)); 115 116 const c = validator({ type: "integer", const: 7 }); 117 assert(c(7) && !c(8)); 118 119 const e = validator({ type: "integer", enum: [1, 2, 3] }); 120 assert(e(2) && !e(4)); 121}); 122 123test("string", () => { 124 const v = validator({ type: "string" }); 125 assert(v("") && v("hello")); 126 assert(!v(123)); 127 128 const l = validator({ type: "string", minLength: 2, maxLength: 4 }); 129 assert(l("ab") && l("abcd")); 130 assert(!l("a") && !l("abcde")); 131 132 const c = validator({ type: "string", const: "x" }); 133 assert(c("x") && !c("y")); 134 135 const e = validator({ type: "string", enum: ["a", "b"] }); 136 assert(e("a") && !e("c")); 137}); 138 139test("bytes", () => { 140 const v = validator({ type: "bytes" }); 141 assert(v({ $bytes: "aGVsbG8=" })); 142 assert(!v({ $bytes: "not!base64!" })); 143 assert(!v({ $bytes: 123 })); 144 assert(!v(null)); 145}); 146 147test("cid-link", () => { 148 const v = validator({ type: "cid-link" }); 149 assert(v({ $link: "bafyabc123" })); 150 assert(!v({ $link: "!!invalid" })); 151 assert(!v({})); 152}); 153 154test("blob", () => { 155 const v = validator({ type: "blob" }); 156 assert(v({ $type: "blob", mimeType: "image/png", size: 100, ref: { $link: "bafy123" } })); 157 assert(!v({ $type: "blob" })); 158 assert(!v(null)); 159 160 const accept = validator({ type: "blob", accept: ["image/*"] }); 161 assert(accept({ $type: "blob", mimeType: "image/png", size: 1, ref: { $link: "bafy1" } })); 162 assert(!accept({ $type: "blob", mimeType: "text/plain", size: 1, ref: { $link: "bafy1" } })); 163 164 const maxSize = validator({ type: "blob", maxSize: 100 }); 165 assert(maxSize({ $type: "blob", mimeType: "x/y", size: 50, ref: { $link: "bafy1" } })); 166 assert(!maxSize({ $type: "blob", mimeType: "x/y", size: 200, ref: { $link: "bafy1" } })); 167}); 168 169test("array", () => { 170 const v = validator({ type: "array", items: { type: "integer" } }); 171 assert(v([]) && v([1, 2, 3])); 172 assert(!v([1, "two"]) && !v("not array")); 173 174 const l = validator({ type: "array", items: { type: "string" }, minLength: 1, maxLength: 2 }); 175 assert(l(["a"]) && l(["a", "b"])); 176 assert(!l([]) && !l(["a", "b", "c"])); 177}); 178 179test("object", () => { 180 const v = validator({ 181 type: "object", 182 required: ["x"], 183 properties: { x: { type: "integer" }, y: { type: "string" } }, 184 }); 185 assert(v({ x: 1 })); 186 assert(v({ x: 1, y: "hi" })); 187 assert(!v({})); 188 assert(!v({ x: "not int" })); 189 assert(!v(null)); 190 assert(!v([])); 191 192 const n = validator({ 193 type: "object", 194 required: ["x"], 195 nullable: ["x"], 196 properties: { x: { type: "integer" } }, 197 }); 198 assert(n({ x: null })); 199 200 const nn = validator({ type: "object", properties: { x: { type: "integer" } } }); 201 assert(!nn({ x: null })); 202}); 203 204test("params", () => { 205 const v = validator({ 206 type: "params", 207 required: ["limit"], 208 properties: { limit: { type: "integer" }, cursor: { type: "string" } }, 209 }); 210 assert(v({ limit: 10 })); 211 assert(!v({})); 212 assert(!v({ limit: "ten" })); 213}); 214 215test("unknown type", () => { 216 const v = validator({ type: "unknown" }); 217 assert(v({ anything: "goes" })); 218 assert(!v(null)); 219 assert(!v([])); 220 assert(!v({ $link: "x" })); 221 assert(!v({ $bytes: "x" })); 222}); 223 224test("record: requires $type matching the lexicon NSID", () => { 225 const v = validator({ 226 type: "record", 227 record: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, 228 }); 229 assert(v({ $type: "test.x", x: 1 })); 230 assert(v({ $type: "test.x#main", x: 1 })); 231 assert(!v({ x: 1 })); 232 assert(!v({ $type: "test.other", x: 1 })); 233 assert(!v({ $type: "test.x" })); 234 assert(!v(null)); 235 assert(!v([])); 236}); 237 238test("token has no data representation", () => { 239 const v = validator({ type: "token" }); 240 assert(!v(null) && !v("anything")); 241}); 242 243test("unknown schema type fails gracefully", () => { 244 const v = validator({ type: "not-a-real-type" }); 245 assert(!v("anything")); 246}); 247 248// --------------------------------------------------------------------------- 249// ref + union (need a real catalog) 250// --------------------------------------------------------------------------- 251 252test("ref resolves through catalog", () => { 253 const catalog = new TinyLex().add({ 254 lexicon: 1, 255 id: "test.ref", 256 defs: { 257 main: { type: "object", required: ["n"], properties: { n: { type: "ref", ref: "#count" } } }, 258 count: { type: "integer", minimum: 0 }, 259 }, 260 }); 261 const schema = catalog.schema("test.ref"); 262 assert(!schema["~standard"].validate({ n: 5 }).issues); 263 assert(schema["~standard"].validate({ n: -1 }).issues); 264}); 265 266test("union: open accepts unknown $type", () => { 267 const catalog = new TinyLex().add({ 268 lexicon: 1, 269 id: "test.u", 270 defs: { 271 main: { type: "union", refs: ["#a"] }, 272 a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, 273 }, 274 }); 275 const schema = catalog.schema("test.u"); 276 assert(!schema["~standard"].validate({ $type: "test.u#a", x: 1 }).issues); 277 assert(!schema["~standard"].validate({ $type: "com.other#thing" }).issues); 278 assert(schema["~standard"].validate({ $type: "test.u#a", x: "bad" }).issues); 279}); 280 281test("union: closed rejects unknown $type", () => { 282 const catalog = new TinyLex().add({ 283 lexicon: 1, 284 id: "test.cu", 285 defs: { 286 main: { type: "union", closed: true, refs: ["#a"] }, 287 a: { type: "object", required: ["x"], properties: { x: { type: "integer" } } }, 288 }, 289 }); 290 const schema = catalog.schema("test.cu"); 291 assert(!schema["~standard"].validate({ $type: "test.cu#a", x: 1 }).issues); 292 assert(schema["~standard"].validate({ $type: "com.other#thing" }).issues); 293});