@notionhq/workers 0.0.82 → 0.0.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,677 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { JSONSchema } from "./json-schema.js";
3
+ import {
4
+ getSchema,
5
+ type Infer,
6
+ j,
7
+ type SchemaBuilder,
8
+ } from "./schema-builder.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // 1. Primitives
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe("json-schema-builder — primitives", () => {
15
+ it("string()", () => {
16
+ expect(getSchema(j.string())).toEqual({ type: "string" });
17
+ });
18
+
19
+ it("number()", () => {
20
+ expect(getSchema(j.number())).toEqual({ type: "number" });
21
+ });
22
+
23
+ it("integer()", () => {
24
+ expect(getSchema(j.integer())).toEqual({ type: "integer" });
25
+ });
26
+
27
+ it("boolean()", () => {
28
+ expect(getSchema(j.boolean())).toEqual({ type: "boolean" });
29
+ });
30
+ });
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // 2. Description
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe("json-schema-builder — description", () => {
37
+ it("adds description field", () => {
38
+ expect(getSchema(j.string().describe("A name"))).toEqual({
39
+ type: "string",
40
+ description: "A name",
41
+ });
42
+ });
43
+
44
+ it("is chainable — last wins", () => {
45
+ const s = j.number().describe("first").describe("second");
46
+ expect(getSchema(s)).toEqual({
47
+ type: "number",
48
+ description: "second",
49
+ });
50
+ });
51
+
52
+ it("works on all primitive types", () => {
53
+ expect(getSchema(j.boolean().describe("flag"))).toEqual({
54
+ type: "boolean",
55
+ description: "flag",
56
+ });
57
+ expect(getSchema(j.integer().describe("count"))).toEqual({
58
+ type: "integer",
59
+ description: "count",
60
+ });
61
+ });
62
+ });
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // 3. Enum
66
+ // ---------------------------------------------------------------------------
67
+
68
+ describe("json-schema-builder — enum", () => {
69
+ it("string enum", () => {
70
+ expect(getSchema(j.enum("a", "b", "c"))).toEqual({
71
+ type: "string",
72
+ enum: ["a", "b", "c"],
73
+ });
74
+ });
75
+
76
+ it("number enum", () => {
77
+ expect(getSchema(j.enum(1, 2, 3))).toEqual({
78
+ type: "number",
79
+ enum: [1, 2, 3],
80
+ });
81
+ });
82
+
83
+ it("string enum type inference", () => {
84
+ const s = j.enum("x", "y");
85
+ type T = Infer<typeof s>;
86
+ const _a: T = "x";
87
+ const _b: T = "y";
88
+ expect(getSchema(s)).toBeDefined();
89
+ });
90
+
91
+ it("number enum type inference", () => {
92
+ const s = j.enum(10, 20);
93
+ type T = Infer<typeof s>;
94
+ const _a: T = 10;
95
+ const _b: T = 20;
96
+ expect(getSchema(s)).toBeDefined();
97
+ });
98
+
99
+ it("enum with description", () => {
100
+ expect(getSchema(j.enum("a", "b").describe("pick one"))).toEqual({
101
+ type: "string",
102
+ enum: ["a", "b"],
103
+ description: "pick one",
104
+ });
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 4. Format shorthands
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("json-schema-builder — format shorthands", () => {
113
+ it("datetime()", () => {
114
+ expect(getSchema(j.datetime())).toEqual({
115
+ type: "string",
116
+ format: "date-time",
117
+ });
118
+ });
119
+
120
+ it("date()", () => {
121
+ expect(getSchema(j.date())).toEqual({ type: "string", format: "date" });
122
+ });
123
+
124
+ it("time()", () => {
125
+ expect(getSchema(j.time())).toEqual({ type: "string", format: "time" });
126
+ });
127
+
128
+ it("duration()", () => {
129
+ expect(getSchema(j.duration())).toEqual({
130
+ type: "string",
131
+ format: "duration",
132
+ });
133
+ });
134
+
135
+ it("email()", () => {
136
+ expect(getSchema(j.email())).toEqual({
137
+ type: "string",
138
+ format: "email",
139
+ });
140
+ });
141
+
142
+ it("hostname()", () => {
143
+ expect(getSchema(j.hostname())).toEqual({
144
+ type: "string",
145
+ format: "hostname",
146
+ });
147
+ });
148
+
149
+ it("ipv4()", () => {
150
+ expect(getSchema(j.ipv4())).toEqual({ type: "string", format: "ipv4" });
151
+ });
152
+
153
+ it("ipv6()", () => {
154
+ expect(getSchema(j.ipv6())).toEqual({ type: "string", format: "ipv6" });
155
+ });
156
+
157
+ it("uuid()", () => {
158
+ expect(getSchema(j.uuid())).toEqual({ type: "string", format: "uuid" });
159
+ });
160
+ });
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // 5. Array
164
+ // ---------------------------------------------------------------------------
165
+
166
+ describe("json-schema-builder — array", () => {
167
+ it("basic array", () => {
168
+ expect(getSchema(j.array(j.string()))).toEqual({
169
+ type: "array",
170
+ items: { type: "string" },
171
+ });
172
+ });
173
+
174
+ it("array with minItems 1", () => {
175
+ expect(getSchema(j.array(j.string(), { minItems: 1 }))).toEqual({
176
+ type: "array",
177
+ items: { type: "string" },
178
+ minItems: 1,
179
+ });
180
+ });
181
+
182
+ it("array with minItems 0", () => {
183
+ expect(getSchema(j.array(j.number(), { minItems: 0 }))).toEqual({
184
+ type: "array",
185
+ items: { type: "number" },
186
+ minItems: 0,
187
+ });
188
+ });
189
+
190
+ it("array of objects", () => {
191
+ const schema = getSchema(
192
+ j.array(j.object({ name: j.string(), age: j.number() })),
193
+ );
194
+ expect(schema).toEqual({
195
+ type: "array",
196
+ items: {
197
+ type: "object",
198
+ properties: {
199
+ name: { type: "string" },
200
+ age: { type: "number" },
201
+ },
202
+ required: ["name", "age"],
203
+ additionalProperties: false,
204
+ },
205
+ });
206
+ });
207
+
208
+ it("rejects minItems > 1 at type level", () => {
209
+ // @ts-expect-error — minItems can only be 0 or 1
210
+ j.array(j.string(), { minItems: 2 });
211
+ });
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // 6. Object
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe("json-schema-builder — object", () => {
219
+ it("auto-generates required and additionalProperties: false", () => {
220
+ const schema = getSchema(
221
+ j.object({
222
+ name: j.string(),
223
+ age: j.number(),
224
+ }),
225
+ );
226
+ expect(schema).toEqual({
227
+ type: "object",
228
+ properties: {
229
+ name: { type: "string" },
230
+ age: { type: "number" },
231
+ },
232
+ required: ["name", "age"],
233
+ additionalProperties: false,
234
+ });
235
+ });
236
+
237
+ it("nested objects", () => {
238
+ const schema = getSchema(
239
+ j.object({
240
+ address: j.object({
241
+ street: j.string(),
242
+ zip: j.string(),
243
+ }),
244
+ }),
245
+ );
246
+ expect(schema).toEqual({
247
+ type: "object",
248
+ properties: {
249
+ address: {
250
+ type: "object",
251
+ properties: {
252
+ street: { type: "string" },
253
+ zip: { type: "string" },
254
+ },
255
+ required: ["street", "zip"],
256
+ additionalProperties: false,
257
+ },
258
+ },
259
+ required: ["address"],
260
+ additionalProperties: false,
261
+ });
262
+ });
263
+
264
+ it("empty object", () => {
265
+ expect(getSchema(j.object({}))).toEqual({
266
+ type: "object",
267
+ properties: {},
268
+ required: [],
269
+ additionalProperties: false,
270
+ });
271
+ });
272
+
273
+ it("object with description", () => {
274
+ const schema = getSchema(j.object({ x: j.number() }).describe("A point"));
275
+ expect(schema).toEqual({
276
+ type: "object",
277
+ properties: { x: { type: "number" } },
278
+ required: ["x"],
279
+ additionalProperties: false,
280
+ description: "A point",
281
+ });
282
+ });
283
+ });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // 7. Nullable
287
+ // ---------------------------------------------------------------------------
288
+
289
+ describe("json-schema-builder — nullable", () => {
290
+ it("wraps in anyOf with null", () => {
291
+ expect(getSchema(j.string().nullable())).toEqual({
292
+ anyOf: [{ type: "string" }, { type: "null" }],
293
+ });
294
+ });
295
+
296
+ it("hoists description to outer wrapper", () => {
297
+ expect(getSchema(j.string().describe("A name").nullable())).toEqual({
298
+ anyOf: [{ type: "string" }, { type: "null" }],
299
+ description: "A name",
300
+ });
301
+ });
302
+
303
+ it("description after nullable sets on wrapper", () => {
304
+ expect(getSchema(j.string().nullable().describe("A name"))).toEqual({
305
+ anyOf: [{ type: "string" }, { type: "null" }],
306
+ description: "A name",
307
+ });
308
+ });
309
+
310
+ it("nullable number", () => {
311
+ expect(getSchema(j.number().nullable())).toEqual({
312
+ anyOf: [{ type: "number" }, { type: "null" }],
313
+ });
314
+ });
315
+
316
+ it("nullable enum", () => {
317
+ expect(getSchema(j.enum("a", "b").nullable())).toEqual({
318
+ anyOf: [{ type: "string", enum: ["a", "b"] }, { type: "null" }],
319
+ });
320
+ });
321
+ });
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // 8. Ref
325
+ // ---------------------------------------------------------------------------
326
+
327
+ describe("json-schema-builder — ref", () => {
328
+ it("produces $ref schema", () => {
329
+ expect(getSchema(j.ref("#/$defs/Foo"))).toEqual({
330
+ $ref: "#/$defs/Foo",
331
+ });
332
+ });
333
+
334
+ it("ref with description", () => {
335
+ expect(getSchema(j.ref("#/$defs/Bar").describe("A bar ref"))).toEqual({
336
+ $ref: "#/$defs/Bar",
337
+ description: "A bar ref",
338
+ });
339
+ });
340
+ });
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // 9. AnyOf
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe("json-schema-builder — anyOf", () => {
347
+ it("produces anyOf schema", () => {
348
+ expect(getSchema(j.anyOf(j.string(), j.number()))).toEqual({
349
+ anyOf: [{ type: "string" }, { type: "number" }],
350
+ });
351
+ });
352
+
353
+ it("anyOf with description", () => {
354
+ expect(
355
+ getSchema(j.anyOf(j.string(), j.number()).describe("string or number")),
356
+ ).toEqual({
357
+ anyOf: [{ type: "string" }, { type: "number" }],
358
+ description: "string or number",
359
+ });
360
+ });
361
+ });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // 9b. Discriminated unions
365
+ // ---------------------------------------------------------------------------
366
+
367
+ describe("json-schema-builder — discriminated unions", () => {
368
+ it("two-variant discriminated union", () => {
369
+ const schema = j.anyOf(
370
+ j.object({ type: j.enum("text"), value: j.string() }),
371
+ j.object({ type: j.enum("number"), value: j.number() }),
372
+ );
373
+
374
+ expect(getSchema(schema)).toEqual({
375
+ anyOf: [
376
+ {
377
+ type: "object",
378
+ properties: {
379
+ type: { type: "string", enum: ["text"] },
380
+ value: { type: "string" },
381
+ },
382
+ required: ["type", "value"],
383
+ additionalProperties: false,
384
+ },
385
+ {
386
+ type: "object",
387
+ properties: {
388
+ type: { type: "string", enum: ["number"] },
389
+ value: { type: "number" },
390
+ },
391
+ required: ["type", "value"],
392
+ additionalProperties: false,
393
+ },
394
+ ],
395
+ });
396
+ });
397
+
398
+ it("three-variant discriminated union with different shapes", () => {
399
+ const schema = j.anyOf(
400
+ j.object({ kind: j.enum("circle"), radius: j.number() }),
401
+ j.object({
402
+ kind: j.enum("rectangle"),
403
+ width: j.number(),
404
+ height: j.number(),
405
+ }),
406
+ j.object({
407
+ kind: j.enum("polygon"),
408
+ sides: j.array(j.object({ x: j.number(), y: j.number() })),
409
+ }),
410
+ );
411
+
412
+ const raw = getSchema(schema) as unknown as {
413
+ anyOf: Record<string, unknown>[];
414
+ };
415
+ expect(raw.anyOf).toHaveLength(3);
416
+
417
+ // Each variant is a valid object schema with required + additionalProperties
418
+ for (const variant of raw.anyOf) {
419
+ expect(variant.type).toBe("object");
420
+ expect(variant.additionalProperties).toBe(false);
421
+ expect(Array.isArray(variant.required)).toBe(true);
422
+ expect(variant.required).toContain("kind");
423
+ }
424
+
425
+ // Verify discriminant values
426
+ const discriminants = raw.anyOf.map(
427
+ (v) =>
428
+ (
429
+ (v.properties as Record<string, Record<string, unknown>>).kind as {
430
+ enum: string[];
431
+ }
432
+ ).enum[0],
433
+ );
434
+ expect(discriminants).toEqual(["circle", "rectangle", "polygon"]);
435
+ });
436
+
437
+ it("discriminated union type inference", () => {
438
+ const schema = j.anyOf(
439
+ j.object({ status: j.enum("ok"), data: j.string() }),
440
+ j.object({ status: j.enum("error"), message: j.string() }),
441
+ );
442
+
443
+ type T = Infer<typeof schema>;
444
+
445
+ // Both variants are assignable
446
+ const _ok: T = { status: "ok", data: "hello" };
447
+ const _err: T = { status: "error", message: "fail" };
448
+ expect(getSchema(schema)).toBeDefined();
449
+ });
450
+
451
+ it("discriminated union with description on wrapper", () => {
452
+ const schema = j
453
+ .anyOf(
454
+ j.object({ type: j.enum("a"), x: j.number() }),
455
+ j.object({ type: j.enum("b"), y: j.string() }),
456
+ )
457
+ .describe("A or B");
458
+
459
+ const raw = getSchema(schema) as unknown as Record<string, unknown>;
460
+ expect(raw.description).toBe("A or B");
461
+ expect(raw.anyOf).toHaveLength(2);
462
+ });
463
+
464
+ it("discriminated union with descriptions on individual variants", () => {
465
+ const schema = j.anyOf(
466
+ j
467
+ .object({ type: j.enum("text"), value: j.string() })
468
+ .describe("A text node"),
469
+ j
470
+ .object({ type: j.enum("image"), url: j.string() })
471
+ .describe("An image node"),
472
+ );
473
+
474
+ const raw = getSchema(schema) as unknown as {
475
+ anyOf: Record<string, unknown>[];
476
+ };
477
+ expect(raw.anyOf[0]?.description).toBe("A text node");
478
+ expect(raw.anyOf[1]?.description).toBe("An image node");
479
+ });
480
+
481
+ it("discriminated union nested inside object property", () => {
482
+ const schema = j.object({
483
+ id: j.string(),
484
+ content: j.anyOf(
485
+ j.object({ type: j.enum("text"), body: j.string() }),
486
+ j.object({
487
+ type: j.enum("code"),
488
+ language: j.string(),
489
+ source: j.string(),
490
+ }),
491
+ ),
492
+ });
493
+
494
+ const raw = getSchema(schema) as unknown as {
495
+ properties: { content: { anyOf: Record<string, unknown>[] } };
496
+ };
497
+ expect(raw.properties.content.anyOf).toHaveLength(2);
498
+
499
+ // The content field is in required
500
+ expect(
501
+ (getSchema(schema) as unknown as Record<string, unknown>).required,
502
+ ).toContain("content");
503
+ });
504
+
505
+ it("nullable discriminated union", () => {
506
+ const schema = j
507
+ .anyOf(
508
+ j.object({ type: j.enum("a"), value: j.number() }),
509
+ j.object({ type: j.enum("b"), value: j.string() }),
510
+ )
511
+ .nullable();
512
+
513
+ const raw = getSchema(schema) as unknown as {
514
+ anyOf: Record<string, unknown>[];
515
+ };
516
+ // Should have the original anyOf wrapper + null
517
+ expect(raw.anyOf).toHaveLength(2);
518
+ expect(raw.anyOf[0]).toHaveProperty("anyOf"); // inner anyOf with the two variants
519
+ expect(raw.anyOf[1]).toEqual({ type: "null" });
520
+ });
521
+
522
+ it("discriminated union validates with AJV", () => {
523
+ const { Ajv } = require("ajv") as typeof import("ajv");
524
+ const ajv = new Ajv();
525
+
526
+ const schema = j.anyOf(
527
+ j.object({ action: j.enum("create"), name: j.string() }),
528
+ j.object({ action: j.enum("delete"), id: j.number() }),
529
+ );
530
+
531
+ const validate = ajv.compile(getSchema(schema));
532
+
533
+ expect(validate({ action: "create", name: "foo" })).toBe(true);
534
+ expect(validate({ action: "delete", id: 42 })).toBe(true);
535
+ expect(validate({ action: "create", id: 42 })).toBe(false);
536
+ expect(validate({ action: "update", name: "foo" })).toBe(false);
537
+ });
538
+ });
539
+
540
+ // ---------------------------------------------------------------------------
541
+ // 10. Type inference
542
+ // ---------------------------------------------------------------------------
543
+
544
+ describe("json-schema-builder — type inference", () => {
545
+ it("getSchema output satisfies JSONSchema<T>", () => {
546
+ const s = j.object({
547
+ name: j.string(),
548
+ tags: j.array(j.string()),
549
+ });
550
+ const schema: JSONSchema<{ name: string; tags: string[] }> = getSchema(s);
551
+ expect(schema).toBeDefined();
552
+ });
553
+
554
+ it("Infer extracts T from SchemaBuilder<T>", () => {
555
+ type S = SchemaBuilder<{ name: string; age: number }>;
556
+ type T = Infer<S>;
557
+ const _: T = { name: "Alice", age: 30 };
558
+ expect(true).toBe(true);
559
+ });
560
+
561
+ it("nullable type inference", () => {
562
+ const s = j.object({
563
+ name: j.string(),
564
+ nickname: j.string().nullable(),
565
+ });
566
+ type T = Infer<typeof s>;
567
+ const _: T = { name: "Alice", nickname: null };
568
+ const _2: T = { name: "Alice", nickname: "Bob" };
569
+ expect(getSchema(s)).toBeDefined();
570
+ });
571
+ });
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // 11. Integration — complex schema round-trip
575
+ // ---------------------------------------------------------------------------
576
+
577
+ describe("json-schema-builder — integration", () => {
578
+ it("complex schema produces correct output", () => {
579
+ const schema = j.object({
580
+ foo: j.enum("a", "b", "c").describe("The foo field"),
581
+ bar: j.string(),
582
+ count: j.number(),
583
+ tags: j.array(j.string()),
584
+ address: j.object({
585
+ street: j.string(),
586
+ zip: j.string(),
587
+ }),
588
+ nickname: j.string().nullable(),
589
+ createdAt: j.datetime(),
590
+ });
591
+
592
+ expect(getSchema(schema)).toEqual({
593
+ type: "object",
594
+ properties: {
595
+ foo: {
596
+ type: "string",
597
+ enum: ["a", "b", "c"],
598
+ description: "The foo field",
599
+ },
600
+ bar: { type: "string" },
601
+ count: { type: "number" },
602
+ tags: { type: "array", items: { type: "string" } },
603
+ address: {
604
+ type: "object",
605
+ properties: {
606
+ street: { type: "string" },
607
+ zip: { type: "string" },
608
+ },
609
+ required: ["street", "zip"],
610
+ additionalProperties: false,
611
+ },
612
+ nickname: {
613
+ anyOf: [{ type: "string" }, { type: "null" }],
614
+ },
615
+ createdAt: { type: "string", format: "date-time" },
616
+ },
617
+ required: [
618
+ "foo",
619
+ "bar",
620
+ "count",
621
+ "tags",
622
+ "address",
623
+ "nickname",
624
+ "createdAt",
625
+ ],
626
+ additionalProperties: false,
627
+ });
628
+ });
629
+ });
630
+
631
+ // ---------------------------------------------------------------------------
632
+ // 12. Constraint enforcement
633
+ // ---------------------------------------------------------------------------
634
+
635
+ describe("json-schema-builder — constraint enforcement", () => {
636
+ it("object always has required matching properties keys", () => {
637
+ const schema = getSchema(
638
+ j.object({ x: j.number(), y: j.string(), z: j.boolean() }),
639
+ );
640
+ const raw = schema as unknown as Record<string, unknown>;
641
+ expect(raw.required).toEqual(["x", "y", "z"]);
642
+ expect(raw.additionalProperties).toBe(false);
643
+ });
644
+
645
+ it("no forbidden keywords in output", () => {
646
+ const schema = getSchema(
647
+ j.object({
648
+ name: j.string(),
649
+ items: j.array(j.number()),
650
+ nested: j.object({ a: j.boolean() }),
651
+ }),
652
+ );
653
+ const json = JSON.stringify(schema);
654
+ expect(json).not.toContain('"allOf"');
655
+ expect(json).not.toContain('"not"');
656
+ expect(json).not.toContain('"if"');
657
+ expect(json).not.toContain('"then"');
658
+ expect(json).not.toContain('"else"');
659
+ });
660
+
661
+ it("nested objects also enforce required and additionalProperties", () => {
662
+ const schema = getSchema(
663
+ j.object({
664
+ outer: j.object({
665
+ inner: j.object({
666
+ value: j.string(),
667
+ }),
668
+ }),
669
+ }),
670
+ );
671
+ const raw = schema as unknown as Record<string, unknown>;
672
+ const outer = (raw as { properties: { outer: Record<string, unknown> } })
673
+ .properties.outer;
674
+ expect(outer.required).toEqual(["inner"]);
675
+ expect(outer.additionalProperties).toBe(false);
676
+ });
677
+ });