@objectql/core 4.0.1 → 4.0.2

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,440 @@
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
+ /**
10
+ * Validation Specification Compliance Tests
11
+ *
12
+ * Ensures that validation rules work according to the latest specification.
13
+ */
14
+
15
+ import { Validator } from '../src/validator';
16
+ import type {
17
+ ValidationContext,
18
+ CrossFieldValidationRule,
19
+ StateMachineValidationRule,
20
+ FieldConfig,
21
+ } from '@objectql/types';
22
+
23
+ describe('Validation Specification Compliance', () => {
24
+ let validator: Validator;
25
+
26
+ beforeEach(() => {
27
+ validator = new Validator();
28
+ });
29
+
30
+ describe('Cross-field validation per specification', () => {
31
+ it('should validate date range as per spec example', async () => {
32
+ // From spec lines 219-235
33
+ const rule: CrossFieldValidationRule = {
34
+ name: 'end_after_start',
35
+ type: 'cross_field',
36
+ rule: {
37
+ field: 'end_date',
38
+ operator: '>=',
39
+ compare_to: 'start_date',
40
+ },
41
+ message: 'End date must be on or after start date',
42
+ error_code: 'INVALID_DATE_RANGE',
43
+ severity: 'error',
44
+ };
45
+
46
+ const validContext: ValidationContext = {
47
+ operation: 'create',
48
+ record: {
49
+ start_date: new Date('2026-01-01'),
50
+ end_date: new Date('2026-12-31'),
51
+ },
52
+ };
53
+
54
+ const result = await validator.validate([rule], validContext);
55
+ expect(result.valid).toBe(true);
56
+ });
57
+
58
+ it('should reject invalid date range', async () => {
59
+ const rule: CrossFieldValidationRule = {
60
+ name: 'end_after_start',
61
+ type: 'cross_field',
62
+ rule: {
63
+ field: 'end_date',
64
+ operator: '>=',
65
+ compare_to: 'start_date',
66
+ },
67
+ message: 'End date must be on or after start date',
68
+ error_code: 'INVALID_DATE_RANGE',
69
+ };
70
+
71
+ const invalidContext: ValidationContext = {
72
+ operation: 'create',
73
+ record: {
74
+ start_date: new Date('2026-12-31'),
75
+ end_date: new Date('2026-01-01'),
76
+ },
77
+ };
78
+
79
+ const result = await validator.validate([rule], invalidContext);
80
+ expect(result.valid).toBe(false);
81
+ expect(result.errors[0].error_code).toBe('INVALID_DATE_RANGE');
82
+ });
83
+
84
+ it('should validate conditional requirements as per spec', async () => {
85
+ // From spec lines 238-258
86
+ const rule: CrossFieldValidationRule = {
87
+ name: 'reason_required_for_rejection',
88
+ type: 'cross_field',
89
+ rule: {
90
+ if: {
91
+ field: 'status',
92
+ operator: '=',
93
+ value: 'rejected',
94
+ },
95
+ then: {
96
+ field: 'rejection_reason',
97
+ operator: '!=',
98
+ value: null,
99
+ },
100
+ },
101
+ message: 'Rejection reason is required when status is rejected',
102
+ error_code: 'REJECTION_REASON_REQUIRED',
103
+ };
104
+
105
+ const validContext: ValidationContext = {
106
+ operation: 'create',
107
+ record: {
108
+ status: 'rejected',
109
+ rejection_reason: 'Does not meet requirements',
110
+ },
111
+ };
112
+
113
+ const result = await validator.validate([rule], validContext);
114
+ expect(result.valid).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe('State machine validation per specification', () => {
119
+ it('should validate state transitions as per spec example', async () => {
120
+ // From spec lines 446-497
121
+ const rule: StateMachineValidationRule = {
122
+ name: 'order_status_flow',
123
+ type: 'state_machine',
124
+ field: 'status',
125
+ message: 'Invalid status transition',
126
+ transitions: {
127
+ draft: {
128
+ allowed_next: ['submitted', 'cancelled'],
129
+ },
130
+ submitted: {
131
+ allowed_next: ['approved', 'rejected'],
132
+ },
133
+ approved: {
134
+ allowed_next: ['processing', 'cancelled'],
135
+ },
136
+ },
137
+ };
138
+
139
+ const validContext: ValidationContext = {
140
+ operation: 'update',
141
+ record: { status: 'submitted' },
142
+ previousRecord: { status: 'draft' },
143
+ };
144
+
145
+ const result = await validator.validate([rule], validContext);
146
+ expect(result.valid).toBe(true);
147
+ });
148
+
149
+ it('should reject invalid state transitions', async () => {
150
+ const rule: StateMachineValidationRule = {
151
+ name: 'order_status_flow',
152
+ type: 'state_machine',
153
+ field: 'status',
154
+ message: 'Invalid status transition from {{old_status}} to {{new_status}}',
155
+ transitions: {
156
+ draft: {
157
+ allowed_next: ['submitted', 'cancelled'],
158
+ },
159
+ submitted: {
160
+ allowed_next: ['approved', 'rejected'],
161
+ },
162
+ },
163
+ };
164
+
165
+ const invalidContext: ValidationContext = {
166
+ operation: 'update',
167
+ record: { status: 'approved' },
168
+ previousRecord: { status: 'draft' },
169
+ };
170
+
171
+ const result = await validator.validate([rule], invalidContext);
172
+ expect(result.valid).toBe(false);
173
+ expect(result.errors[0].message).toContain('draft');
174
+ expect(result.errors[0].message).toContain('approved');
175
+ });
176
+
177
+ it('should handle terminal states correctly', async () => {
178
+ const rule: StateMachineValidationRule = {
179
+ name: 'status_transition',
180
+ type: 'state_machine',
181
+ field: 'status',
182
+ message: 'Invalid status transition',
183
+ transitions: {
184
+ completed: {
185
+ allowed_next: [],
186
+ is_terminal: true,
187
+ },
188
+ },
189
+ };
190
+
191
+ const invalidContext: ValidationContext = {
192
+ operation: 'update',
193
+ record: { status: 'active' },
194
+ previousRecord: { status: 'completed' },
195
+ };
196
+
197
+ const result = await validator.validate([rule], invalidContext);
198
+ expect(result.valid).toBe(false);
199
+ });
200
+ });
201
+
202
+ describe('Field-level validation per specification', () => {
203
+ it('should validate email format as per spec', async () => {
204
+ // From spec lines 152-159
205
+ const fieldConfig: FieldConfig = {
206
+ type: 'email',
207
+ required: true,
208
+ validation: {
209
+ format: 'email',
210
+ message: 'Please enter a valid email address',
211
+ },
212
+ };
213
+
214
+ const context: ValidationContext = {
215
+ operation: 'create',
216
+ record: { email: 'test@example.com' },
217
+ };
218
+
219
+ const results = await validator.validateField('email', fieldConfig, 'test@example.com', context);
220
+ expect(results.length).toBe(0);
221
+ });
222
+
223
+ it('should reject invalid email format', async () => {
224
+ const fieldConfig: FieldConfig = {
225
+ type: 'email',
226
+ required: true,
227
+ validation: {
228
+ format: 'email',
229
+ message: 'Please enter a valid email address',
230
+ },
231
+ };
232
+
233
+ const context: ValidationContext = {
234
+ operation: 'create',
235
+ record: { email: 'invalid-email' },
236
+ };
237
+
238
+ const results = await validator.validateField('email', fieldConfig, 'invalid-email', context);
239
+ expect(results.length).toBeGreaterThan(0);
240
+ expect(results[0].valid).toBe(false);
241
+ });
242
+
243
+ it('should validate URL format with protocol restrictions', async () => {
244
+ // From spec lines 190-199
245
+ const fieldConfig: FieldConfig = {
246
+ type: 'url',
247
+ validation: {
248
+ format: 'url',
249
+ protocols: ['http', 'https'],
250
+ message: 'Please enter a valid URL',
251
+ },
252
+ };
253
+
254
+ const context: ValidationContext = {
255
+ operation: 'create',
256
+ record: { website: 'https://example.com' },
257
+ };
258
+
259
+ const results = await validator.validateField('website', fieldConfig, 'https://example.com', context);
260
+ expect(results.length).toBe(0);
261
+ });
262
+
263
+ it('should reject URL with invalid protocol', async () => {
264
+ const fieldConfig: FieldConfig = {
265
+ type: 'url',
266
+ validation: {
267
+ format: 'url',
268
+ protocols: ['http', 'https'],
269
+ message: 'Please enter a valid URL',
270
+ },
271
+ };
272
+
273
+ const context: ValidationContext = {
274
+ operation: 'create',
275
+ record: { website: 'ftp://example.com' },
276
+ };
277
+
278
+ const results = await validator.validateField('website', fieldConfig, 'ftp://example.com', context);
279
+ expect(results.length).toBeGreaterThan(0);
280
+ expect(results[0].valid).toBe(false);
281
+ });
282
+
283
+ it('should validate min/max range as per spec', async () => {
284
+ // From spec lines 160-170
285
+ const fieldConfig: FieldConfig = {
286
+ type: 'number',
287
+ validation: {
288
+ min: 0,
289
+ max: 150,
290
+ message: 'Age must be between 0 and 150',
291
+ },
292
+ };
293
+
294
+ const context: ValidationContext = {
295
+ operation: 'create',
296
+ record: { age: 25 },
297
+ };
298
+
299
+ const results = await validator.validateField('age', fieldConfig, 25, context);
300
+ expect(results.length).toBe(0);
301
+ });
302
+
303
+ it('should validate string length constraints', async () => {
304
+ // From spec lines 172-185
305
+ const fieldConfig: FieldConfig = {
306
+ type: 'text',
307
+ required: true,
308
+ validation: {
309
+ min_length: 3,
310
+ max_length: 20,
311
+ message: 'Username must be 3-20 characters',
312
+ },
313
+ };
314
+
315
+ const context: ValidationContext = {
316
+ operation: 'create',
317
+ record: { username: 'john_doe' },
318
+ };
319
+
320
+ const results = await validator.validateField('username', fieldConfig, 'john_doe', context);
321
+ expect(results.length).toBe(0);
322
+ });
323
+
324
+ it('should validate pattern matching', async () => {
325
+ const fieldConfig: FieldConfig = {
326
+ type: 'text',
327
+ validation: {
328
+ pattern: '^[a-zA-Z0-9_]+$',
329
+ message: 'Username must be alphanumeric',
330
+ },
331
+ };
332
+
333
+ const validContext: ValidationContext = {
334
+ operation: 'create',
335
+ record: { username: 'john_doe_123' },
336
+ };
337
+
338
+ const results = await validator.validateField('username', fieldConfig, 'john_doe_123', validContext);
339
+ expect(results.length).toBe(0);
340
+ });
341
+
342
+ it('should reject invalid pattern', async () => {
343
+ const fieldConfig: FieldConfig = {
344
+ type: 'text',
345
+ validation: {
346
+ pattern: '^[a-zA-Z0-9_]+$',
347
+ message: 'Username must be alphanumeric',
348
+ },
349
+ };
350
+
351
+ const invalidContext: ValidationContext = {
352
+ operation: 'create',
353
+ record: { username: 'john@doe!' },
354
+ };
355
+
356
+ const results = await validator.validateField('username', fieldConfig, 'john@doe!', invalidContext);
357
+ expect(results.length).toBeGreaterThan(0);
358
+ expect(results[0].valid).toBe(false);
359
+ });
360
+ });
361
+
362
+ describe('Validation triggers and conditions', () => {
363
+ it('should apply rule only on specified operations', async () => {
364
+ const rule: CrossFieldValidationRule = {
365
+ name: 'budget_check',
366
+ type: 'cross_field',
367
+ trigger: ['create', 'update'],
368
+ rule: {
369
+ field: 'budget',
370
+ operator: '>=',
371
+ value: 0,
372
+ },
373
+ message: 'Budget must be positive',
374
+ };
375
+
376
+ const deleteContext: ValidationContext = {
377
+ operation: 'delete',
378
+ record: { budget: -100 },
379
+ };
380
+
381
+ // Rule should be skipped on delete operation
382
+ const result = await validator.validate([rule], deleteContext);
383
+ expect(result.valid).toBe(true);
384
+ });
385
+
386
+ it('should apply conditional validation', async () => {
387
+ const rule: CrossFieldValidationRule = {
388
+ name: 'high_value_approval',
389
+ type: 'cross_field',
390
+ apply_when: {
391
+ field: 'total_amount',
392
+ operator: '>',
393
+ value: 10000,
394
+ },
395
+ rule: {
396
+ field: 'manager_approval_id',
397
+ operator: '!=',
398
+ value: null,
399
+ },
400
+ message: 'Manager approval required for orders over $10,000',
401
+ };
402
+
403
+ // Rule should not apply for low-value orders
404
+ const lowValueContext: ValidationContext = {
405
+ operation: 'create',
406
+ record: {
407
+ total_amount: 5000,
408
+ manager_approval_id: null,
409
+ },
410
+ };
411
+
412
+ const result = await validator.validate([rule], lowValueContext);
413
+ expect(result.valid).toBe(true);
414
+ });
415
+ });
416
+
417
+ describe('Message formatting with templates', () => {
418
+ it('should format messages with template variables', async () => {
419
+ const rule: CrossFieldValidationRule = {
420
+ name: 'budget_limit',
421
+ type: 'cross_field',
422
+ rule: {
423
+ field: 'budget',
424
+ operator: '<=',
425
+ value: 100000,
426
+ },
427
+ message: 'Budget {{budget}} exceeds limit',
428
+ };
429
+
430
+ const context: ValidationContext = {
431
+ operation: 'create',
432
+ record: { budget: 150000 },
433
+ };
434
+
435
+ const result = await validator.validate([rule], context);
436
+ expect(result.valid).toBe(false);
437
+ expect(result.errors[0].message).toContain('150000');
438
+ });
439
+ });
440
+ });