@dismissible/nestjs-dismissible 0.0.2-canary.8976e84.0 → 0.0.2-canary.d2f56d7.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 (42) hide show
  1. package/README.md +51 -67
  2. package/package.json +4 -4
  3. package/src/api/dismissible-item-response.dto.ts +0 -8
  4. package/src/api/dismissible-item.mapper.spec.ts +0 -12
  5. package/src/api/dismissible-item.mapper.ts +2 -8
  6. package/src/api/index.ts +3 -0
  7. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +1 -2
  8. package/src/api/use-cases/dismiss/dismiss.controller.ts +8 -8
  9. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +2 -42
  10. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +10 -56
  11. package/src/api/use-cases/get-or-create/index.ts +0 -1
  12. package/src/api/use-cases/restore/restore.controller.spec.ts +1 -2
  13. package/src/api/use-cases/restore/restore.controller.ts +8 -8
  14. package/src/api/validation/index.ts +2 -0
  15. package/src/api/validation/param-validation.pipe.spec.ts +317 -0
  16. package/src/api/validation/param-validation.pipe.ts +42 -0
  17. package/src/api/validation/param.decorators.ts +32 -0
  18. package/src/core/dismissible-core.service.spec.ts +75 -29
  19. package/src/core/dismissible-core.service.ts +40 -24
  20. package/src/core/dismissible.service.spec.ts +111 -25
  21. package/src/core/dismissible.service.ts +115 -49
  22. package/src/core/hook-runner.service.spec.ts +486 -53
  23. package/src/core/hook-runner.service.ts +144 -18
  24. package/src/core/index.ts +0 -1
  25. package/src/core/lifecycle-hook.interface.ts +56 -10
  26. package/src/core/service-responses.interface.ts +9 -9
  27. package/src/dismissible.module.integration.spec.ts +685 -0
  28. package/src/dismissible.module.ts +6 -10
  29. package/src/events/dismissible.events.ts +16 -39
  30. package/src/index.ts +1 -0
  31. package/src/request/request-context.decorator.ts +1 -0
  32. package/src/request/request-context.interface.ts +6 -0
  33. package/src/response/http-exception-filter.spec.ts +213 -0
  34. package/src/response/http-exception-filter.ts +3 -3
  35. package/src/testing/factories.ts +5 -8
  36. package/src/utils/dismissible.helper.ts +2 -2
  37. package/src/validation/dismissible-input.dto.ts +47 -0
  38. package/src/validation/index.ts +1 -0
  39. package/tsconfig.json +3 -0
  40. package/tsconfig.spec.json +12 -0
  41. package/src/api/use-cases/get-or-create/get-or-create.request.dto.ts +0 -17
  42. package/src/core/create-options.ts +0 -9
@@ -2,12 +2,11 @@ import { mock } from 'ts-jest-mocker';
2
2
  import { ForbiddenException } from '@nestjs/common';
3
3
  import { HookRunner } from './hook-runner.service';
4
4
  import { IDismissibleLifecycleHook } from './lifecycle-hook.interface';
5
- import { BaseMetadata } from '@dismissible/nestjs-dismissible-item';
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,99 @@ 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: { authorizationHeader: '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({ ...context, authorizationHeader: 'Bearer mutated-token' });
179
+ });
180
+
181
+ it('should ignore context mutation when no context provided', async () => {
182
+ const mutatingHook: IDismissibleLifecycleHook = {
183
+ onBeforeRequest: jest.fn(async () => ({
184
+ proceed: true,
185
+ mutations: {
186
+ context: { authorizationHeader: 'Bearer token' },
187
+ },
188
+ })),
189
+ };
190
+
191
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
192
+ const result = await hookRunner.runPreRequest('test-id', testUserId, undefined);
193
+
194
+ expect(result.context).toBeUndefined();
195
+ });
196
+
197
+ it('should handle hooks with default priority (undefined)', async () => {
198
+ const executionOrder: string[] = [];
199
+
200
+ const hook1: IDismissibleLifecycleHook = {
201
+ priority: 5,
202
+ onBeforeRequest: jest.fn(async () => {
203
+ executionOrder.push('priority-5');
204
+ return { proceed: true };
205
+ }),
206
+ };
207
+
208
+ const hook2: IDismissibleLifecycleHook = {
209
+ // No priority - defaults to 0
210
+ onBeforeRequest: jest.fn(async () => {
211
+ executionOrder.push('priority-default');
212
+ return { proceed: true };
213
+ }),
214
+ };
215
+
216
+ hookRunner = new HookRunner([hook1, hook2], mockLogger);
217
+ await hookRunner.runPreRequest('test-id', testUserId, createTestContext());
218
+
219
+ expect(executionOrder).toEqual(['priority-default', 'priority-5']);
220
+ });
176
221
  });
177
222
 
178
223
  describe('error handling', () => {
179
224
  it('should throw error from pre-hook', async () => {
180
- const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
181
- onBeforeGetOrCreate: jest.fn(async () => {
225
+ const errorHook: IDismissibleLifecycleHook = {
226
+ onBeforeRequest: jest.fn(async () => {
182
227
  throw new Error('Hook error');
183
228
  }),
184
229
  };
@@ -186,15 +231,31 @@ describe('HookRunner', () => {
186
231
  hookRunner = new HookRunner([errorHook], mockLogger);
187
232
 
188
233
  await expect(
189
- hookRunner.runPreGetOrCreate('test-id', testUserId, createTestContext()),
234
+ hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
190
235
  ).rejects.toThrow('Hook error');
191
236
 
192
237
  expect(mockLogger.error).toHaveBeenCalled();
193
238
  });
194
239
 
240
+ it('should throw non-Error from pre-hook', async () => {
241
+ const errorHook: IDismissibleLifecycleHook = {
242
+ onBeforeRequest: jest.fn(async () => {
243
+ throw 'string error';
244
+ }),
245
+ };
246
+
247
+ hookRunner = new HookRunner([errorHook], mockLogger);
248
+
249
+ await expect(
250
+ hookRunner.runPreRequest('test-id', testUserId, createTestContext()),
251
+ ).rejects.toBe('string error');
252
+
253
+ expect(mockLogger.error).toHaveBeenCalled();
254
+ });
255
+
195
256
  it('should log but not throw errors from post-hooks', async () => {
196
- const errorHook: IDismissibleLifecycleHook<BaseMetadata> = {
197
- onAfterGetOrCreate: jest.fn(async () => {
257
+ const errorHook: IDismissibleLifecycleHook = {
258
+ onAfterRequest: jest.fn(async () => {
198
259
  throw new Error('Post-hook error');
199
260
  }),
200
261
  };
@@ -202,7 +263,23 @@ describe('HookRunner', () => {
202
263
  hookRunner = new HookRunner([errorHook], mockLogger);
203
264
 
204
265
  await expect(
205
- hookRunner.runPostGetOrCreate('test-id', createTestItem(), testUserId, createTestContext()),
266
+ hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
267
+ ).resolves.not.toThrow();
268
+
269
+ expect(mockLogger.error).toHaveBeenCalled();
270
+ });
271
+
272
+ it('should log but not throw non-Error from post-hooks', async () => {
273
+ const errorHook: IDismissibleLifecycleHook = {
274
+ onAfterRequest: jest.fn(async () => {
275
+ throw 'string post-hook error';
276
+ }),
277
+ };
278
+
279
+ hookRunner = new HookRunner([errorHook], mockLogger);
280
+
281
+ await expect(
282
+ hookRunner.runPostRequest('test-id', createTestItem(), testUserId, createTestContext()),
206
283
  ).resolves.not.toThrow();
207
284
 
208
285
  expect(mockLogger.error).toHaveBeenCalled();
@@ -222,6 +299,17 @@ describe('HookRunner', () => {
222
299
  ).toThrow(ForbiddenException);
223
300
  });
224
301
 
302
+ it('should throw ForbiddenException with default reason when blocked without reason', () => {
303
+ expect(() =>
304
+ HookRunner.throwIfBlocked({
305
+ proceed: false,
306
+ id: 'test',
307
+ userId: testUserId,
308
+ context: createTestContext(),
309
+ }),
310
+ ).toThrow('Operation blocked by lifecycle hook');
311
+ });
312
+
225
313
  it('should not throw when not blocked', () => {
226
314
  expect(() =>
227
315
  HookRunner.throwIfBlocked({
@@ -234,14 +322,332 @@ describe('HookRunner', () => {
234
322
  });
235
323
  });
236
324
 
325
+ describe('runPreGet and runPostGet', () => {
326
+ it('should pass item to onBeforeGet hook', async () => {
327
+ const item = createTestItem();
328
+ const context = createTestContext();
329
+
330
+ const hook: IDismissibleLifecycleHook = {
331
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
332
+ };
333
+
334
+ hookRunner = new HookRunner([hook], mockLogger);
335
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
336
+
337
+ expect(hook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
338
+ });
339
+
340
+ it('should block operation when onBeforeGet returns proceed: false', async () => {
341
+ const item = createTestItem();
342
+ const context = createTestContext();
343
+
344
+ const blockingHook: IDismissibleLifecycleHook = {
345
+ onBeforeGet: jest.fn().mockResolvedValue({
346
+ proceed: false,
347
+ reason: 'Item is in invalid state',
348
+ }),
349
+ };
350
+
351
+ hookRunner = new HookRunner([blockingHook], mockLogger);
352
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
353
+
354
+ expect(result.proceed).toBe(false);
355
+ expect(result.reason).toBe('Item is in invalid state');
356
+ });
357
+
358
+ it('should run onAfterGet hook', async () => {
359
+ const item = createTestItem();
360
+ const context = createTestContext();
361
+
362
+ const hook: IDismissibleLifecycleHook = {
363
+ onAfterGet: jest.fn(),
364
+ };
365
+
366
+ hookRunner = new HookRunner([hook], mockLogger);
367
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
368
+
369
+ expect(hook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
370
+ });
371
+
372
+ it('should apply mutations from onBeforeGet hook', async () => {
373
+ const item = createTestItem();
374
+ const context = createTestContext();
375
+
376
+ const mutatingHook: IDismissibleLifecycleHook = {
377
+ onBeforeGet: jest.fn().mockResolvedValue({
378
+ proceed: true,
379
+ mutations: {
380
+ id: 'mutated-id',
381
+ },
382
+ }),
383
+ };
384
+
385
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
386
+ const result = await hookRunner.runPreGet('original-id', item, testUserId, context);
387
+
388
+ expect(result.id).toBe('mutated-id');
389
+ });
390
+
391
+ it('should apply userId mutation from onBeforeGet hook', async () => {
392
+ const item = createTestItem();
393
+ const context = createTestContext();
394
+
395
+ const mutatingHook: IDismissibleLifecycleHook = {
396
+ onBeforeGet: jest.fn().mockResolvedValue({
397
+ proceed: true,
398
+ mutations: {
399
+ userId: 'mutated-user-id',
400
+ },
401
+ }),
402
+ };
403
+
404
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
405
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
406
+
407
+ expect(result.userId).toBe('mutated-user-id');
408
+ });
409
+
410
+ it('should apply context mutation from onBeforeGet hook', async () => {
411
+ const item = createTestItem();
412
+ const context = createTestContext();
413
+
414
+ const mutatingHook: IDismissibleLifecycleHook = {
415
+ onBeforeGet: jest.fn().mockResolvedValue({
416
+ proceed: true,
417
+ mutations: {
418
+ context: { authorizationHeader: 'Bearer custom-token' },
419
+ },
420
+ }),
421
+ };
422
+
423
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
424
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, context);
425
+
426
+ expect(result.context).toEqual({ ...context, authorizationHeader: 'Bearer custom-token' });
427
+ });
428
+
429
+ it('should ignore context mutation when no context provided to onBeforeGet', async () => {
430
+ const item = createTestItem();
431
+
432
+ const mutatingHook: IDismissibleLifecycleHook = {
433
+ onBeforeGet: jest.fn().mockResolvedValue({
434
+ proceed: true,
435
+ mutations: {
436
+ context: { authorizationHeader: 'Bearer token' },
437
+ },
438
+ }),
439
+ };
440
+
441
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
442
+ const result = await hookRunner.runPreGet('test-id', item, testUserId, undefined);
443
+
444
+ expect(result.context).toBeUndefined();
445
+ });
446
+
447
+ it('should throw error from onBeforeGet hook', async () => {
448
+ const item = createTestItem();
449
+ const context = createTestContext();
450
+
451
+ const errorHook: IDismissibleLifecycleHook = {
452
+ onBeforeGet: jest.fn().mockRejectedValue(new Error('Get hook error')),
453
+ };
454
+
455
+ hookRunner = new HookRunner([errorHook], mockLogger);
456
+
457
+ await expect(hookRunner.runPreGet('test-id', item, testUserId, context)).rejects.toThrow(
458
+ 'Get hook error',
459
+ );
460
+
461
+ expect(mockLogger.error).toHaveBeenCalled();
462
+ });
463
+
464
+ it('should throw non-Error from onBeforeGet hook', async () => {
465
+ const item = createTestItem();
466
+ const context = createTestContext();
467
+
468
+ const errorHook: IDismissibleLifecycleHook = {
469
+ onBeforeGet: jest.fn().mockRejectedValue('string error'),
470
+ };
471
+
472
+ hookRunner = new HookRunner([errorHook], mockLogger);
473
+
474
+ await expect(hookRunner.runPreGet('test-id', item, testUserId, context)).rejects.toBe(
475
+ 'string error',
476
+ );
477
+
478
+ expect(mockLogger.error).toHaveBeenCalled();
479
+ });
480
+ });
481
+
482
+ describe('runPreCreate, runPreDismiss, runPreRestore mutations', () => {
483
+ it('should apply mutations from onBeforeCreate hook', async () => {
484
+ const context = createTestContext();
485
+
486
+ const mutatingHook: IDismissibleLifecycleHook = {
487
+ onBeforeCreate: jest.fn().mockResolvedValue({
488
+ proceed: true,
489
+ mutations: {
490
+ id: 'mutated-create-id',
491
+ userId: 'mutated-create-user',
492
+ },
493
+ }),
494
+ };
495
+
496
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
497
+ const result = await hookRunner.runPreCreate('original-id', testUserId, context);
498
+
499
+ expect(result.id).toBe('mutated-create-id');
500
+ expect(result.userId).toBe('mutated-create-user');
501
+ });
502
+
503
+ it('should apply context mutation from onBeforeCreate when context is provided', async () => {
504
+ const context = createTestContext();
505
+
506
+ const mutatingHook: IDismissibleLifecycleHook = {
507
+ onBeforeCreate: jest.fn().mockResolvedValue({
508
+ proceed: true,
509
+ mutations: {
510
+ context: { authorizationHeader: 'Bearer create-token' },
511
+ },
512
+ }),
513
+ };
514
+
515
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
516
+ const result = await hookRunner.runPreCreate('test-id', testUserId, context);
517
+
518
+ expect(result.context).toEqual({ ...context, authorizationHeader: 'Bearer create-token' });
519
+ });
520
+
521
+ it('should block operation when onBeforeDismiss returns proceed: false', async () => {
522
+ const blockingHook: IDismissibleLifecycleHook = {
523
+ onBeforeDismiss: jest.fn().mockResolvedValue({
524
+ proceed: false,
525
+ reason: 'Cannot dismiss this item',
526
+ }),
527
+ };
528
+
529
+ hookRunner = new HookRunner([blockingHook], mockLogger);
530
+ const result = await hookRunner.runPreDismiss('test-id', testUserId, createTestContext());
531
+
532
+ expect(result.proceed).toBe(false);
533
+ expect(result.reason).toBe('Cannot dismiss this item');
534
+ });
535
+
536
+ it('should apply mutations from onBeforeRestore hook', async () => {
537
+ const context = createTestContext();
538
+
539
+ const mutatingHook: IDismissibleLifecycleHook = {
540
+ onBeforeRestore: jest.fn().mockResolvedValue({
541
+ proceed: true,
542
+ mutations: {
543
+ id: 'mutated-restore-id',
544
+ },
545
+ }),
546
+ };
547
+
548
+ hookRunner = new HookRunner([mutatingHook], mockLogger);
549
+ const result = await hookRunner.runPreRestore('original-id', testUserId, context);
550
+
551
+ expect(result.id).toBe('mutated-restore-id');
552
+ });
553
+
554
+ it('should throw error from onBeforeCreate hook', async () => {
555
+ const errorHook: IDismissibleLifecycleHook = {
556
+ onBeforeCreate: jest.fn().mockRejectedValue(new Error('Create hook error')),
557
+ };
558
+
559
+ hookRunner = new HookRunner([errorHook], mockLogger);
560
+
561
+ await expect(
562
+ hookRunner.runPreCreate('test-id', testUserId, createTestContext()),
563
+ ).rejects.toThrow('Create hook error');
564
+
565
+ expect(mockLogger.error).toHaveBeenCalled();
566
+ });
567
+
568
+ it('should throw non-Error from onBeforeDismiss hook', async () => {
569
+ const errorHook: IDismissibleLifecycleHook = {
570
+ onBeforeDismiss: jest.fn().mockRejectedValue('dismiss string error'),
571
+ };
572
+
573
+ hookRunner = new HookRunner([errorHook], mockLogger);
574
+
575
+ await expect(
576
+ hookRunner.runPreDismiss('test-id', testUserId, createTestContext()),
577
+ ).rejects.toBe('dismiss string error');
578
+
579
+ expect(mockLogger.error).toHaveBeenCalled();
580
+ });
581
+ });
582
+
583
+ describe('post-hook error handling', () => {
584
+ it('should log but not throw Error from onAfterCreate', async () => {
585
+ const errorHook: IDismissibleLifecycleHook = {
586
+ onAfterCreate: jest.fn().mockRejectedValue(new Error('After create error')),
587
+ };
588
+
589
+ hookRunner = new HookRunner([errorHook], mockLogger);
590
+
591
+ await expect(
592
+ hookRunner.runPostCreate('test-id', createTestItem(), testUserId, createTestContext()),
593
+ ).resolves.not.toThrow();
594
+
595
+ expect(mockLogger.error).toHaveBeenCalled();
596
+ });
597
+
598
+ it('should log but not throw non-Error from onAfterDismiss', async () => {
599
+ const errorHook: IDismissibleLifecycleHook = {
600
+ onAfterDismiss: jest.fn().mockRejectedValue('dismiss post error'),
601
+ };
602
+
603
+ hookRunner = new HookRunner([errorHook], mockLogger);
604
+
605
+ await expect(
606
+ hookRunner.runPostDismiss('test-id', createTestItem(), testUserId, createTestContext()),
607
+ ).resolves.not.toThrow();
608
+
609
+ expect(mockLogger.error).toHaveBeenCalled();
610
+ });
611
+
612
+ it('should log but not throw Error from onAfterRestore', async () => {
613
+ const errorHook: IDismissibleLifecycleHook = {
614
+ onAfterRestore: jest.fn().mockRejectedValue(new Error('After restore error')),
615
+ };
616
+
617
+ hookRunner = new HookRunner([errorHook], mockLogger);
618
+
619
+ await expect(
620
+ hookRunner.runPostRestore('test-id', createTestItem(), testUserId, createTestContext()),
621
+ ).resolves.not.toThrow();
622
+
623
+ expect(mockLogger.error).toHaveBeenCalled();
624
+ });
625
+
626
+ it('should log but not throw non-Error from onAfterGet', async () => {
627
+ const errorHook: IDismissibleLifecycleHook = {
628
+ onAfterGet: jest.fn().mockRejectedValue('get post error'),
629
+ };
630
+
631
+ hookRunner = new HookRunner([errorHook], mockLogger);
632
+
633
+ await expect(
634
+ hookRunner.runPostGet('test-id', createTestItem(), testUserId, createTestContext()),
635
+ ).resolves.not.toThrow();
636
+
637
+ expect(mockLogger.error).toHaveBeenCalled();
638
+ });
639
+ });
640
+
237
641
  describe('all hook methods', () => {
238
- let allMethodsHook: IDismissibleLifecycleHook<BaseMetadata>;
642
+ let allMethodsHook: IDismissibleLifecycleHook;
239
643
 
240
644
  beforeEach(() => {
241
645
  allMethodsHook = {
242
646
  priority: 0,
243
- onBeforeGetOrCreate: jest.fn().mockResolvedValue({ proceed: true }),
244
- onAfterGetOrCreate: jest.fn(),
647
+ onBeforeRequest: jest.fn().mockResolvedValue({ proceed: true }),
648
+ onAfterRequest: jest.fn(),
649
+ onBeforeGet: jest.fn().mockResolvedValue({ proceed: true }),
650
+ onAfterGet: jest.fn(),
245
651
  onBeforeCreate: jest.fn().mockResolvedValue({ proceed: true }),
246
652
  onAfterCreate: jest.fn(),
247
653
  onBeforeDismiss: jest.fn().mockResolvedValue({ proceed: true }),
@@ -253,6 +659,33 @@ describe('HookRunner', () => {
253
659
  hookRunner = new HookRunner([allMethodsHook], mockLogger);
254
660
  });
255
661
 
662
+ it('should run pre and post request hooks', async () => {
663
+ const context = createTestContext();
664
+ const item = createTestItem();
665
+
666
+ await hookRunner.runPreRequest('test-id', testUserId, context);
667
+ await hookRunner.runPostRequest('test-id', item, testUserId, context);
668
+
669
+ expect(allMethodsHook.onBeforeRequest).toHaveBeenCalledWith('test-id', testUserId, context);
670
+ expect(allMethodsHook.onAfterRequest).toHaveBeenCalledWith(
671
+ 'test-id',
672
+ item,
673
+ testUserId,
674
+ context,
675
+ );
676
+ });
677
+
678
+ it('should run pre and post get hooks', async () => {
679
+ const context = createTestContext();
680
+ const item = createTestItem();
681
+
682
+ await hookRunner.runPreGet('test-id', item, testUserId, context);
683
+ await hookRunner.runPostGet('test-id', item, testUserId, context);
684
+
685
+ expect(allMethodsHook.onBeforeGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
686
+ expect(allMethodsHook.onAfterGet).toHaveBeenCalledWith('test-id', item, testUserId, context);
687
+ });
688
+
256
689
  it('should run pre and post create hooks', async () => {
257
690
  const context = createTestContext();
258
691
  const item = createTestItem();