@objectql/core 1.3.0 → 1.4.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.
package/test/hook.test.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ObjectQL } from '../src';
2
- import { MockDriver } from './utils';
2
+ import { MockDriver } from './mock-driver';
3
3
 
4
4
  describe('ObjectQL Hooks', () => {
5
5
  let app: ObjectQL;
@@ -16,7 +16,8 @@ describe('ObjectQL Hooks', () => {
16
16
  name: 'post',
17
17
  fields: {
18
18
  title: { type: 'text' },
19
- status: { type: 'text' }
19
+ status: { type: 'text' },
20
+ views: { type: 'number' }
20
21
  }
21
22
  }
22
23
  }
@@ -24,37 +25,319 @@ describe('ObjectQL Hooks', () => {
24
25
  await app.init();
25
26
  });
26
27
 
27
- it('should trigger beforeFind and modify query', async () => {
28
- const repo = app.createContext({}).object('post');
29
-
30
- let hookTriggered = false;
31
- app.on('beforeFind', 'post', async (ctx) => {
32
- hookTriggered = true;
33
- (ctx as any).query = { ...(ctx as any).query, filters: [['status', '=', 'published']] };
28
+ describe('Find Hooks', () => {
29
+ it('should trigger beforeFind and modify query', async () => {
30
+ const repo = app.createContext({}).object('post');
31
+
32
+ let hookTriggered = false;
33
+ app.on('beforeFind', 'post', async (ctx) => {
34
+ hookTriggered = true;
35
+ (ctx as any).query = { ...(ctx as any).query, filters: [['status', '=', 'published']] };
36
+ });
37
+
38
+ const spyFind = jest.spyOn(driver, 'find');
39
+
40
+ await repo.find({});
41
+
42
+ expect(hookTriggered).toBe(true);
43
+ expect(spyFind).toHaveBeenCalledWith('post', { filters: [['status', '=', 'published']] }, expect.any(Object));
34
44
  });
35
45
 
36
- // Mock driver find to check query
37
- const spyFind = jest.spyOn(driver, 'find');
46
+ it('should trigger afterFind and transform results', async () => {
47
+ const repo = app.createContext({}).object('post');
48
+
49
+ app.on('afterFind', 'post', async (ctx) => {
50
+ if (Array.isArray(ctx.result)) {
51
+ ctx.result = ctx.result.map(item => ({
52
+ ...item,
53
+ transformed: true
54
+ }));
55
+ }
56
+ });
57
+
58
+ const results = await repo.find({});
59
+
60
+ expect(results).toBeDefined();
61
+ // Results should be transformed even if empty
62
+ expect(Array.isArray(results)).toBe(true);
63
+ });
38
64
 
39
- await repo.find({});
40
-
41
- expect(hookTriggered).toBe(true);
42
- expect(spyFind).toHaveBeenCalledWith('post', { filters: [['status', '=', 'published']] }, expect.any(Object));
65
+ it('should provide user context in beforeFind', async () => {
66
+ const repo = app.createContext({ userId: 'user123' }).object('post');
67
+
68
+ let capturedUser: any;
69
+ app.on('beforeFind', 'post', async (ctx) => {
70
+ capturedUser = ctx.user;
71
+ });
72
+
73
+ await repo.find({});
74
+
75
+ expect(capturedUser).toBeDefined();
76
+ expect(capturedUser.id).toBe('user123');
77
+ });
43
78
  });
44
79
 
45
- it('should trigger afterCreate and return result', async () => {
46
- const repo = app.createContext({ userId: 'u1' }).object('post');
47
-
48
- app.on('afterCreate', 'post', async (ctx) => {
49
- if (ctx.result) {
50
- ctx.result.augmented = true;
51
- }
80
+ describe('Count Hooks', () => {
81
+ it('should trigger beforeCount and modify query', async () => {
82
+ const repo = app.createContext({}).object('post');
83
+
84
+ let hookTriggered = false;
85
+ app.on('beforeCount', 'post', async (ctx) => {
86
+ hookTriggered = true;
87
+ ctx.query = { filters: [['status', '=', 'published']] };
88
+ });
89
+
90
+ await repo.count({});
91
+
92
+ expect(hookTriggered).toBe(true);
93
+ });
94
+
95
+ it('should trigger afterCount and access result', async () => {
96
+ const repo = app.createContext({}).object('post');
97
+
98
+ let capturedResult: any;
99
+ app.on('afterCount', 'post', async (ctx) => {
100
+ capturedResult = ctx.result;
101
+ });
102
+
103
+ const count = await repo.count({});
104
+
105
+ expect(capturedResult).toBeDefined();
106
+ expect(typeof capturedResult).toBe('number');
107
+ expect(count).toBe(capturedResult);
108
+ });
109
+ });
110
+
111
+ describe('Create Hooks', () => {
112
+ it('should trigger beforeCreate and modify data', async () => {
113
+ const repo = app.createContext({ userId: 'u1' }).object('post');
114
+
115
+ app.on('beforeCreate', 'post', async (ctx) => {
116
+ if (ctx.data) {
117
+ ctx.data.status = ctx.data.status || 'draft';
118
+ ctx.data.views = 0;
119
+ }
120
+ });
121
+
122
+ const created = await repo.create({ title: 'New Post' });
123
+
124
+ expect(created.status).toBe('draft');
125
+ expect(created.views).toBe(0);
126
+ });
127
+
128
+ it('should trigger afterCreate and access result', async () => {
129
+ const repo = app.createContext({ userId: 'u1' }).object('post');
130
+
131
+ let capturedResult: any;
132
+ app.on('afterCreate', 'post', async (ctx) => {
133
+ capturedResult = ctx.result;
134
+ if (ctx.result) {
135
+ ctx.result.augmented = true;
136
+ }
137
+ });
138
+
139
+ const created = await repo.create({ title: 'New Post' });
140
+
141
+ expect(capturedResult).toBeDefined();
142
+ expect(created._id).toBeDefined();
143
+ expect(created.created_by).toBe('u1');
144
+ expect(created.augmented).toBe(true);
145
+ });
146
+
147
+ it('should provide api access in beforeCreate', async () => {
148
+ const repo = app.createContext({}).object('post');
149
+
150
+ app.on('beforeCreate', 'post', async (ctx) => {
151
+ // Check for duplicate titles
152
+ const existing = await ctx.api.count('post', { filters: [['title', '=', ctx.data?.title]] });
153
+ if (existing > 0) {
154
+ throw new Error('Title already exists');
155
+ }
156
+ });
157
+
158
+ await repo.create({ title: 'Unique Title' });
159
+
160
+ // This should work fine on first create
161
+ expect(true).toBe(true);
162
+ });
163
+ });
164
+
165
+ describe('Update Hooks', () => {
166
+ it('should trigger beforeUpdate with previousData', async () => {
167
+ const repo = app.createContext({}).object('post');
168
+
169
+ const created = await repo.create({ title: 'Original', status: 'draft' });
170
+
171
+ let capturedPrevious: any;
172
+ app.on('beforeUpdate', 'post', async (ctx) => {
173
+ capturedPrevious = ctx.previousData;
174
+ });
175
+
176
+ await repo.update(created._id, { title: 'Updated' });
177
+
178
+ expect(capturedPrevious).toBeDefined();
179
+ expect(capturedPrevious.title).toBe('Original');
180
+ expect(capturedPrevious.status).toBe('draft');
181
+ });
182
+
183
+ it('should use isModified helper correctly', async () => {
184
+ const repo = app.createContext({}).object('post');
185
+
186
+ const created = await repo.create({ title: 'Test', status: 'draft', views: 0 });
187
+
188
+ let titleModified = false;
189
+ let statusModified = false;
190
+ app.on('beforeUpdate', 'post', async (ctx) => {
191
+ if ('isModified' in ctx) {
192
+ titleModified = ctx.isModified('title' as any);
193
+ statusModified = ctx.isModified('status' as any);
194
+ }
195
+ });
196
+
197
+ await repo.update(created._id, { title: 'New Title' });
198
+
199
+ expect(titleModified).toBe(true);
200
+ expect(statusModified).toBe(false);
52
201
  });
53
202
 
54
- const created = await repo.create({ title: 'New Post' });
55
-
56
- expect(created.id).toBeDefined();
57
- expect(created.created_by).toBe('u1');
58
- expect(created.augmented).toBe(true);
203
+ it('should trigger afterUpdate with result', async () => {
204
+ const repo = app.createContext({}).object('post');
205
+
206
+ const created = await repo.create({ title: 'Test', status: 'draft' });
207
+
208
+ let capturedResult: any;
209
+ app.on('afterUpdate', 'post', async (ctx) => {
210
+ capturedResult = ctx.result;
211
+ });
212
+
213
+ await repo.update(created._id, { status: 'published' });
214
+
215
+ expect(capturedResult).toBeDefined();
216
+ expect(capturedResult.status).toBe('published');
217
+ });
218
+
219
+ it('should validate state transitions in beforeUpdate', async () => {
220
+ const repo = app.createContext({}).object('post');
221
+
222
+ const created = await repo.create({ title: 'Test', status: 'published' });
223
+
224
+ app.on('beforeUpdate', 'post', async (ctx) => {
225
+ if ('isModified' in ctx && ctx.isModified('status' as any)) {
226
+ if (ctx.previousData?.status === 'published' && ctx.data?.status === 'draft') {
227
+ throw new Error('Cannot revert published post to draft');
228
+ }
229
+ }
230
+ });
231
+
232
+ await expect(repo.update(created._id, { status: 'draft' }))
233
+ .rejects
234
+ .toThrow('Cannot revert published post to draft');
235
+ });
236
+ });
237
+
238
+ describe('Delete Hooks', () => {
239
+ it('should trigger beforeDelete with id and previousData', async () => {
240
+ const repo = app.createContext({}).object('post');
241
+
242
+ const created = await repo.create({ title: 'To Delete', status: 'draft' });
243
+
244
+ let capturedId: any;
245
+ let capturedPrevious: any;
246
+ app.on('beforeDelete', 'post', async (ctx) => {
247
+ capturedId = ctx.id;
248
+ capturedPrevious = ctx.previousData;
249
+ });
250
+
251
+ await repo.delete(created._id);
252
+
253
+ expect(capturedId).toBe(created._id);
254
+ expect(capturedPrevious).toBeDefined();
255
+ expect(capturedPrevious.title).toBe('To Delete');
256
+ });
257
+
258
+ it('should trigger afterDelete with result', async () => {
259
+ const repo = app.createContext({}).object('post');
260
+
261
+ const created = await repo.create({ title: 'To Delete' });
262
+
263
+ let capturedResult: any;
264
+ app.on('afterDelete', 'post', async (ctx) => {
265
+ capturedResult = ctx.result;
266
+ });
267
+
268
+ await repo.delete(created._id);
269
+
270
+ expect(capturedResult).toBeDefined();
271
+ });
272
+
273
+ it('should check dependencies in beforeDelete', async () => {
274
+ const repo = app.createContext({}).object('post');
275
+
276
+ const created = await repo.create({ title: 'Protected Post', status: 'published' });
277
+
278
+ app.on('beforeDelete', 'post', async (ctx) => {
279
+ if (ctx.previousData?.status === 'published') {
280
+ throw new Error('Cannot delete published posts');
281
+ }
282
+ });
283
+
284
+ await expect(repo.delete(created._id))
285
+ .rejects
286
+ .toThrow('Cannot delete published posts');
287
+ });
288
+ });
289
+
290
+ describe('State Sharing', () => {
291
+ it('should share state between before and after hooks', async () => {
292
+ const repo = app.createContext({}).object('post');
293
+
294
+ app.on('beforeCreate', 'post', async (ctx) => {
295
+ ctx.state.timestamp = Date.now();
296
+ ctx.state.customData = 'test';
297
+ });
298
+
299
+ let capturedState: any;
300
+ app.on('afterCreate', 'post', async (ctx) => {
301
+ capturedState = ctx.state;
302
+ });
303
+
304
+ await repo.create({ title: 'Test' });
305
+
306
+ expect(capturedState).toBeDefined();
307
+ expect(capturedState.timestamp).toBeDefined();
308
+ expect(capturedState.customData).toBe('test');
309
+ });
310
+ });
311
+
312
+ describe('Error Handling', () => {
313
+ it('should prevent operation when beforeCreate throws error', async () => {
314
+ const repo = app.createContext({}).object('post');
315
+
316
+ app.on('beforeCreate', 'post', async (ctx) => {
317
+ if (!ctx.data?.title || ctx.data.title.length < 5) {
318
+ throw new Error('Title must be at least 5 characters');
319
+ }
320
+ });
321
+
322
+ await expect(repo.create({ title: 'Hi' }))
323
+ .rejects
324
+ .toThrow('Title must be at least 5 characters');
325
+ });
326
+
327
+ it('should prevent update when beforeUpdate throws error', async () => {
328
+ const repo = app.createContext({}).object('post');
329
+
330
+ const created = await repo.create({ title: 'Test Post', status: 'draft' });
331
+
332
+ app.on('beforeUpdate', 'post', async (ctx) => {
333
+ if (ctx.data?.status === 'archived') {
334
+ throw new Error('Archiving is not allowed');
335
+ }
336
+ });
337
+
338
+ await expect(repo.update(created._id, { status: 'archived' }))
339
+ .rejects
340
+ .toThrow('Archiving is not allowed');
341
+ });
59
342
  });
60
343
  });
@@ -17,7 +17,7 @@ export class MockDriver implements Driver {
17
17
  const items = this.getData(objectName);
18
18
  // Very basic filter implementation for testing
19
19
  if (query.filters) {
20
- return items.filter(item => {
20
+ const filtered = items.filter(item => {
21
21
  // Assuming simple filter: [['field', '=', 'value']]
22
22
  const filter = query.filters[0];
23
23
  if (filter && Array.isArray(filter) && filter[1] === '=') {
@@ -25,6 +25,7 @@ export class MockDriver implements Driver {
25
25
  }
26
26
  return true;
27
27
  });
28
+ return filtered;
28
29
  }
29
30
  return items;
30
31
  }
@@ -64,8 +65,10 @@ export class MockDriver implements Driver {
64
65
  return false;
65
66
  }
66
67
 
67
- async count(objectName: string, filters: any, options?: any): Promise<number> {
68
- return (await this.find(objectName, { filters }, options)).length;
68
+ async count(objectName: string, query: any, options?: any): Promise<number> {
69
+ // query can be a full query object { filters: [...] } or just filters for compatibility
70
+ const queryObj = Array.isArray(query) ? { filters: query } : (query || {});
71
+ return (await this.find(objectName, queryObj, options)).length;
69
72
  }
70
73
 
71
74
  async beginTransaction(): Promise<any> {