@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
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
|
+
})
|