@luxexchange/notifications 1.0.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 (45) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/README.md +548 -0
  4. package/package.json +42 -0
  5. package/project.json +30 -0
  6. package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
  7. package/src/global.d.ts +2 -0
  8. package/src/index.ts +41 -0
  9. package/src/notification-data-source/NotificationDataSource.ts +8 -0
  10. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  11. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +73 -0
  12. package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
  13. package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
  14. package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
  15. package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
  16. package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
  17. package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
  18. package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
  19. package/src/notification-processor/NotificationProcessor.ts +26 -0
  20. package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
  21. package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +239 -0
  22. package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
  23. package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
  24. package/src/notification-renderer/NotificationRenderer.ts +8 -0
  25. package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
  26. package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
  27. package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
  28. package/src/notification-renderer/utils/iconUtils.ts +103 -0
  29. package/src/notification-service/NotificationService.ts +47 -0
  30. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  31. package/src/notification-service/implementations/createNotificationService.ts +364 -0
  32. package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
  33. package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
  34. package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
  35. package/src/notification-tracker/NotificationTracker.ts +14 -0
  36. package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
  37. package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
  38. package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
  39. package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
  40. package/src/utils/formatNotificationType.test.ts +25 -0
  41. package/src/utils/formatNotificationType.ts +25 -0
  42. package/tsconfig.json +24 -0
  43. package/tsconfig.lint.json +8 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,1092 @@
1
+ import { type InAppNotification, OnClickAction } from '@luxfi/api'
2
+ import type { NotificationDataSource } from '@luxfi/notifications/src/notification-data-source/NotificationDataSource'
3
+ import type { NotificationProcessor } from '@luxfi/notifications/src/notification-processor/NotificationProcessor'
4
+ import type { NotificationRenderer } from '@luxfi/notifications/src/notification-renderer/NotificationRenderer'
5
+ import { createNotificationService } from '@luxfi/notifications/src/notification-service/implementations/createNotificationService'
6
+ import type {
7
+ NotificationTracker,
8
+ TrackingMetadata,
9
+ } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
10
+ import { sleep } from '@luxfi/utilities/src/time/timing'
11
+ import { describe, expect, it, vi } from 'vitest'
12
+
13
+ describe('createNotificationService', () => {
14
+ const createMockNotification = (params: { name: string; timestamp: number; id?: string }): InAppNotification =>
15
+ ({
16
+ id: params.id ?? `${params.name}-id`,
17
+ notificationName: params.name,
18
+ timestamp: params.timestamp,
19
+ content: { style: 'CONTENT_STYLE_MODAL', title: `${params.name}-title` },
20
+ metaData: {},
21
+ userId: 'user-1',
22
+ }) as InAppNotification
23
+
24
+ function createMockDataSource(): {
25
+ dataSource: NotificationDataSource
26
+ triggerNotifications: (notifications: InAppNotification[], source?: string) => void
27
+ } {
28
+ let callback: ((notifications: InAppNotification[], source: string) => void) | undefined
29
+
30
+ return {
31
+ dataSource: {
32
+ start: (onNotifications): void => {
33
+ callback = onNotifications
34
+ },
35
+ stop: vi.fn().mockResolvedValue(undefined),
36
+ },
37
+ triggerNotifications: (notifications, source = 'test_source'): void => {
38
+ if (callback) {
39
+ callback(notifications, source)
40
+ }
41
+ },
42
+ }
43
+ }
44
+
45
+ function createMockTracker(initialProcessedIds: Set<string> = new Set()): {
46
+ tracker: NotificationTracker
47
+ getTrackedCalls: () => Array<{ id: string; metadata: TrackingMetadata }>
48
+ } {
49
+ const trackedCalls: Array<{ id: string; metadata: TrackingMetadata }> = []
50
+
51
+ return {
52
+ tracker: {
53
+ isProcessed: vi.fn((id: string) => Promise.resolve(initialProcessedIds.has(id))),
54
+ getProcessedIds: vi.fn(() => Promise.resolve(new Set(initialProcessedIds))),
55
+ track: vi.fn((id: string, metadata: TrackingMetadata) => {
56
+ trackedCalls.push({ id, metadata })
57
+ initialProcessedIds.add(id)
58
+ return Promise.resolve()
59
+ }),
60
+ },
61
+ getTrackedCalls: () => trackedCalls,
62
+ }
63
+ }
64
+
65
+ function createMockProcessor(
66
+ filterFn?: (notifications: InAppNotification[]) => InAppNotification[],
67
+ ): NotificationProcessor {
68
+ return {
69
+ process: vi.fn((notifications: InAppNotification[]) => {
70
+ const filteredNotifications = filterFn ? filterFn(notifications) : notifications
71
+ // Return NotificationProcessorResult with primary and chained
72
+ return Promise.resolve({
73
+ primary: filteredNotifications,
74
+ chained: new Map(),
75
+ })
76
+ }),
77
+ }
78
+ }
79
+
80
+ function createMockRenderer(canRenderAll = true): {
81
+ renderer: NotificationRenderer
82
+ getRenderedNotifications: () => InAppNotification[]
83
+ getCleanupCallCount: () => number
84
+ } {
85
+ const rendered: InAppNotification[] = []
86
+ let cleanupCallCount = 0
87
+
88
+ return {
89
+ renderer: {
90
+ render: vi.fn((notification: InAppNotification) => {
91
+ rendered.push(notification)
92
+ return () => {
93
+ cleanupCallCount++
94
+ }
95
+ }),
96
+ canRender: vi.fn(() => canRenderAll),
97
+ },
98
+ getRenderedNotifications: () => rendered,
99
+ getCleanupCallCount: () => cleanupCallCount,
100
+ }
101
+ }
102
+
103
+ // Helper function to create a notification with specific button configuration
104
+ function createNotificationWithButton(params: {
105
+ id: string
106
+ timestamp: number
107
+ buttonLabel: string
108
+ buttonActions: OnClickAction[]
109
+ buttonLink?: string
110
+ }): InAppNotification {
111
+ return {
112
+ id: params.id,
113
+ notificationName: params.id,
114
+ timestamp: params.timestamp,
115
+ content: {
116
+ style: 'CONTENT_STYLE_MODAL',
117
+ title: `${params.id}-title`,
118
+ subtitle: '',
119
+ version: 0,
120
+ buttons: [
121
+ {
122
+ label: params.buttonLabel,
123
+ onClick: {
124
+ onClick: params.buttonActions,
125
+ onClickLink: params.buttonLink,
126
+ },
127
+ },
128
+ ],
129
+ },
130
+ metaData: {},
131
+ userId: 'user-1',
132
+ } as unknown as InAppNotification
133
+ }
134
+
135
+ // Helper function to create a notification with background onClick
136
+ function createNotificationWithBackground(params: {
137
+ id: string
138
+ timestamp: number
139
+ buttonLabel: string
140
+ buttonActions: OnClickAction[]
141
+ backgroundActions: OnClickAction[]
142
+ backgroundLink?: string
143
+ }): InAppNotification {
144
+ return {
145
+ id: params.id,
146
+ notificationName: params.id,
147
+ timestamp: params.timestamp,
148
+ content: {
149
+ style: 'CONTENT_STYLE_MODAL',
150
+ title: `${params.id}-title`,
151
+ buttons: [
152
+ {
153
+ label: params.buttonLabel,
154
+ onClick: { onClick: params.buttonActions },
155
+ },
156
+ ],
157
+ background: {
158
+ backgroundOnClick: {
159
+ onClick: params.backgroundActions,
160
+ onClickLink: params.backgroundLink,
161
+ },
162
+ },
163
+ },
164
+ metaData: {},
165
+ userId: 'user-1',
166
+ } as unknown as InAppNotification
167
+ }
168
+
169
+ describe('initialization', () => {
170
+ it('creates a notification system with required methods', () => {
171
+ const { dataSource } = createMockDataSource()
172
+ const { tracker } = createMockTracker()
173
+ const processor = createMockProcessor()
174
+ const { renderer } = createMockRenderer()
175
+
176
+ const system = createNotificationService({
177
+ dataSources: [dataSource],
178
+ tracker,
179
+ processor,
180
+ renderer,
181
+ })
182
+
183
+ expect(system).toBeDefined()
184
+ expect(typeof system.initialize).toBe('function')
185
+ expect(typeof system.onNotificationClick).toBe('function')
186
+ expect(typeof system.onNotificationShown).toBe('function')
187
+ expect(typeof system.onRenderFailed).toBe('function')
188
+ expect(typeof system.destroy).toBe('function')
189
+ })
190
+
191
+ it('starts all data sources during initialization', async () => {
192
+ const { dataSource: dataSource1 } = createMockDataSource()
193
+ const { dataSource: dataSource2 } = createMockDataSource()
194
+ const { tracker } = createMockTracker()
195
+ const processor = createMockProcessor()
196
+ const { renderer } = createMockRenderer()
197
+
198
+ const startSpy1 = vi.spyOn(dataSource1, 'start')
199
+ const startSpy2 = vi.spyOn(dataSource2, 'start')
200
+
201
+ const system = createNotificationService({
202
+ dataSources: [dataSource1, dataSource2],
203
+ tracker,
204
+ processor,
205
+ renderer,
206
+ })
207
+
208
+ await system.initialize()
209
+
210
+ expect(startSpy1).toHaveBeenCalledOnce()
211
+ expect(startSpy2).toHaveBeenCalledOnce()
212
+ })
213
+ })
214
+
215
+ describe('notification handling', () => {
216
+ it('processes and renders new notifications', async () => {
217
+ const { dataSource, triggerNotifications } = createMockDataSource()
218
+ const { tracker } = createMockTracker()
219
+ const processor = createMockProcessor()
220
+ const { renderer, getRenderedNotifications } = createMockRenderer()
221
+
222
+ const system = createNotificationService({
223
+ dataSources: [dataSource],
224
+ tracker,
225
+ processor,
226
+ renderer,
227
+ })
228
+
229
+ await system.initialize()
230
+
231
+ const notifications = [
232
+ createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' }),
233
+ createMockNotification({ name: 'notif-2', timestamp: 2000, id: 'id-2' }),
234
+ ]
235
+
236
+ triggerNotifications(notifications)
237
+
238
+ // Wait for async handling
239
+ await sleep(10)
240
+
241
+ expect(processor.process).toHaveBeenCalledWith(notifications)
242
+ expect(getRenderedNotifications()).toHaveLength(2)
243
+ })
244
+
245
+ it('filters out already-processed notifications', async () => {
246
+ const { dataSource, triggerNotifications } = createMockDataSource()
247
+ const { tracker } = createMockTracker(new Set(['id-1']))
248
+ // Create processor that filters out id-1
249
+ const processor = createMockProcessor((notifications) => notifications.filter((n) => n.id !== 'id-1'))
250
+ const { renderer, getRenderedNotifications } = createMockRenderer()
251
+
252
+ const system = createNotificationService({
253
+ dataSources: [dataSource],
254
+ tracker,
255
+ processor,
256
+ renderer,
257
+ })
258
+
259
+ await system.initialize()
260
+
261
+ const notifications = [
262
+ createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' }),
263
+ createMockNotification({ name: 'notif-2', timestamp: 2000, id: 'id-2' }),
264
+ ]
265
+
266
+ triggerNotifications(notifications)
267
+
268
+ // Wait for async handling
269
+ await sleep(10)
270
+
271
+ // Only notif-2 should be rendered (notif-1 was already processed)
272
+ expect(getRenderedNotifications()).toHaveLength(1)
273
+ expect(getRenderedNotifications()[0].id).toBe('id-2')
274
+ })
275
+
276
+ it('does not render notifications that cannot be rendered', async () => {
277
+ const { dataSource, triggerNotifications } = createMockDataSource()
278
+ const { tracker } = createMockTracker()
279
+ const processor = createMockProcessor()
280
+ const { renderer, getRenderedNotifications } = createMockRenderer(false)
281
+
282
+ const system = createNotificationService({
283
+ dataSources: [dataSource],
284
+ tracker,
285
+ processor,
286
+ renderer,
287
+ })
288
+
289
+ await system.initialize()
290
+
291
+ const notifications = [createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })]
292
+
293
+ triggerNotifications(notifications)
294
+
295
+ // Wait for async handling
296
+ await sleep(10)
297
+
298
+ expect(renderer.canRender).toHaveBeenCalled()
299
+ expect(getRenderedNotifications()).toHaveLength(0)
300
+ })
301
+
302
+ it('does not render the same notification twice', async () => {
303
+ const { dataSource, triggerNotifications } = createMockDataSource()
304
+ const { tracker } = createMockTracker()
305
+ const processor = createMockProcessor()
306
+ const { renderer, getRenderedNotifications } = createMockRenderer()
307
+
308
+ const system = createNotificationService({
309
+ dataSources: [dataSource],
310
+ tracker,
311
+ processor,
312
+ renderer,
313
+ })
314
+
315
+ await system.initialize()
316
+
317
+ const notifications = [createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })]
318
+
319
+ // Trigger same notification twice
320
+ triggerNotifications(notifications)
321
+ await sleep(10)
322
+
323
+ triggerNotifications(notifications)
324
+ await sleep(10)
325
+
326
+ // Should only be rendered once
327
+ expect(getRenderedNotifications()).toHaveLength(1)
328
+ })
329
+
330
+ it('handles notifications from multiple data sources', async () => {
331
+ const { dataSource: dataSource1, triggerNotifications: trigger1 } = createMockDataSource()
332
+ const { dataSource: dataSource2, triggerNotifications: trigger2 } = createMockDataSource()
333
+ const { tracker } = createMockTracker()
334
+ const processor = createMockProcessor()
335
+ const { renderer, getRenderedNotifications } = createMockRenderer()
336
+
337
+ const system = createNotificationService({
338
+ dataSources: [dataSource1, dataSource2],
339
+ tracker,
340
+ processor,
341
+ renderer,
342
+ })
343
+
344
+ await system.initialize()
345
+
346
+ trigger1([createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })])
347
+ await sleep(10)
348
+
349
+ trigger2([createMockNotification({ name: 'notif-2', timestamp: 2000, id: 'id-2' })])
350
+ await sleep(10)
351
+
352
+ expect(getRenderedNotifications()).toHaveLength(2)
353
+ })
354
+ })
355
+
356
+ describe('onDismiss', () => {
357
+ it('does not track when notification is dismissed (only ACK tracks)', async () => {
358
+ const { dataSource } = createMockDataSource()
359
+ const { tracker, getTrackedCalls } = createMockTracker()
360
+ const processor = createMockProcessor()
361
+ const { renderer } = createMockRenderer()
362
+
363
+ const system = createNotificationService({
364
+ dataSources: [dataSource],
365
+ tracker,
366
+ processor,
367
+ renderer,
368
+ })
369
+
370
+ await system.initialize()
371
+ system.onNotificationClick('id-1', { type: 'dismiss' })
372
+ // Wait for async operations to complete
373
+ await sleep(0)
374
+
375
+ const trackedCalls = getTrackedCalls()
376
+ expect(trackedCalls).toHaveLength(0) // Dismiss should NOT track
377
+ })
378
+
379
+ it('calls cleanup function for rendered notification', async () => {
380
+ const { dataSource, triggerNotifications } = createMockDataSource()
381
+ const { tracker } = createMockTracker()
382
+ const processor = createMockProcessor()
383
+ const { renderer, getCleanupCallCount } = createMockRenderer()
384
+
385
+ const system = createNotificationService({
386
+ dataSources: [dataSource],
387
+ tracker,
388
+ processor,
389
+ renderer,
390
+ })
391
+
392
+ await system.initialize()
393
+
394
+ const notifications = [createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })]
395
+
396
+ triggerNotifications(notifications)
397
+ await sleep(10)
398
+
399
+ expect(getCleanupCallCount()).toBe(0)
400
+
401
+ system.onNotificationClick('id-1', { type: 'dismiss' })
402
+ // Wait for async cleanup to complete
403
+ await sleep(0)
404
+
405
+ expect(getCleanupCallCount()).toBe(1)
406
+ })
407
+
408
+ it('handles dismiss for non-rendered notification gracefully', async () => {
409
+ const { dataSource } = createMockDataSource()
410
+ const { tracker } = createMockTracker()
411
+ const processor = createMockProcessor()
412
+ const { renderer, getCleanupCallCount } = createMockRenderer()
413
+
414
+ const system = createNotificationService({
415
+ dataSources: [dataSource],
416
+ tracker,
417
+ processor,
418
+ renderer,
419
+ })
420
+
421
+ await system.initialize()
422
+
423
+ // Dismiss notification that was never rendered
424
+ system.onNotificationClick('non-existent-id', { type: 'dismiss' })
425
+ // Wait for async operations to complete
426
+ await sleep(0)
427
+
428
+ // Should not throw and should NOT track (dismiss doesn't track)
429
+ expect(tracker.track).not.toHaveBeenCalled()
430
+ expect(getCleanupCallCount()).toBe(0)
431
+ })
432
+
433
+ it('dismissed notifications can be re-rendered (only ACK prevents re-render)', async () => {
434
+ const { dataSource, triggerNotifications } = createMockDataSource()
435
+ const { tracker } = createMockTracker()
436
+ // Create processor that filters based on tracker's processed IDs
437
+ const processor: NotificationProcessor = {
438
+ process: vi.fn(async (notifications: InAppNotification[]) => {
439
+ const processedIds = await tracker.getProcessedIds()
440
+ const filtered = notifications.filter((n) => !processedIds.has(n.id))
441
+ return {
442
+ primary: filtered,
443
+ chained: new Map(),
444
+ }
445
+ }),
446
+ }
447
+ const { renderer, getRenderedNotifications } = createMockRenderer()
448
+
449
+ const system = createNotificationService({
450
+ dataSources: [dataSource],
451
+ tracker,
452
+ processor,
453
+ renderer,
454
+ })
455
+
456
+ await system.initialize()
457
+
458
+ const notification = createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })
459
+
460
+ // Render notification
461
+ triggerNotifications([notification])
462
+ await sleep(10)
463
+ expect(getRenderedNotifications().length).toBe(1)
464
+
465
+ // Dismiss it (dismiss doesn't track)
466
+ system.onNotificationClick('id-1', { type: 'dismiss' })
467
+ await sleep(0)
468
+
469
+ // Try to render it again - SHOULD render because dismiss doesn't track
470
+ triggerNotifications([notification])
471
+ await sleep(10)
472
+
473
+ expect(getRenderedNotifications().length).toBe(2) // Rendered twice!
474
+ })
475
+ })
476
+
477
+ describe('onRenderFailed', () => {
478
+ it('cleans up the failed render without tracking', async () => {
479
+ const { dataSource, triggerNotifications } = createMockDataSource()
480
+ const { tracker, getTrackedCalls } = createMockTracker()
481
+ const processor = createMockProcessor()
482
+ const { renderer, getCleanupCallCount } = createMockRenderer()
483
+
484
+ const system = createNotificationService({
485
+ dataSources: [dataSource],
486
+ tracker,
487
+ processor,
488
+ renderer,
489
+ })
490
+
491
+ await system.initialize()
492
+
493
+ const notification = createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })
494
+ triggerNotifications([notification])
495
+ await sleep(10)
496
+
497
+ // Call onRenderFailed instead of onDismiss
498
+ system.onRenderFailed('id-1')
499
+
500
+ // Should call cleanup but NOT track
501
+ expect(getCleanupCallCount()).toBe(1)
502
+ expect(getTrackedCalls()).toHaveLength(0)
503
+ })
504
+
505
+ it('allows notification to be re-rendered after failed render', async () => {
506
+ const { dataSource, triggerNotifications } = createMockDataSource()
507
+ const { tracker } = createMockTracker()
508
+ // Processor that filters based on tracker's processed IDs
509
+ const processor: NotificationProcessor = {
510
+ process: vi.fn(async (notifications: InAppNotification[]) => {
511
+ const processedIds = await tracker.getProcessedIds()
512
+ const filtered = notifications.filter((n) => !processedIds.has(n.id))
513
+ return {
514
+ primary: filtered,
515
+ chained: new Map(),
516
+ }
517
+ }),
518
+ }
519
+ const { renderer, getRenderedNotifications } = createMockRenderer()
520
+
521
+ const system = createNotificationService({
522
+ dataSources: [dataSource],
523
+ tracker,
524
+ processor,
525
+ renderer,
526
+ })
527
+
528
+ await system.initialize()
529
+
530
+ const notification = createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })
531
+
532
+ // First render
533
+ triggerNotifications([notification])
534
+ await sleep(10)
535
+ expect(getRenderedNotifications()).toHaveLength(1)
536
+
537
+ // Mark as failed render
538
+ system.onRenderFailed('id-1')
539
+
540
+ // Should be able to render again (not in processedIds)
541
+ triggerNotifications([notification])
542
+ await sleep(10)
543
+
544
+ // Should be rendered twice total (once initially, once after failed render cleanup)
545
+ expect(getRenderedNotifications()).toHaveLength(2)
546
+ })
547
+
548
+ it('handles onRenderFailed for non-rendered notification gracefully', () => {
549
+ const { dataSource } = createMockDataSource()
550
+ const { tracker, getTrackedCalls } = createMockTracker()
551
+ const processor = createMockProcessor()
552
+ const { renderer, getCleanupCallCount } = createMockRenderer()
553
+
554
+ const system = createNotificationService({
555
+ dataSources: [dataSource],
556
+ tracker,
557
+ processor,
558
+ renderer,
559
+ })
560
+
561
+ // Call onRenderFailed for notification that was never rendered
562
+ expect(() => system.onRenderFailed('non-existent-id')).not.toThrow()
563
+
564
+ // Should not track or call cleanup
565
+ expect(getTrackedCalls()).toHaveLength(0)
566
+ expect(getCleanupCallCount()).toBe(0)
567
+ })
568
+ })
569
+
570
+ describe('onNotificationClick', () => {
571
+ it('handles notification click without throwing', () => {
572
+ const { dataSource } = createMockDataSource()
573
+ const { tracker } = createMockTracker()
574
+ const processor = createMockProcessor()
575
+ const { renderer } = createMockRenderer()
576
+
577
+ const system = createNotificationService({
578
+ dataSources: [dataSource],
579
+ tracker,
580
+ processor,
581
+ renderer,
582
+ })
583
+
584
+ expect(() => system.onNotificationClick('id-1', { type: 'button', index: 0 })).not.toThrow()
585
+ })
586
+ })
587
+
588
+ describe('destroy', () => {
589
+ it('stops all data sources', async () => {
590
+ const { dataSource: dataSource1 } = createMockDataSource()
591
+ const { dataSource: dataSource2 } = createMockDataSource()
592
+ const { tracker } = createMockTracker()
593
+ const processor = createMockProcessor()
594
+ const { renderer } = createMockRenderer()
595
+
596
+ const system = createNotificationService({
597
+ dataSources: [dataSource1, dataSource2],
598
+ tracker,
599
+ processor,
600
+ renderer,
601
+ })
602
+
603
+ await system.initialize()
604
+ system.destroy()
605
+
606
+ expect(dataSource1.stop).toHaveBeenCalledOnce()
607
+ expect(dataSource2.stop).toHaveBeenCalledOnce()
608
+ })
609
+
610
+ it('calls cleanup for all active renders', async () => {
611
+ const { dataSource, triggerNotifications } = createMockDataSource()
612
+ const { tracker } = createMockTracker()
613
+ const processor = createMockProcessor()
614
+ const { renderer, getCleanupCallCount } = createMockRenderer()
615
+
616
+ const system = createNotificationService({
617
+ dataSources: [dataSource],
618
+ tracker,
619
+ processor,
620
+ renderer,
621
+ })
622
+
623
+ await system.initialize()
624
+
625
+ const notifications = [
626
+ createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' }),
627
+ createMockNotification({ name: 'notif-2', timestamp: 2000, id: 'id-2' }),
628
+ createMockNotification({ name: 'notif-3', timestamp: 3000, id: 'id-3' }),
629
+ ]
630
+
631
+ triggerNotifications(notifications)
632
+ await sleep(10)
633
+
634
+ expect(getCleanupCallCount()).toBe(0)
635
+
636
+ system.destroy()
637
+
638
+ expect(getCleanupCallCount()).toBe(3)
639
+ })
640
+
641
+ it('can be called before initialization without error', () => {
642
+ const { dataSource } = createMockDataSource()
643
+ const { tracker } = createMockTracker()
644
+ const processor = createMockProcessor()
645
+ const { renderer } = createMockRenderer()
646
+
647
+ const system = createNotificationService({
648
+ dataSources: [dataSource],
649
+ tracker,
650
+ processor,
651
+ renderer,
652
+ })
653
+
654
+ expect(() => system.destroy()).not.toThrow()
655
+ })
656
+ })
657
+
658
+ describe('edge cases', () => {
659
+ it('handles empty notification arrays', async () => {
660
+ const { dataSource, triggerNotifications } = createMockDataSource()
661
+ const { tracker } = createMockTracker()
662
+ const processor = createMockProcessor()
663
+ const { renderer, getRenderedNotifications } = createMockRenderer()
664
+
665
+ const system = createNotificationService({
666
+ dataSources: [dataSource],
667
+ tracker,
668
+ processor,
669
+ renderer,
670
+ })
671
+
672
+ await system.initialize()
673
+
674
+ triggerNotifications([])
675
+ await sleep(10)
676
+
677
+ expect(getRenderedNotifications()).toHaveLength(0)
678
+ })
679
+
680
+ it('handles processor returning empty primary array', async () => {
681
+ const { dataSource, triggerNotifications } = createMockDataSource()
682
+ const { tracker } = createMockTracker()
683
+ const processor = createMockProcessor(() => []) // Always return empty
684
+ const { renderer, getRenderedNotifications } = createMockRenderer()
685
+
686
+ const system = createNotificationService({
687
+ dataSources: [dataSource],
688
+ tracker,
689
+ processor,
690
+ renderer,
691
+ })
692
+
693
+ await system.initialize()
694
+
695
+ const notifications = [createMockNotification({ name: 'notif-1', timestamp: 1000, id: 'id-1' })]
696
+
697
+ triggerNotifications(notifications)
698
+ await sleep(10)
699
+
700
+ expect(getRenderedNotifications()).toHaveLength(0)
701
+ })
702
+
703
+ it('handles system with no data sources', async () => {
704
+ const { tracker } = createMockTracker()
705
+ const processor = createMockProcessor()
706
+ const { renderer } = createMockRenderer()
707
+
708
+ const system = createNotificationService({
709
+ dataSources: [],
710
+ tracker,
711
+ processor,
712
+ renderer,
713
+ })
714
+
715
+ await expect(system.initialize()).resolves.not.toThrow()
716
+ expect(() => system.destroy()).not.toThrow()
717
+ })
718
+ })
719
+
720
+ describe('downstream chain tracking', () => {
721
+ it('tracks all downstream notifications when a notification is acknowledged', async () => {
722
+ const { dataSource, triggerNotifications } = createMockDataSource()
723
+ const { tracker, getTrackedCalls } = createMockTracker()
724
+
725
+ // Create chain: A → B → C
726
+ const notificationC = createNotificationWithButton({
727
+ id: 'notif-C',
728
+ timestamp: 3000,
729
+ buttonLabel: 'Dismiss',
730
+ buttonActions: [OnClickAction.DISMISS],
731
+ })
732
+
733
+ const notificationB = createNotificationWithButton({
734
+ id: 'notif-B',
735
+ timestamp: 2000,
736
+ buttonLabel: 'Show C',
737
+ buttonActions: [OnClickAction.POPUP, OnClickAction.DISMISS],
738
+ buttonLink: 'notif-C',
739
+ })
740
+
741
+ const notificationA = createNotificationWithButton({
742
+ id: 'notif-A',
743
+ timestamp: 1000,
744
+ buttonLabel: 'Show B',
745
+ buttonActions: [OnClickAction.POPUP, OnClickAction.ACK],
746
+ buttonLink: 'notif-B',
747
+ })
748
+
749
+ const processor: NotificationProcessor = {
750
+ process: vi.fn(async () => ({
751
+ primary: [notificationA],
752
+ chained: new Map([
753
+ ['notif-B', notificationB],
754
+ ['notif-C', notificationC],
755
+ ]),
756
+ })),
757
+ }
758
+ const { renderer } = createMockRenderer()
759
+
760
+ const system = createNotificationService({
761
+ dataSources: [dataSource],
762
+ tracker,
763
+ processor,
764
+ renderer,
765
+ })
766
+
767
+ await system.initialize()
768
+
769
+ triggerNotifications([notificationA, notificationB, notificationC])
770
+ await sleep(10)
771
+
772
+ // Click the button that has ACK action (triggers tracking)
773
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
774
+ await sleep(10)
775
+
776
+ const trackedCalls = getTrackedCalls()
777
+ // Should track A, B, and C
778
+ expect(trackedCalls).toHaveLength(3)
779
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-A')
780
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-B')
781
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-C')
782
+ })
783
+
784
+ it('tracks only the specific chain when multiple independent chains exist', async () => {
785
+ const { dataSource, triggerNotifications } = createMockDataSource()
786
+ const { tracker, getTrackedCalls } = createMockTracker()
787
+
788
+ // Create two independent chains: A → B and C → D
789
+ const notificationD = createNotificationWithButton({
790
+ id: 'notif-D',
791
+ timestamp: 4000,
792
+ buttonLabel: 'Dismiss',
793
+ buttonActions: [OnClickAction.DISMISS],
794
+ })
795
+
796
+ const notificationC = createNotificationWithButton({
797
+ id: 'notif-C',
798
+ timestamp: 3000,
799
+ buttonLabel: 'Show D',
800
+ buttonActions: [OnClickAction.POPUP, OnClickAction.ACK],
801
+ buttonLink: 'notif-D',
802
+ })
803
+
804
+ const notificationB = createNotificationWithButton({
805
+ id: 'notif-B',
806
+ timestamp: 2000,
807
+ buttonLabel: 'Dismiss',
808
+ buttonActions: [OnClickAction.DISMISS],
809
+ })
810
+
811
+ const notificationA = createNotificationWithButton({
812
+ id: 'notif-A',
813
+ timestamp: 1000,
814
+ buttonLabel: 'Show B',
815
+ buttonActions: [OnClickAction.POPUP, OnClickAction.ACK],
816
+ buttonLink: 'notif-B',
817
+ })
818
+
819
+ const processor: NotificationProcessor = {
820
+ process: vi.fn(async () => ({
821
+ primary: [notificationA, notificationC],
822
+ chained: new Map([
823
+ ['notif-B', notificationB],
824
+ ['notif-D', notificationD],
825
+ ]),
826
+ })),
827
+ }
828
+ const { renderer } = createMockRenderer()
829
+
830
+ const system = createNotificationService({
831
+ dataSources: [dataSource],
832
+ tracker,
833
+ processor,
834
+ renderer,
835
+ })
836
+
837
+ await system.initialize()
838
+
839
+ triggerNotifications([notificationA, notificationB, notificationC, notificationD])
840
+ await sleep(10)
841
+
842
+ // Acknowledge notification A (should track A and B, but NOT C or D)
843
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
844
+ await sleep(10)
845
+
846
+ const trackedCalls = getTrackedCalls()
847
+ expect(trackedCalls).toHaveLength(2)
848
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-A')
849
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-B')
850
+ expect(trackedCalls.map((c) => c.id)).not.toContain('notif-C')
851
+ expect(trackedCalls.map((c) => c.id)).not.toContain('notif-D')
852
+ })
853
+
854
+ it('tracks notifications with background popup actions', async () => {
855
+ const { dataSource, triggerNotifications } = createMockDataSource()
856
+ const { tracker, getTrackedCalls } = createMockTracker()
857
+
858
+ const notificationB = createNotificationWithButton({
859
+ id: 'notif-B',
860
+ timestamp: 2000,
861
+ buttonLabel: 'Dismiss',
862
+ buttonActions: [OnClickAction.DISMISS],
863
+ })
864
+
865
+ const notificationA = createNotificationWithBackground({
866
+ id: 'notif-A',
867
+ timestamp: 1000,
868
+ buttonLabel: 'Acknowledge',
869
+ buttonActions: [OnClickAction.ACK],
870
+ backgroundActions: [OnClickAction.POPUP],
871
+ backgroundLink: 'notif-B',
872
+ })
873
+
874
+ const processor: NotificationProcessor = {
875
+ process: vi.fn(async () => ({
876
+ primary: [notificationA],
877
+ chained: new Map([['notif-B', notificationB]]),
878
+ })),
879
+ }
880
+ const { renderer } = createMockRenderer()
881
+
882
+ const system = createNotificationService({
883
+ dataSources: [dataSource],
884
+ tracker,
885
+ processor,
886
+ renderer,
887
+ })
888
+
889
+ await system.initialize()
890
+
891
+ triggerNotifications([notificationA, notificationB])
892
+ await sleep(10)
893
+
894
+ // Click button with ACK action
895
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
896
+ await sleep(10)
897
+
898
+ const trackedCalls = getTrackedCalls()
899
+ // Should track both A and B (B is referenced via background popup)
900
+ expect(trackedCalls).toHaveLength(2)
901
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-A')
902
+ expect(trackedCalls.map((c) => c.id)).toContain('notif-B')
903
+ })
904
+
905
+ it('handles circular references gracefully without infinite loops', async () => {
906
+ const { dataSource, triggerNotifications } = createMockDataSource()
907
+ const { tracker, getTrackedCalls } = createMockTracker()
908
+
909
+ // Create circular chain: A → B → A (pathological case)
910
+ const notificationB = createNotificationWithButton({
911
+ id: 'notif-B',
912
+ timestamp: 2000,
913
+ buttonLabel: 'Show A',
914
+ buttonActions: [OnClickAction.POPUP, OnClickAction.DISMISS],
915
+ buttonLink: 'notif-A',
916
+ })
917
+
918
+ const notificationA = createNotificationWithButton({
919
+ id: 'notif-A',
920
+ timestamp: 1000,
921
+ buttonLabel: 'Show B',
922
+ buttonActions: [OnClickAction.POPUP, OnClickAction.ACK],
923
+ buttonLink: 'notif-B',
924
+ })
925
+
926
+ const processor: NotificationProcessor = {
927
+ process: vi.fn(async () => ({
928
+ primary: [notificationA],
929
+ chained: new Map([['notif-B', notificationB]]),
930
+ })),
931
+ }
932
+ const { renderer } = createMockRenderer()
933
+
934
+ const system = createNotificationService({
935
+ dataSources: [dataSource],
936
+ tracker,
937
+ processor,
938
+ renderer,
939
+ })
940
+
941
+ await system.initialize()
942
+
943
+ triggerNotifications([notificationA, notificationB])
944
+ await sleep(10)
945
+
946
+ // Acknowledge A - should handle circular reference without hanging
947
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
948
+ await sleep(10)
949
+
950
+ const trackedCalls = getTrackedCalls()
951
+ // Should track A and B exactly once each (no duplicates from circular reference)
952
+ expect(trackedCalls).toHaveLength(2)
953
+ expect(trackedCalls.filter((c) => c.id === 'notif-A')).toHaveLength(1)
954
+ expect(trackedCalls.filter((c) => c.id === 'notif-B')).toHaveLength(1)
955
+ })
956
+
957
+ it('does not track non-existent downstream notifications referenced by POPUP', async () => {
958
+ const { dataSource, triggerNotifications } = createMockDataSource()
959
+ const { tracker, getTrackedCalls } = createMockTracker()
960
+
961
+ // Create notification A that references non-existent notification B via POPUP
962
+ const notificationA = createNotificationWithButton({
963
+ id: 'notif-A',
964
+ timestamp: 1000,
965
+ buttonLabel: 'Show B',
966
+ buttonActions: [OnClickAction.POPUP, OnClickAction.ACK],
967
+ buttonLink: 'notif-B-does-not-exist', // This notification doesn't exist
968
+ })
969
+
970
+ const processor: NotificationProcessor = {
971
+ process: vi.fn(async () => ({
972
+ primary: [notificationA],
973
+ chained: new Map(), // notif-B is NOT in chained map
974
+ })),
975
+ }
976
+ const { renderer } = createMockRenderer()
977
+
978
+ const system = createNotificationService({
979
+ dataSources: [dataSource],
980
+ tracker,
981
+ processor,
982
+ renderer,
983
+ })
984
+
985
+ await system.initialize()
986
+
987
+ triggerNotifications([notificationA])
988
+ await sleep(10)
989
+
990
+ // Acknowledge A - should only track A, not the non-existent B
991
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
992
+ await sleep(10)
993
+
994
+ const trackedCalls = getTrackedCalls()
995
+ // Should only track A, not the non-existent notification
996
+ expect(trackedCalls).toHaveLength(1)
997
+ expect(trackedCalls[0].id).toBe('notif-A')
998
+ })
999
+
1000
+ it('does not track non-existent downstream notifications referenced by background POPUP', async () => {
1001
+ const { dataSource, triggerNotifications } = createMockDataSource()
1002
+ const { tracker, getTrackedCalls } = createMockTracker()
1003
+
1004
+ // Create notification with background that references non-existent notification
1005
+ const notificationA = createNotificationWithBackground({
1006
+ id: 'notif-A',
1007
+ timestamp: 1000,
1008
+ buttonLabel: 'Acknowledge',
1009
+ buttonActions: [OnClickAction.ACK],
1010
+ backgroundActions: [OnClickAction.POPUP],
1011
+ backgroundLink: 'notif-B-does-not-exist', // This notification doesn't exist
1012
+ })
1013
+
1014
+ const processor: NotificationProcessor = {
1015
+ process: vi.fn(async () => ({
1016
+ primary: [notificationA],
1017
+ chained: new Map(), // notif-B is NOT in chained map
1018
+ })),
1019
+ }
1020
+ const { renderer } = createMockRenderer()
1021
+
1022
+ const system = createNotificationService({
1023
+ dataSources: [dataSource],
1024
+ tracker,
1025
+ processor,
1026
+ renderer,
1027
+ })
1028
+
1029
+ await system.initialize()
1030
+
1031
+ triggerNotifications([notificationA])
1032
+ await sleep(10)
1033
+
1034
+ // Acknowledge A via button - should only track A, not the non-existent B
1035
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
1036
+ await sleep(10)
1037
+
1038
+ const trackedCalls = getTrackedCalls()
1039
+ // Should only track A, not the non-existent notification
1040
+ expect(trackedCalls).toHaveLength(1)
1041
+ expect(trackedCalls[0].id).toBe('notif-A')
1042
+ })
1043
+
1044
+ it('does not track downstream notifications when notification is dismissed', async () => {
1045
+ const { dataSource, triggerNotifications } = createMockDataSource()
1046
+ const { tracker, getTrackedCalls } = createMockTracker()
1047
+
1048
+ const notificationB = createNotificationWithButton({
1049
+ id: 'notif-B',
1050
+ timestamp: 2000,
1051
+ buttonLabel: 'Dismiss',
1052
+ buttonActions: [OnClickAction.DISMISS],
1053
+ })
1054
+
1055
+ const notificationA = createNotificationWithButton({
1056
+ id: 'notif-A',
1057
+ timestamp: 1000,
1058
+ buttonLabel: 'Show B',
1059
+ buttonActions: [OnClickAction.POPUP, OnClickAction.DISMISS],
1060
+ buttonLink: 'notif-B',
1061
+ })
1062
+
1063
+ const processor: NotificationProcessor = {
1064
+ process: vi.fn(async () => ({
1065
+ primary: [notificationA],
1066
+ chained: new Map([['notif-B', notificationB]]),
1067
+ })),
1068
+ }
1069
+ const { renderer } = createMockRenderer()
1070
+
1071
+ const system = createNotificationService({
1072
+ dataSources: [dataSource],
1073
+ tracker,
1074
+ processor,
1075
+ renderer,
1076
+ })
1077
+
1078
+ await system.initialize()
1079
+
1080
+ triggerNotifications([notificationA, notificationB])
1081
+ await sleep(10)
1082
+
1083
+ // Dismiss notification (has DISMISS but not ACK)
1084
+ system.onNotificationClick('notif-A', { type: 'button', index: 0 })
1085
+ await sleep(10)
1086
+
1087
+ const trackedCalls = getTrackedCalls()
1088
+ // Should NOT track anything (DISMISS doesn't track)
1089
+ expect(trackedCalls).toHaveLength(0)
1090
+ })
1091
+ })
1092
+ })