@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,465 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { NotificationsApiClient } from '@luxfi/api'
|
|
3
|
+
import { createApiNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createApiNotificationTracker'
|
|
4
|
+
import type { TrackingMetadata } from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
|
|
5
|
+
import { describe, expect, it, type Mock, vi } from 'vitest'
|
|
6
|
+
|
|
7
|
+
describe('createApiNotificationTracker', () => {
|
|
8
|
+
const createMockApiClient = (): NotificationsApiClient =>
|
|
9
|
+
({
|
|
10
|
+
ackNotification: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
}) as unknown as NotificationsApiClient
|
|
12
|
+
|
|
13
|
+
const createMockQueryClient = (): QueryClient =>
|
|
14
|
+
({
|
|
15
|
+
fetchQuery: vi.fn((options) => options.queryFn()),
|
|
16
|
+
}) as unknown as QueryClient
|
|
17
|
+
|
|
18
|
+
const createMockStorage = (): {
|
|
19
|
+
has: Mock<(notificationId: string) => Promise<boolean>>
|
|
20
|
+
add: Mock<(notificationId: string, metadata?: { timestamp: number }) => Promise<void>>
|
|
21
|
+
getAll: Mock<() => Promise<Set<string>>>
|
|
22
|
+
deleteOlderThan: Mock<(timestamp: number) => Promise<void>>
|
|
23
|
+
} => ({
|
|
24
|
+
has: vi.fn().mockResolvedValue(false),
|
|
25
|
+
add: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
getAll: vi.fn().mockResolvedValue(new Set<string>()),
|
|
27
|
+
deleteOlderThan: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const mockMetadata: TrackingMetadata = {
|
|
31
|
+
timestamp: Date.now(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('isProcessed', () => {
|
|
35
|
+
it('returns true when storage has the notification', async () => {
|
|
36
|
+
const mockApiClient = createMockApiClient()
|
|
37
|
+
const mockStorage = createMockStorage()
|
|
38
|
+
mockStorage.has.mockResolvedValue(true)
|
|
39
|
+
|
|
40
|
+
const tracker = createApiNotificationTracker({
|
|
41
|
+
notificationsApiClient: mockApiClient,
|
|
42
|
+
queryClient: createMockQueryClient(),
|
|
43
|
+
storage: mockStorage,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const result = await tracker.isProcessed('notif-1')
|
|
47
|
+
|
|
48
|
+
expect(result).toBe(true)
|
|
49
|
+
expect(mockStorage.has).toHaveBeenCalledWith('notif-1')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns false when storage does not have the notification', async () => {
|
|
53
|
+
const mockApiClient = createMockApiClient()
|
|
54
|
+
const mockStorage = createMockStorage()
|
|
55
|
+
mockStorage.has.mockResolvedValue(false)
|
|
56
|
+
|
|
57
|
+
const tracker = createApiNotificationTracker({
|
|
58
|
+
notificationsApiClient: mockApiClient,
|
|
59
|
+
queryClient: createMockQueryClient(),
|
|
60
|
+
storage: mockStorage,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const result = await tracker.isProcessed('notif-1')
|
|
64
|
+
|
|
65
|
+
expect(result).toBe(false)
|
|
66
|
+
expect(mockStorage.has).toHaveBeenCalledWith('notif-1')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('getProcessedIds', () => {
|
|
71
|
+
it('returns all IDs from storage when storage is provided', async () => {
|
|
72
|
+
const mockApiClient = createMockApiClient()
|
|
73
|
+
const mockStorage = createMockStorage()
|
|
74
|
+
const expectedIds = new Set(['notif-1', 'notif-2', 'notif-3'])
|
|
75
|
+
mockStorage.getAll.mockResolvedValue(expectedIds)
|
|
76
|
+
|
|
77
|
+
const tracker = createApiNotificationTracker({
|
|
78
|
+
notificationsApiClient: mockApiClient,
|
|
79
|
+
queryClient: createMockQueryClient(),
|
|
80
|
+
storage: mockStorage,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const result = await tracker.getProcessedIds()
|
|
84
|
+
|
|
85
|
+
expect(result).toBe(expectedIds)
|
|
86
|
+
expect(mockStorage.getAll).toHaveBeenCalled()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('track', () => {
|
|
91
|
+
it('calls API and updates storage on successful acknowledgment', async () => {
|
|
92
|
+
const mockApiClient = createMockApiClient()
|
|
93
|
+
const mockStorage = createMockStorage()
|
|
94
|
+
|
|
95
|
+
const tracker = createApiNotificationTracker({
|
|
96
|
+
notificationsApiClient: mockApiClient,
|
|
97
|
+
queryClient: createMockQueryClient(),
|
|
98
|
+
storage: mockStorage,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await tracker.track('notif-1', mockMetadata)
|
|
102
|
+
|
|
103
|
+
expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
|
|
104
|
+
ids: ['notif-1'],
|
|
105
|
+
})
|
|
106
|
+
expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
|
|
107
|
+
timestamp: mockMetadata.timestamp,
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('updates storage and logs error when API call fails', async () => {
|
|
112
|
+
const mockApiClient = createMockApiClient()
|
|
113
|
+
const mockStorage = createMockStorage()
|
|
114
|
+
const apiError = new Error('API request failed')
|
|
115
|
+
mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
|
|
116
|
+
|
|
117
|
+
const tracker = createApiNotificationTracker({
|
|
118
|
+
notificationsApiClient: mockApiClient,
|
|
119
|
+
queryClient: createMockQueryClient(),
|
|
120
|
+
storage: mockStorage,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
// Should not throw - errors are caught and logged
|
|
124
|
+
await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
|
|
125
|
+
|
|
126
|
+
// Verify storage was still updated despite API failure
|
|
127
|
+
expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
|
|
128
|
+
timestamp: mockMetadata.timestamp,
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('logs error when API fails and no storage provided', async () => {
|
|
133
|
+
const mockApiClient = createMockApiClient()
|
|
134
|
+
const apiError = new Error('Network error')
|
|
135
|
+
mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
|
|
136
|
+
|
|
137
|
+
const tracker = createApiNotificationTracker({
|
|
138
|
+
notificationsApiClient: mockApiClient,
|
|
139
|
+
storage: createMockStorage(),
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// Should not throw - errors are caught and logged
|
|
143
|
+
await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('calls API and updates storage even when API fails', async () => {
|
|
147
|
+
const mockApiClient = createMockApiClient()
|
|
148
|
+
const mockStorage = createMockStorage()
|
|
149
|
+
const apiError = new Error('Connection timeout')
|
|
150
|
+
mockApiClient.ackNotification = vi.fn().mockRejectedValue(apiError)
|
|
151
|
+
|
|
152
|
+
const tracker = createApiNotificationTracker({
|
|
153
|
+
notificationsApiClient: mockApiClient,
|
|
154
|
+
queryClient: createMockQueryClient(),
|
|
155
|
+
storage: mockStorage,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Should not throw - errors are caught and logged
|
|
159
|
+
await tracker.track('notif-1', mockMetadata)
|
|
160
|
+
|
|
161
|
+
// Verify API was called
|
|
162
|
+
expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
|
|
163
|
+
ids: ['notif-1'],
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
// Verify storage was updated despite API failure
|
|
167
|
+
expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
|
|
168
|
+
timestamp: mockMetadata.timestamp,
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('handles non-Error objects thrown by API', async () => {
|
|
173
|
+
const mockApiClient = createMockApiClient()
|
|
174
|
+
const mockStorage = createMockStorage()
|
|
175
|
+
mockApiClient.ackNotification = vi.fn().mockRejectedValue('String error')
|
|
176
|
+
|
|
177
|
+
const tracker = createApiNotificationTracker({
|
|
178
|
+
notificationsApiClient: mockApiClient,
|
|
179
|
+
queryClient: createMockQueryClient(),
|
|
180
|
+
storage: mockStorage,
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// Should not throw - errors are caught and logged
|
|
184
|
+
await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
|
|
185
|
+
|
|
186
|
+
// Verify storage was updated despite API failure
|
|
187
|
+
expect(mockStorage.add).toHaveBeenCalledWith('notif-1', {
|
|
188
|
+
timestamp: mockMetadata.timestamp,
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('preserves metadata with different tracking strategies', async () => {
|
|
193
|
+
const mockApiClient = createMockApiClient()
|
|
194
|
+
const mockStorage = createMockStorage()
|
|
195
|
+
|
|
196
|
+
const tracker = createApiNotificationTracker({
|
|
197
|
+
notificationsApiClient: mockApiClient,
|
|
198
|
+
queryClient: createMockQueryClient(),
|
|
199
|
+
storage: mockStorage,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const notificationIds = ['notif-1', 'notif-2', 'notif-3', 'notif-4']
|
|
203
|
+
|
|
204
|
+
for (let i = 0; i < notificationIds.length; i++) {
|
|
205
|
+
const metadata: TrackingMetadata = {
|
|
206
|
+
timestamp: Date.now() + i,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
await tracker.track(notificationIds[i], metadata)
|
|
210
|
+
|
|
211
|
+
expect(mockStorage.add).toHaveBeenCalledWith(notificationIds[i], {
|
|
212
|
+
timestamp: metadata.timestamp,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
it('makes separate API calls for separate track invocations', async () => {
|
|
218
|
+
const mockApiClient = createMockApiClient()
|
|
219
|
+
const mockStorage = createMockStorage()
|
|
220
|
+
|
|
221
|
+
const tracker = createApiNotificationTracker({
|
|
222
|
+
notificationsApiClient: mockApiClient,
|
|
223
|
+
queryClient: createMockQueryClient(),
|
|
224
|
+
storage: mockStorage,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await tracker.track('notif-1', mockMetadata)
|
|
228
|
+
await tracker.track('notif-2', mockMetadata)
|
|
229
|
+
await tracker.track('notif-3', mockMetadata)
|
|
230
|
+
|
|
231
|
+
expect(mockApiClient.ackNotification).toHaveBeenCalledTimes(3)
|
|
232
|
+
expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(1, { ids: ['notif-1'] })
|
|
233
|
+
expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(2, { ids: ['notif-2'] })
|
|
234
|
+
expect(mockApiClient.ackNotification).toHaveBeenNthCalledWith(3, { ids: ['notif-3'] })
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
describe('cleanup', () => {
|
|
239
|
+
it('calls storage deleteOlderThan when provided', async () => {
|
|
240
|
+
const mockApiClient = createMockApiClient()
|
|
241
|
+
const mockStorage = createMockStorage()
|
|
242
|
+
|
|
243
|
+
const tracker = createApiNotificationTracker({
|
|
244
|
+
notificationsApiClient: mockApiClient,
|
|
245
|
+
queryClient: createMockQueryClient(),
|
|
246
|
+
storage: mockStorage,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
expect(tracker.cleanup).toBeDefined()
|
|
250
|
+
|
|
251
|
+
const timestamp = Date.now() - 7 * 24 * 60 * 60 * 1000 // 7 days ago
|
|
252
|
+
await tracker.cleanup?.(timestamp)
|
|
253
|
+
|
|
254
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenCalledWith(timestamp)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('handles multiple cleanup calls with different timestamps', async () => {
|
|
258
|
+
const mockApiClient = createMockApiClient()
|
|
259
|
+
const mockStorage = createMockStorage()
|
|
260
|
+
|
|
261
|
+
const tracker = createApiNotificationTracker({
|
|
262
|
+
notificationsApiClient: mockApiClient,
|
|
263
|
+
queryClient: createMockQueryClient(),
|
|
264
|
+
storage: mockStorage,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const timestamp1 = Date.now() - 7 * 24 * 60 * 60 * 1000
|
|
268
|
+
const timestamp2 = Date.now() - 14 * 24 * 60 * 60 * 1000
|
|
269
|
+
const timestamp3 = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
270
|
+
|
|
271
|
+
await tracker.cleanup?.(timestamp1)
|
|
272
|
+
await tracker.cleanup?.(timestamp2)
|
|
273
|
+
await tracker.cleanup?.(timestamp3)
|
|
274
|
+
|
|
275
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenCalledTimes(3)
|
|
276
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(1, timestamp1)
|
|
277
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(2, timestamp2)
|
|
278
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenNthCalledWith(3, timestamp3)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
describe('local notifications', () => {
|
|
283
|
+
it('skips API call for local: prefixed notifications but persists to storage', async () => {
|
|
284
|
+
const mockApiClient = createMockApiClient()
|
|
285
|
+
const mockStorage = createMockStorage()
|
|
286
|
+
|
|
287
|
+
const tracker = createApiNotificationTracker({
|
|
288
|
+
notificationsApiClient: mockApiClient,
|
|
289
|
+
queryClient: createMockQueryClient(),
|
|
290
|
+
storage: mockStorage,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
await tracker.track('local:backup_reminder', mockMetadata)
|
|
294
|
+
|
|
295
|
+
// API should NOT be called for local notifications
|
|
296
|
+
expect(mockApiClient.ackNotification).not.toHaveBeenCalled()
|
|
297
|
+
|
|
298
|
+
// Storage should still be updated (persists across sessions)
|
|
299
|
+
expect(mockStorage.add).toHaveBeenCalledWith('local:backup_reminder', {
|
|
300
|
+
timestamp: mockMetadata.timestamp,
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('skips both API and storage for local:session: prefixed notifications (memory-only)', async () => {
|
|
305
|
+
const mockApiClient = createMockApiClient()
|
|
306
|
+
const mockStorage = createMockStorage()
|
|
307
|
+
|
|
308
|
+
const tracker = createApiNotificationTracker({
|
|
309
|
+
notificationsApiClient: mockApiClient,
|
|
310
|
+
queryClient: createMockQueryClient(),
|
|
311
|
+
storage: mockStorage,
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
await tracker.track('local:session:offline', mockMetadata)
|
|
315
|
+
|
|
316
|
+
// API should NOT be called
|
|
317
|
+
expect(mockApiClient.ackNotification).not.toHaveBeenCalled()
|
|
318
|
+
|
|
319
|
+
// Storage should NOT be updated (session-scoped = memory only)
|
|
320
|
+
expect(mockStorage.add).not.toHaveBeenCalled()
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('marks session-scoped notification as processed via pendingAcks', async () => {
|
|
324
|
+
const mockApiClient = createMockApiClient()
|
|
325
|
+
const mockStorage = createMockStorage()
|
|
326
|
+
mockStorage.has.mockResolvedValue(false) // Not in storage
|
|
327
|
+
|
|
328
|
+
const tracker = createApiNotificationTracker({
|
|
329
|
+
notificationsApiClient: mockApiClient,
|
|
330
|
+
queryClient: createMockQueryClient(),
|
|
331
|
+
storage: mockStorage,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// Initially not processed
|
|
335
|
+
expect(await tracker.isProcessed('local:session:offline')).toBe(false)
|
|
336
|
+
|
|
337
|
+
// Track it (memory-only)
|
|
338
|
+
await tracker.track('local:session:offline', mockMetadata)
|
|
339
|
+
|
|
340
|
+
// Now processed (via pendingAcks in-memory set)
|
|
341
|
+
expect(await tracker.isProcessed('local:session:offline')).toBe(true)
|
|
342
|
+
|
|
343
|
+
// But storage was NOT written to
|
|
344
|
+
expect(mockStorage.add).not.toHaveBeenCalled()
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('integration scenarios', () => {
|
|
349
|
+
it('prevents race condition: isProcessed returns true immediately after track is called', async () => {
|
|
350
|
+
const mockApiClient = createMockApiClient()
|
|
351
|
+
const mockStorage = createMockStorage()
|
|
352
|
+
|
|
353
|
+
// Simulate slow storage writes (e.g., IndexedDB or localStorage)
|
|
354
|
+
let storageWriteResolver: (() => void) | undefined
|
|
355
|
+
const storageWritePromise = new Promise<void>((resolve) => {
|
|
356
|
+
storageWriteResolver = resolve
|
|
357
|
+
})
|
|
358
|
+
mockStorage.add.mockReturnValue(storageWritePromise)
|
|
359
|
+
|
|
360
|
+
// Storage hasn't been written to yet
|
|
361
|
+
mockStorage.has.mockResolvedValue(false)
|
|
362
|
+
|
|
363
|
+
const tracker = createApiNotificationTracker({
|
|
364
|
+
notificationsApiClient: mockApiClient,
|
|
365
|
+
queryClient: createMockQueryClient(),
|
|
366
|
+
storage: mockStorage,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
// Initially not processed
|
|
370
|
+
expect(await tracker.isProcessed('notif-1')).toBe(false)
|
|
371
|
+
|
|
372
|
+
// Start tracking (but don't await - simulates in-flight acknowledgment)
|
|
373
|
+
const trackPromise = tracker.track('notif-1', mockMetadata)
|
|
374
|
+
|
|
375
|
+
// CRITICAL: Even though storage.has still returns false and storage write hasn't completed,
|
|
376
|
+
// isProcessed should return true due to in-memory pending set
|
|
377
|
+
// This prevents notifications from re-appearing when data source refetches
|
|
378
|
+
expect(await tracker.isProcessed('notif-1')).toBe(true)
|
|
379
|
+
|
|
380
|
+
// Complete the storage write
|
|
381
|
+
storageWriteResolver?.()
|
|
382
|
+
await trackPromise
|
|
383
|
+
|
|
384
|
+
// Still processed after storage write completes
|
|
385
|
+
mockStorage.has.mockResolvedValue(true)
|
|
386
|
+
expect(await tracker.isProcessed('notif-1')).toBe(true)
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('handles complete notification lifecycle', async () => {
|
|
390
|
+
const mockApiClient = createMockApiClient()
|
|
391
|
+
const mockStorage = createMockStorage()
|
|
392
|
+
|
|
393
|
+
const tracker = createApiNotificationTracker({
|
|
394
|
+
notificationsApiClient: mockApiClient,
|
|
395
|
+
queryClient: createMockQueryClient(),
|
|
396
|
+
storage: mockStorage,
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// Initially not processed
|
|
400
|
+
mockStorage.has.mockResolvedValue(false)
|
|
401
|
+
expect(await tracker.isProcessed('notif-1')).toBe(false)
|
|
402
|
+
|
|
403
|
+
// Track notification
|
|
404
|
+
await tracker.track('notif-1', mockMetadata)
|
|
405
|
+
|
|
406
|
+
// Now marked as processed
|
|
407
|
+
mockStorage.has.mockResolvedValue(true)
|
|
408
|
+
expect(await tracker.isProcessed('notif-1')).toBe(true)
|
|
409
|
+
|
|
410
|
+
// Appears in processed IDs
|
|
411
|
+
mockStorage.getAll.mockResolvedValue(new Set(['notif-1']))
|
|
412
|
+
expect(await tracker.getProcessedIds()).toEqual(new Set(['notif-1']))
|
|
413
|
+
|
|
414
|
+
// Clean up old entries
|
|
415
|
+
await tracker.cleanup?.(Date.now() - 1000)
|
|
416
|
+
expect(mockStorage.deleteOlderThan).toHaveBeenCalled()
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('marks notification as processed even when storage fails (UX priority)', async () => {
|
|
420
|
+
const mockApiClient = createMockApiClient()
|
|
421
|
+
const mockStorage = createMockStorage()
|
|
422
|
+
|
|
423
|
+
const tracker = createApiNotificationTracker({
|
|
424
|
+
notificationsApiClient: mockApiClient,
|
|
425
|
+
queryClient: createMockQueryClient(),
|
|
426
|
+
storage: mockStorage,
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
// Simulate storage failure
|
|
430
|
+
mockStorage.add.mockRejectedValue(new Error('Storage quota exceeded'))
|
|
431
|
+
mockStorage.has.mockResolvedValue(false)
|
|
432
|
+
|
|
433
|
+
// Track should not throw - errors are caught and logged
|
|
434
|
+
await expect(tracker.track('notif-1', mockMetadata)).resolves.not.toThrow()
|
|
435
|
+
|
|
436
|
+
// Verify API was called
|
|
437
|
+
expect(mockApiClient.ackNotification).toHaveBeenCalledWith({
|
|
438
|
+
ids: ['notif-1'],
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// CRITICAL: Even though storage failed, the notification should still be
|
|
442
|
+
// considered processed due to in-memory pending set (UX priority)
|
|
443
|
+
expect(await tracker.isProcessed('notif-1')).toBe(true)
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
it('tracks same notification multiple times independently without deduplication', async () => {
|
|
447
|
+
const mockApiClient = createMockApiClient()
|
|
448
|
+
const mockStorage = createMockStorage()
|
|
449
|
+
|
|
450
|
+
const tracker = createApiNotificationTracker({
|
|
451
|
+
notificationsApiClient: mockApiClient,
|
|
452
|
+
queryClient: createMockQueryClient(),
|
|
453
|
+
storage: mockStorage,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
await tracker.track('notif-1', mockMetadata)
|
|
457
|
+
await tracker.track('notif-1', { ...mockMetadata, timestamp: Date.now() + 1000 })
|
|
458
|
+
await tracker.track('notif-1', { ...mockMetadata, timestamp: Date.now() + 2000 })
|
|
459
|
+
|
|
460
|
+
// Each call should independently call the API and storage
|
|
461
|
+
expect(mockApiClient.ackNotification).toHaveBeenCalledTimes(3)
|
|
462
|
+
expect(mockStorage.add).toHaveBeenCalledTimes(3)
|
|
463
|
+
})
|
|
464
|
+
})
|
|
465
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { FetchQueryOptions, QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import type { NotificationsApiClient } from '@luxfi/api'
|
|
3
|
+
import { createNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createNotificationTracker'
|
|
4
|
+
import {
|
|
5
|
+
NotificationTracker,
|
|
6
|
+
TrackingMetadata,
|
|
7
|
+
} from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
|
|
8
|
+
import { getLogger } from '@luxfi/utilities/src/logger/logger'
|
|
9
|
+
import { ReactQueryCacheKey } from '@luxfi/utilities/src/reactQuery/cache'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Context for creating an API-based notification tracker
|
|
13
|
+
*/
|
|
14
|
+
export interface ApiNotificationTrackerContext {
|
|
15
|
+
notificationsApiClient: NotificationsApiClient
|
|
16
|
+
queryClient: QueryClient
|
|
17
|
+
/**
|
|
18
|
+
* Optional local storage for tracking state (e.g., to avoid duplicate API calls)
|
|
19
|
+
* If not provided, the tracker will always call the API
|
|
20
|
+
*/
|
|
21
|
+
storage: {
|
|
22
|
+
has: (notificationId: string) => Promise<boolean>
|
|
23
|
+
add: (notificationId: string, metadata?: { timestamp: number }) => Promise<void>
|
|
24
|
+
getAll: () => Promise<Set<string>>
|
|
25
|
+
deleteOlderThan: (timestamp: number) => Promise<void>
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a notification tracker that uses the backend API to mark notifications as tracked.
|
|
31
|
+
*
|
|
32
|
+
* This implementation calls the AckNotifications API endpoint when a notification is tracked,
|
|
33
|
+
* allowing the backend to record that the user has seen/interacted with the notification.
|
|
34
|
+
*
|
|
35
|
+
* Example usage:
|
|
36
|
+
* ```typescript
|
|
37
|
+
* import { createApiNotificationTracker } from '@luxfi/notifications'
|
|
38
|
+
* import { createNotificationsApiClient } from '@luxfi/api'
|
|
39
|
+
*
|
|
40
|
+
* const apiClient = createNotificationsApiClient({
|
|
41
|
+
* fetchClient: myFetchClient,
|
|
42
|
+
* getApiPathPrefix: () => '/notifications/v1'
|
|
43
|
+
* })
|
|
44
|
+
*
|
|
45
|
+
* const tracker = createApiNotificationTracker({
|
|
46
|
+
* notificationsApiClient: apiClient,
|
|
47
|
+
* queryClient: myQueryClient
|
|
48
|
+
* })
|
|
49
|
+
*
|
|
50
|
+
* // Track a notification (sends request to backend with automatic retries)
|
|
51
|
+
* await tracker.track('notif-123', {
|
|
52
|
+
* timestamp: Date.now()
|
|
53
|
+
* })
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function createApiNotificationTracker(ctx: ApiNotificationTrackerContext): NotificationTracker {
|
|
57
|
+
const { notificationsApiClient, queryClient, storage } = ctx
|
|
58
|
+
|
|
59
|
+
// Track in-flight acknowledgments to prevent race conditions
|
|
60
|
+
// If a notification is being acknowledged but storage write hasn't completed yet,
|
|
61
|
+
// this ensures isProcessed() returns true immediately to prevent re-appearing notifications
|
|
62
|
+
const pendingAcks = new Set<string>()
|
|
63
|
+
|
|
64
|
+
const ackNotificationOptions = (notificationId: string): FetchQueryOptions => ({
|
|
65
|
+
queryKey: [ReactQueryCacheKey.AckNotification, notificationId],
|
|
66
|
+
queryFn: () =>
|
|
67
|
+
notificationsApiClient.ackNotification({
|
|
68
|
+
ids: [notificationId],
|
|
69
|
+
}),
|
|
70
|
+
// Retry up to 3 times for network errors or 5xx server errors
|
|
71
|
+
retry: 3,
|
|
72
|
+
// Exponential backoff: 1s, 2s, 4s, capped at 30s
|
|
73
|
+
retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
74
|
+
// Don't cache the result - this is a write operation
|
|
75
|
+
gcTime: 0,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const isProcessed = async (notificationId: string): Promise<boolean> => {
|
|
79
|
+
if (pendingAcks.has(notificationId)) {
|
|
80
|
+
return true
|
|
81
|
+
}
|
|
82
|
+
return storage.has(notificationId)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const getProcessedIds = async (): Promise<Set<string>> => {
|
|
86
|
+
return storage.getAll()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const track = async (notificationId: string, metadata: TrackingMetadata): Promise<void> => {
|
|
90
|
+
// Immediately mark as pending (synchronous) to prevent race condition
|
|
91
|
+
// where data source refetches before storage write completes
|
|
92
|
+
pendingAcks.add(notificationId)
|
|
93
|
+
|
|
94
|
+
// Check notification type by prefix
|
|
95
|
+
const isLocalNotification = notificationId.startsWith('local:')
|
|
96
|
+
const isSessionScopedNotification = notificationId.startsWith('local:session:')
|
|
97
|
+
|
|
98
|
+
// Session-scoped notifications only track in memory (pendingAcks), not storage.
|
|
99
|
+
// They will reset on app restart, allowing the notification to show again if
|
|
100
|
+
// the condition is still true (e.g., offline banner shows again after restart).
|
|
101
|
+
if (isSessionScopedNotification) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Attempt to call the backend API to acknowledge the notification
|
|
106
|
+
try {
|
|
107
|
+
await Promise.all([
|
|
108
|
+
// Skip API call for local notifications since the backend has no knowledge of them
|
|
109
|
+
isLocalNotification ? Promise.resolve() : queryClient.fetchQuery(ackNotificationOptions(notificationId)),
|
|
110
|
+
// Even if API fails, update localStorage so the notification stays dismissed
|
|
111
|
+
// from the user's perspective. This prioritizes UX over perfect backend consistency.
|
|
112
|
+
//
|
|
113
|
+
// Tradeoff: If localStorage is later cleared but the backend never received the ack,
|
|
114
|
+
// the notification could reappear. However, by the time localStorage is cleaned up
|
|
115
|
+
// or cleared, the notification will typically be expired on the backend anyway.
|
|
116
|
+
storage
|
|
117
|
+
.add(notificationId, { timestamp: metadata.timestamp })
|
|
118
|
+
.catch((storageError) => {
|
|
119
|
+
getLogger().error(
|
|
120
|
+
`Storage write failed for notification ${notificationId}: ${storageError instanceof Error ? storageError.message : String(storageError)}`,
|
|
121
|
+
{
|
|
122
|
+
tags: { file: 'createApiNotificationTracker', function: 'track' },
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
}),
|
|
126
|
+
])
|
|
127
|
+
} catch (error) {
|
|
128
|
+
getLogger().error(
|
|
129
|
+
`Failed to acknowledge notification ${notificationId} after retries: ${error instanceof Error ? error.message : String(error)}`,
|
|
130
|
+
{
|
|
131
|
+
tags: {
|
|
132
|
+
file: 'createApiNotificationTracker',
|
|
133
|
+
function: 'track',
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
)
|
|
137
|
+
// Keep notification in pendingAcks even on error - prioritize UX
|
|
138
|
+
// The user dismissed it, so it should stay dismissed from their perspective
|
|
139
|
+
}
|
|
140
|
+
// Note: We intentionally don't remove from pendingAcks after completion
|
|
141
|
+
// Once acknowledged, it should always be considered processed
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const cleanup = async (olderThan: number): Promise<void> => {
|
|
145
|
+
await storage.deleteOlderThan(olderThan)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return createNotificationTracker({
|
|
149
|
+
isProcessed,
|
|
150
|
+
getProcessedIds,
|
|
151
|
+
track,
|
|
152
|
+
cleanup,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createNotificationTracker } from '@luxfi/notifications/src/notification-tracker/implementations/createNotificationTracker'
|
|
2
|
+
import {
|
|
3
|
+
NotificationTracker,
|
|
4
|
+
TrackingMetadata,
|
|
5
|
+
} from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates a no-op notification tracker that doesn't persist any state.
|
|
9
|
+
*
|
|
10
|
+
* Example usage:
|
|
11
|
+
* ```typescript
|
|
12
|
+
* import { createNoopNotificationTracker } from '@luxfi/notifications'
|
|
13
|
+
*
|
|
14
|
+
* const tracker = createNoopNotificationTracker()
|
|
15
|
+
*
|
|
16
|
+
* // All operations are no-ops
|
|
17
|
+
* const processed = await tracker.isProcessed('notif-123') // always false
|
|
18
|
+
* await tracker.track('notif-123', { timestamp: Date.now(), strategy: 'render' }) // no-op
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function createNoopNotificationTracker(): NotificationTracker {
|
|
22
|
+
const isProcessed = async (_notificationId: string): Promise<boolean> => {
|
|
23
|
+
return false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const getProcessedIds = async (): Promise<Set<string>> => {
|
|
27
|
+
return new Set()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const track = async (_notificationId: string, _metadata: TrackingMetadata): Promise<void> => {
|
|
31
|
+
// no-op
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const cleanup = async (_olderThan: number): Promise<void> => {
|
|
35
|
+
// no-op
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return createNotificationTracker({
|
|
39
|
+
isProcessed,
|
|
40
|
+
getProcessedIds,
|
|
41
|
+
track,
|
|
42
|
+
cleanup,
|
|
43
|
+
})
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NotificationTracker,
|
|
3
|
+
TrackingMetadata,
|
|
4
|
+
} from '@luxfi/notifications/src/notification-tracker/NotificationTracker'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Basic implementation of the NotificationTracker interface.
|
|
8
|
+
*/
|
|
9
|
+
export function createNotificationTracker(ctx: {
|
|
10
|
+
isProcessed: (notificationId: string) => Promise<boolean>
|
|
11
|
+
getProcessedIds: () => Promise<Set<string>>
|
|
12
|
+
track: (notificationId: string, metadata: TrackingMetadata) => Promise<void>
|
|
13
|
+
cleanup?: (olderThan: number) => Promise<void>
|
|
14
|
+
}): NotificationTracker {
|
|
15
|
+
return {
|
|
16
|
+
isProcessed: async (notificationId: string): Promise<boolean> => {
|
|
17
|
+
return ctx.isProcessed(notificationId)
|
|
18
|
+
},
|
|
19
|
+
getProcessedIds: async (): Promise<Set<string>> => {
|
|
20
|
+
return ctx.getProcessedIds()
|
|
21
|
+
},
|
|
22
|
+
track: async (notificationId: string, metadata: TrackingMetadata): Promise<void> => {
|
|
23
|
+
return ctx.track(notificationId, metadata)
|
|
24
|
+
},
|
|
25
|
+
cleanup: ctx.cleanup
|
|
26
|
+
? async (olderThan: number): Promise<void> => {
|
|
27
|
+
return ctx.cleanup?.(olderThan)
|
|
28
|
+
}
|
|
29
|
+
: undefined,
|
|
30
|
+
}
|
|
31
|
+
}
|