@objectql/platform-node 4.0.1 → 4.0.3
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.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +32 -0
- package/README.md +13 -11
- package/dist/driver.js +0 -1
- package/dist/driver.js.map +1 -1
- package/dist/loader.js +1 -1
- package/dist/loader.js.map +1 -1
- package/jest.config.js +3 -1
- package/package.json +4 -4
- package/src/driver.ts +1 -1
- package/src/loader.ts +1 -1
- package/test/__mocks__/@objectstack/core.ts +6 -0
- package/test/__mocks__/@objectstack/objectql.ts +59 -0
- package/test/__mocks__/@objectstack/runtime.ts +8 -8
- package/tsconfig.tsbuildinfo +1 -1
- package/test/validation.test.ts +0 -785
package/test/validation.test.ts
DELETED
|
@@ -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
|
-
});
|