@objectql/core 1.7.1 → 1.7.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.
@@ -0,0 +1,343 @@
1
+ import { ObjectQL } from '../src/index';
2
+ import { MockDriver } from './mock-driver';
3
+ import { ObjectConfig, ValidationError } from '@objectql/types';
4
+
5
+ const userObject: ObjectConfig = {
6
+ name: 'user',
7
+ fields: {
8
+ name: {
9
+ type: 'text',
10
+ required: true,
11
+ validation: {
12
+ min_length: 3,
13
+ max_length: 50,
14
+ }
15
+ },
16
+ email: {
17
+ type: 'email',
18
+ required: true,
19
+ validation: {
20
+ format: 'email',
21
+ }
22
+ },
23
+ age: {
24
+ type: 'number',
25
+ validation: {
26
+ min: 0,
27
+ max: 150,
28
+ }
29
+ }
30
+ }
31
+ };
32
+
33
+ const projectObject: ObjectConfig = {
34
+ name: 'project',
35
+ fields: {
36
+ name: {
37
+ type: 'text',
38
+ required: true,
39
+ },
40
+ status: {
41
+ type: 'select',
42
+ defaultValue: 'planning',
43
+ },
44
+ start_date: {
45
+ type: 'date',
46
+ },
47
+ end_date: {
48
+ type: 'date',
49
+ },
50
+ },
51
+ validation: {
52
+ rules: [
53
+ {
54
+ name: 'valid_date_range',
55
+ type: 'cross_field',
56
+ rule: {
57
+ field: 'end_date',
58
+ operator: '>=',
59
+ compare_to: 'start_date',
60
+ },
61
+ message: 'End date must be on or after start date',
62
+ error_code: 'INVALID_DATE_RANGE',
63
+ },
64
+ {
65
+ name: 'status_transition',
66
+ type: 'state_machine',
67
+ field: 'status',
68
+ transitions: {
69
+ planning: {
70
+ allowed_next: ['active', 'cancelled'],
71
+ },
72
+ active: {
73
+ allowed_next: ['on_hold', 'completed', 'cancelled'],
74
+ },
75
+ completed: {
76
+ allowed_next: [],
77
+ is_terminal: true,
78
+ },
79
+ },
80
+ message: 'Invalid status transition from {{old_status}} to {{new_status}}',
81
+ error_code: 'INVALID_STATE_TRANSITION',
82
+ }
83
+ ]
84
+ }
85
+ };
86
+
87
+ describe('ObjectQL Repository Validation Integration', () => {
88
+ let app: ObjectQL;
89
+ let driver: MockDriver;
90
+
91
+ beforeEach(async () => {
92
+ driver = new MockDriver();
93
+ app = new ObjectQL({
94
+ datasources: {
95
+ default: driver
96
+ },
97
+ objects: {
98
+ user: userObject,
99
+ project: projectObject,
100
+ }
101
+ });
102
+ await app.init();
103
+ });
104
+
105
+ describe('Field-level validation on create', () => {
106
+ it('should reject creation with missing required field', async () => {
107
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
108
+ const repo = ctx.object('user');
109
+
110
+ await expect(
111
+ repo.create({ email: 'test@example.com' })
112
+ ).rejects.toThrow(ValidationError);
113
+ });
114
+
115
+ it('should reject creation with invalid email format', async () => {
116
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
117
+ const repo = ctx.object('user');
118
+
119
+ await expect(
120
+ repo.create({ name: 'John Doe', email: 'invalid-email' })
121
+ ).rejects.toThrow(ValidationError);
122
+ });
123
+
124
+ it('should reject creation with value below minimum', async () => {
125
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
126
+ const repo = ctx.object('user');
127
+
128
+ await expect(
129
+ repo.create({ name: 'John Doe', email: 'test@example.com', age: -5 })
130
+ ).rejects.toThrow(ValidationError);
131
+ });
132
+
133
+ it('should reject creation with value above maximum', async () => {
134
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
135
+ const repo = ctx.object('user');
136
+
137
+ await expect(
138
+ repo.create({ name: 'John Doe', email: 'test@example.com', age: 200 })
139
+ ).rejects.toThrow(ValidationError);
140
+ });
141
+
142
+ it('should reject creation with string too short', async () => {
143
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
144
+ const repo = ctx.object('user');
145
+
146
+ await expect(
147
+ repo.create({ name: 'Jo', email: 'test@example.com' })
148
+ ).rejects.toThrow(ValidationError);
149
+ });
150
+
151
+ it('should accept valid creation', async () => {
152
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
153
+ const repo = ctx.object('user');
154
+
155
+ const created = await repo.create({
156
+ name: 'John Doe',
157
+ email: 'john@example.com',
158
+ age: 30
159
+ });
160
+
161
+ expect(created.name).toBe('John Doe');
162
+ expect(created.email).toBe('john@example.com');
163
+ expect(created.age).toBe(30);
164
+ });
165
+ });
166
+
167
+ describe('Field-level validation on update', () => {
168
+ it('should reject update with invalid email format', async () => {
169
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
170
+ const repo = ctx.object('user');
171
+
172
+ const created = await repo.create({
173
+ name: 'John Doe',
174
+ email: 'john@example.com'
175
+ });
176
+
177
+ await expect(
178
+ repo.update(created._id, { email: 'invalid-email' })
179
+ ).rejects.toThrow(ValidationError);
180
+ });
181
+
182
+ it('should reject update with value below minimum', async () => {
183
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
184
+ const repo = ctx.object('user');
185
+
186
+ const created = await repo.create({
187
+ name: 'John Doe',
188
+ email: 'john@example.com',
189
+ age: 30
190
+ });
191
+
192
+ await expect(
193
+ repo.update(created._id, { age: -10 })
194
+ ).rejects.toThrow(ValidationError);
195
+ });
196
+
197
+ it('should accept valid update', async () => {
198
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
199
+ const repo = ctx.object('user');
200
+
201
+ const created = await repo.create({
202
+ name: 'John Doe',
203
+ email: 'john@example.com',
204
+ age: 30
205
+ });
206
+
207
+ const updated = await repo.update(created._id, { age: 35 });
208
+ expect(updated.age).toBe(35);
209
+ });
210
+
211
+ it('should allow partial update without validating unmodified required fields', async () => {
212
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
213
+ const repo = ctx.object('user');
214
+
215
+ const created = await repo.create({
216
+ name: 'John Doe',
217
+ email: 'john@example.com',
218
+ age: 30
219
+ });
220
+
221
+ // Update only age - should not require name and email to be in the update payload
222
+ const updated = await repo.update(created._id, { age: 35 });
223
+ expect(updated.age).toBe(35);
224
+ expect(updated.name).toBe('John Doe');
225
+ expect(updated.email).toBe('john@example.com');
226
+ });
227
+ });
228
+
229
+ describe('Object-level validation rules', () => {
230
+ it('should reject cross-field validation failure on create', async () => {
231
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
232
+ const repo = ctx.object('project');
233
+
234
+ await expect(
235
+ repo.create({
236
+ name: 'Test Project',
237
+ start_date: '2024-12-31',
238
+ end_date: '2024-01-01', // Before start_date
239
+ })
240
+ ).rejects.toThrow(ValidationError);
241
+ });
242
+
243
+ it('should accept valid cross-field validation on create', async () => {
244
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
245
+ const repo = ctx.object('project');
246
+
247
+ const created = await repo.create({
248
+ name: 'Test Project',
249
+ start_date: '2024-01-01',
250
+ end_date: '2024-12-31',
251
+ });
252
+
253
+ expect(created.name).toBe('Test Project');
254
+ });
255
+
256
+ it('should reject invalid state transition on update', async () => {
257
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
258
+ const repo = ctx.object('project');
259
+
260
+ const created = await repo.create({
261
+ name: 'Test Project',
262
+ status: 'completed',
263
+ start_date: '2024-01-01',
264
+ end_date: '2024-12-31',
265
+ });
266
+
267
+ await expect(
268
+ repo.update(created._id, { status: 'active' })
269
+ ).rejects.toThrow(ValidationError);
270
+ });
271
+
272
+ it('should accept valid state transition on update', async () => {
273
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
274
+ const repo = ctx.object('project');
275
+
276
+ const created = await repo.create({
277
+ name: 'Test Project',
278
+ status: 'planning',
279
+ start_date: '2024-01-01',
280
+ end_date: '2024-12-31',
281
+ });
282
+
283
+ const updated = await repo.update(created._id, { status: 'active' });
284
+ expect(updated.status).toBe('active');
285
+ });
286
+
287
+ it('should allow same state (no transition)', async () => {
288
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
289
+ const repo = ctx.object('project');
290
+
291
+ const created = await repo.create({
292
+ name: 'Test Project',
293
+ status: 'completed',
294
+ start_date: '2024-01-01',
295
+ end_date: '2024-12-31',
296
+ });
297
+
298
+ const updated = await repo.update(created._id, { name: 'Updated Project' });
299
+ expect(updated.name).toBe('Updated Project');
300
+ expect(updated.status).toBe('completed');
301
+ });
302
+
303
+ it('should validate cross-field rules when updating unrelated fields', async () => {
304
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
305
+ const repo = ctx.object('project');
306
+
307
+ // Create a project with valid date range
308
+ const created = await repo.create({
309
+ name: 'Test Project',
310
+ start_date: '2024-01-01',
311
+ end_date: '2024-12-31',
312
+ });
313
+
314
+ // Update only the name - should still pass cross-field validation
315
+ // because the merged record has valid dates
316
+ const updated = await repo.update(created._id, { name: 'Updated Project' });
317
+ expect(updated.name).toBe('Updated Project');
318
+ expect(updated.start_date).toBe('2024-01-01');
319
+ expect(updated.end_date).toBe('2024-12-31');
320
+ });
321
+ });
322
+
323
+ describe('ValidationError structure', () => {
324
+ it('should throw ValidationError with proper structure', async () => {
325
+ const ctx = app.createContext({ userId: 'u1', isSystem: true });
326
+ const repo = ctx.object('user');
327
+
328
+ await expect(async () => {
329
+ await repo.create({ name: 'Jo', email: 'invalid' });
330
+ }).rejects.toThrow(ValidationError);
331
+
332
+ // Additional validation of error structure
333
+ try {
334
+ await repo.create({ name: 'Jo', email: 'invalid' });
335
+ } catch (error) {
336
+ expect(error).toBeInstanceOf(ValidationError);
337
+ expect((error as ValidationError).results).toBeDefined();
338
+ expect((error as ValidationError).results.length).toBeGreaterThan(0);
339
+ expect((error as ValidationError).message).toContain('characters');
340
+ }
341
+ });
342
+ });
343
+ });