@dismissible/nestjs-dismissible 0.0.2-canary.6b97aa7.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 (69) hide show
  1. package/README.md +490 -0
  2. package/jest.config.ts +29 -0
  3. package/package.json +66 -0
  4. package/project.json +42 -0
  5. package/src/api/dismissible-item-response.dto.ts +30 -0
  6. package/src/api/dismissible-item.mapper.spec.ts +51 -0
  7. package/src/api/dismissible-item.mapper.ts +27 -0
  8. package/src/api/index.ts +7 -0
  9. package/src/api/use-cases/api-tags.constants.ts +4 -0
  10. package/src/api/use-cases/dismiss/dismiss.controller.spec.ts +41 -0
  11. package/src/api/use-cases/dismiss/dismiss.controller.ts +63 -0
  12. package/src/api/use-cases/dismiss/dismiss.response.dto.ts +7 -0
  13. package/src/api/use-cases/dismiss/index.ts +2 -0
  14. package/src/api/use-cases/get-or-create/get-or-create.controller.spec.ts +36 -0
  15. package/src/api/use-cases/get-or-create/get-or-create.controller.ts +60 -0
  16. package/src/api/use-cases/get-or-create/get-or-create.response.dto.ts +7 -0
  17. package/src/api/use-cases/get-or-create/index.ts +2 -0
  18. package/src/api/use-cases/index.ts +3 -0
  19. package/src/api/use-cases/restore/index.ts +2 -0
  20. package/src/api/use-cases/restore/restore.controller.spec.ts +41 -0
  21. package/src/api/use-cases/restore/restore.controller.ts +63 -0
  22. package/src/api/use-cases/restore/restore.response.dto.ts +7 -0
  23. package/src/api/validation/index.ts +2 -0
  24. package/src/api/validation/param-validation.pipe.spec.ts +313 -0
  25. package/src/api/validation/param-validation.pipe.ts +38 -0
  26. package/src/api/validation/param.decorators.ts +32 -0
  27. package/src/core/dismissible-core.service.spec.ts +403 -0
  28. package/src/core/dismissible-core.service.ts +173 -0
  29. package/src/core/dismissible.service.spec.ts +226 -0
  30. package/src/core/dismissible.service.ts +227 -0
  31. package/src/core/hook-runner.service.spec.ts +736 -0
  32. package/src/core/hook-runner.service.ts +368 -0
  33. package/src/core/index.ts +5 -0
  34. package/src/core/lifecycle-hook.interface.ts +148 -0
  35. package/src/core/service-responses.interface.ts +34 -0
  36. package/src/dismissible.module.integration.spec.ts +687 -0
  37. package/src/dismissible.module.ts +79 -0
  38. package/src/events/dismissible.events.ts +82 -0
  39. package/src/events/events.constants.ts +21 -0
  40. package/src/events/index.ts +2 -0
  41. package/src/exceptions/dismissible.exceptions.spec.ts +50 -0
  42. package/src/exceptions/dismissible.exceptions.ts +69 -0
  43. package/src/exceptions/index.ts +1 -0
  44. package/src/index.ts +9 -0
  45. package/src/request/index.ts +2 -0
  46. package/src/request/request-context.decorator.ts +15 -0
  47. package/src/request/request-context.interface.ts +12 -0
  48. package/src/response/dtos/base-response.dto.ts +11 -0
  49. package/src/response/dtos/error-response.dto.ts +36 -0
  50. package/src/response/dtos/index.ts +3 -0
  51. package/src/response/dtos/success-response.dto.ts +34 -0
  52. package/src/response/http-exception-filter.spec.ts +179 -0
  53. package/src/response/http-exception-filter.ts +21 -0
  54. package/src/response/index.ts +4 -0
  55. package/src/response/response.module.ts +9 -0
  56. package/src/response/response.service.spec.ts +72 -0
  57. package/src/response/response.service.ts +20 -0
  58. package/src/testing/factories.ts +42 -0
  59. package/src/testing/index.ts +1 -0
  60. package/src/utils/date/date.service.spec.ts +104 -0
  61. package/src/utils/date/date.service.ts +19 -0
  62. package/src/utils/date/index.ts +1 -0
  63. package/src/utils/dismissible.helper.ts +9 -0
  64. package/src/utils/index.ts +3 -0
  65. package/src/validation/dismissible-input.dto.ts +47 -0
  66. package/src/validation/index.ts +1 -0
  67. package/tsconfig.json +16 -0
  68. package/tsconfig.lib.json +14 -0
  69. package/tsconfig.spec.json +12 -0
@@ -0,0 +1,687 @@
1
+ import { Test, TestingModule } from '@nestjs/testing';
2
+ import { Injectable, ForbiddenException } from '@nestjs/common';
3
+ import { DismissibleModule, IDismissibleModuleOptions } from './dismissible.module';
4
+ import { DismissibleService } from './core/dismissible.service';
5
+ import { IDismissibleLifecycleHook, IHookResult } from './core/lifecycle-hook.interface';
6
+ import { DismissibleItemDto } from '@dismissible/nestjs-dismissible-item';
7
+ import { IRequestContext } from './request/request-context.interface';
8
+ import {
9
+ MemoryStorageAdapter,
10
+ StorageModule,
11
+ DISMISSIBLE_STORAGE_ADAPTER,
12
+ } from '@dismissible/nestjs-storage';
13
+ import { createTestContext } from './testing/factories';
14
+ import { NullLogger } from '@dismissible/nestjs-logger';
15
+
16
+ /**
17
+ * Test hook that tracks all lifecycle method invocations.
18
+ * Each method pushes its name to the `calls` array when invoked.
19
+ */
20
+ @Injectable()
21
+ class TestLifecycleHook implements IDismissibleLifecycleHook {
22
+ readonly priority = 0;
23
+
24
+ /** Track all hook method calls in order */
25
+ static calls: string[] = [];
26
+
27
+ /** Track the arguments passed to each hook */
28
+ static callArgs: Map<string, unknown[]> = new Map();
29
+
30
+ /** Control whether hooks should block (proceed: false) */
31
+ static shouldBlock: Map<string, boolean> = new Map();
32
+
33
+ /** Custom reason for blocking */
34
+ static blockReason = 'Blocked by test hook';
35
+
36
+ static reset(): void {
37
+ TestLifecycleHook.calls = [];
38
+ TestLifecycleHook.callArgs = new Map();
39
+ TestLifecycleHook.shouldBlock = new Map();
40
+ TestLifecycleHook.blockReason = 'Blocked by test hook';
41
+ }
42
+
43
+ private recordCall(method: string, args: unknown[]): IHookResult {
44
+ TestLifecycleHook.calls.push(method);
45
+ TestLifecycleHook.callArgs.set(method, args);
46
+
47
+ const shouldBlock = TestLifecycleHook.shouldBlock.get(method) ?? false;
48
+ return {
49
+ proceed: !shouldBlock,
50
+ reason: shouldBlock ? TestLifecycleHook.blockReason : undefined,
51
+ };
52
+ }
53
+
54
+ private recordPostCall(method: string, args: unknown[]): void {
55
+ TestLifecycleHook.calls.push(method);
56
+ TestLifecycleHook.callArgs.set(method, args);
57
+ }
58
+
59
+ async onBeforeRequest(
60
+ itemId: string,
61
+ userId: string,
62
+ context?: IRequestContext,
63
+ ): Promise<IHookResult> {
64
+ return this.recordCall('onBeforeRequest', [itemId, userId, context]);
65
+ }
66
+
67
+ async onAfterRequest(
68
+ itemId: string,
69
+ item: DismissibleItemDto,
70
+ userId: string,
71
+ context?: IRequestContext,
72
+ ): Promise<void> {
73
+ this.recordPostCall('onAfterRequest', [itemId, item, userId, context]);
74
+ }
75
+
76
+ async onBeforeGet(
77
+ itemId: string,
78
+ item: DismissibleItemDto,
79
+ userId: string,
80
+ context?: IRequestContext,
81
+ ): Promise<IHookResult> {
82
+ return this.recordCall('onBeforeGet', [itemId, item, userId, context]);
83
+ }
84
+
85
+ async onAfterGet(
86
+ itemId: string,
87
+ item: DismissibleItemDto,
88
+ userId: string,
89
+ context?: IRequestContext,
90
+ ): Promise<void> {
91
+ this.recordPostCall('onAfterGet', [itemId, item, userId, context]);
92
+ }
93
+
94
+ async onBeforeCreate(
95
+ itemId: string,
96
+ userId: string,
97
+ context?: IRequestContext,
98
+ ): Promise<IHookResult> {
99
+ return this.recordCall('onBeforeCreate', [itemId, userId, context]);
100
+ }
101
+
102
+ async onAfterCreate(
103
+ itemId: string,
104
+ item: DismissibleItemDto,
105
+ userId: string,
106
+ context?: IRequestContext,
107
+ ): Promise<void> {
108
+ this.recordPostCall('onAfterCreate', [itemId, item, userId, context]);
109
+ }
110
+
111
+ async onBeforeDismiss(
112
+ itemId: string,
113
+ userId: string,
114
+ context?: IRequestContext,
115
+ ): Promise<IHookResult> {
116
+ return this.recordCall('onBeforeDismiss', [itemId, userId, context]);
117
+ }
118
+
119
+ async onAfterDismiss(
120
+ itemId: string,
121
+ item: DismissibleItemDto,
122
+ userId: string,
123
+ context?: IRequestContext,
124
+ ): Promise<void> {
125
+ this.recordPostCall('onAfterDismiss', [itemId, item, userId, context]);
126
+ }
127
+
128
+ async onBeforeRestore(
129
+ itemId: string,
130
+ userId: string,
131
+ context?: IRequestContext,
132
+ ): Promise<IHookResult> {
133
+ return this.recordCall('onBeforeRestore', [itemId, userId, context]);
134
+ }
135
+
136
+ async onAfterRestore(
137
+ itemId: string,
138
+ item: DismissibleItemDto,
139
+ userId: string,
140
+ context?: IRequestContext,
141
+ ): Promise<void> {
142
+ this.recordPostCall('onAfterRestore', [itemId, item, userId, context]);
143
+ }
144
+ }
145
+
146
+ describe('DismissibleModule Integration - Hook Lifecycle', () => {
147
+ let module: TestingModule;
148
+ let service: DismissibleService;
149
+ let storage: MemoryStorageAdapter;
150
+
151
+ const testUserId = 'test-user-123';
152
+ const testItemId = 'test-item-456';
153
+
154
+ beforeAll(async () => {
155
+ const moduleOptions: IDismissibleModuleOptions = {
156
+ storage: StorageModule,
157
+ logger: NullLogger,
158
+ hooks: [TestLifecycleHook],
159
+ };
160
+
161
+ module = await Test.createTestingModule({
162
+ imports: [DismissibleModule.forRoot(moduleOptions)],
163
+ }).compile();
164
+
165
+ service = module.get<DismissibleService>(DismissibleService);
166
+ storage = module.get<MemoryStorageAdapter>(DISMISSIBLE_STORAGE_ADAPTER);
167
+ });
168
+
169
+ afterAll(async () => {
170
+ await module.close();
171
+ });
172
+
173
+ beforeEach(() => {
174
+ TestLifecycleHook.reset();
175
+ storage.clear();
176
+ });
177
+
178
+ describe('getOrCreate - Create Flow', () => {
179
+ it('should invoke hooks in correct order when creating a new item', async () => {
180
+ const context = createTestContext();
181
+
182
+ await service.getOrCreate(testItemId, testUserId, context);
183
+
184
+ expect(TestLifecycleHook.calls).toEqual([
185
+ 'onBeforeRequest',
186
+ 'onBeforeCreate',
187
+ 'onAfterCreate',
188
+ 'onAfterRequest',
189
+ ]);
190
+ });
191
+
192
+ it('should pass correct arguments to onBeforeRequest', async () => {
193
+ const context = createTestContext();
194
+
195
+ await service.getOrCreate(testItemId, testUserId, context);
196
+
197
+ const args = TestLifecycleHook.callArgs.get('onBeforeRequest');
198
+ expect(args).toBeDefined();
199
+ expect(args![0]).toBe(testItemId);
200
+ expect(args![1]).toBe(testUserId);
201
+ expect(args![2]).toEqual(context);
202
+ });
203
+
204
+ it('should pass correct arguments to onBeforeCreate', async () => {
205
+ const context = createTestContext();
206
+
207
+ await service.getOrCreate(testItemId, testUserId, context);
208
+
209
+ const args = TestLifecycleHook.callArgs.get('onBeforeCreate');
210
+ expect(args).toBeDefined();
211
+ expect(args![0]).toBe(testItemId);
212
+ expect(args![1]).toBe(testUserId);
213
+ expect(args![2]).toEqual(context);
214
+ });
215
+
216
+ it('should pass correct arguments to onAfterCreate', async () => {
217
+ const context = createTestContext();
218
+
219
+ await service.getOrCreate(testItemId, testUserId, context);
220
+
221
+ const args = TestLifecycleHook.callArgs.get('onAfterCreate');
222
+ expect(args).toBeDefined();
223
+ expect(args![0]).toBe(testItemId);
224
+ expect(args![1]).toMatchObject({
225
+ id: testItemId,
226
+ userId: testUserId,
227
+ });
228
+ expect(args![2]).toBe(testUserId);
229
+ expect(args![3]).toEqual(context);
230
+ });
231
+
232
+ it('should pass correct arguments to onAfterRequest on create', async () => {
233
+ const context = createTestContext();
234
+
235
+ await service.getOrCreate(testItemId, testUserId, context);
236
+
237
+ const args = TestLifecycleHook.callArgs.get('onAfterRequest');
238
+ expect(args).toBeDefined();
239
+ expect(args![0]).toBe(testItemId);
240
+ expect(args![1]).toMatchObject({
241
+ id: testItemId,
242
+ userId: testUserId,
243
+ });
244
+ expect(args![2]).toBe(testUserId);
245
+ expect(args![3]).toEqual(context);
246
+ });
247
+
248
+ it('should block operation when onBeforeRequest returns proceed: false', async () => {
249
+ TestLifecycleHook.shouldBlock.set('onBeforeRequest', true);
250
+ TestLifecycleHook.blockReason = 'Authentication failed';
251
+
252
+ await expect(service.getOrCreate(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
253
+
254
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest']);
255
+ });
256
+
257
+ it('should block operation when onBeforeCreate returns proceed: false', async () => {
258
+ TestLifecycleHook.shouldBlock.set('onBeforeCreate', true);
259
+ TestLifecycleHook.blockReason = 'Quota exceeded';
260
+
261
+ await expect(service.getOrCreate(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
262
+
263
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest', 'onBeforeCreate']);
264
+ });
265
+ });
266
+
267
+ describe('getOrCreate - Get Flow', () => {
268
+ beforeEach(async () => {
269
+ await service.getOrCreate(testItemId, testUserId);
270
+ TestLifecycleHook.reset();
271
+ });
272
+
273
+ it('should invoke hooks in correct order when getting an existing item', async () => {
274
+ const context = createTestContext();
275
+
276
+ await service.getOrCreate(testItemId, testUserId, context);
277
+
278
+ expect(TestLifecycleHook.calls).toEqual([
279
+ 'onBeforeRequest',
280
+ 'onBeforeGet',
281
+ 'onAfterGet',
282
+ 'onAfterRequest',
283
+ ]);
284
+ });
285
+
286
+ it('should pass correct arguments to onBeforeGet', async () => {
287
+ const context = createTestContext();
288
+
289
+ await service.getOrCreate(testItemId, testUserId, context);
290
+
291
+ const args = TestLifecycleHook.callArgs.get('onBeforeGet');
292
+ expect(args).toBeDefined();
293
+ expect(args![0]).toBe(testItemId);
294
+ expect(args![1]).toMatchObject({
295
+ id: testItemId,
296
+ userId: testUserId,
297
+ });
298
+ expect(args![2]).toBe(testUserId);
299
+ expect(args![3]).toEqual(context);
300
+ });
301
+
302
+ it('should pass correct arguments to onAfterGet', async () => {
303
+ const context = createTestContext();
304
+
305
+ await service.getOrCreate(testItemId, testUserId, context);
306
+
307
+ const args = TestLifecycleHook.callArgs.get('onAfterGet');
308
+ expect(args).toBeDefined();
309
+ expect(args![0]).toBe(testItemId);
310
+ expect(args![1]).toMatchObject({
311
+ id: testItemId,
312
+ userId: testUserId,
313
+ });
314
+ expect(args![2]).toBe(testUserId);
315
+ expect(args![3]).toEqual(context);
316
+ });
317
+
318
+ it('should block operation when onBeforeGet returns proceed: false', async () => {
319
+ TestLifecycleHook.shouldBlock.set('onBeforeGet', true);
320
+ TestLifecycleHook.blockReason = 'Item access denied';
321
+
322
+ await expect(service.getOrCreate(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
323
+
324
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest', 'onBeforeGet']);
325
+ });
326
+ });
327
+
328
+ describe('dismiss', () => {
329
+ beforeEach(async () => {
330
+ await service.getOrCreate(testItemId, testUserId);
331
+ TestLifecycleHook.reset();
332
+ });
333
+
334
+ it('should invoke hooks in correct order when dismissing an item', async () => {
335
+ const context = createTestContext();
336
+
337
+ await service.dismiss(testItemId, testUserId, context);
338
+
339
+ expect(TestLifecycleHook.calls).toEqual([
340
+ 'onBeforeRequest',
341
+ 'onBeforeDismiss',
342
+ 'onAfterDismiss',
343
+ 'onAfterRequest',
344
+ ]);
345
+ });
346
+
347
+ it('should pass correct arguments to onBeforeDismiss', async () => {
348
+ const context = createTestContext();
349
+
350
+ await service.dismiss(testItemId, testUserId, context);
351
+
352
+ const args = TestLifecycleHook.callArgs.get('onBeforeDismiss');
353
+ expect(args).toBeDefined();
354
+ expect(args![0]).toBe(testItemId);
355
+ expect(args![1]).toBe(testUserId);
356
+ expect(args![2]).toEqual(context);
357
+ });
358
+
359
+ it('should pass correct arguments to onAfterDismiss', async () => {
360
+ const context = createTestContext();
361
+
362
+ await service.dismiss(testItemId, testUserId, context);
363
+
364
+ const args = TestLifecycleHook.callArgs.get('onAfterDismiss');
365
+ expect(args).toBeDefined();
366
+ expect(args![0]).toBe(testItemId);
367
+ expect(args![1]).toMatchObject({
368
+ id: testItemId,
369
+ userId: testUserId,
370
+ dismissedAt: expect.any(Date),
371
+ });
372
+ expect(args![2]).toBe(testUserId);
373
+ expect(args![3]).toEqual(context);
374
+ });
375
+
376
+ it('should block operation when onBeforeDismiss returns proceed: false', async () => {
377
+ TestLifecycleHook.shouldBlock.set('onBeforeDismiss', true);
378
+ TestLifecycleHook.blockReason = 'Cannot dismiss protected item';
379
+
380
+ await expect(service.dismiss(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
381
+
382
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest', 'onBeforeDismiss']);
383
+ });
384
+
385
+ it('should not run dismiss hooks when onBeforeRequest blocks', async () => {
386
+ TestLifecycleHook.shouldBlock.set('onBeforeRequest', true);
387
+
388
+ await expect(service.dismiss(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
389
+
390
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest']);
391
+ expect(TestLifecycleHook.calls).not.toContain('onBeforeDismiss');
392
+ });
393
+ });
394
+
395
+ describe('restore', () => {
396
+ beforeEach(async () => {
397
+ await service.getOrCreate(testItemId, testUserId);
398
+ await service.dismiss(testItemId, testUserId);
399
+ TestLifecycleHook.reset();
400
+ });
401
+
402
+ it('should invoke hooks in correct order when restoring an item', async () => {
403
+ const context = createTestContext();
404
+
405
+ await service.restore(testItemId, testUserId, context);
406
+
407
+ expect(TestLifecycleHook.calls).toEqual([
408
+ 'onBeforeRequest',
409
+ 'onBeforeRestore',
410
+ 'onAfterRestore',
411
+ 'onAfterRequest',
412
+ ]);
413
+ });
414
+
415
+ it('should pass correct arguments to onBeforeRestore', async () => {
416
+ const context = createTestContext();
417
+
418
+ await service.restore(testItemId, testUserId, context);
419
+
420
+ const args = TestLifecycleHook.callArgs.get('onBeforeRestore');
421
+ expect(args).toBeDefined();
422
+ expect(args![0]).toBe(testItemId);
423
+ expect(args![1]).toBe(testUserId);
424
+ expect(args![2]).toEqual(context);
425
+ });
426
+
427
+ it('should pass correct arguments to onAfterRestore', async () => {
428
+ const context = createTestContext();
429
+
430
+ await service.restore(testItemId, testUserId, context);
431
+
432
+ const args = TestLifecycleHook.callArgs.get('onAfterRestore');
433
+ expect(args).toBeDefined();
434
+ expect(args![0]).toBe(testItemId);
435
+ expect(args![1]).toMatchObject({
436
+ id: testItemId,
437
+ userId: testUserId,
438
+ });
439
+ expect((args![1] as DismissibleItemDto).dismissedAt).toBeUndefined();
440
+ expect(args![2]).toBe(testUserId);
441
+ expect(args![3]).toEqual(context);
442
+ });
443
+
444
+ it('should block operation when onBeforeRestore returns proceed: false', async () => {
445
+ TestLifecycleHook.shouldBlock.set('onBeforeRestore', true);
446
+ TestLifecycleHook.blockReason = 'Cannot restore archived item';
447
+
448
+ await expect(service.restore(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
449
+
450
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest', 'onBeforeRestore']);
451
+ });
452
+
453
+ it('should not run restore hooks when onBeforeRequest blocks', async () => {
454
+ TestLifecycleHook.shouldBlock.set('onBeforeRequest', true);
455
+
456
+ await expect(service.restore(testItemId, testUserId)).rejects.toThrow(ForbiddenException);
457
+
458
+ expect(TestLifecycleHook.calls).toEqual(['onBeforeRequest']);
459
+ expect(TestLifecycleHook.calls).not.toContain('onBeforeRestore');
460
+ });
461
+ });
462
+
463
+ describe('full lifecycle flow', () => {
464
+ it('should invoke all hook types through create -> dismiss -> restore cycle', async () => {
465
+ const context = createTestContext();
466
+ const allCalls: string[] = [];
467
+
468
+ await service.getOrCreate(testItemId, testUserId, context);
469
+ allCalls.push(...TestLifecycleHook.calls);
470
+ TestLifecycleHook.reset();
471
+
472
+ await service.getOrCreate(testItemId, testUserId, context);
473
+ allCalls.push(...TestLifecycleHook.calls);
474
+ TestLifecycleHook.reset();
475
+
476
+ await service.dismiss(testItemId, testUserId, context);
477
+ allCalls.push(...TestLifecycleHook.calls);
478
+ TestLifecycleHook.reset();
479
+
480
+ await service.restore(testItemId, testUserId, context);
481
+ allCalls.push(...TestLifecycleHook.calls);
482
+
483
+ expect(allCalls).toContain('onBeforeRequest');
484
+ expect(allCalls).toContain('onAfterRequest');
485
+ expect(allCalls).toContain('onBeforeCreate');
486
+ expect(allCalls).toContain('onAfterCreate');
487
+ expect(allCalls).toContain('onBeforeGet');
488
+ expect(allCalls).toContain('onAfterGet');
489
+ expect(allCalls).toContain('onBeforeDismiss');
490
+ expect(allCalls).toContain('onAfterDismiss');
491
+ expect(allCalls).toContain('onBeforeRestore');
492
+ expect(allCalls).toContain('onAfterRestore');
493
+ });
494
+ });
495
+
496
+ describe('context handling', () => {
497
+ it('should pass undefined context through hooks when not provided', async () => {
498
+ await service.getOrCreate(testItemId, testUserId);
499
+
500
+ const args = TestLifecycleHook.callArgs.get('onBeforeRequest');
501
+ expect(args![2]).toBeUndefined();
502
+ });
503
+
504
+ it('should preserve context through the entire hook chain', async () => {
505
+ const context: IRequestContext = {
506
+ requestId: 'unique-request-id',
507
+ authorizationHeader: 'Bearer test-token',
508
+ };
509
+
510
+ await service.getOrCreate(testItemId, testUserId, context);
511
+
512
+ const beforeRequestArgs = TestLifecycleHook.callArgs.get('onBeforeRequest');
513
+ const beforeCreateArgs = TestLifecycleHook.callArgs.get('onBeforeCreate');
514
+ const afterCreateArgs = TestLifecycleHook.callArgs.get('onAfterCreate');
515
+ const afterRequestArgs = TestLifecycleHook.callArgs.get('onAfterRequest');
516
+
517
+ expect(beforeRequestArgs![2]).toEqual(context);
518
+ expect(beforeCreateArgs![2]).toEqual(context);
519
+ expect(afterCreateArgs![3]).toEqual(context);
520
+ expect(afterRequestArgs![3]).toEqual(context);
521
+ });
522
+
523
+ it('should pass context to all hooks', async () => {
524
+ const context = createTestContext();
525
+
526
+ await service.getOrCreate(testItemId, testUserId);
527
+ TestLifecycleHook.reset();
528
+
529
+ await service.getOrCreate(testItemId, testUserId, context);
530
+
531
+ const beforeRequestArgs = TestLifecycleHook.callArgs.get('onBeforeRequest');
532
+ expect(beforeRequestArgs![2]).toEqual(context);
533
+
534
+ const beforeGetArgs = TestLifecycleHook.callArgs.get('onBeforeGet');
535
+ expect(beforeGetArgs![3]).toEqual(context);
536
+
537
+ const afterGetArgs = TestLifecycleHook.callArgs.get('onAfterGet');
538
+ expect(afterGetArgs![3]).toEqual(context);
539
+
540
+ const afterRequestArgs = TestLifecycleHook.callArgs.get('onAfterRequest');
541
+ expect(afterRequestArgs![3]).toEqual(context);
542
+ });
543
+ });
544
+ });
545
+
546
+ describe('DismissibleModule Integration - Multiple Hooks', () => {
547
+ let module: TestingModule;
548
+ let service: DismissibleService;
549
+ let storage: MemoryStorageAdapter;
550
+
551
+ const testUserId = 'test-user-123';
552
+ const testItemId = 'test-item-456';
553
+
554
+ const executionOrder: string[] = [];
555
+
556
+ @Injectable()
557
+ class FirstHook implements IDismissibleLifecycleHook {
558
+ readonly priority = 1;
559
+
560
+ async onBeforeRequest(): Promise<IHookResult> {
561
+ executionOrder.push('FirstHook:onBeforeRequest');
562
+ return { proceed: true };
563
+ }
564
+
565
+ async onAfterRequest(): Promise<void> {
566
+ executionOrder.push('FirstHook:onAfterRequest');
567
+ }
568
+ }
569
+
570
+ @Injectable()
571
+ class SecondHook implements IDismissibleLifecycleHook {
572
+ readonly priority = 2;
573
+
574
+ async onBeforeRequest(): Promise<IHookResult> {
575
+ executionOrder.push('SecondHook:onBeforeRequest');
576
+ return { proceed: true };
577
+ }
578
+
579
+ async onAfterRequest(): Promise<void> {
580
+ executionOrder.push('SecondHook:onAfterRequest');
581
+ }
582
+ }
583
+
584
+ @Injectable()
585
+ class ThirdHook implements IDismissibleLifecycleHook {
586
+ readonly priority = 3;
587
+
588
+ async onBeforeRequest(): Promise<IHookResult> {
589
+ executionOrder.push('ThirdHook:onBeforeRequest');
590
+ return { proceed: true };
591
+ }
592
+
593
+ async onAfterRequest(): Promise<void> {
594
+ executionOrder.push('ThirdHook:onAfterRequest');
595
+ }
596
+ }
597
+
598
+ beforeAll(async () => {
599
+ const moduleOptions: IDismissibleModuleOptions = {
600
+ storage: StorageModule,
601
+ hooks: [ThirdHook, FirstHook, SecondHook], // Intentionally out of order
602
+ };
603
+
604
+ module = await Test.createTestingModule({
605
+ imports: [DismissibleModule.forRoot(moduleOptions)],
606
+ }).compile();
607
+
608
+ service = module.get<DismissibleService>(DismissibleService);
609
+ storage = module.get<MemoryStorageAdapter>(DISMISSIBLE_STORAGE_ADAPTER);
610
+ });
611
+
612
+ afterAll(async () => {
613
+ await module.close();
614
+ });
615
+
616
+ beforeEach(() => {
617
+ executionOrder.length = 0;
618
+ storage.clear();
619
+ });
620
+
621
+ it('should execute pre-hooks in priority order (low to high)', async () => {
622
+ await service.getOrCreate(testItemId, testUserId);
623
+
624
+ const preHooks = executionOrder.filter((call) => call.includes('onBeforeRequest'));
625
+ expect(preHooks).toEqual([
626
+ 'FirstHook:onBeforeRequest',
627
+ 'SecondHook:onBeforeRequest',
628
+ 'ThirdHook:onBeforeRequest',
629
+ ]);
630
+ });
631
+
632
+ it('should execute post-hooks in reverse priority order (high to low)', async () => {
633
+ await service.getOrCreate(testItemId, testUserId);
634
+
635
+ const postHooks = executionOrder.filter((call) => call.includes('onAfterRequest'));
636
+ expect(postHooks).toEqual([
637
+ 'ThirdHook:onAfterRequest',
638
+ 'SecondHook:onAfterRequest',
639
+ 'FirstHook:onAfterRequest',
640
+ ]);
641
+ });
642
+ });
643
+
644
+ describe('DismissibleModule Integration - No Hooks', () => {
645
+ let module: TestingModule;
646
+ let service: DismissibleService;
647
+ let storage: MemoryStorageAdapter;
648
+
649
+ const testUserId = 'test-user-123';
650
+ const testItemId = 'test-item-456';
651
+
652
+ beforeAll(async () => {
653
+ const moduleOptions: IDismissibleModuleOptions = {
654
+ storage: StorageModule,
655
+ };
656
+
657
+ module = await Test.createTestingModule({
658
+ imports: [DismissibleModule.forRoot(moduleOptions)],
659
+ }).compile();
660
+
661
+ service = module.get<DismissibleService>(DismissibleService);
662
+ storage = module.get<MemoryStorageAdapter>(DISMISSIBLE_STORAGE_ADAPTER);
663
+ });
664
+
665
+ afterAll(async () => {
666
+ await module.close();
667
+ });
668
+
669
+ beforeEach(() => {
670
+ storage.clear();
671
+ });
672
+
673
+ it('should complete operations without hooks', async () => {
674
+ const createResult = await service.getOrCreate(testItemId, testUserId);
675
+ expect(createResult.created).toBe(true);
676
+ expect(createResult.item.id).toBe(testItemId);
677
+
678
+ const getResult = await service.getOrCreate(testItemId, testUserId);
679
+ expect(getResult.created).toBe(false);
680
+
681
+ const dismissResult = await service.dismiss(testItemId, testUserId);
682
+ expect(dismissResult.item.dismissedAt).toBeDefined();
683
+
684
+ const restoreResult = await service.restore(testItemId, testUserId);
685
+ expect(restoreResult.item.dismissedAt).toBeUndefined();
686
+ });
687
+ });