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