@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
package/src/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ export { getIsNotificationServiceLocalOverrideEnabled } from './getIsNotificationServiceLocalOverrideEnabled'
2
+ export { getNotificationQueryOptions } from './notification-data-source/getNotificationQueryOptions'
3
+ export { createIntervalNotificationDataSource } from './notification-data-source/implementations/createIntervalNotificationDataSource'
4
+ export {
5
+ type CreateLocalTriggerDataSourceContext,
6
+ createLocalTriggerDataSource,
7
+ getTriggerById,
8
+ type TriggerCondition,
9
+ } from './notification-data-source/implementations/createLocalTriggerDataSource'
10
+ export { createNotificationDataSource } from './notification-data-source/implementations/createNotificationDataSource'
11
+ export { createPollingNotificationDataSource } from './notification-data-source/implementations/createPollingNotificationDataSource'
12
+ export {
13
+ type CreateReactiveDataSourceContext,
14
+ createReactiveDataSource,
15
+ } from './notification-data-source/implementations/createReactiveDataSource'
16
+ export { type NotificationDataSource } from './notification-data-source/NotificationDataSource'
17
+ export { type ReactiveCondition } from './notification-data-source/types/ReactiveCondition'
18
+ export { createBaseNotificationProcessor } from './notification-processor/implementations/createBaseNotificationProcessor'
19
+ export { type NotificationProcessor } from './notification-processor/NotificationProcessor'
20
+ export { BannerTemplate } from './notification-renderer/components/BannerTemplate'
21
+ export { InlineBannerNotification } from './notification-renderer/components/InlineBannerNotification'
22
+ export { createNotificationRenderer } from './notification-renderer/implementations/createNotificationRenderer'
23
+ export { type NotificationRenderer } from './notification-renderer/NotificationRenderer'
24
+ export { createNotificationService } from './notification-service/implementations/createNotificationService'
25
+ export {
26
+ type NotificationClickTarget,
27
+ type NotificationService,
28
+ type NotificationServiceConfig,
29
+ } from './notification-service/NotificationService'
30
+ export { createNotificationTelemetry } from './notification-telemetry/implementations/createNotificationTelemetry'
31
+ export {
32
+ createNoopNotificationTelemetry,
33
+ type NotificationTelemetry,
34
+ } from './notification-telemetry/NotificationTelemetry'
35
+ export {
36
+ type ApiNotificationTrackerContext,
37
+ createApiNotificationTracker,
38
+ } from './notification-tracker/implementations/createApiNotificationTracker'
39
+ export { createNoopNotificationTracker } from './notification-tracker/implementations/createNoopNotificationTracker'
40
+ export { createNotificationTracker } from './notification-tracker/implementations/createNotificationTracker'
41
+ export { type NotificationTracker } from './notification-tracker/NotificationTracker'
@@ -0,0 +1,8 @@
1
+ import { type InAppNotification } from '@luxfi/api'
2
+
3
+ export interface NotificationDataSource {
4
+ // Start receiving notifications (implementation determines mechanism: fetch, websocket, polling, etc.)
5
+ start(onNotifications: (notifications: InAppNotification[], source: string) => void): void
6
+ // Stop receiving notifications and cleanup
7
+ stop(): Promise<void>
8
+ }
@@ -0,0 +1,85 @@
1
+ import { toPlainMessage } from '@bufbuild/protobuf'
2
+ import { queryOptions } from '@tanstack/react-query'
3
+ import { PlatformType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
4
+ import type { InAppNotification, NotificationsApiClient } from '@luxexchange/api'
5
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
6
+ import { ReactQueryCacheKey } from '@luxfi/utilities/src/reactQuery/cache'
7
+ import { type QueryOptionsResult } from '@luxfi/utilities/src/reactQuery/queryOptions'
8
+ import { ONE_MINUTE_MS } from '@luxfi/utilities/src/time/time'
9
+
10
+ const DEFAULT_POLL_INTERVAL_MS = 2 * ONE_MINUTE_MS
11
+
12
+ interface GetNotificationQueryOptionsContext {
13
+ apiClient: NotificationsApiClient
14
+ getPlatformType: () => PlatformType
15
+ pollIntervalMs?: number
16
+ getIsSessionInitialized?: () => boolean
17
+ }
18
+
19
+ /**
20
+ * Creates query options for polling notifications.
21
+ * This can be used directly in hooks or injected into the notification data source.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * import { getNotificationQueryOptions } from '@luxexchange/notifications'
26
+ * import { useQuery } from '@tanstack/react-query'
27
+ *
28
+ * // Use in a hook
29
+ * const queryOptions = getNotificationQueryOptions({ apiClient })
30
+ * const { data } = useQuery(queryOptions)
31
+ *
32
+ * // Or inject into data source
33
+ * const dataSource = createFetchNotificationDataSource({
34
+ * queryClient,
35
+ * queryOptions,
36
+ * })
37
+ * ```
38
+ */
39
+ export function getNotificationQueryOptions(
40
+ ctx: GetNotificationQueryOptionsContext,
41
+ ): QueryOptionsResult<InAppNotification[], Error, InAppNotification[], [ReactQueryCacheKey.Notifications]> {
42
+ const { apiClient, getPlatformType, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, getIsSessionInitialized } = ctx
43
+
44
+ return queryOptions({
45
+ queryKey: [ReactQueryCacheKey.Notifications],
46
+ queryFn: async (): Promise<InAppNotification[]> => {
47
+ const isSessionInitialized = getIsSessionInitialized?.() ?? true
48
+
49
+ if (getIsSessionInitialized && !isSessionInitialized) {
50
+ return []
51
+ }
52
+
53
+ try {
54
+ const platformType = getPlatformType()
55
+ const response = await apiClient.getNotifications({ platform_type: platformType })
56
+ // Convert protobuf Messages to plain objects for React Query caching
57
+ // toPlainMessage strips the Message prototype chain and preserves numeric enum values
58
+ // It's schema-aware and automatically handles nested messages, making it resilient to schema changes
59
+ const serialized: InAppNotification[] = response.notifications.map((notification) =>
60
+ toPlainMessage(notification),
61
+ )
62
+ return serialized
63
+ } catch (error) {
64
+ getLogger().error(error, {
65
+ tags: { file: 'notificationQueryOptions', function: 'queryFn' },
66
+ })
67
+ throw error
68
+ }
69
+ },
70
+ refetchInterval: getIsSessionInitialized
71
+ ? (): number => {
72
+ const isInit = getIsSessionInitialized()
73
+ // Poll faster (2s) when waiting for session, normal interval once initialized
74
+ return isInit ? pollIntervalMs : 2000
75
+ }
76
+ : (): number => pollIntervalMs,
77
+ refetchIntervalInBackground: true,
78
+ refetchOnWindowFocus: false,
79
+ // Use short staleTime when session check is enabled - allows faster refetches when session becomes ready
80
+ // Without this, empty results from pre-session fetches would be cached too long
81
+ staleTime: getIsSessionInitialized ? 1000 : pollIntervalMs - 1000,
82
+ retry: 2,
83
+ retryDelay: (attemptIndex: number): number => Math.min(1000 * 2 ** attemptIndex, 30000),
84
+ })
85
+ }
@@ -0,0 +1,73 @@
1
+ import { type InAppNotification } from '@luxexchange/api'
2
+ import { createNotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/implementations/createNotificationDataSource'
3
+ import { type NotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/NotificationDataSource'
4
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
5
+
6
+ interface CreateIntervalNotificationDataSourceContext {
7
+ pollIntervalMs: number
8
+ source: string
9
+ logFileTag: string
10
+ getNotifications: () => Promise<InAppNotification[]>
11
+ }
12
+
13
+ /**
14
+ * Helper for building interval-based notification data sources.
15
+ * Handles start/stop lifecycle, immediate initial poll, and consistent error logging.
16
+ */
17
+ export function createIntervalNotificationDataSource(
18
+ ctx: CreateIntervalNotificationDataSourceContext,
19
+ ): NotificationDataSource {
20
+ const { pollIntervalMs, source, logFileTag, getNotifications } = ctx
21
+
22
+ let intervalId: ReturnType<typeof setInterval> | null = null
23
+ let currentCallback: ((notifications: InAppNotification[], source: string) => void) | null = null
24
+
25
+ const pollAndEmit = async (logFunctionTag: string): Promise<void> => {
26
+ if (!currentCallback) {
27
+ return
28
+ }
29
+
30
+ try {
31
+ const notifications = await getNotifications()
32
+ currentCallback(notifications, source)
33
+ } catch (error) {
34
+ getLogger().error(error, {
35
+ tags: { file: logFileTag, function: logFunctionTag },
36
+ })
37
+ }
38
+ }
39
+
40
+ const start = (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
41
+ if (intervalId) {
42
+ return
43
+ }
44
+
45
+ currentCallback = onNotifications
46
+
47
+ // Check immediately on start
48
+ pollAndEmit('start').catch((error) => {
49
+ getLogger().error(error, {
50
+ tags: { file: logFileTag, function: 'start' },
51
+ })
52
+ })
53
+
54
+ // Then poll at interval
55
+ intervalId = setInterval(() => {
56
+ pollAndEmit('setInterval').catch((error) => {
57
+ getLogger().error(error, {
58
+ tags: { file: logFileTag, function: 'setInterval' },
59
+ })
60
+ })
61
+ }, pollIntervalMs)
62
+ }
63
+
64
+ const stop = async (): Promise<void> => {
65
+ if (intervalId) {
66
+ clearInterval(intervalId)
67
+ intervalId = null
68
+ }
69
+ currentCallback = null
70
+ }
71
+
72
+ return createNotificationDataSource({ start, stop })
73
+ }
@@ -0,0 +1,492 @@
1
+ import {
2
+ Content,
3
+ Metadata,
4
+ Notification,
5
+ } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
6
+ import type { InAppNotification } from '@luxexchange/api'
7
+ import { ContentStyle } from '@luxexchange/api'
8
+ import {
9
+ createLocalTriggerDataSource,
10
+ getTriggerById,
11
+ type TriggerCondition,
12
+ } from '@luxexchange/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource'
13
+ import { type NotificationTracker } from '@luxexchange/notifications/src/notification-tracker/NotificationTracker'
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ function createMockNotification(id: string): InAppNotification {
17
+ return new Notification({
18
+ id,
19
+ metadata: new Metadata({ owner: 'test', business: 'test' }),
20
+ content: new Content({ style: ContentStyle.MODAL, title: 'Test Notification' }),
21
+ })
22
+ }
23
+
24
+ function createMockTracker(processedIds: Set<string> = new Set()): NotificationTracker {
25
+ return {
26
+ isProcessed: vi.fn().mockImplementation((id: string) => Promise.resolve(processedIds.has(id))),
27
+ getProcessedIds: vi.fn().mockResolvedValue(processedIds),
28
+ track: vi.fn().mockResolvedValue(undefined),
29
+ cleanup: vi.fn().mockResolvedValue(undefined),
30
+ }
31
+ }
32
+
33
+ describe('createLocalTriggerDataSource', () => {
34
+ let mockTracker: NotificationTracker
35
+
36
+ beforeEach(() => {
37
+ vi.useFakeTimers()
38
+ mockTracker = createMockTracker()
39
+ })
40
+
41
+ afterEach(() => {
42
+ vi.useRealTimers()
43
+ vi.clearAllMocks()
44
+ })
45
+
46
+ it('creates a notification data source with start and stop methods', () => {
47
+ const dataSource = createLocalTriggerDataSource({
48
+ triggers: [],
49
+ tracker: mockTracker,
50
+ })
51
+
52
+ expect(dataSource).toBeDefined()
53
+ expect(typeof dataSource.start).toBe('function')
54
+ expect(typeof dataSource.stop).toBe('function')
55
+ })
56
+
57
+ it('checks triggers immediately on start', async () => {
58
+ const mockNotification = createMockNotification('local:test')
59
+ const trigger: TriggerCondition = {
60
+ id: 'local:test',
61
+ shouldShow: vi.fn().mockReturnValue(true),
62
+ createNotification: vi.fn().mockReturnValue(mockNotification),
63
+ }
64
+
65
+ const dataSource = createLocalTriggerDataSource({
66
+ triggers: [trigger],
67
+ tracker: mockTracker,
68
+ })
69
+
70
+ const onNotifications = vi.fn()
71
+ dataSource.start(onNotifications)
72
+
73
+ // Allow the initial async poll to complete
74
+ await vi.advanceTimersByTimeAsync(0)
75
+
76
+ expect(trigger.shouldShow).toHaveBeenCalled()
77
+ expect(onNotifications).toHaveBeenCalledWith([mockNotification], 'local_triggers')
78
+
79
+ await dataSource.stop()
80
+ })
81
+
82
+ it('polls triggers at the specified interval', async () => {
83
+ const trigger: TriggerCondition = {
84
+ id: 'local:test',
85
+ shouldShow: vi.fn().mockReturnValue(false),
86
+ createNotification: vi.fn(),
87
+ }
88
+
89
+ const dataSource = createLocalTriggerDataSource({
90
+ triggers: [trigger],
91
+ tracker: mockTracker,
92
+ pollIntervalMs: 1000,
93
+ })
94
+
95
+ const onNotifications = vi.fn()
96
+ dataSource.start(onNotifications)
97
+
98
+ // Initial check (immediate poll on start)
99
+ await vi.advanceTimersByTimeAsync(0)
100
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
101
+
102
+ // Advance by poll interval
103
+ await vi.advanceTimersByTimeAsync(1000)
104
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(2)
105
+
106
+ // Advance again
107
+ await vi.advanceTimersByTimeAsync(1000)
108
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(3)
109
+
110
+ await dataSource.stop()
111
+ })
112
+
113
+ it('skips triggers that are already processed', async () => {
114
+ const processedTracker = createMockTracker(new Set(['local:processed']))
115
+
116
+ const processedTrigger: TriggerCondition = {
117
+ id: 'local:processed',
118
+ shouldShow: vi.fn().mockReturnValue(true),
119
+ createNotification: vi.fn(),
120
+ }
121
+
122
+ const unprocessedTrigger: TriggerCondition = {
123
+ id: 'local:unprocessed',
124
+ shouldShow: vi.fn().mockReturnValue(true),
125
+ createNotification: vi.fn().mockReturnValue(createMockNotification('local:unprocessed')),
126
+ }
127
+
128
+ const dataSource = createLocalTriggerDataSource({
129
+ triggers: [processedTrigger, unprocessedTrigger],
130
+ tracker: processedTracker,
131
+ })
132
+
133
+ const onNotifications = vi.fn()
134
+ dataSource.start(onNotifications)
135
+
136
+ await vi.advanceTimersByTimeAsync(0)
137
+
138
+ // Processed trigger should not have shouldShow called
139
+ expect(processedTrigger.shouldShow).not.toHaveBeenCalled()
140
+ // Unprocessed trigger should be evaluated
141
+ expect(unprocessedTrigger.shouldShow).toHaveBeenCalled()
142
+ expect(unprocessedTrigger.createNotification).toHaveBeenCalled()
143
+
144
+ await dataSource.stop()
145
+ })
146
+
147
+ it('does not create notification when shouldShow returns false', async () => {
148
+ const trigger: TriggerCondition = {
149
+ id: 'local:test',
150
+ shouldShow: vi.fn().mockReturnValue(false),
151
+ createNotification: vi.fn(),
152
+ }
153
+
154
+ const dataSource = createLocalTriggerDataSource({
155
+ triggers: [trigger],
156
+ tracker: mockTracker,
157
+ })
158
+
159
+ const onNotifications = vi.fn()
160
+ dataSource.start(onNotifications)
161
+
162
+ await vi.advanceTimersByTimeAsync(0)
163
+
164
+ expect(trigger.shouldShow).toHaveBeenCalled()
165
+ expect(trigger.createNotification).not.toHaveBeenCalled()
166
+ expect(onNotifications).toHaveBeenCalledWith([], 'local_triggers')
167
+
168
+ await dataSource.stop()
169
+ })
170
+
171
+ it('handles async shouldShow functions', async () => {
172
+ const mockNotification = createMockNotification('local:async')
173
+ const trigger: TriggerCondition = {
174
+ id: 'local:async',
175
+ shouldShow: vi.fn().mockResolvedValue(true),
176
+ createNotification: vi.fn().mockReturnValue(mockNotification),
177
+ }
178
+
179
+ const dataSource = createLocalTriggerDataSource({
180
+ triggers: [trigger],
181
+ tracker: mockTracker,
182
+ })
183
+
184
+ const onNotifications = vi.fn()
185
+ dataSource.start(onNotifications)
186
+
187
+ await vi.advanceTimersByTimeAsync(0)
188
+
189
+ expect(trigger.shouldShow).toHaveBeenCalled()
190
+ expect(onNotifications).toHaveBeenCalledWith([mockNotification], 'local_triggers')
191
+
192
+ await dataSource.stop()
193
+ })
194
+
195
+ it('handles multiple triggers', async () => {
196
+ const notification1 = createMockNotification('local:trigger1')
197
+ const notification2 = createMockNotification('local:trigger2')
198
+
199
+ const trigger1: TriggerCondition = {
200
+ id: 'local:trigger1',
201
+ shouldShow: vi.fn().mockReturnValue(true),
202
+ createNotification: vi.fn().mockReturnValue(notification1),
203
+ }
204
+
205
+ const trigger2: TriggerCondition = {
206
+ id: 'local:trigger2',
207
+ shouldShow: vi.fn().mockReturnValue(true),
208
+ createNotification: vi.fn().mockReturnValue(notification2),
209
+ }
210
+
211
+ const trigger3: TriggerCondition = {
212
+ id: 'local:trigger3',
213
+ shouldShow: vi.fn().mockReturnValue(false),
214
+ createNotification: vi.fn(),
215
+ }
216
+
217
+ const dataSource = createLocalTriggerDataSource({
218
+ triggers: [trigger1, trigger2, trigger3],
219
+ tracker: mockTracker,
220
+ })
221
+
222
+ const onNotifications = vi.fn()
223
+ dataSource.start(onNotifications)
224
+
225
+ await vi.advanceTimersByTimeAsync(0)
226
+
227
+ expect(onNotifications).toHaveBeenCalledWith([notification1, notification2], 'local_triggers')
228
+
229
+ await dataSource.stop()
230
+ })
231
+
232
+ it('handles errors in individual triggers gracefully', async () => {
233
+ const goodNotification = createMockNotification('local:good')
234
+
235
+ const errorTrigger: TriggerCondition = {
236
+ id: 'local:error',
237
+ shouldShow: vi.fn().mockImplementation(() => {
238
+ throw new Error('Trigger error')
239
+ }),
240
+ createNotification: vi.fn(),
241
+ }
242
+
243
+ const goodTrigger: TriggerCondition = {
244
+ id: 'local:good',
245
+ shouldShow: vi.fn().mockReturnValue(true),
246
+ createNotification: vi.fn().mockReturnValue(goodNotification),
247
+ }
248
+
249
+ const dataSource = createLocalTriggerDataSource({
250
+ triggers: [errorTrigger, goodTrigger],
251
+ tracker: mockTracker,
252
+ })
253
+
254
+ const onNotifications = vi.fn()
255
+ dataSource.start(onNotifications)
256
+
257
+ await vi.advanceTimersByTimeAsync(0)
258
+
259
+ // Should still emit good notification despite error in first trigger
260
+ expect(onNotifications).toHaveBeenCalledWith([goodNotification], 'local_triggers')
261
+
262
+ await dataSource.stop()
263
+ })
264
+
265
+ it('handles errors in tracker.isProcessed gracefully', async () => {
266
+ const errorTracker = createMockTracker()
267
+ errorTracker.isProcessed = vi.fn().mockRejectedValue(new Error('Tracker error'))
268
+
269
+ const trigger: TriggerCondition = {
270
+ id: 'local:test',
271
+ shouldShow: vi.fn().mockReturnValue(true),
272
+ createNotification: vi.fn(),
273
+ }
274
+
275
+ const dataSource = createLocalTriggerDataSource({
276
+ triggers: [trigger],
277
+ tracker: errorTracker,
278
+ })
279
+
280
+ const onNotifications = vi.fn()
281
+ dataSource.start(onNotifications)
282
+
283
+ await vi.advanceTimersByTimeAsync(0)
284
+
285
+ // Should emit empty array on error
286
+ expect(onNotifications).toHaveBeenCalledWith([], 'local_triggers')
287
+
288
+ await dataSource.stop()
289
+ })
290
+
291
+ it('does not start twice if already active', async () => {
292
+ const trigger: TriggerCondition = {
293
+ id: 'local:test',
294
+ shouldShow: vi.fn().mockReturnValue(false),
295
+ createNotification: vi.fn(),
296
+ }
297
+
298
+ const dataSource = createLocalTriggerDataSource({
299
+ triggers: [trigger],
300
+ tracker: mockTracker,
301
+ })
302
+
303
+ const onNotifications = vi.fn()
304
+ dataSource.start(onNotifications)
305
+
306
+ // Try to start again
307
+ dataSource.start(onNotifications)
308
+
309
+ await vi.advanceTimersByTimeAsync(0)
310
+
311
+ // Should only call shouldShow once from the initial start
312
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
313
+
314
+ await dataSource.stop()
315
+ })
316
+
317
+ it('stops polling when stop is called', async () => {
318
+ const trigger: TriggerCondition = {
319
+ id: 'local:test',
320
+ shouldShow: vi.fn().mockReturnValue(false),
321
+ createNotification: vi.fn(),
322
+ }
323
+
324
+ const dataSource = createLocalTriggerDataSource({
325
+ triggers: [trigger],
326
+ tracker: mockTracker,
327
+ pollIntervalMs: 100,
328
+ })
329
+
330
+ const onNotifications = vi.fn()
331
+ dataSource.start(onNotifications)
332
+
333
+ await vi.advanceTimersByTimeAsync(0)
334
+ const callCountBeforeStop = (trigger.shouldShow as ReturnType<typeof vi.fn>).mock.calls.length
335
+
336
+ await dataSource.stop()
337
+
338
+ // Advance timers after stop
339
+ await vi.advanceTimersByTimeAsync(500)
340
+
341
+ // Should not have additional calls
342
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(callCountBeforeStop)
343
+ })
344
+
345
+ it('can be started again after stopping', async () => {
346
+ const trigger: TriggerCondition = {
347
+ id: 'local:test',
348
+ shouldShow: vi.fn().mockReturnValue(false),
349
+ createNotification: vi.fn(),
350
+ }
351
+
352
+ const dataSource = createLocalTriggerDataSource({
353
+ triggers: [trigger],
354
+ tracker: mockTracker,
355
+ })
356
+
357
+ const onNotifications = vi.fn()
358
+
359
+ // Start, check, stop
360
+ dataSource.start(onNotifications)
361
+ await vi.advanceTimersByTimeAsync(0)
362
+ await dataSource.stop()
363
+
364
+ vi.clearAllMocks()
365
+
366
+ // Start again
367
+ dataSource.start(onNotifications)
368
+ await vi.advanceTimersByTimeAsync(0)
369
+
370
+ expect(trigger.shouldShow).toHaveBeenCalled()
371
+
372
+ await dataSource.stop()
373
+ })
374
+
375
+ it('uses custom source name', async () => {
376
+ const trigger: TriggerCondition = {
377
+ id: 'local:test',
378
+ shouldShow: vi.fn().mockReturnValue(false),
379
+ createNotification: vi.fn(),
380
+ }
381
+
382
+ const dataSource = createLocalTriggerDataSource({
383
+ triggers: [trigger],
384
+ tracker: mockTracker,
385
+ source: 'custom_source',
386
+ })
387
+
388
+ const onNotifications = vi.fn()
389
+ dataSource.start(onNotifications)
390
+
391
+ await vi.advanceTimersByTimeAsync(0)
392
+
393
+ expect(onNotifications).toHaveBeenCalledWith([], 'custom_source')
394
+
395
+ await dataSource.stop()
396
+ })
397
+
398
+ it('uses default poll interval of 5000ms', async () => {
399
+ const trigger: TriggerCondition = {
400
+ id: 'local:test',
401
+ shouldShow: vi.fn().mockReturnValue(false),
402
+ createNotification: vi.fn(),
403
+ }
404
+
405
+ const dataSource = createLocalTriggerDataSource({
406
+ triggers: [trigger],
407
+ tracker: mockTracker,
408
+ })
409
+
410
+ const onNotifications = vi.fn()
411
+ dataSource.start(onNotifications)
412
+
413
+ // Initial check
414
+ await vi.advanceTimersByTimeAsync(0)
415
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
416
+
417
+ // Advance less than default interval
418
+ await vi.advanceTimersByTimeAsync(4000)
419
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
420
+
421
+ // Advance to complete the default interval
422
+ await vi.advanceTimersByTimeAsync(1000)
423
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(2)
424
+
425
+ await dataSource.stop()
426
+ })
427
+
428
+ it('does not call onNotifications after stop even if polling was in progress', async () => {
429
+ const trigger: TriggerCondition = {
430
+ id: 'local:test',
431
+ shouldShow: vi.fn().mockReturnValue(true),
432
+ createNotification: vi.fn().mockReturnValue(createMockNotification('local:test')),
433
+ }
434
+
435
+ const dataSource = createLocalTriggerDataSource({
436
+ triggers: [trigger],
437
+ tracker: mockTracker,
438
+ })
439
+
440
+ const onNotifications = vi.fn()
441
+ dataSource.start(onNotifications)
442
+
443
+ // Stop immediately before any async operations complete
444
+ await dataSource.stop()
445
+
446
+ // Clear any pending timers
447
+ await vi.runAllTimersAsync()
448
+
449
+ // The callback should not be set after stop, so no additional calls
450
+ // (Initial call may or may not have happened depending on timing)
451
+ const callCount = onNotifications.mock.calls.length
452
+ await vi.advanceTimersByTimeAsync(10000)
453
+ expect(onNotifications).toHaveBeenCalledTimes(callCount)
454
+ })
455
+ })
456
+
457
+ describe('getTriggerById', () => {
458
+ it('returns the trigger with matching ID', () => {
459
+ const trigger1: TriggerCondition = {
460
+ id: 'local:trigger1',
461
+ shouldShow: () => true,
462
+ createNotification: () => createMockNotification('local:trigger1'),
463
+ }
464
+
465
+ const trigger2: TriggerCondition = {
466
+ id: 'local:trigger2',
467
+ shouldShow: () => false,
468
+ createNotification: () => createMockNotification('local:trigger2'),
469
+ }
470
+
471
+ const triggers = [trigger1, trigger2]
472
+
473
+ expect(getTriggerById(triggers, 'local:trigger1')).toBe(trigger1)
474
+ expect(getTriggerById(triggers, 'local:trigger2')).toBe(trigger2)
475
+ })
476
+
477
+ it('returns undefined when no trigger matches', () => {
478
+ const triggers: TriggerCondition[] = [
479
+ {
480
+ id: 'local:trigger1',
481
+ shouldShow: () => true,
482
+ createNotification: () => createMockNotification('local:trigger1'),
483
+ },
484
+ ]
485
+
486
+ expect(getTriggerById(triggers, 'local:nonexistent')).toBeUndefined()
487
+ })
488
+
489
+ it('returns undefined for empty triggers array', () => {
490
+ expect(getTriggerById([], 'local:any')).toBeUndefined()
491
+ })
492
+ })