@objectql/platform-node 1.6.0

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,486 @@
1
+ import { Validator } from '@objectql/core';
2
+ import {
3
+ ValidationContext,
4
+ AnyValidationRule,
5
+ CrossFieldValidationRule,
6
+ StateMachineValidationRule,
7
+ FieldConfig,
8
+ } from '@objectql/types';
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+ import * as yaml from 'js-yaml';
12
+
13
+ describe('Validation System', () => {
14
+ let validator: Validator;
15
+
16
+ beforeEach(() => {
17
+ validator = new Validator();
18
+ });
19
+
20
+ describe('Field-level validation', () => {
21
+ it('should validate required fields', async () => {
22
+ const fieldConfig: FieldConfig = {
23
+ type: 'text',
24
+ label: 'Name',
25
+ required: true,
26
+ };
27
+
28
+ const context: ValidationContext = {
29
+ record: { name: '' },
30
+ operation: 'create',
31
+ };
32
+
33
+ const results = await validator.validateField('name', fieldConfig, '', context);
34
+
35
+ expect(results).toHaveLength(1);
36
+ expect(results[0].valid).toBe(false);
37
+ expect(results[0].message).toContain('required');
38
+ });
39
+
40
+ it('should validate email format', async () => {
41
+ const fieldConfig: FieldConfig = {
42
+ type: 'email',
43
+ validation: {
44
+ format: 'email',
45
+ message: 'Invalid email',
46
+ },
47
+ };
48
+
49
+ const context: ValidationContext = {
50
+ record: { email: 'invalid-email' },
51
+ operation: 'create',
52
+ };
53
+
54
+ const results = await validator.validateField('email', fieldConfig, 'invalid-email', context);
55
+
56
+ expect(results).toHaveLength(1);
57
+ expect(results[0].valid).toBe(false);
58
+ expect(results[0].message).toBe('Invalid email');
59
+ });
60
+
61
+ it('should validate valid email format', async () => {
62
+ const fieldConfig: FieldConfig = {
63
+ type: 'email',
64
+ validation: {
65
+ format: 'email',
66
+ },
67
+ };
68
+
69
+ const context: ValidationContext = {
70
+ record: { email: 'test@example.com' },
71
+ operation: 'create',
72
+ };
73
+
74
+ const results = await validator.validateField('email', fieldConfig, 'test@example.com', context);
75
+
76
+ expect(results).toHaveLength(0);
77
+ });
78
+
79
+ it('should validate URL format', async () => {
80
+ const fieldConfig: FieldConfig = {
81
+ type: 'url',
82
+ validation: {
83
+ format: 'url',
84
+ protocols: ['http', 'https'],
85
+ },
86
+ };
87
+
88
+ const context: ValidationContext = {
89
+ record: { website: 'not-a-url' },
90
+ operation: 'create',
91
+ };
92
+
93
+ const results = await validator.validateField('website', fieldConfig, 'not-a-url', context);
94
+
95
+ expect(results.length).toBeGreaterThan(0);
96
+ expect(results[0].valid).toBe(false);
97
+ });
98
+
99
+ it('should validate min/max values', async () => {
100
+ const fieldConfig: FieldConfig = {
101
+ type: 'number',
102
+ validation: {
103
+ min: 0,
104
+ max: 100,
105
+ },
106
+ };
107
+
108
+ const context: ValidationContext = {
109
+ record: { age: 150 },
110
+ operation: 'create',
111
+ };
112
+
113
+ const results = await validator.validateField('age', fieldConfig, 150, context);
114
+
115
+ expect(results).toHaveLength(1);
116
+ expect(results[0].valid).toBe(false);
117
+ expect(results[0].message).toContain('100');
118
+ });
119
+
120
+ it('should validate string length', async () => {
121
+ const fieldConfig: FieldConfig = {
122
+ type: 'text',
123
+ validation: {
124
+ min_length: 3,
125
+ max_length: 10,
126
+ },
127
+ };
128
+
129
+ const context: ValidationContext = {
130
+ record: { username: 'ab' },
131
+ operation: 'create',
132
+ };
133
+
134
+ const results = await validator.validateField('username', fieldConfig, 'ab', context);
135
+
136
+ expect(results).toHaveLength(1);
137
+ expect(results[0].valid).toBe(false);
138
+ expect(results[0].message).toContain('3');
139
+ });
140
+
141
+ it('should validate regex pattern', async () => {
142
+ const fieldConfig: FieldConfig = {
143
+ type: 'text',
144
+ validation: {
145
+ pattern: '^[a-zA-Z0-9_]+$',
146
+ message: 'Username must be alphanumeric',
147
+ },
148
+ };
149
+
150
+ const context: ValidationContext = {
151
+ record: { username: 'user@123' },
152
+ operation: 'create',
153
+ };
154
+
155
+ const results = await validator.validateField('username', fieldConfig, 'user@123', context);
156
+
157
+ expect(results).toHaveLength(1);
158
+ expect(results[0].valid).toBe(false);
159
+ expect(results[0].message).toBe('Username must be alphanumeric');
160
+ });
161
+
162
+ it('should handle invalid regex pattern gracefully', async () => {
163
+ const fieldConfig: FieldConfig = {
164
+ type: 'text',
165
+ validation: {
166
+ pattern: '[invalid(regex', // Invalid regex
167
+ message: 'Should not see this',
168
+ },
169
+ };
170
+
171
+ const context: ValidationContext = {
172
+ record: { username: 'test' },
173
+ operation: 'create',
174
+ };
175
+
176
+ const results = await validator.validateField('username', fieldConfig, 'test', context);
177
+
178
+ expect(results).toHaveLength(1);
179
+ expect(results[0].valid).toBe(false);
180
+ expect(results[0].message).toContain('Invalid regex pattern');
181
+ });
182
+ });
183
+
184
+ describe('Cross-field validation', () => {
185
+ it('should validate date range with compare_to', async () => {
186
+ const rule: CrossFieldValidationRule = {
187
+ name: 'valid_date_range',
188
+ type: 'cross_field',
189
+ rule: {
190
+ field: 'end_date',
191
+ operator: '>=',
192
+ compare_to: 'start_date',
193
+ },
194
+ message: 'End date must be on or after start date',
195
+ error_code: 'INVALID_DATE_RANGE',
196
+ };
197
+
198
+ const context: ValidationContext = {
199
+ record: {
200
+ start_date: '2024-01-01',
201
+ end_date: '2023-12-31', // Before start date
202
+ },
203
+ operation: 'create',
204
+ };
205
+
206
+ const result = await validator.validate([rule], context);
207
+
208
+ expect(result.valid).toBe(false);
209
+ expect(result.errors).toHaveLength(1);
210
+ expect(result.errors[0].error_code).toBe('INVALID_DATE_RANGE');
211
+ });
212
+
213
+ it('should pass valid date range with compare_to', async () => {
214
+ const rule: CrossFieldValidationRule = {
215
+ name: 'valid_date_range',
216
+ type: 'cross_field',
217
+ rule: {
218
+ field: 'end_date',
219
+ operator: '>=',
220
+ compare_to: 'start_date',
221
+ },
222
+ message: 'End date must be on or after start date',
223
+ };
224
+
225
+ const context: ValidationContext = {
226
+ record: {
227
+ start_date: '2024-01-01',
228
+ end_date: '2024-02-01', // After start date
229
+ },
230
+ operation: 'create',
231
+ };
232
+
233
+ const result = await validator.validate([rule], context);
234
+
235
+ expect(result.valid).toBe(true);
236
+ expect(result.errors).toHaveLength(0);
237
+ });
238
+
239
+ it('should validate with fixed value comparison', async () => {
240
+ const rule: CrossFieldValidationRule = {
241
+ name: 'min_value_check',
242
+ type: 'cross_field',
243
+ rule: {
244
+ field: 'budget',
245
+ operator: '>=',
246
+ value: 1000,
247
+ },
248
+ message: 'Budget must be at least 1000',
249
+ };
250
+
251
+ const context: ValidationContext = {
252
+ record: {
253
+ budget: 500,
254
+ },
255
+ operation: 'create',
256
+ };
257
+
258
+ const result = await validator.validate([rule], context);
259
+
260
+ expect(result.valid).toBe(false);
261
+ expect(result.errors).toHaveLength(1);
262
+ });
263
+ });
264
+
265
+ describe('State machine validation', () => {
266
+ it('should validate allowed state transitions', async () => {
267
+ const rule: StateMachineValidationRule = {
268
+ name: 'status_transition',
269
+ type: 'state_machine',
270
+ field: 'status',
271
+ transitions: {
272
+ planning: {
273
+ allowed_next: ['active', 'cancelled'],
274
+ },
275
+ active: {
276
+ allowed_next: ['on_hold', 'completed', 'cancelled'],
277
+ },
278
+ completed: {
279
+ allowed_next: [],
280
+ is_terminal: true,
281
+ },
282
+ },
283
+ message: 'Invalid status transition from {{old_status}} to {{new_status}}',
284
+ error_code: 'INVALID_STATE_TRANSITION',
285
+ };
286
+
287
+ const context: ValidationContext = {
288
+ record: { status: 'active' },
289
+ previousRecord: { status: 'planning' },
290
+ operation: 'update',
291
+ };
292
+
293
+ const result = await validator.validate([rule], context);
294
+
295
+ expect(result.valid).toBe(true);
296
+ });
297
+
298
+ it('should reject invalid state transitions', async () => {
299
+ const rule: StateMachineValidationRule = {
300
+ name: 'status_transition',
301
+ type: 'state_machine',
302
+ field: 'status',
303
+ transitions: {
304
+ completed: {
305
+ allowed_next: [],
306
+ is_terminal: true,
307
+ },
308
+ },
309
+ message: 'Invalid status transition from {{old_status}} to {{new_status}}',
310
+ error_code: 'INVALID_STATE_TRANSITION',
311
+ };
312
+
313
+ const context: ValidationContext = {
314
+ record: { status: 'active' },
315
+ previousRecord: { status: 'completed' },
316
+ operation: 'update',
317
+ };
318
+
319
+ const result = await validator.validate([rule], context);
320
+
321
+ expect(result.valid).toBe(false);
322
+ expect(result.errors).toHaveLength(1);
323
+ expect(result.errors[0].message).toContain('completed');
324
+ expect(result.errors[0].message).toContain('active');
325
+ });
326
+
327
+ it('should allow same state (no transition)', async () => {
328
+ const rule: StateMachineValidationRule = {
329
+ name: 'status_transition',
330
+ type: 'state_machine',
331
+ field: 'status',
332
+ transitions: {
333
+ completed: {
334
+ allowed_next: [],
335
+ is_terminal: true,
336
+ },
337
+ },
338
+ message: 'Invalid status transition',
339
+ };
340
+
341
+ const context: ValidationContext = {
342
+ record: { status: 'completed' },
343
+ previousRecord: { status: 'completed' },
344
+ operation: 'update',
345
+ };
346
+
347
+ const result = await validator.validate([rule], context);
348
+
349
+ expect(result.valid).toBe(true);
350
+ });
351
+ });
352
+
353
+ describe('Validation with triggers', () => {
354
+ it('should only run validation on specified triggers', async () => {
355
+ const rule: AnyValidationRule = {
356
+ name: 'create_only',
357
+ type: 'cross_field',
358
+ trigger: ['create'],
359
+ rule: {
360
+ field: 'name',
361
+ operator: '!=',
362
+ value: null,
363
+ },
364
+ message: 'Name is required',
365
+ };
366
+
367
+ // Should run on create
368
+ const createContext: ValidationContext = {
369
+ record: { name: null },
370
+ operation: 'create',
371
+ };
372
+
373
+ const createResult = await validator.validate([rule], createContext);
374
+ expect(createResult.valid).toBe(false);
375
+
376
+ // Should not run on update
377
+ const updateContext: ValidationContext = {
378
+ record: { name: null },
379
+ operation: 'update',
380
+ };
381
+
382
+ const updateResult = await validator.validate([rule], updateContext);
383
+ expect(updateResult.valid).toBe(true); // Rule not applied
384
+ });
385
+
386
+ it('should only run validation when specific fields change', async () => {
387
+ const rule: AnyValidationRule = {
388
+ name: 'budget_check',
389
+ type: 'cross_field',
390
+ fields: ['budget'],
391
+ rule: {
392
+ field: 'budget',
393
+ operator: '<=',
394
+ value: 1000000,
395
+ },
396
+ message: 'Budget too high',
397
+ };
398
+
399
+ // Should run when budget changes
400
+ const withBudgetChange: ValidationContext = {
401
+ record: { budget: 2000000 },
402
+ operation: 'update',
403
+ changedFields: ['budget', 'name'],
404
+ };
405
+
406
+ const budgetResult = await validator.validate([rule], withBudgetChange);
407
+ expect(budgetResult.valid).toBe(false);
408
+
409
+ // Should not run when budget doesn't change
410
+ const withoutBudgetChange: ValidationContext = {
411
+ record: { budget: 2000000 },
412
+ operation: 'update',
413
+ changedFields: ['name', 'description'],
414
+ };
415
+
416
+ const noBudgetResult = await validator.validate([rule], withoutBudgetChange);
417
+ expect(noBudgetResult.valid).toBe(true); // Rule not applied
418
+ });
419
+ });
420
+
421
+ describe('Load validation from YAML fixture', () => {
422
+ it('should load and parse validation rules from YAML', () => {
423
+ const yamlPath = path.join(__dirname, 'fixtures', 'project-with-validation.object.yml');
424
+ const fileContents = fs.readFileSync(yamlPath, 'utf8');
425
+ const objectDef = yaml.load(fileContents) as any;
426
+
427
+ expect(objectDef.validation).toBeDefined();
428
+ expect(objectDef.validation.rules).toBeDefined();
429
+ expect(objectDef.validation.rules.length).toBeGreaterThan(0);
430
+
431
+ // Check cross-field rule
432
+ const dateRangeRule = objectDef.validation.rules.find((r: any) => r.name === 'valid_date_range');
433
+ expect(dateRangeRule).toBeDefined();
434
+ expect(dateRangeRule.type).toBe('cross_field');
435
+ expect(dateRangeRule.error_code).toBe('INVALID_DATE_RANGE');
436
+
437
+ // Check state machine rule
438
+ const statusRule = objectDef.validation.rules.find((r: any) => r.name === 'status_transition');
439
+ expect(statusRule).toBeDefined();
440
+ expect(statusRule.type).toBe('state_machine');
441
+ expect(statusRule.field).toBe('status');
442
+ expect(statusRule.transitions).toBeDefined();
443
+ expect(statusRule.transitions.planning.allowed_next).toContain('active');
444
+ });
445
+ });
446
+
447
+ describe('Severity levels', () => {
448
+ it('should categorize errors by severity', async () => {
449
+ const rules: AnyValidationRule[] = [
450
+ {
451
+ name: 'error_rule',
452
+ type: 'cross_field',
453
+ severity: 'error',
454
+ rule: { field: 'x', operator: '=', value: 1 },
455
+ message: 'Error',
456
+ },
457
+ {
458
+ name: 'warning_rule',
459
+ type: 'cross_field',
460
+ severity: 'warning',
461
+ rule: { field: 'y', operator: '=', value: 1 },
462
+ message: 'Warning',
463
+ },
464
+ {
465
+ name: 'info_rule',
466
+ type: 'cross_field',
467
+ severity: 'info',
468
+ rule: { field: 'z', operator: '=', value: 1 },
469
+ message: 'Info',
470
+ },
471
+ ];
472
+
473
+ const context: ValidationContext = {
474
+ record: { x: 2, y: 2, z: 2 }, // All fail
475
+ operation: 'create',
476
+ };
477
+
478
+ const result = await validator.validate(rules, context);
479
+
480
+ expect(result.errors).toHaveLength(1);
481
+ expect(result.warnings).toHaveLength(1);
482
+ expect(result.info).toHaveLength(1);
483
+ expect(result.valid).toBe(false); // Errors make it invalid
484
+ });
485
+ });
486
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../types" },
10
+ { "path": "../core" },
11
+ { "path": "../../drivers/sql" },
12
+ { "path": "../../drivers/mongo" }
13
+ ]
14
+ }