@atlaspack/utils 3.0.4-dev-68c10d2af.0 → 3.1.1

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,748 @@
1
+ import assert from 'assert';
2
+ import validateSchema, {SchemaEntity, fuzzySearch} from '../src/schema';
3
+ import ThrowableDiagnostic from '@atlaspack/diagnostic';
4
+
5
+ describe('validateSchema', () => {
6
+ describe('basic validation', () => {
7
+ it('should validate a simple object schema', () => {
8
+ const schema: SchemaEntity = {
9
+ type: 'object',
10
+ properties: {
11
+ name: {type: 'string'},
12
+ age: {type: 'number'},
13
+ },
14
+ };
15
+
16
+ const errors = validateSchema(schema, {name: 'John', age: 30});
17
+ assert.equal(errors.length, 0);
18
+ });
19
+
20
+ it('should return type error for invalid type', () => {
21
+ const schema: SchemaEntity = {
22
+ type: 'object',
23
+ properties: {
24
+ name: {type: 'string'},
25
+ },
26
+ };
27
+
28
+ const errors = validateSchema(schema, {name: 123});
29
+ assert.equal(errors.length, 1);
30
+ assert.equal(errors[0].type, 'type');
31
+ });
32
+
33
+ it('should return enum error for invalid enum value', () => {
34
+ const schema: SchemaEntity = {
35
+ type: 'object',
36
+ properties: {
37
+ env: {
38
+ type: 'string',
39
+ enum: ['development', 'production', 'test'],
40
+ },
41
+ },
42
+ };
43
+
44
+ const errors = validateSchema(schema, {env: 'staging'});
45
+ assert.equal(errors.length, 1);
46
+ assert.equal(errors[0].type, 'enum');
47
+ });
48
+
49
+ it('should return missing-prop error for required properties', () => {
50
+ const schema: SchemaEntity = {
51
+ type: 'object',
52
+ properties: {
53
+ name: {type: 'string'},
54
+ email: {type: 'string'},
55
+ },
56
+ required: ['name', 'email'],
57
+ };
58
+
59
+ const errors = validateSchema(schema, {name: 'John'});
60
+ assert.equal(errors.length, 1);
61
+ assert.equal(errors[0].type, 'missing-prop');
62
+ });
63
+
64
+ it('should return forbidden-prop error for forbidden properties', () => {
65
+ const schema: SchemaEntity = {
66
+ type: 'object',
67
+ properties: {
68
+ name: {type: 'string'},
69
+ },
70
+ __forbiddenProperties: ['age', 'email'],
71
+ };
72
+
73
+ const errors = validateSchema(schema, {name: 'John', age: 30});
74
+ assert.equal(errors.length, 1);
75
+ assert.equal(errors[0].type, 'forbidden-prop');
76
+ });
77
+ });
78
+
79
+ describe('fuzzySearch', () => {
80
+ it('should find close matches', () => {
81
+ const expectedValues = ['development', 'production', 'test'];
82
+ const actualValue = 'developement'; // typo
83
+
84
+ const results = fuzzySearch(expectedValues, actualValue);
85
+ assert(results.includes('development'));
86
+ });
87
+
88
+ it('should return empty array for distant matches', () => {
89
+ const expectedValues = ['foo', 'bar'];
90
+ const actualValue = 'verylongstring';
91
+
92
+ const results = fuzzySearch(expectedValues, actualValue);
93
+ assert.equal(results.length, 0);
94
+ });
95
+ });
96
+
97
+ describe('validateSchema.diagnostic', () => {
98
+ describe('deferred source (function)', () => {
99
+ it('should accept source as a function that returns JSON string', () => {
100
+ const schema: SchemaEntity = {
101
+ type: 'object',
102
+ properties: {
103
+ name: {type: 'string'},
104
+ },
105
+ };
106
+
107
+ const validData = {name: 'John'};
108
+ const sourceLoader = () => JSON.stringify(validData);
109
+
110
+ // Should not throw for valid data
111
+ assert.doesNotThrow(() => {
112
+ validateSchema.diagnostic(
113
+ schema,
114
+ {source: sourceLoader},
115
+ '@test/origin',
116
+ 'Test validation',
117
+ );
118
+ });
119
+ });
120
+
121
+ it('should throw diagnostic error with deferred source for invalid data', () => {
122
+ const schema: SchemaEntity = {
123
+ type: 'object',
124
+ properties: {
125
+ name: {type: 'string'},
126
+ },
127
+ };
128
+
129
+ const invalidData = {name: 123}; // wrong type
130
+ const sourceLoader = () => JSON.stringify(invalidData);
131
+
132
+ assert.throws(
133
+ () => {
134
+ validateSchema.diagnostic(
135
+ schema,
136
+ {source: sourceLoader, filePath: 'test.json'},
137
+ '@test/origin',
138
+ 'Test validation',
139
+ );
140
+ },
141
+ (error: any) => {
142
+ assert(error instanceof ThrowableDiagnostic);
143
+ assert.equal(error.diagnostics[0].message, 'Test validation');
144
+ assert.equal(error.diagnostics[0].origin, '@test/origin');
145
+ assert(error.diagnostics[0].codeFrames);
146
+ return true;
147
+ },
148
+ );
149
+ });
150
+
151
+ it('should only call source function once when loading', () => {
152
+ const schema: SchemaEntity = {
153
+ type: 'object',
154
+ properties: {
155
+ name: {type: 'string'},
156
+ },
157
+ };
158
+
159
+ let callCount = 0;
160
+ const sourceLoader = () => {
161
+ callCount++;
162
+ return JSON.stringify({name: 'John'});
163
+ };
164
+
165
+ validateSchema.diagnostic(
166
+ schema,
167
+ {source: sourceLoader},
168
+ '@test/origin',
169
+ 'Test validation',
170
+ );
171
+
172
+ // The function should only be called once even though it might be referenced multiple times
173
+ assert.equal(callCount, 1);
174
+ });
175
+
176
+ it('should handle deferred source with enum error', () => {
177
+ const schema: SchemaEntity = {
178
+ type: 'object',
179
+ properties: {
180
+ env: {
181
+ type: 'string',
182
+ enum: ['development', 'production', 'test'],
183
+ },
184
+ },
185
+ };
186
+
187
+ const invalidData = {env: 'staging'};
188
+ const sourceLoader = () => JSON.stringify(invalidData);
189
+
190
+ assert.throws(
191
+ () => {
192
+ validateSchema.diagnostic(
193
+ schema,
194
+ {source: sourceLoader, filePath: 'config.json'},
195
+ '@test/config',
196
+ 'Invalid configuration',
197
+ );
198
+ },
199
+ (error: any) => {
200
+ assert(error instanceof ThrowableDiagnostic);
201
+ const diagnostic = error.diagnostics[0];
202
+ assert.equal(diagnostic.message, 'Invalid configuration');
203
+ const codeFrame = diagnostic.codeFrames?.[0];
204
+ assert(codeFrame);
205
+ assert(codeFrame.codeHighlights.length > 0);
206
+ return true;
207
+ },
208
+ );
209
+ });
210
+
211
+ it('should handle deferred source with missing property error', () => {
212
+ const schema: SchemaEntity = {
213
+ type: 'object',
214
+ properties: {
215
+ name: {type: 'string'},
216
+ email: {type: 'string'},
217
+ },
218
+ required: ['name', 'email'],
219
+ };
220
+
221
+ const invalidData = {name: 'John'};
222
+ const sourceLoader = () => JSON.stringify(invalidData);
223
+
224
+ assert.throws(
225
+ () => {
226
+ validateSchema.diagnostic(
227
+ schema,
228
+ {source: sourceLoader, filePath: 'user.json'},
229
+ '@test/user',
230
+ 'Missing required fields',
231
+ );
232
+ },
233
+ (error: any) => {
234
+ assert(error instanceof ThrowableDiagnostic);
235
+ return true;
236
+ },
237
+ );
238
+ });
239
+ });
240
+
241
+ describe('direct source (string)', () => {
242
+ it('should accept source as a string', () => {
243
+ const schema: SchemaEntity = {
244
+ type: 'object',
245
+ properties: {
246
+ name: {type: 'string'},
247
+ },
248
+ };
249
+
250
+ const validData = {name: 'John'};
251
+
252
+ assert.doesNotThrow(() => {
253
+ validateSchema.diagnostic(
254
+ schema,
255
+ {source: JSON.stringify(validData)},
256
+ '@test/origin',
257
+ 'Test validation',
258
+ );
259
+ });
260
+ });
261
+
262
+ it('should throw diagnostic error with string source for invalid data', () => {
263
+ const schema: SchemaEntity = {
264
+ type: 'object',
265
+ properties: {
266
+ name: {type: 'string'},
267
+ },
268
+ };
269
+
270
+ const invalidData = {name: 123};
271
+
272
+ assert.throws(
273
+ () => {
274
+ validateSchema.diagnostic(
275
+ schema,
276
+ {
277
+ source: JSON.stringify(invalidData, null, 2),
278
+ filePath: 'test.json',
279
+ },
280
+ '@test/origin',
281
+ 'Test validation',
282
+ );
283
+ },
284
+ (error: any) => {
285
+ assert(error instanceof ThrowableDiagnostic);
286
+ const diagnostic = error.diagnostics[0];
287
+ assert.equal(diagnostic.message, 'Test validation');
288
+ assert.equal(diagnostic.origin, '@test/origin');
289
+ assert.equal(diagnostic.codeFrames?.[0].filePath, 'test.json');
290
+ return true;
291
+ },
292
+ );
293
+ });
294
+ });
295
+
296
+ describe('data property', () => {
297
+ it('should accept data property directly', () => {
298
+ const schema: SchemaEntity = {
299
+ type: 'object',
300
+ properties: {
301
+ name: {type: 'string'},
302
+ },
303
+ };
304
+
305
+ const validData = {name: 'John'};
306
+
307
+ assert.doesNotThrow(() => {
308
+ validateSchema.diagnostic(
309
+ schema,
310
+ {data: validData},
311
+ '@test/origin',
312
+ 'Test validation',
313
+ );
314
+ });
315
+ });
316
+
317
+ it('should throw diagnostic error with data property for invalid data', () => {
318
+ const schema: SchemaEntity = {
319
+ type: 'object',
320
+ properties: {
321
+ name: {type: 'string'},
322
+ },
323
+ };
324
+
325
+ const invalidData = {name: 123};
326
+
327
+ assert.throws(
328
+ () => {
329
+ validateSchema.diagnostic(
330
+ schema,
331
+ {data: invalidData, filePath: 'test.json'},
332
+ '@test/origin',
333
+ 'Test validation',
334
+ );
335
+ },
336
+ (error: any) => {
337
+ assert(error instanceof ThrowableDiagnostic);
338
+ return true;
339
+ },
340
+ );
341
+ });
342
+
343
+ it('should accept both data and source together (common pattern)', () => {
344
+ const schema: SchemaEntity = {
345
+ type: 'object',
346
+ properties: {
347
+ name: {type: 'string'},
348
+ version: {type: 'string'},
349
+ },
350
+ };
351
+
352
+ const validData = {name: 'my-package', version: '1.0.0'};
353
+ const source = JSON.stringify(validData, null, 2);
354
+
355
+ assert.doesNotThrow(() => {
356
+ validateSchema.diagnostic(
357
+ schema,
358
+ {data: validData, source, filePath: 'package.json'},
359
+ '@test/origin',
360
+ 'Package validation',
361
+ );
362
+ });
363
+ });
364
+
365
+ it('should throw diagnostic with both data and source for invalid data', () => {
366
+ const schema: SchemaEntity = {
367
+ type: 'object',
368
+ properties: {
369
+ name: {type: 'string'},
370
+ version: {type: 'string'},
371
+ },
372
+ };
373
+
374
+ const invalidData = {name: 'my-package', version: 123};
375
+ const source = JSON.stringify(invalidData, null, 2);
376
+
377
+ assert.throws(
378
+ () => {
379
+ validateSchema.diagnostic(
380
+ schema,
381
+ {data: invalidData, source, filePath: 'package.json'},
382
+ '@test/origin',
383
+ 'Package validation',
384
+ );
385
+ },
386
+ (error: any) => {
387
+ assert(error instanceof ThrowableDiagnostic);
388
+ const diagnostic = error.diagnostics[0];
389
+ assert.equal(diagnostic.message, 'Package validation');
390
+ assert.equal(diagnostic.origin, '@test/origin');
391
+ assert.equal(diagnostic.codeFrames?.[0]?.filePath, 'package.json');
392
+ // Code highlighting should work with the provided source
393
+ const codeFrame = diagnostic.codeFrames?.[0];
394
+ assert(codeFrame);
395
+ assert(codeFrame.codeHighlights.length > 0);
396
+ return true;
397
+ },
398
+ );
399
+ });
400
+
401
+ it('should accept both data and deferred source together', () => {
402
+ const schema: SchemaEntity = {
403
+ type: 'object',
404
+ properties: {
405
+ name: {type: 'string'},
406
+ version: {type: 'string'},
407
+ },
408
+ };
409
+
410
+ const validData = {name: 'my-package', version: '1.0.0'};
411
+ const sourceLoader = () => JSON.stringify(validData, null, 2);
412
+
413
+ assert.doesNotThrow(() => {
414
+ validateSchema.diagnostic(
415
+ schema,
416
+ {data: validData, source: sourceLoader, filePath: 'package.json'},
417
+ '@test/origin',
418
+ 'Package validation',
419
+ );
420
+ });
421
+ });
422
+
423
+ it('should throw diagnostic with both data and deferred source for invalid data', () => {
424
+ const schema: SchemaEntity = {
425
+ type: 'object',
426
+ properties: {
427
+ name: {type: 'string'},
428
+ version: {type: 'string'},
429
+ },
430
+ };
431
+
432
+ const invalidData = {name: 'my-package', version: 123};
433
+ const sourceLoader = () => JSON.stringify(invalidData, null, 2);
434
+
435
+ assert.throws(
436
+ () => {
437
+ validateSchema.diagnostic(
438
+ schema,
439
+ {
440
+ data: invalidData,
441
+ source: sourceLoader,
442
+ filePath: 'package.json',
443
+ },
444
+ '@test/origin',
445
+ 'Package validation',
446
+ );
447
+ },
448
+ (error: any) => {
449
+ assert(error instanceof ThrowableDiagnostic);
450
+ const diagnostic = error.diagnostics[0];
451
+ assert.equal(diagnostic.message, 'Package validation');
452
+ assert.equal(diagnostic.origin, '@test/origin');
453
+ assert.equal(diagnostic.codeFrames?.[0]?.filePath, 'package.json');
454
+ // Code highlighting should work with the deferred source
455
+ const codeFrame = diagnostic.codeFrames?.[0];
456
+ assert(codeFrame);
457
+ assert(codeFrame.codeHighlights.length > 0);
458
+ return true;
459
+ },
460
+ );
461
+ });
462
+ });
463
+
464
+ describe('error messages', () => {
465
+ it('should generate "Did you mean" message for enum errors with close matches', () => {
466
+ const schema: SchemaEntity = {
467
+ type: 'object',
468
+ properties: {
469
+ env: {
470
+ type: 'string',
471
+ enum: ['development', 'production', 'test'],
472
+ },
473
+ },
474
+ };
475
+
476
+ const invalidData = {env: 'developement'}; // typo
477
+
478
+ assert.throws(
479
+ () => {
480
+ validateSchema.diagnostic(
481
+ schema,
482
+ {data: invalidData},
483
+ '@test/origin',
484
+ 'Invalid config',
485
+ );
486
+ },
487
+ (error: any) => {
488
+ assert(error instanceof ThrowableDiagnostic);
489
+ const codeHighlights =
490
+ error.diagnostics[0].codeFrames?.[0].codeHighlights;
491
+ const message = codeHighlights?.[0].message;
492
+ assert(message?.includes('Did you mean'));
493
+ assert(message?.includes('development'));
494
+ return true;
495
+ },
496
+ );
497
+ });
498
+
499
+ it('should generate "Possible values" message for enum errors without close matches', () => {
500
+ const schema: SchemaEntity = {
501
+ type: 'object',
502
+ properties: {
503
+ env: {
504
+ type: 'string',
505
+ enum: ['development', 'production', 'test'],
506
+ },
507
+ },
508
+ };
509
+
510
+ const invalidData = {env: 'xyz'};
511
+
512
+ assert.throws(
513
+ () => {
514
+ validateSchema.diagnostic(
515
+ schema,
516
+ {data: invalidData},
517
+ '@test/origin',
518
+ 'Invalid config',
519
+ );
520
+ },
521
+ (error: any) => {
522
+ assert(error instanceof ThrowableDiagnostic);
523
+ const codeHighlights =
524
+ error.diagnostics[0].codeFrames?.[0].codeHighlights;
525
+ const message = codeHighlights?.[0].message;
526
+ assert(message?.includes('Possible values'));
527
+ return true;
528
+ },
529
+ );
530
+ });
531
+
532
+ it('should generate "Unexpected property" message for forbidden props', () => {
533
+ const schema: SchemaEntity = {
534
+ type: 'object',
535
+ properties: {
536
+ name: {type: 'string'},
537
+ },
538
+ __forbiddenProperties: ['age'],
539
+ };
540
+
541
+ const invalidData = {name: 'John', age: 30};
542
+
543
+ assert.throws(
544
+ () => {
545
+ validateSchema.diagnostic(
546
+ schema,
547
+ {data: invalidData},
548
+ '@test/origin',
549
+ 'Invalid config',
550
+ );
551
+ },
552
+ (error: any) => {
553
+ assert(error instanceof ThrowableDiagnostic);
554
+ return true;
555
+ },
556
+ );
557
+ });
558
+
559
+ it('should generate type error message', () => {
560
+ const schema: SchemaEntity = {
561
+ type: 'object',
562
+ properties: {
563
+ name: {type: 'string', __type: 'a string value'},
564
+ },
565
+ };
566
+
567
+ const invalidData = {name: 123};
568
+
569
+ assert.throws(
570
+ () => {
571
+ validateSchema.diagnostic(
572
+ schema,
573
+ {data: invalidData},
574
+ '@test/origin',
575
+ 'Invalid config',
576
+ );
577
+ },
578
+ (error: any) => {
579
+ assert(error instanceof ThrowableDiagnostic);
580
+ const codeHighlights =
581
+ error.diagnostics[0].codeFrames?.[0].codeHighlights;
582
+ const message = codeHighlights?.[0].message;
583
+ assert(message?.includes('Expected a string value'));
584
+ return true;
585
+ },
586
+ );
587
+ });
588
+ });
589
+
590
+ describe('map with pointers', () => {
591
+ it('should handle map with data and pointers', () => {
592
+ const schema: SchemaEntity = {
593
+ type: 'object',
594
+ properties: {
595
+ name: {type: 'string'},
596
+ },
597
+ };
598
+
599
+ const invalidData = {name: 123};
600
+ const source = JSON.stringify(invalidData);
601
+
602
+ assert.throws(
603
+ () => {
604
+ validateSchema.diagnostic(
605
+ schema,
606
+ {
607
+ source,
608
+ map: {
609
+ data: invalidData,
610
+ pointers: {
611
+ '/name': {
612
+ key: {line: 1, column: 2, pos: 2},
613
+ keyEnd: {line: 1, column: 8, pos: 8},
614
+ value: {line: 1, column: 10, pos: 10},
615
+ valueEnd: {line: 1, column: 13, pos: 13},
616
+ },
617
+ },
618
+ },
619
+ filePath: 'test.json',
620
+ },
621
+ '@test/origin',
622
+ 'Test validation',
623
+ );
624
+ },
625
+ (error: any) => {
626
+ assert(error instanceof ThrowableDiagnostic);
627
+ return true;
628
+ },
629
+ );
630
+ });
631
+ });
632
+
633
+ describe('array validation', () => {
634
+ it('should validate array items', () => {
635
+ const schema: SchemaEntity = {
636
+ type: 'object',
637
+ properties: {
638
+ items: {
639
+ type: 'array',
640
+ items: {type: 'string'},
641
+ },
642
+ },
643
+ };
644
+
645
+ const invalidData = {items: ['foo', 123, 'bar']};
646
+
647
+ assert.throws(
648
+ () => {
649
+ validateSchema.diagnostic(
650
+ schema,
651
+ {data: invalidData},
652
+ '@test/origin',
653
+ 'Invalid array',
654
+ );
655
+ },
656
+ (error: any) => {
657
+ assert(error instanceof ThrowableDiagnostic);
658
+ return true;
659
+ },
660
+ );
661
+ });
662
+ });
663
+
664
+ describe('custom validation', () => {
665
+ it('should use custom __validate function', () => {
666
+ const schema: SchemaEntity = {
667
+ type: 'object',
668
+ properties: {
669
+ email: {
670
+ type: 'string',
671
+ __validate: (val: string) => {
672
+ if (!val.includes('@')) {
673
+ return 'Must be a valid email address';
674
+ }
675
+ return undefined;
676
+ },
677
+ },
678
+ },
679
+ };
680
+
681
+ const invalidData = {email: 'notanemail'};
682
+
683
+ assert.throws(
684
+ () => {
685
+ validateSchema.diagnostic(
686
+ schema,
687
+ {data: invalidData},
688
+ '@test/origin',
689
+ 'Invalid email',
690
+ );
691
+ },
692
+ (error: any) => {
693
+ assert(error instanceof ThrowableDiagnostic);
694
+ const codeHighlights =
695
+ error.diagnostics[0].codeFrames?.[0].codeHighlights;
696
+ const message = codeHighlights?.[0].message;
697
+ assert.equal(message, 'Must be a valid email address');
698
+ return true;
699
+ },
700
+ );
701
+ });
702
+ });
703
+
704
+ describe('complex nested schemas', () => {
705
+ it('should validate deeply nested objects', () => {
706
+ const schema: SchemaEntity = {
707
+ type: 'object',
708
+ properties: {
709
+ user: {
710
+ type: 'object',
711
+ properties: {
712
+ profile: {
713
+ type: 'object',
714
+ properties: {
715
+ age: {type: 'number'},
716
+ },
717
+ },
718
+ },
719
+ },
720
+ },
721
+ };
722
+
723
+ const invalidData = {
724
+ user: {
725
+ profile: {
726
+ age: 'not a number',
727
+ },
728
+ },
729
+ };
730
+
731
+ assert.throws(
732
+ () => {
733
+ validateSchema.diagnostic(
734
+ schema,
735
+ {data: invalidData},
736
+ '@test/origin',
737
+ 'Nested validation failed',
738
+ );
739
+ },
740
+ (error: any) => {
741
+ assert(error instanceof ThrowableDiagnostic);
742
+ return true;
743
+ },
744
+ );
745
+ });
746
+ });
747
+ });
748
+ });