@objectql/platform-node 4.0.2 → 4.0.4

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.
@@ -1,785 +0,0 @@
1
- /**
2
- * ObjectQL
3
- * Copyright (c) 2026-present ObjectStack Inc.
4
- *
5
- * This source code is licensed under the MIT license found in the
6
- * LICENSE file in the root directory of this source tree.
7
- */
8
-
9
- import { Validator } from '@objectql/core';
10
- import {
11
- ValidationContext,
12
- AnyValidationRule,
13
- CrossFieldValidationRule,
14
- StateMachineValidationRule,
15
- FieldConfig,
16
- } from '@objectql/types';
17
- import * as fs from 'fs';
18
- import * as path from 'path';
19
- import * as yaml from 'js-yaml';
20
-
21
- describe('Validation System', () => {
22
- let validator: Validator;
23
-
24
- beforeEach(() => {
25
- validator = new Validator();
26
- });
27
-
28
- describe('Field-level validation', () => {
29
- it('should validate required fields', async () => {
30
- const fieldConfig: FieldConfig = {
31
- type: 'text',
32
- label: 'Name',
33
- required: true,
34
- };
35
-
36
- const context: ValidationContext = {
37
- record: { name: '' },
38
- operation: 'create',
39
- };
40
-
41
- const results = await validator.validateField('name', fieldConfig, '', context);
42
-
43
- expect(results).toHaveLength(1);
44
- expect(results[0].valid).toBe(false);
45
- expect(results[0].message).toContain('required');
46
- });
47
-
48
- it('should validate email format', async () => {
49
- const fieldConfig: FieldConfig = {
50
- type: 'email',
51
- validation: {
52
- format: 'email',
53
- message: 'Invalid email',
54
- },
55
- };
56
-
57
- const context: ValidationContext = {
58
- record: { email: 'invalid-email' },
59
- operation: 'create',
60
- };
61
-
62
- const results = await validator.validateField('email', fieldConfig, 'invalid-email', context);
63
-
64
- expect(results).toHaveLength(1);
65
- expect(results[0].valid).toBe(false);
66
- expect(results[0].message).toBe('Invalid email');
67
- });
68
-
69
- it('should validate valid email format', async () => {
70
- const fieldConfig: FieldConfig = {
71
- type: 'email',
72
- validation: {
73
- format: 'email',
74
- },
75
- };
76
-
77
- const context: ValidationContext = {
78
- record: { email: 'test@example.com' },
79
- operation: 'create',
80
- };
81
-
82
- const results = await validator.validateField('email', fieldConfig, 'test@example.com', context);
83
-
84
- expect(results).toHaveLength(0);
85
- });
86
-
87
- it('should validate URL format', async () => {
88
- const fieldConfig: FieldConfig = {
89
- type: 'url',
90
- validation: {
91
- format: 'url',
92
- protocols: ['http', 'https'],
93
- },
94
- };
95
-
96
- const context: ValidationContext = {
97
- record: { website: 'not-a-url' },
98
- operation: 'create',
99
- };
100
-
101
- const results = await validator.validateField('website', fieldConfig, 'not-a-url', context);
102
-
103
- expect(results.length).toBeGreaterThan(0);
104
- expect(results[0].valid).toBe(false);
105
- });
106
-
107
- it('should validate min/max values', async () => {
108
- const fieldConfig: FieldConfig = {
109
- type: 'number',
110
- validation: {
111
- min: 0,
112
- max: 100,
113
- },
114
- };
115
-
116
- const context: ValidationContext = {
117
- record: { age: 150 },
118
- operation: 'create',
119
- };
120
-
121
- const results = await validator.validateField('age', fieldConfig, 150, context);
122
-
123
- expect(results).toHaveLength(1);
124
- expect(results[0].valid).toBe(false);
125
- expect(results[0].message).toContain('100');
126
- });
127
-
128
- it('should validate string length', async () => {
129
- const fieldConfig: FieldConfig = {
130
- type: 'text',
131
- validation: {
132
- min_length: 3,
133
- max_length: 10,
134
- },
135
- };
136
-
137
- const context: ValidationContext = {
138
- record: { username: 'ab' },
139
- operation: 'create',
140
- };
141
-
142
- const results = await validator.validateField('username', fieldConfig, 'ab', context);
143
-
144
- expect(results).toHaveLength(1);
145
- expect(results[0].valid).toBe(false);
146
- expect(results[0].message).toContain('3');
147
- });
148
-
149
- it('should validate regex pattern', async () => {
150
- const fieldConfig: FieldConfig = {
151
- type: 'text',
152
- validation: {
153
- pattern: '^[a-zA-Z0-9_]+$',
154
- message: 'Username must be alphanumeric',
155
- },
156
- };
157
-
158
- const context: ValidationContext = {
159
- record: { username: 'user@123' },
160
- operation: 'create',
161
- };
162
-
163
- const results = await validator.validateField('username', fieldConfig, 'user@123', context);
164
-
165
- expect(results).toHaveLength(1);
166
- expect(results[0].valid).toBe(false);
167
- expect(results[0].message).toBe('Username must be alphanumeric');
168
- });
169
-
170
- it('should handle invalid regex pattern gracefully', async () => {
171
- const fieldConfig: FieldConfig = {
172
- type: 'text',
173
- validation: {
174
- pattern: '[invalid(regex', // Invalid regex
175
- message: 'Should not see this',
176
- },
177
- };
178
-
179
- const context: ValidationContext = {
180
- record: { username: 'test' },
181
- operation: 'create',
182
- };
183
-
184
- const results = await validator.validateField('username', fieldConfig, 'test', context);
185
-
186
- expect(results).toHaveLength(1);
187
- expect(results[0].valid).toBe(false);
188
- expect(results[0].message).toContain('Invalid regex pattern');
189
- });
190
- });
191
-
192
- describe('Cross-field validation', () => {
193
- it('should validate date range with compare_to', async () => {
194
- const rule: CrossFieldValidationRule = {
195
- name: 'valid_date_range',
196
- type: 'cross_field',
197
- rule: {
198
- field: 'end_date',
199
- operator: '>=',
200
- compare_to: 'start_date',
201
- },
202
- message: 'End date must be on or after start date',
203
- error_code: 'INVALID_DATE_RANGE',
204
- };
205
-
206
- const context: ValidationContext = {
207
- record: {
208
- start_date: '2024-01-01',
209
- end_date: '2023-12-31', // Before start date
210
- },
211
- operation: 'create',
212
- };
213
-
214
- const result = await validator.validate([rule], context);
215
-
216
- expect(result.valid).toBe(false);
217
- expect(result.errors).toHaveLength(1);
218
- expect(result.errors[0].error_code).toBe('INVALID_DATE_RANGE');
219
- });
220
-
221
- it('should pass valid date range with compare_to', async () => {
222
- const rule: CrossFieldValidationRule = {
223
- name: 'valid_date_range',
224
- type: 'cross_field',
225
- rule: {
226
- field: 'end_date',
227
- operator: '>=',
228
- compare_to: 'start_date',
229
- },
230
- message: 'End date must be on or after start date',
231
- };
232
-
233
- const context: ValidationContext = {
234
- record: {
235
- start_date: '2024-01-01',
236
- end_date: '2024-02-01', // After start date
237
- },
238
- operation: 'create',
239
- };
240
-
241
- const result = await validator.validate([rule], context);
242
-
243
- expect(result.valid).toBe(true);
244
- expect(result.errors).toHaveLength(0);
245
- });
246
-
247
- it('should validate with fixed value comparison', async () => {
248
- const rule: CrossFieldValidationRule = {
249
- name: 'min_value_check',
250
- type: 'cross_field',
251
- rule: {
252
- field: 'budget',
253
- operator: '>=',
254
- value: 1000,
255
- },
256
- message: 'Budget must be at least 1000',
257
- };
258
-
259
- const context: ValidationContext = {
260
- record: {
261
- budget: 500,
262
- },
263
- operation: 'create',
264
- };
265
-
266
- const result = await validator.validate([rule], context);
267
-
268
- expect(result.valid).toBe(false);
269
- expect(result.errors).toHaveLength(1);
270
- });
271
- });
272
-
273
- describe('State machine validation', () => {
274
- it('should validate allowed state transitions', async () => {
275
- const rule: StateMachineValidationRule = {
276
- name: 'status_transition',
277
- type: 'state_machine',
278
- field: 'status',
279
- transitions: {
280
- planning: {
281
- allowed_next: ['active', 'cancelled'],
282
- },
283
- active: {
284
- allowed_next: ['on_hold', 'completed', 'cancelled'],
285
- },
286
- completed: {
287
- allowed_next: [],
288
- is_terminal: true,
289
- },
290
- },
291
- message: 'Invalid status transition from {{old_status}} to {{new_status}}',
292
- error_code: 'INVALID_STATE_TRANSITION',
293
- };
294
-
295
- const context: ValidationContext = {
296
- record: { status: 'active' },
297
- previousRecord: { status: 'planning' },
298
- operation: 'update',
299
- };
300
-
301
- const result = await validator.validate([rule], context);
302
-
303
- expect(result.valid).toBe(true);
304
- });
305
-
306
- it('should reject invalid state transitions', async () => {
307
- const rule: StateMachineValidationRule = {
308
- name: 'status_transition',
309
- type: 'state_machine',
310
- field: 'status',
311
- transitions: {
312
- completed: {
313
- allowed_next: [],
314
- is_terminal: true,
315
- },
316
- },
317
- message: 'Invalid status transition from {{old_status}} to {{new_status}}',
318
- error_code: 'INVALID_STATE_TRANSITION',
319
- };
320
-
321
- const context: ValidationContext = {
322
- record: { status: 'active' },
323
- previousRecord: { status: 'completed' },
324
- operation: 'update',
325
- };
326
-
327
- const result = await validator.validate([rule], context);
328
-
329
- expect(result.valid).toBe(false);
330
- expect(result.errors).toHaveLength(1);
331
- expect(result.errors[0].message).toContain('completed');
332
- expect(result.errors[0].message).toContain('active');
333
- });
334
-
335
- it('should allow same state (no transition)', async () => {
336
- const rule: StateMachineValidationRule = {
337
- name: 'status_transition',
338
- type: 'state_machine',
339
- field: 'status',
340
- transitions: {
341
- completed: {
342
- allowed_next: [],
343
- is_terminal: true,
344
- },
345
- },
346
- message: 'Invalid status transition',
347
- };
348
-
349
- const context: ValidationContext = {
350
- record: { status: 'completed' },
351
- previousRecord: { status: 'completed' },
352
- operation: 'update',
353
- };
354
-
355
- const result = await validator.validate([rule], context);
356
-
357
- expect(result.valid).toBe(true);
358
- });
359
- });
360
-
361
- describe('Validation with triggers', () => {
362
- it('should only run validation on specified triggers', async () => {
363
- const rule: AnyValidationRule = {
364
- name: 'create_only',
365
- type: 'cross_field',
366
- trigger: ['create'],
367
- rule: {
368
- field: 'name',
369
- operator: '!=',
370
- value: null,
371
- },
372
- message: 'Name is required',
373
- };
374
-
375
- // Should run on create
376
- const createContext: ValidationContext = {
377
- record: { name: null },
378
- operation: 'create',
379
- };
380
-
381
- const createResult = await validator.validate([rule], createContext);
382
- expect(createResult.valid).toBe(false);
383
-
384
- // Should not run on update
385
- const updateContext: ValidationContext = {
386
- record: { name: null },
387
- operation: 'update',
388
- };
389
-
390
- const updateResult = await validator.validate([rule], updateContext);
391
- expect(updateResult.valid).toBe(true); // Rule not applied
392
- });
393
-
394
- it('should only run validation when specific fields change', async () => {
395
- const rule: AnyValidationRule = {
396
- name: 'budget_check',
397
- type: 'cross_field',
398
- fields: ['budget'],
399
- rule: {
400
- field: 'budget',
401
- operator: '<=',
402
- value: 1000000,
403
- },
404
- message: 'Budget too high',
405
- };
406
-
407
- // Should run when budget changes
408
- const withBudgetChange: ValidationContext = {
409
- record: { budget: 2000000 },
410
- operation: 'update',
411
- changedFields: ['budget', 'name'],
412
- };
413
-
414
- const budgetResult = await validator.validate([rule], withBudgetChange);
415
- expect(budgetResult.valid).toBe(false);
416
-
417
- // Should not run when budget doesn't change
418
- const withoutBudgetChange: ValidationContext = {
419
- record: { budget: 2000000 },
420
- operation: 'update',
421
- changedFields: ['name', 'description'],
422
- };
423
-
424
- const noBudgetResult = await validator.validate([rule], withoutBudgetChange);
425
- expect(noBudgetResult.valid).toBe(true); // Rule not applied
426
- });
427
- });
428
-
429
- describe('Load validation from YAML fixture', () => {
430
- it('should load and parse validation rules from YAML', () => {
431
- const yamlPath = path.join(__dirname, 'fixtures', 'project-with-validation.object.yml');
432
- const fileContents = fs.readFileSync(yamlPath, 'utf8');
433
- const objectDef = yaml.load(fileContents) as any;
434
-
435
- expect(objectDef.validation).toBeDefined();
436
- expect(objectDef.validation.rules).toBeDefined();
437
- expect(objectDef.validation.rules.length).toBeGreaterThan(0);
438
-
439
- // Check cross-field rule
440
- const dateRangeRule = objectDef.validation.rules.find((r: any) => r.name === 'valid_date_range');
441
- expect(dateRangeRule).toBeDefined();
442
- expect(dateRangeRule.type).toBe('cross_field');
443
- expect(dateRangeRule.error_code).toBe('INVALID_DATE_RANGE');
444
-
445
- // Check state machine rule
446
- const statusRule = objectDef.validation.rules.find((r: any) => r.name === 'status_transition');
447
- expect(statusRule).toBeDefined();
448
- expect(statusRule.type).toBe('state_machine');
449
- expect(statusRule.field).toBe('status');
450
- expect(statusRule.transitions).toBeDefined();
451
- expect(statusRule.transitions.planning.allowed_next).toContain('active');
452
- });
453
- });
454
-
455
- describe('Severity levels', () => {
456
- it('should categorize errors by severity', async () => {
457
- const rules: AnyValidationRule[] = [
458
- {
459
- name: 'error_rule',
460
- type: 'cross_field',
461
- severity: 'error',
462
- rule: { field: 'x', operator: '=', value: 1 },
463
- message: 'Error',
464
- },
465
- {
466
- name: 'warning_rule',
467
- type: 'cross_field',
468
- severity: 'warning',
469
- rule: { field: 'y', operator: '=', value: 1 },
470
- message: 'Warning',
471
- },
472
- {
473
- name: 'info_rule',
474
- type: 'cross_field',
475
- severity: 'info',
476
- rule: { field: 'z', operator: '=', value: 1 },
477
- message: 'Info',
478
- },
479
- ];
480
-
481
- const context: ValidationContext = {
482
- record: { x: 2, y: 2, z: 2 }, // All fail
483
- operation: 'create',
484
- };
485
-
486
- const result = await validator.validate(rules, context);
487
-
488
- expect(result.errors).toHaveLength(1);
489
- expect(result.warnings).toHaveLength(1);
490
- expect(result.info).toHaveLength(1);
491
- expect(result.valid).toBe(false); // Errors make it invalid
492
- });
493
- });
494
-
495
- describe('Uniqueness validation', () => {
496
- it('should validate uniqueness with API access', async () => {
497
- const rule: AnyValidationRule = {
498
- name: 'unique_email',
499
- type: 'unique',
500
- field: 'email',
501
- message: 'Email address already exists',
502
- error_code: 'DUPLICATE_EMAIL',
503
- };
504
-
505
- // Mock API that returns count
506
- const mockApi = {
507
- count: jest.fn().mockResolvedValue(1), // Duplicate found
508
- };
509
-
510
- const context: ValidationContext = {
511
- record: { email: 'test@example.com' },
512
- operation: 'create',
513
- api: mockApi,
514
- metadata: {
515
- objectName: 'user',
516
- ruleName: 'unique_email',
517
- },
518
- };
519
-
520
- const result = await validator.validate([rule], context);
521
-
522
- expect(result.valid).toBe(false);
523
- expect(result.errors).toHaveLength(1);
524
- expect(result.errors[0].error_code).toBe('DUPLICATE_EMAIL');
525
- expect(mockApi.count).toHaveBeenCalledWith('user', { email: 'test@example.com' });
526
- });
527
-
528
- it('should pass uniqueness when no duplicates found', async () => {
529
- const rule: AnyValidationRule = {
530
- name: 'unique_email',
531
- type: 'unique',
532
- field: 'email',
533
- message: 'Email address already exists',
534
- };
535
-
536
- const mockApi = {
537
- count: jest.fn().mockResolvedValue(0), // No duplicates
538
- };
539
-
540
- const context: ValidationContext = {
541
- record: { email: 'unique@example.com' },
542
- operation: 'create',
543
- api: mockApi,
544
- metadata: {
545
- objectName: 'user',
546
- ruleName: 'unique_email',
547
- },
548
- };
549
-
550
- const result = await validator.validate([rule], context);
551
-
552
- expect(result.valid).toBe(true);
553
- expect(result.errors).toHaveLength(0);
554
- });
555
-
556
- it('should validate composite uniqueness', async () => {
557
- const rule: AnyValidationRule = {
558
- name: 'unique_user_space',
559
- type: 'unique',
560
- fields: ['username', 'space_id'],
561
- message: 'Username already exists in this space',
562
- };
563
-
564
- const mockApi = {
565
- count: jest.fn().mockResolvedValue(0),
566
- };
567
-
568
- const context: ValidationContext = {
569
- record: { username: 'john', space_id: '123' },
570
- operation: 'create',
571
- api: mockApi,
572
- metadata: {
573
- objectName: 'user',
574
- ruleName: 'unique_user_space',
575
- },
576
- };
577
-
578
- const result = await validator.validate([rule], context);
579
-
580
- expect(result.valid).toBe(true);
581
- expect(mockApi.count).toHaveBeenCalledWith('user', {
582
- username: 'john',
583
- space_id: '123',
584
- });
585
- });
586
-
587
- it('should exclude current record in update operations', async () => {
588
- const rule: AnyValidationRule = {
589
- name: 'unique_email',
590
- type: 'unique',
591
- field: 'email',
592
- message: 'Email already exists',
593
- };
594
-
595
- const mockApi = {
596
- count: jest.fn().mockResolvedValue(0),
597
- };
598
-
599
- const context: ValidationContext = {
600
- record: { email: 'test@example.com' },
601
- previousRecord: { _id: 'user123', email: 'old@example.com' },
602
- operation: 'update',
603
- api: mockApi,
604
- metadata: {
605
- objectName: 'user',
606
- ruleName: 'unique_email',
607
- },
608
- };
609
-
610
- const result = await validator.validate([rule], context);
611
-
612
- expect(result.valid).toBe(true);
613
- expect(mockApi.count).toHaveBeenCalledWith('user', {
614
- email: 'test@example.com',
615
- _id: { $ne: 'user123' },
616
- });
617
- });
618
-
619
- it('should skip uniqueness check when field value is null', async () => {
620
- const rule: AnyValidationRule = {
621
- name: 'unique_email',
622
- type: 'unique',
623
- field: 'email',
624
- message: 'Email already exists',
625
- };
626
-
627
- const mockApi = {
628
- count: jest.fn(),
629
- };
630
-
631
- const context: ValidationContext = {
632
- record: { email: null },
633
- operation: 'create',
634
- api: mockApi,
635
- metadata: {
636
- objectName: 'user',
637
- ruleName: 'unique_email',
638
- },
639
- };
640
-
641
- const result = await validator.validate([rule], context);
642
-
643
- expect(result.valid).toBe(true);
644
- expect(mockApi.count).not.toHaveBeenCalled();
645
- });
646
-
647
- it('should pass validation when no API provided', async () => {
648
- const rule: AnyValidationRule = {
649
- name: 'unique_email',
650
- type: 'unique',
651
- field: 'email',
652
- message: 'Email already exists',
653
- };
654
-
655
- const context: ValidationContext = {
656
- record: { email: 'test@example.com' },
657
- operation: 'create',
658
- metadata: {
659
- objectName: 'user',
660
- ruleName: 'unique_email',
661
- },
662
- };
663
-
664
- const result = await validator.validate([rule], context);
665
-
666
- // Without API, validation passes by default
667
- expect(result.valid).toBe(true);
668
- });
669
- });
670
-
671
- describe('Business rule validation', () => {
672
- it('should validate all_of conditions', async () => {
673
- const rule: AnyValidationRule = {
674
- name: 'budget_with_approval',
675
- type: 'business_rule',
676
- constraint: {
677
- all_of: [
678
- { field: 'budget', operator: '>', value: 10000 },
679
- { field: 'approved', operator: '=', value: true },
680
- ],
681
- },
682
- message: 'Budget over 10,000 requires approval',
683
- error_code: 'BUDGET_APPROVAL_REQUIRED',
684
- };
685
-
686
- // Should fail when one condition is false
687
- const context1: ValidationContext = {
688
- record: { budget: 15000, approved: false },
689
- operation: 'create',
690
- };
691
-
692
- const result1 = await validator.validate([rule], context1);
693
- expect(result1.valid).toBe(false);
694
- expect(result1.errors[0].error_code).toBe('BUDGET_APPROVAL_REQUIRED');
695
-
696
- // Should pass when all conditions are true
697
- const context2: ValidationContext = {
698
- record: { budget: 15000, approved: true },
699
- operation: 'create',
700
- };
701
-
702
- const result2 = await validator.validate([rule], context2);
703
- expect(result2.valid).toBe(true);
704
- });
705
-
706
- it('should validate any_of conditions', async () => {
707
- const rule: AnyValidationRule = {
708
- name: 'contact_method',
709
- type: 'business_rule',
710
- constraint: {
711
- any_of: [
712
- { field: 'email', operator: '!=', value: null },
713
- { field: 'phone', operator: '!=', value: null },
714
- ],
715
- },
716
- message: 'At least one contact method is required',
717
- };
718
-
719
- // Should fail when all conditions are false
720
- const context1: ValidationContext = {
721
- record: { email: null, phone: null },
722
- operation: 'create',
723
- };
724
-
725
- const result1 = await validator.validate([rule], context1);
726
- expect(result1.valid).toBe(false);
727
-
728
- // Should pass when at least one condition is true
729
- const context2: ValidationContext = {
730
- record: { email: 'test@example.com', phone: null },
731
- operation: 'create',
732
- };
733
-
734
- const result2 = await validator.validate([rule], context2);
735
- expect(result2.valid).toBe(true);
736
- });
737
-
738
- it('should validate then_require conditions', async () => {
739
- const rule: AnyValidationRule = {
740
- name: 'discount_requires_reason',
741
- type: 'business_rule',
742
- constraint: {
743
- then_require: [
744
- { field: 'discount_reason', operator: '!=', value: null },
745
- ],
746
- },
747
- message: 'Discount reason is required',
748
- };
749
-
750
- // Should fail when condition is not met
751
- const context1: ValidationContext = {
752
- record: { discount: 20, discount_reason: null },
753
- operation: 'create',
754
- };
755
-
756
- const result1 = await validator.validate([rule], context1);
757
- expect(result1.valid).toBe(false);
758
-
759
- // Should pass when condition is met
760
- const context2: ValidationContext = {
761
- record: { discount: 20, discount_reason: 'Holiday promotion' },
762
- operation: 'create',
763
- };
764
-
765
- const result2 = await validator.validate([rule], context2);
766
- expect(result2.valid).toBe(true);
767
- });
768
-
769
- it('should pass when no constraint is specified', async () => {
770
- const rule: AnyValidationRule = {
771
- name: 'empty_rule',
772
- type: 'business_rule',
773
- message: 'Should not fail',
774
- };
775
-
776
- const context: ValidationContext = {
777
- record: { field: 'value' },
778
- operation: 'create',
779
- };
780
-
781
- const result = await validator.validate([rule], context);
782
- expect(result.valid).toBe(true);
783
- });
784
- });
785
- });