@dismissible/nestjs-dismissible 0.0.2-canary.738340d.0 → 0.0.2-canary.b0d8bfe.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.
Files changed (46) hide show
  1. package/README.md +58 -74
  2. package/jest.config.ts +1 -1
  3. package/package.json +12 -12
  4. package/project.json +1 -1
  5. package/src/api/dismissible-item-response.dto.ts +0 -8
  6. package/src/api/dismissible-item.mapper.spec.ts +0 -12
  7. package/src/api/dismissible-item.mapper.ts +2 -8
  8. package/src/api/index.ts +2 -3
  9. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +1 -2
  10. package/src/api/use-cases/dismiss/dismiss.controller.ts +9 -10
  11. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +2 -42
  12. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +11 -58
  13. package/src/api/use-cases/get-or-create/index.ts +0 -1
  14. package/src/api/use-cases/restore/restore.controller.spec.ts +1 -2
  15. package/src/api/use-cases/restore/restore.controller.ts +9 -10
  16. package/src/api/validation/index.ts +2 -0
  17. package/src/api/validation/param-validation.pipe.spec.ts +313 -0
  18. package/src/api/validation/param-validation.pipe.ts +38 -0
  19. package/src/api/validation/param.decorators.ts +32 -0
  20. package/src/core/dismissible-core.service.spec.ts +75 -29
  21. package/src/core/dismissible-core.service.ts +40 -28
  22. package/src/core/dismissible.service.spec.ts +106 -24
  23. package/src/core/dismissible.service.ts +93 -54
  24. package/src/core/hook-runner.service.spec.ts +495 -54
  25. package/src/core/hook-runner.service.ts +125 -24
  26. package/src/core/index.ts +0 -1
  27. package/src/core/lifecycle-hook.interface.ts +7 -122
  28. package/src/core/service-responses.interface.ts +9 -9
  29. package/src/dismissible.module.integration.spec.ts +704 -0
  30. package/src/dismissible.module.ts +10 -11
  31. package/src/events/dismissible.events.ts +17 -40
  32. package/src/index.ts +1 -1
  33. package/src/response/http-exception-filter.spec.ts +179 -0
  34. package/src/response/http-exception-filter.ts +3 -3
  35. package/src/response/response.service.spec.ts +0 -14
  36. package/src/testing/factories.ts +24 -9
  37. package/src/utils/dismissible.helper.ts +2 -2
  38. package/src/validation/dismissible-input.dto.ts +47 -0
  39. package/src/validation/index.ts +1 -0
  40. package/tsconfig.json +3 -0
  41. package/tsconfig.spec.json +12 -0
  42. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
  43. package/src/core/create-options.ts +0 -9
  44. package/src/request/index.ts +0 -2
  45. package/src/request/request-context.decorator.ts +0 -14
  46. package/src/request/request-context.interface.ts +0 -6
@@ -1,13 +1,12 @@
1
1
  import { mock } from 'ts-jest-mocker';
2
2
  import { ForbiddenException } from '@nestjs/common';
3
3
  import { HookRunner } from './hook-runner.service';
4
- import { IDismissibleLifecycleHook } from './lifecycle-hook.interface';
5
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
4
+ import { IDismissibleLifecycleHook } from '@dismissible/nestjs-dismissible-hooks';
6
5
  import { createTestItem, createTestContext } from '../testing/factories';
7
6
  import { IDismissibleLogger } from '@dismissible/nestjs-logger';
8
7
 
9
8
  describe('HookRunner', () => {
10
- let hookRunner: HookRunner<BaseMetadata>;
9
+ let hookRunner: HookRunner;
11
10
  let mockLogger: jest.Mocked<IDismissibleLogger>;
12
11
 
13
12
  const testUserId = 'test-user-id';
@@ -21,9 +20,9 @@ describe('HookRunner', () => {
21
20
  hookRunner = new HookRunner([], mockLogger);
22
21
  });
23
22
 
24
- it('should return proceed: true for pre-getOrCreate', async () => {
23
+ it('should return proceed: true for pre-request', async () => {
25
24
  const context = createTestContext();
26
- const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, context);
25
+ const result = await hookRunner.runPreRequest('test-id', testUserId, context);
27
26
 
28
27
  expect(result.proceed).toBe(true);
29
28
  expect(result.id).toBe('test-id');
@@ -31,12 +30,12 @@ describe('HookRunner', () => {
31
30
  expect(result.context).toEqual(context);
32
31
  });
33
32
 
34
- it('should complete post-getOrCreate without error', async () => {
33
+ it('should complete post-request without error', async () => {
35
34
  const item = createTestItem();
36
35
  const context = createTestContext();
37
36
 
38
37
  await expect(
39
- hookRunner.runPostGetOrCreate('test-id', item, testUserId, context),
38
+ hookRunner.runPostRequest('test-id', item, testUserId, context),
40
39
  ).resolves.not.toThrow();
41
40
  });
42
41
  });
@@ -45,32 +44,32 @@ describe('HookRunner', () => {
45
44
  it('should execute hooks in priority order (low to high) for pre-hooks', async () => {
46
45
  const executionOrder: number[] = [];
47
46
 
48
- const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
47
+ const hook1: IDismissibleLifecycleHook = {
49
48
  priority: 10,
50
- onBeforeGetOrCreate: jest.fn(async () => {
49
+ onBeforeRequest: jest.fn(async () => {
51
50
  executionOrder.push(10);
52
51
  return { proceed: true };
53
52
  }),
54
53
  };
55
54
 
56
- const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
55
+ const hook2: IDismissibleLifecycleHook = {
57
56
  priority: 5,
58
- onBeforeGetOrCreate: jest.fn(async () => {
57
+ onBeforeRequest: jest.fn(async () => {
59
58
  executionOrder.push(5);
60
59
  return { proceed: true };
61
60
  }),
62
61
  };
63
62
 
64
- const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
63
+ const hook3: IDismissibleLifecycleHook = {
65
64
  priority: 15,
66
- onBeforeGetOrCreate: jest.fn(async () => {
65
+ onBeforeRequest: jest.fn(async () => {
67
66
  executionOrder.push(15);
68
67
  return { proceed: true };
69
68
  }),
70
69
  };
71
70
 
72
71
  hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
73
- await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
72
+ await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
74
73
 
75
74
  expect(executionOrder).toEqual([5, 10, 15]);
76
75
  });
@@ -78,56 +77,51 @@ describe('HookRunner', () => {
78
77
  it('should execute hooks in reverse priority order for post-hooks', async () => {
79
78
  const executionOrder: number[] = [];
80
79
 
81
- const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
80
+ const hook1: IDismissibleLifecycleHook = {
82
81
  priority: 10,
83
- onAfterGetOrCreate: jest.fn(async () => {
82
+ onAfterRequest: jest.fn(async () => {
84
83
  executionOrder.push(10);
85
84
  }),
86
85
  };
87
86
 
88
- const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
87
+ const hook2: IDismissibleLifecycleHook = {
89
88
  priority: 5,
90
- onAfterGetOrCreate: jest.fn(async () => {
89
+ onAfterRequest: jest.fn(async () => {
91
90
  executionOrder.push(5);
92
91
  }),
93
92
  };
94
93
 
95
- const hook3: IDismissibleLifecycleHook<BaseMetadata> = {
94
+ const hook3: IDismissibleLifecycleHook = {
96
95
  priority: 15,
97
- onAfterGetOrCreate: jest.fn(async () => {
96
+ onAfterRequest: jest.fn(async () => {
98
97
  executionOrder.push(15);
99
98
  }),
100
99
  };
101
100
 
102
101
  hookRunner = new HookRunner([hook1, hook2, hook3], mockLogger);
103
- await hookRunner.runPostGetOrCreate(
104
- 'test-id',
105
- createTestItem(),
106
- testUserId,
107
- createTestContext(),
108
- );
102
+ await hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext());
109
103
 
110
104
  expect(executionOrder).toEqual([15, 10, 5]);
111
105
  });
112
106
 
113
107
  it('should block operation when pre-hook returns proceed: false', async () => {
114
- const blockingHook: IDismissibleLifecycleHook<BaseMetadata> = {
115
- onBeforeGetOrCreate: jest.fn(async () => ({
108
+ const blockingHook: IDismissibleLifecycleHook = {
109
+ onBeforeRequest: jest.fn(async () => ({
116
110
  proceed: false,
117
111
  reason: 'Rate limit exceeded',
118
112
  })),
119
113
  };
120
114
 
121
115
  hookRunner = new HookRunner([blockingHook], mockLogger);
122
- const result = await hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext());
116
+ const result = await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
123
117
 
124
118
  expect(result.proceed).toBe(false);
125
119
  expect(result.reason).toBe('Rate limit exceeded');
126
120
  });
127
121
 
128
122
  it('should apply mutations from pre-hooks', async () => {
129
- const mutatingHook: IDismissibleLifecycleHook<BaseMetadata> = {
130
- onBeforeGetOrCreate: jest.fn(async () => ({
123
+ const mutatingHook: IDismissibleLifecycleHook = {
124
+ onBeforeRequest: jest.fn(async () => ({
131
125
  proceed: true,
132
126
  mutations: {
133
127
  id: 'mutated-id',
@@ -137,48 +131,101 @@ describe('HookRunner', () => {
137
131
  };
138
132
 
139
133
  hookRunner = new HookRunner([mutatingHook], mockLogger);
140
- const result = await hookRunner.runPreGetOrCreate(
141
- 'original-id',
142
- testUserId,
143
- createTestContext(),
144
- );
134
+ const result = await hookRunner.runPreRequest('original-id', testUserId, createTestContext());
145
135
 
146
136
  expect(result.id).toBe('mutated-id');
147
137
  expect(result.userId).toBe('mutated-user');
148
138
  });
149
139
 
150
140
  it('should pass mutations through multiple hooks', async () => {
151
- const hook1: IDismissibleLifecycleHook<BaseMetadata> = {
141
+ const hook1: IDismissibleLifecycleHook = {
152
142
  priority: 1,
153
- onBeforeGetOrCreate: jest.fn(async (itemId) => ({
143
+ onBeforeRequest: jest.fn(async (itemId) => ({
154
144
  proceed: true,
155
145
  mutations: { id: `${itemId}-hook1` },
156
146
  })),
157
147
  };
158
148
 
159
- const hook2: IDismissibleLifecycleHook<BaseMetadata> = {
149
+ const hook2: IDismissibleLifecycleHook = {
160
150
  priority: 2,
161
- onBeforeGetOrCreate: jest.fn(async (itemId) => ({
151
+ onBeforeRequest: jest.fn(async (itemId) => ({
162
152
  proceed: true,
163
153
  mutations: { id: `${itemId}-hook2` },
164
154
  })),
165
155
  };
166
156
 
167
157
  hookRunner = new HookRunner([hook1, hook2], mockLogger);
168
- const result = await hookRunner.runPreGetOrCreate(
169
- 'original',
170
- testUserId,
171
- createTestContext(),
172
- );
158
+ const result = await hookRunner.runPreRequest('original', testUserId, createTestContext());
173
159
 
174
160
  expect(result.id).toBe('original-hook1-hook2');
175
161
  });
162
+
163
+ it('should apply context mutations from pre-hooks', async () => {
164
+ const context = createTestContext();
165
+
166
+ const mutatingHook: IDismissibleLifecycleHook = {
167
+ onBeforeRequest: jest.fn(async () => ({
168
+ proceed: true,
169
+ mutations: {
170
+ context: { headers: { authorization: 'Bearer mutated-token' } },
171
+ },
172
+ })),
173
+ };
174
+
175
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
176
+ const result = await hookRunner.runPreRequest('test-id', testUserId, context);
177
+
178
+ expect(result.context).toEqual({
179
+ ...context,
180
+ headers: { authorization: 'Bearer mutated-token' },
181
+ });
182
+ });
183
+
184
+ it('should ignore context mutation when no context provided', async () => {
185
+ const mutatingHook: IDismissibleLifecycleHook = {
186
+ onBeforeRequest: jest.fn(async () => ({
187
+ proceed: true,
188
+ mutations: {
189
+ context: { headers: { authorization: 'Bearer token' } },
190
+ },
191
+ })),
192
+ };
193
+
194
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
195
+ const result = await hookRunner.runPreRequest('test-id', testUserId, undefined);
196
+
197
+ expect(result.context).toBeUndefined();
198
+ });
199
+
200
+ it('should handle hooks with default priority (undefined)', async () => {
201
+ const executionOrder: string[] = [];
202
+
203
+ const hook1: IDismissibleLifecycleHook = {
204
+ priority: 5,
205
+ onBeforeRequest: jest.fn(async () => {
206
+ executionOrder.push('priority-5');
207
+ return { proceed: true };
208
+ }),
209
+ };
210
+
211
+ const hook2: IDismissibleLifecycleHook = {
212
+ onBeforeRequest: jest.fn(async () => {
213
+ executionOrder.push('priority-default');
214
+ return { proceed: true };
215
+ }),
216
+ };
217
+
218
+ hookRunner = new HookRunner([hook1, hook2], mockLogger);
219
+ await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
220
+
221
+ expect(executionOrder).toEqual(['priority-default', 'priority-5']);
222
+ });
176
223
  });
177
224
 
178
225
  describe('error handling', () => {
179
226
  it('should throw error from pre-hook', async () => {
180
- const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
181
- onBeforeGetOrCreate: jest.fn(async () => {
227
+ const errorHook: IDismissibleLifecycleHook = {
228
+ onBeforeRequest: jest.fn(async () => {
182
229
  throw new Error('Hook error');
183
230
  }),
184
231
  };
@@ -186,15 +233,31 @@ describe('HookRunner', () => {
186
233
  hookRunner = new HookRunner([errorHook], mockLogger);
187
234
 
188
235
  await expect(
189
- hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext()),
236
+ hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
190
237
  ).rejects.toThrow('Hook error');
191
238
 
192
239
  expect(mockLogger.error).toHaveBeenCalled();
193
240
  });
194
241
 
242
+ it('should throw non-Error from pre-hook', async () => {
243
+ const errorHook: IDismissibleLifecycleHook = {
244
+ onBeforeRequest: jest.fn(async () => {
245
+ throw 'string error';
246
+ }),
247
+ };
248
+
249
+ hookRunner = new HookRunner([errorHook], mockLogger);
250
+
251
+ await expect(
252
+ hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
253
+ ).rejects.toBe('string error');
254
+
255
+ expect(mockLogger.error).toHaveBeenCalled();
256
+ });
257
+
195
258
  it('should log but not throw errors from post-hooks', async () => {
196
- const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
197
- onAfterGetOrCreate: jest.fn(async () => {
259
+ const errorHook: IDismissibleLifecycleHook = {
260
+ onAfterRequest: jest.fn(async () => {
198
261
  throw new Error('Post-hook error');
199
262
  }),
200
263
  };
@@ -202,7 +265,23 @@ describe('HookRunner', () => {
202
265
  hookRunner = new HookRunner([errorHook], mockLogger);
203
266
 
204
267
  await expect(
205
- hookRunner.runPostGetOrCreate('test-id', createTestItem(), testUserId, createTestContext()),
268
+ hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
269
+ ).resolves.not.toThrow();
270
+
271
+ expect(mockLogger.error).toHaveBeenCalled();
272
+ });
273
+
274
+ it('should log but not throw non-Error from post-hooks', async () => {
275
+ const errorHook: IDismissibleLifecycleHook = {
276
+ onAfterRequest: jest.fn(async () => {
277
+ throw 'string post-hook error';
278
+ }),
279
+ };
280
+
281
+ hookRunner = new HookRunner([errorHook], mockLogger);
282
+
283
+ await expect(
284
+ hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
206
285
  ).resolves.not.toThrow();
207
286
 
208
287
  expect(mockLogger.error).toHaveBeenCalled();
@@ -222,6 +301,17 @@ describe('HookRunner', () => {
222
301
  ).toThrow(ForbiddenException);
223
302
  });
224
303
 
304
+ it('should throw ForbiddenException with default reason when blocked without reason', () => {
305
+ expect(() =>
306
+ HookRunner.throwIfBlocked({
307
+ proceed: false,
308
+ id: 'test',
309
+ userId: testUserId,
310
+ context: createTestContext(),
311
+ }),
312
+ ).toThrow('Operation blocked by lifecycle hook');
313
+ });
314
+
225
315
  it('should not throw when not blocked', () => {
226
316
  expect(() =>
227
317
  HookRunner.throwIfBlocked({
@@ -234,14 +324,338 @@ describe('HookRunner', () => {
234
324
  });
235
325
  });
236
326
 
327
+ describe('runPreGet and runPostGet', () => {
328
+ it('should pass item to onBeforeGet hook', async () => {
329
+ const item = createTestItem();
330
+ const context = createTestContext();
331
+
332
+ const hook: IDismissibleLifecycleHook = {
333
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
334
+ };
335
+
336
+ hookRunner = new HookRunner([hook], mockLogger);
337
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
338
+
339
+ expect(hook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
340
+ });
341
+
342
+ it('should block operation when onBeforeGet returns proceed: false', async () => {
343
+ const item = createTestItem();
344
+ const context = createTestContext();
345
+
346
+ const blockingHook: IDismissibleLifecycleHook = {
347
+ onBeforeGet: jest.fn().mockResolvedValue({
348
+ proceed: false,
349
+ reason: 'Item is in invalid state',
350
+ }),
351
+ };
352
+
353
+ hookRunner = new HookRunner([blockingHook], mockLogger);
354
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
355
+
356
+ expect(result.proceed).toBe(false);
357
+ expect(result.reason).toBe('Item is in invalid state');
358
+ });
359
+
360
+ it('should run onAfterGet hook', async () => {
361
+ const item = createTestItem();
362
+ const context = createTestContext();
363
+
364
+ const hook: IDismissibleLifecycleHook = {
365
+ onAfterGet: jest.fn(),
366
+ };
367
+
368
+ hookRunner = new HookRunner([hook], mockLogger);
369
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
370
+
371
+ expect(hook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
372
+ });
373
+
374
+ it('should apply mutations from onBeforeGet hook', async () => {
375
+ const item = createTestItem();
376
+ const context = createTestContext();
377
+
378
+ const mutatingHook: IDismissibleLifecycleHook = {
379
+ onBeforeGet: jest.fn().mockResolvedValue({
380
+ proceed: true,
381
+ mutations: {
382
+ id: 'mutated-id',
383
+ },
384
+ }),
385
+ };
386
+
387
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
388
+ const result = await hookRunner.runPreGet('original-id', item, testUserId, context);
389
+
390
+ expect(result.id).toBe('mutated-id');
391
+ });
392
+
393
+ it('should apply userId mutation from onBeforeGet hook', async () => {
394
+ const item = createTestItem();
395
+ const context = createTestContext();
396
+
397
+ const mutatingHook: IDismissibleLifecycleHook = {
398
+ onBeforeGet: jest.fn().mockResolvedValue({
399
+ proceed: true,
400
+ mutations: {
401
+ userId: 'mutated-user-id',
402
+ },
403
+ }),
404
+ };
405
+
406
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
407
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
408
+
409
+ expect(result.userId).toBe('mutated-user-id');
410
+ });
411
+
412
+ it('should apply context mutation from onBeforeGet hook', async () => {
413
+ const item = createTestItem();
414
+ const context = createTestContext();
415
+
416
+ const mutatingHook: IDismissibleLifecycleHook = {
417
+ onBeforeGet: jest.fn().mockResolvedValue({
418
+ proceed: true,
419
+ mutations: {
420
+ context: { headers: { authorization: 'Bearer custom-token' } },
421
+ },
422
+ }),
423
+ };
424
+
425
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
426
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
427
+
428
+ expect(result.context).toEqual({
429
+ ...context,
430
+ headers: { authorization: 'Bearer custom-token' },
431
+ });
432
+ });
433
+
434
+ it('should ignore context mutation when no context provided to onBeforeGet', async () => {
435
+ const item = createTestItem();
436
+
437
+ const mutatingHook: IDismissibleLifecycleHook = {
438
+ onBeforeGet: jest.fn().mockResolvedValue({
439
+ proceed: true,
440
+ mutations: {
441
+ context: { headers: { authorization: 'Bearer token' } },
442
+ },
443
+ }),
444
+ };
445
+
446
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
447
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, undefined);
448
+
449
+ expect(result.context).toBeUndefined();
450
+ });
451
+
452
+ it('should throw error from onBeforeGet hook', async () => {
453
+ const item = createTestItem();
454
+ const context = createTestContext();
455
+
456
+ const errorHook: IDismissibleLifecycleHook = {
457
+ onBeforeGet: jest.fn().mockRejectedValue(new Error('Get hook error')),
458
+ };
459
+
460
+ hookRunner = new HookRunner([errorHook], mockLogger);
461
+
462
+ await expect(hookRunner.runPreGet('test-id', item, testUserId, context)).rejects.toThrow(
463
+ 'Get hook error',
464
+ );
465
+
466
+ expect(mockLogger.error).toHaveBeenCalled();
467
+ });
468
+
469
+ it('should throw non-Error from onBeforeGet hook', async () => {
470
+ const item = createTestItem();
471
+ const context = createTestContext();
472
+
473
+ const errorHook: IDismissibleLifecycleHook = {
474
+ onBeforeGet: jest.fn().mockRejectedValue('string error'),
475
+ };
476
+
477
+ hookRunner = new HookRunner([errorHook], mockLogger);
478
+
479
+ await expect(hookRunner.runPreGet('test-id', item, testUserId, context)).rejects.toBe(
480
+ 'string error',
481
+ );
482
+
483
+ expect(mockLogger.error).toHaveBeenCalled();
484
+ });
485
+ });
486
+
487
+ describe('runPreCreate, runPreDismiss, runPreRestore mutations', () => {
488
+ it('should apply mutations from onBeforeCreate hook', async () => {
489
+ const context = createTestContext();
490
+
491
+ const mutatingHook: IDismissibleLifecycleHook = {
492
+ onBeforeCreate: jest.fn().mockResolvedValue({
493
+ proceed: true,
494
+ mutations: {
495
+ id: 'mutated-create-id',
496
+ userId: 'mutated-create-user',
497
+ },
498
+ }),
499
+ };
500
+
501
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
502
+ const result = await hookRunner.runPreCreate('original-id', testUserId, context);
503
+
504
+ expect(result.id).toBe('mutated-create-id');
505
+ expect(result.userId).toBe('mutated-create-user');
506
+ });
507
+
508
+ it('should apply context mutation from onBeforeCreate when context is provided', async () => {
509
+ const context = createTestContext();
510
+
511
+ const mutatingHook: IDismissibleLifecycleHook = {
512
+ onBeforeCreate: jest.fn().mockResolvedValue({
513
+ proceed: true,
514
+ mutations: {
515
+ context: { headers: { authorization: 'Bearer create-token' } },
516
+ },
517
+ }),
518
+ };
519
+
520
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
521
+ const result = await hookRunner.runPreCreate('test-id', testUserId, context);
522
+
523
+ expect(result.context).toEqual({
524
+ ...context,
525
+ headers: { authorization: 'Bearer create-token' },
526
+ });
527
+ });
528
+
529
+ it('should block operation when onBeforeDismiss returns proceed: false', async () => {
530
+ const blockingHook: IDismissibleLifecycleHook = {
531
+ onBeforeDismiss: jest.fn().mockResolvedValue({
532
+ proceed: false,
533
+ reason: 'Cannot dismiss this item',
534
+ }),
535
+ };
536
+
537
+ hookRunner = new HookRunner([blockingHook], mockLogger);
538
+ const result = await hookRunner.runPreDismiss('test-id', testUserId, createTestContext());
539
+
540
+ expect(result.proceed).toBe(false);
541
+ expect(result.reason).toBe('Cannot dismiss this item');
542
+ });
543
+
544
+ it('should apply mutations from onBeforeRestore hook', async () => {
545
+ const context = createTestContext();
546
+
547
+ const mutatingHook: IDismissibleLifecycleHook = {
548
+ onBeforeRestore: jest.fn().mockResolvedValue({
549
+ proceed: true,
550
+ mutations: {
551
+ id: 'mutated-restore-id',
552
+ },
553
+ }),
554
+ };
555
+
556
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
557
+ const result = await hookRunner.runPreRestore('original-id', testUserId, context);
558
+
559
+ expect(result.id).toBe('mutated-restore-id');
560
+ });
561
+
562
+ it('should throw error from onBeforeCreate hook', async () => {
563
+ const errorHook: IDismissibleLifecycleHook = {
564
+ onBeforeCreate: jest.fn().mockRejectedValue(new Error('Create hook error')),
565
+ };
566
+
567
+ hookRunner = new HookRunner([errorHook], mockLogger);
568
+
569
+ await expect(
570
+ hookRunner.runPreCreate('test-id', testUserId, createTestContext()),
571
+ ).rejects.toThrow('Create hook error');
572
+
573
+ expect(mockLogger.error).toHaveBeenCalled();
574
+ });
575
+
576
+ it('should throw non-Error from onBeforeDismiss hook', async () => {
577
+ const errorHook: IDismissibleLifecycleHook = {
578
+ onBeforeDismiss: jest.fn().mockRejectedValue('dismiss string error'),
579
+ };
580
+
581
+ hookRunner = new HookRunner([errorHook], mockLogger);
582
+
583
+ await expect(
584
+ hookRunner.runPreDismiss('test-id', testUserId, createTestContext()),
585
+ ).rejects.toBe('dismiss string error');
586
+
587
+ expect(mockLogger.error).toHaveBeenCalled();
588
+ });
589
+ });
590
+
591
+ describe('post-hook error handling', () => {
592
+ it('should log but not throw Error from onAfterCreate', async () => {
593
+ const errorHook: IDismissibleLifecycleHook = {
594
+ onAfterCreate: jest.fn().mockRejectedValue(new Error('After create error')),
595
+ };
596
+
597
+ hookRunner = new HookRunner([errorHook], mockLogger);
598
+
599
+ await expect(
600
+ hookRunner.runPostCreate('test-id', createTestItem(), testUserId, createTestContext()),
601
+ ).resolves.not.toThrow();
602
+
603
+ expect(mockLogger.error).toHaveBeenCalled();
604
+ });
605
+
606
+ it('should log but not throw non-Error from onAfterDismiss', async () => {
607
+ const errorHook: IDismissibleLifecycleHook = {
608
+ onAfterDismiss: jest.fn().mockRejectedValue('dismiss post error'),
609
+ };
610
+
611
+ hookRunner = new HookRunner([errorHook], mockLogger);
612
+
613
+ await expect(
614
+ hookRunner.runPostDismiss('test-id', createTestItem(), testUserId, createTestContext()),
615
+ ).resolves.not.toThrow();
616
+
617
+ expect(mockLogger.error).toHaveBeenCalled();
618
+ });
619
+
620
+ it('should log but not throw Error from onAfterRestore', async () => {
621
+ const errorHook: IDismissibleLifecycleHook = {
622
+ onAfterRestore: jest.fn().mockRejectedValue(new Error('After restore error')),
623
+ };
624
+
625
+ hookRunner = new HookRunner([errorHook], mockLogger);
626
+
627
+ await expect(
628
+ hookRunner.runPostRestore('test-id', createTestItem(), testUserId, createTestContext()),
629
+ ).resolves.not.toThrow();
630
+
631
+ expect(mockLogger.error).toHaveBeenCalled();
632
+ });
633
+
634
+ it('should log but not throw non-Error from onAfterGet', async () => {
635
+ const errorHook: IDismissibleLifecycleHook = {
636
+ onAfterGet: jest.fn().mockRejectedValue('get post error'),
637
+ };
638
+
639
+ hookRunner = new HookRunner([errorHook], mockLogger);
640
+
641
+ await expect(
642
+ hookRunner.runPostGet('test-id', createTestItem(), testUserId, createTestContext()),
643
+ ).resolves.not.toThrow();
644
+
645
+ expect(mockLogger.error).toHaveBeenCalled();
646
+ });
647
+ });
648
+
237
649
  describe('all hook methods', () => {
238
- let allMethodsHook: IDismissibleLifecycleHook<BaseMetadata>;
650
+ let allMethodsHook: IDismissibleLifecycleHook;
239
651
 
240
652
  beforeEach(() => {
241
653
  allMethodsHook = {
242
654
  priority: 0,
243
- onBeforeGetOrCreate: jest.fn().mockResolvedValue({ proceed: true }),
244
- onAfterGetOrCreate: jest.fn(),
655
+ onBeforeRequest: jest.fn().mockResolvedValue({ proceed: true }),
656
+ onAfterRequest: jest.fn(),
657
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
658
+ onAfterGet: jest.fn(),
245
659
  onBeforeCreate: jest.fn().mockResolvedValue({ proceed: true }),
246
660
  onAfterCreate: jest.fn(),
247
661
  onBeforeDismiss: jest.fn().mockResolvedValue({ proceed: true }),
@@ -253,6 +667,33 @@ describe('HookRunner', () => {
253
667
  hookRunner = new HookRunner([allMethodsHook], mockLogger);
254
668
  });
255
669
 
670
+ it('should run pre and post request hooks', async () => {
671
+ const context = createTestContext();
672
+ const item = createTestItem();
673
+
674
+ await hookRunner.runPreRequest('test-id', testUserId, context);
675
+ await hookRunner.runPostRequest('test-id', item, testUserId, context);
676
+
677
+ expect(allMethodsHook.onBeforeRequest).toHaveBeenCalledWith('test-id', testUserId, context);
678
+ expect(allMethodsHook.onAfterRequest).toHaveBeenCalledWith(
679
+ 'test-id',
680
+ item,
681
+ testUserId,
682
+ context,
683
+ );
684
+ });
685
+
686
+ it('should run pre and post get hooks', async () => {
687
+ const context = createTestContext();
688
+ const item = createTestItem();
689
+
690
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
691
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
692
+
693
+ expect(allMethodsHook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
694
+ expect(allMethodsHook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
695
+ });
696
+
256
697
  it('should run pre and post create hooks', async () => {
257
698
  const context = createTestContext();
258
699
  const item = createTestItem();