@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.
- package/.depcheckrc +14 -0
- package/.eslintrc.js +20 -0
- package/README.md +548 -0
- package/package.json +42 -0
- package/project.json +30 -0
- package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
- package/src/global.d.ts +2 -0
- package/src/index.ts +41 -0
- package/src/notification-data-source/NotificationDataSource.ts +8 -0
- package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
- package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +73 -0
- package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
- package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
- package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
- package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
- package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
- package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
- package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
- package/src/notification-processor/NotificationProcessor.ts +26 -0
- package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
- package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +239 -0
- package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
- package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
- package/src/notification-renderer/NotificationRenderer.ts +8 -0
- package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
- package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
- package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
- package/src/notification-renderer/utils/iconUtils.ts +103 -0
- package/src/notification-service/NotificationService.ts +47 -0
- package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
- package/src/notification-service/implementations/createNotificationService.ts +364 -0
- package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
- package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
- package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
- package/src/notification-tracker/NotificationTracker.ts +14 -0
- package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
- package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
- package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
- package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
- package/src/utils/formatNotificationType.test.ts +25 -0
- package/src/utils/formatNotificationType.ts +25 -0
- package/tsconfig.json +24 -0
- package/tsconfig.lint.json +8 -0
- package/vitest-setup.ts +1 -0
- 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
|
+
})
|