@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,177 @@
|
|
|
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 { type NotificationTracker } from '@luxexchange/notifications/src/notification-tracker/NotificationTracker'
|
|
5
|
+
import { getLogger } from '@luxfi/utilities/src/logger/logger'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Configuration for a single trigger condition.
|
|
9
|
+
* Each trigger represents a local notification that should be shown when conditions are met.
|
|
10
|
+
*/
|
|
11
|
+
export interface TriggerCondition {
|
|
12
|
+
/**
|
|
13
|
+
* Unique ID for this notification.
|
|
14
|
+
* Must use 'local:' prefix to distinguish from backend-generated notifications
|
|
15
|
+
* and prevent API tracker from trying to acknowledge them server-side.
|
|
16
|
+
*/
|
|
17
|
+
id: string
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the notification should be shown now.
|
|
21
|
+
* Called on each poll interval.
|
|
22
|
+
*/
|
|
23
|
+
shouldShow: () => boolean | Promise<boolean>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create the notification object to be rendered.
|
|
27
|
+
* Only called when shouldShow returns true.
|
|
28
|
+
*/
|
|
29
|
+
createNotification: () => InAppNotification
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Optional callback when user acknowledges the notification.
|
|
33
|
+
* Use this to update Redux state or perform other side effects.
|
|
34
|
+
*/
|
|
35
|
+
onAcknowledge?: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CreateLocalTriggerDataSourceContext {
|
|
39
|
+
/** Array of trigger conditions to evaluate */
|
|
40
|
+
triggers: TriggerCondition[]
|
|
41
|
+
|
|
42
|
+
/** Tracker for checking/storing processed state */
|
|
43
|
+
tracker: NotificationTracker
|
|
44
|
+
|
|
45
|
+
/** How often to check triggers in milliseconds (default: 5000ms) */
|
|
46
|
+
pollIntervalMs?: number
|
|
47
|
+
|
|
48
|
+
/** Source identifier for telemetry */
|
|
49
|
+
source?: string
|
|
50
|
+
|
|
51
|
+
/** File tag for logging */
|
|
52
|
+
logFileTag?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000
|
|
56
|
+
const DEFAULT_SOURCE = 'local_triggers'
|
|
57
|
+
const DEFAULT_LOG_FILE_TAG = 'createLocalTriggerDataSource'
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Creates a data source for condition-based local notifications.
|
|
61
|
+
*
|
|
62
|
+
* Unlike API-based data sources, this polls local state (e.g., Redux selectors)
|
|
63
|
+
* to determine when to show notifications. Useful for modals that should auto-open
|
|
64
|
+
* based on user state or behavior (e.g., app rating prompts, backup reminders).
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const localTriggers = createLocalTriggerDataSource({
|
|
69
|
+
* triggers: [
|
|
70
|
+
* {
|
|
71
|
+
* id: 'local:app_rating',
|
|
72
|
+
* shouldShow: () => appRatingSelector(getState()).shouldPrompt,
|
|
73
|
+
* createNotification: () => createAppRatingNotification(),
|
|
74
|
+
* onAcknowledge: () => dispatch(setAppRating({})),
|
|
75
|
+
* },
|
|
76
|
+
* ],
|
|
77
|
+
* tracker,
|
|
78
|
+
* pollIntervalMs: 5000,
|
|
79
|
+
* })
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function createLocalTriggerDataSource(ctx: CreateLocalTriggerDataSourceContext): NotificationDataSource {
|
|
83
|
+
const {
|
|
84
|
+
triggers,
|
|
85
|
+
tracker,
|
|
86
|
+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
|
|
87
|
+
source = DEFAULT_SOURCE,
|
|
88
|
+
logFileTag = DEFAULT_LOG_FILE_TAG,
|
|
89
|
+
} = ctx
|
|
90
|
+
|
|
91
|
+
let intervalId: ReturnType<typeof setInterval> | null = null
|
|
92
|
+
let currentCallback: ((notifications: InAppNotification[], source: string) => void) | null = null
|
|
93
|
+
|
|
94
|
+
const checkTriggers = async (): Promise<InAppNotification[]> => {
|
|
95
|
+
const notifications: InAppNotification[] = []
|
|
96
|
+
|
|
97
|
+
for (const trigger of triggers) {
|
|
98
|
+
try {
|
|
99
|
+
// Skip if already processed
|
|
100
|
+
const isProcessed = await tracker.isProcessed(trigger.id)
|
|
101
|
+
if (isProcessed) {
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Check if trigger condition is met
|
|
106
|
+
const shouldShow = await Promise.resolve(trigger.shouldShow())
|
|
107
|
+
if (shouldShow) {
|
|
108
|
+
notifications.push(trigger.createNotification())
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
getLogger().error(error, {
|
|
112
|
+
tags: { file: logFileTag, function: 'checkTriggers' },
|
|
113
|
+
extra: { triggerId: trigger.id },
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return notifications
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const pollAndEmit = async (logFunctionTag: string): Promise<void> => {
|
|
122
|
+
if (!currentCallback) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const notifications = await checkTriggers()
|
|
128
|
+
currentCallback(notifications, source)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
getLogger().error(error, {
|
|
131
|
+
tags: { file: logFileTag, function: logFunctionTag },
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const start = (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
|
|
137
|
+
if (intervalId) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
currentCallback = onNotifications
|
|
142
|
+
|
|
143
|
+
// Check immediately on start
|
|
144
|
+
pollAndEmit('start').catch((error) => {
|
|
145
|
+
getLogger().error(error, {
|
|
146
|
+
tags: { file: logFileTag, function: 'start' },
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Then poll at interval
|
|
151
|
+
intervalId = setInterval(() => {
|
|
152
|
+
pollAndEmit('setInterval').catch((error) => {
|
|
153
|
+
getLogger().error(error, {
|
|
154
|
+
tags: { file: logFileTag, function: 'setInterval' },
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
}, pollIntervalMs)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const stop = async (): Promise<void> => {
|
|
161
|
+
if (intervalId) {
|
|
162
|
+
clearInterval(intervalId)
|
|
163
|
+
intervalId = null
|
|
164
|
+
}
|
|
165
|
+
currentCallback = null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return createNotificationDataSource({ start, stop })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get a trigger by notification ID.
|
|
173
|
+
* Useful for finding the trigger's callbacks when a notification is interacted with.
|
|
174
|
+
*/
|
|
175
|
+
export function getTriggerById(triggers: TriggerCondition[], notificationId: string): TriggerCondition | undefined {
|
|
176
|
+
return triggers.find((t) => t.id === notificationId)
|
|
177
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type InAppNotification } from '@luxfi/api'
|
|
2
|
+
import { type NotificationDataSource } from '@luxfi/notifications/src/notification-data-source/NotificationDataSource'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Basic implementation of the NotificationDataSource interface.
|
|
6
|
+
*/
|
|
7
|
+
export function createNotificationDataSource(ctx: {
|
|
8
|
+
start: (onNotifications: (notifications: InAppNotification[], source: string) => void) => void
|
|
9
|
+
stop: () => Promise<void>
|
|
10
|
+
}): NotificationDataSource {
|
|
11
|
+
return {
|
|
12
|
+
start: (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
|
|
13
|
+
ctx.start(onNotifications)
|
|
14
|
+
},
|
|
15
|
+
stop: async (): Promise<void> => {
|
|
16
|
+
await ctx.stop()
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
2
|
+
import {
|
|
3
|
+
Content,
|
|
4
|
+
GetNotificationsResponse,
|
|
5
|
+
Metadata,
|
|
6
|
+
Notification,
|
|
7
|
+
PlatformType,
|
|
8
|
+
} from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
|
|
9
|
+
import type { InAppNotification, NotificationsApiClient } from '@luxexchange/api'
|
|
10
|
+
import { ContentStyle } from '@luxexchange/api'
|
|
11
|
+
import { getNotificationQueryOptions } from '@luxexchange/notifications/src/notification-data-source/getNotificationQueryOptions'
|
|
12
|
+
import { createPollingNotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/implementations/createPollingNotificationDataSource'
|
|
13
|
+
import { ReactQueryCacheKey } from '@luxfi/utilities/src/reactQuery/cache'
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
function createMockNotification(id: string): InAppNotification {
|
|
17
|
+
return new Notification({
|
|
18
|
+
id,
|
|
19
|
+
metadata: new Metadata({ owner: 'foo', business: 'bar' }),
|
|
20
|
+
content: new Content({ style: ContentStyle.MODAL, title: 'Hello' }),
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('createPollingNotificationDataSource', () => {
|
|
25
|
+
let queryClient: QueryClient
|
|
26
|
+
let mockApiClient: NotificationsApiClient
|
|
27
|
+
let mockNotifications: InAppNotification[]
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks()
|
|
31
|
+
queryClient = new QueryClient()
|
|
32
|
+
|
|
33
|
+
mockNotifications = [createMockNotification('1'), createMockNotification('2')]
|
|
34
|
+
|
|
35
|
+
mockApiClient = {
|
|
36
|
+
getNotifications: vi.fn().mockResolvedValue(new GetNotificationsResponse({ notifications: mockNotifications })),
|
|
37
|
+
ackNotification: vi.fn().mockResolvedValue(undefined),
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
queryClient.clear()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('creates a notification data source with start and stop methods', () => {
|
|
46
|
+
const testQueryClient = new QueryClient({
|
|
47
|
+
defaultOptions: { queries: { retry: false } },
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
const dataSource = createPollingNotificationDataSource({
|
|
51
|
+
queryClient: testQueryClient,
|
|
52
|
+
queryOptions: getNotificationQueryOptions({
|
|
53
|
+
apiClient: mockApiClient,
|
|
54
|
+
getPlatformType: () => PlatformType.WEB,
|
|
55
|
+
}),
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(dataSource).toBeDefined()
|
|
59
|
+
expect(typeof dataSource.start).toBe('function')
|
|
60
|
+
expect(typeof dataSource.stop).toBe('function')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('calls onNotifications when notifications are received', async () => {
|
|
64
|
+
const testQueryClient = new QueryClient({
|
|
65
|
+
defaultOptions: { queries: { retry: false } },
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const dataSource = createPollingNotificationDataSource({
|
|
69
|
+
queryClient: testQueryClient,
|
|
70
|
+
queryOptions: getNotificationQueryOptions({
|
|
71
|
+
apiClient: mockApiClient,
|
|
72
|
+
getPlatformType: () => PlatformType.WEB,
|
|
73
|
+
}),
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const onNotifications = vi.fn()
|
|
77
|
+
dataSource.start(onNotifications)
|
|
78
|
+
|
|
79
|
+
// Wait for the query to resolve
|
|
80
|
+
await vi.waitFor(() => {
|
|
81
|
+
expect(onNotifications).toHaveBeenCalledWith(mockNotifications, 'polling_api')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await dataSource.stop()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('calls apiClient.getNotifications when started', async () => {
|
|
88
|
+
const testQueryClient = new QueryClient({
|
|
89
|
+
defaultOptions: { queries: { retry: false } },
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const dataSource = createPollingNotificationDataSource({
|
|
93
|
+
queryClient: testQueryClient,
|
|
94
|
+
queryOptions: getNotificationQueryOptions({
|
|
95
|
+
apiClient: mockApiClient,
|
|
96
|
+
getPlatformType: () => PlatformType.WEB,
|
|
97
|
+
}),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const onNotifications = vi.fn()
|
|
101
|
+
dataSource.start(onNotifications)
|
|
102
|
+
|
|
103
|
+
await vi.waitFor(() => {
|
|
104
|
+
expect(mockApiClient.getNotifications).toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
await dataSource.stop()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it.each([
|
|
111
|
+
['WEB', PlatformType.WEB],
|
|
112
|
+
['MOBILE', PlatformType.MOBILE],
|
|
113
|
+
['EXTENSION', PlatformType.EXTENSION],
|
|
114
|
+
] as const)(
|
|
115
|
+
'calls apiClient.getNotifications with platform_type %s when getPlatformType returns that platform',
|
|
116
|
+
async (_label, platformType) => {
|
|
117
|
+
const testQueryClient = new QueryClient({
|
|
118
|
+
defaultOptions: { queries: { retry: false } },
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
const dataSource = createPollingNotificationDataSource({
|
|
122
|
+
queryClient: testQueryClient,
|
|
123
|
+
queryOptions: getNotificationQueryOptions({
|
|
124
|
+
apiClient: mockApiClient,
|
|
125
|
+
getPlatformType: () => platformType,
|
|
126
|
+
}),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const onNotifications = vi.fn()
|
|
130
|
+
dataSource.start(onNotifications)
|
|
131
|
+
|
|
132
|
+
await vi.waitFor(() => {
|
|
133
|
+
expect(mockApiClient.getNotifications).toHaveBeenCalledWith({ platform_type: platformType })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
await dataSource.stop()
|
|
137
|
+
},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
it('does not start twice if already active', async () => {
|
|
141
|
+
const testQueryClient = new QueryClient({
|
|
142
|
+
defaultOptions: { queries: { retry: false } },
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const dataSource = createPollingNotificationDataSource({
|
|
146
|
+
queryClient: testQueryClient,
|
|
147
|
+
queryOptions: getNotificationQueryOptions({
|
|
148
|
+
apiClient: mockApiClient,
|
|
149
|
+
getPlatformType: () => PlatformType.WEB,
|
|
150
|
+
}),
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const onNotifications = vi.fn()
|
|
154
|
+
dataSource.start(onNotifications)
|
|
155
|
+
|
|
156
|
+
// Try to start again
|
|
157
|
+
dataSource.start(onNotifications)
|
|
158
|
+
|
|
159
|
+
// Wait a bit to ensure no double calls
|
|
160
|
+
await vi.waitFor(() => {
|
|
161
|
+
expect(mockApiClient.getNotifications).toHaveBeenCalledTimes(1)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
await dataSource.stop()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('handles API errors gracefully', async () => {
|
|
168
|
+
const testQueryClient = new QueryClient({
|
|
169
|
+
defaultOptions: { queries: { retry: false } },
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const error = new Error('API Error')
|
|
173
|
+
mockApiClient.getNotifications = vi.fn().mockRejectedValue(error)
|
|
174
|
+
|
|
175
|
+
const dataSource = createPollingNotificationDataSource({
|
|
176
|
+
queryClient: testQueryClient,
|
|
177
|
+
queryOptions: getNotificationQueryOptions({
|
|
178
|
+
apiClient: mockApiClient,
|
|
179
|
+
getPlatformType: () => PlatformType.WEB,
|
|
180
|
+
}),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const onNotifications = vi.fn()
|
|
184
|
+
dataSource.start(onNotifications)
|
|
185
|
+
|
|
186
|
+
// Wait for error handling
|
|
187
|
+
await vi.waitFor(() => {
|
|
188
|
+
expect(mockApiClient.getNotifications).toHaveBeenCalled()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// onNotifications should not be called on error
|
|
192
|
+
expect(onNotifications).not.toHaveBeenCalled()
|
|
193
|
+
|
|
194
|
+
await dataSource.stop()
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('stops polling when stop is called', async () => {
|
|
198
|
+
const testQueryClient = new QueryClient({
|
|
199
|
+
defaultOptions: { queries: { retry: false } },
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const dataSource = createPollingNotificationDataSource({
|
|
203
|
+
queryClient: testQueryClient,
|
|
204
|
+
queryOptions: getNotificationQueryOptions({
|
|
205
|
+
apiClient: mockApiClient,
|
|
206
|
+
getPlatformType: () => PlatformType.WEB,
|
|
207
|
+
pollIntervalMs: 100, // Short interval for testing
|
|
208
|
+
}),
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const onNotifications = vi.fn()
|
|
212
|
+
dataSource.start(onNotifications)
|
|
213
|
+
|
|
214
|
+
await vi.waitFor(() => {
|
|
215
|
+
expect(onNotifications).toHaveBeenCalled()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
const callCountBeforeStop = (mockApiClient.getNotifications as Mock).mock.calls.length
|
|
219
|
+
|
|
220
|
+
await dataSource.stop()
|
|
221
|
+
|
|
222
|
+
// Wait a bit longer than poll interval
|
|
223
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
224
|
+
|
|
225
|
+
// Should not have made additional API calls after stop
|
|
226
|
+
expect((mockApiClient.getNotifications as Mock).mock.calls.length).toBe(callCountBeforeStop)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('can be started again after stopping', async () => {
|
|
230
|
+
const testQueryClient = new QueryClient({
|
|
231
|
+
defaultOptions: { queries: { retry: false } },
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const dataSource = createPollingNotificationDataSource({
|
|
235
|
+
queryClient: testQueryClient,
|
|
236
|
+
queryOptions: getNotificationQueryOptions({
|
|
237
|
+
apiClient: mockApiClient,
|
|
238
|
+
getPlatformType: () => PlatformType.WEB,
|
|
239
|
+
}),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
const onNotifications = vi.fn()
|
|
243
|
+
|
|
244
|
+
// Start, wait, then stop
|
|
245
|
+
dataSource.start(onNotifications)
|
|
246
|
+
await vi.waitFor(() => {
|
|
247
|
+
expect(onNotifications).toHaveBeenCalledWith(mockNotifications, 'polling_api')
|
|
248
|
+
})
|
|
249
|
+
await dataSource.stop()
|
|
250
|
+
|
|
251
|
+
// Wait a bit for cleanup to complete
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
253
|
+
|
|
254
|
+
// Clear the query cache completely
|
|
255
|
+
testQueryClient.clear()
|
|
256
|
+
|
|
257
|
+
// Clear mocks
|
|
258
|
+
vi.clearAllMocks()
|
|
259
|
+
mockApiClient.getNotifications = vi
|
|
260
|
+
.fn()
|
|
261
|
+
.mockResolvedValue(new GetNotificationsResponse({ notifications: mockNotifications }))
|
|
262
|
+
|
|
263
|
+
// Start again with a new callback
|
|
264
|
+
const onNotifications2 = vi.fn()
|
|
265
|
+
dataSource.start(onNotifications2)
|
|
266
|
+
await vi.waitFor(() => {
|
|
267
|
+
expect(onNotifications2).toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
await dataSource.stop()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('uses custom poll interval when provided', async () => {
|
|
274
|
+
const testQueryClient = new QueryClient({
|
|
275
|
+
defaultOptions: { queries: { retry: false } },
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const customPollInterval = 100
|
|
279
|
+
const dataSource = createPollingNotificationDataSource({
|
|
280
|
+
queryClient: testQueryClient,
|
|
281
|
+
queryOptions: getNotificationQueryOptions({
|
|
282
|
+
apiClient: mockApiClient,
|
|
283
|
+
getPlatformType: () => PlatformType.WEB,
|
|
284
|
+
pollIntervalMs: customPollInterval,
|
|
285
|
+
}),
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const onNotifications = vi.fn()
|
|
289
|
+
dataSource.start(onNotifications)
|
|
290
|
+
|
|
291
|
+
// Wait for initial call
|
|
292
|
+
await vi.waitFor(() => {
|
|
293
|
+
expect(mockApiClient.getNotifications).toHaveBeenCalled()
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
// Verify the data source was created successfully with custom interval
|
|
297
|
+
// Note: Testing actual polling behavior is complex due to React Query's internal timing
|
|
298
|
+
// The main goal is to ensure the custom interval is accepted without errors
|
|
299
|
+
expect(dataSource).toBeDefined()
|
|
300
|
+
expect(typeof dataSource.start).toBe('function')
|
|
301
|
+
expect(typeof dataSource.stop).toBe('function')
|
|
302
|
+
|
|
303
|
+
await dataSource.stop()
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('passes empty array to onNotifications when API returns empty array', async () => {
|
|
307
|
+
const testQueryClient = new QueryClient({
|
|
308
|
+
defaultOptions: { queries: { retry: false } },
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
mockApiClient.getNotifications = vi.fn().mockResolvedValue(new GetNotificationsResponse({ notifications: [] }))
|
|
312
|
+
|
|
313
|
+
const dataSource = createPollingNotificationDataSource({
|
|
314
|
+
queryClient: testQueryClient,
|
|
315
|
+
queryOptions: getNotificationQueryOptions({
|
|
316
|
+
apiClient: mockApiClient,
|
|
317
|
+
getPlatformType: () => PlatformType.WEB,
|
|
318
|
+
}),
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const onNotifications = vi.fn()
|
|
322
|
+
dataSource.start(onNotifications)
|
|
323
|
+
|
|
324
|
+
await vi.waitFor(() => {
|
|
325
|
+
expect(onNotifications).toHaveBeenCalledWith([], 'polling_api')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
await dataSource.stop()
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
it('cancels queries when stopped', async () => {
|
|
332
|
+
const testQueryClient = new QueryClient({
|
|
333
|
+
defaultOptions: { queries: { retry: false } },
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
const dataSource = createPollingNotificationDataSource({
|
|
337
|
+
queryClient: testQueryClient,
|
|
338
|
+
queryOptions: getNotificationQueryOptions({
|
|
339
|
+
apiClient: mockApiClient,
|
|
340
|
+
getPlatformType: () => PlatformType.WEB,
|
|
341
|
+
}),
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
const cancelQueriesSpy = vi.spyOn(testQueryClient, 'cancelQueries')
|
|
345
|
+
|
|
346
|
+
const onNotifications = vi.fn()
|
|
347
|
+
dataSource.start(onNotifications)
|
|
348
|
+
|
|
349
|
+
await vi.waitFor(() => {
|
|
350
|
+
expect(onNotifications).toHaveBeenCalled()
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
await dataSource.stop()
|
|
354
|
+
|
|
355
|
+
expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey: [ReactQueryCacheKey.Notifications] })
|
|
356
|
+
|
|
357
|
+
cancelQueriesSpy.mockRestore()
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('retries failed requests according to retry configuration', async () => {
|
|
361
|
+
let callCount = 0
|
|
362
|
+
mockApiClient.getNotifications = vi.fn().mockImplementation(() => {
|
|
363
|
+
callCount++
|
|
364
|
+
if (callCount <= 2) {
|
|
365
|
+
return Promise.reject(new Error('Temporary error'))
|
|
366
|
+
}
|
|
367
|
+
return Promise.resolve(new GetNotificationsResponse({ notifications: mockNotifications }))
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Create a fresh QueryClient to test actual retry behavior from queryOptions
|
|
371
|
+
const retryQueryClient = new QueryClient()
|
|
372
|
+
|
|
373
|
+
const dataSource = createPollingNotificationDataSource({
|
|
374
|
+
queryClient: retryQueryClient,
|
|
375
|
+
queryOptions: getNotificationQueryOptions({
|
|
376
|
+
apiClient: mockApiClient,
|
|
377
|
+
getPlatformType: () => PlatformType.WEB,
|
|
378
|
+
}),
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
const onNotifications = vi.fn()
|
|
382
|
+
dataSource.start(onNotifications)
|
|
383
|
+
|
|
384
|
+
// Should eventually succeed after retries
|
|
385
|
+
await vi.waitFor(
|
|
386
|
+
() => {
|
|
387
|
+
expect(onNotifications).toHaveBeenCalled()
|
|
388
|
+
},
|
|
389
|
+
{ timeout: 10000 },
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
// Should have made multiple attempts (initial + 2 retries)
|
|
393
|
+
expect(callCount).toBe(3)
|
|
394
|
+
|
|
395
|
+
await dataSource.stop()
|
|
396
|
+
retryQueryClient.clear()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { type QueryClient, type QueryKey, QueryObserver } from '@tanstack/react-query'
|
|
2
|
+
import { type InAppNotification } from '@luxfi/api'
|
|
3
|
+
import { createNotificationDataSource } from '@luxfi/notifications/src/notification-data-source/implementations/createNotificationDataSource'
|
|
4
|
+
import { type NotificationDataSource } from '@luxfi/notifications/src/notification-data-source/NotificationDataSource'
|
|
5
|
+
import { getLogger } from '@luxfi/utilities/src/logger/logger'
|
|
6
|
+
import { type QueryOptionsResult } from '@luxfi/utilities/src/reactQuery/queryOptions'
|
|
7
|
+
|
|
8
|
+
interface CreatePollingNotificationDataSourceContext<TQueryKey extends QueryKey = QueryKey> {
|
|
9
|
+
queryClient: QueryClient
|
|
10
|
+
queryOptions: QueryOptionsResult<InAppNotification[], Error, InAppNotification[], TQueryKey>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a polling notification data source using React Query.
|
|
15
|
+
* This handles the lifecycle orchestration of the query (start/stop).
|
|
16
|
+
*/
|
|
17
|
+
export function createPollingNotificationDataSource<TQueryKey extends QueryKey = QueryKey>(
|
|
18
|
+
ctx: CreatePollingNotificationDataSourceContext<TQueryKey>,
|
|
19
|
+
): NotificationDataSource {
|
|
20
|
+
const { queryClient, queryOptions } = ctx
|
|
21
|
+
|
|
22
|
+
let observer: QueryObserver<InAppNotification[], Error, InAppNotification[], InAppNotification[], TQueryKey> | null =
|
|
23
|
+
null
|
|
24
|
+
let unsubscribe: (() => void) | null = null
|
|
25
|
+
let isActive = false
|
|
26
|
+
|
|
27
|
+
const start = async (
|
|
28
|
+
onNotifications: (notifications: InAppNotification[], source: string) => void,
|
|
29
|
+
): Promise<void> => {
|
|
30
|
+
if (isActive) {
|
|
31
|
+
return // Prevent multiple starts
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isActive = true
|
|
35
|
+
|
|
36
|
+
observer = new QueryObserver<InAppNotification[], Error, InAppNotification[], InAppNotification[], TQueryKey>(
|
|
37
|
+
queryClient,
|
|
38
|
+
queryOptions,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
unsubscribe = observer.subscribe((result) => {
|
|
42
|
+
// Only trigger callback when we have successful data
|
|
43
|
+
// Check both result.data exists AND status is success to avoid partial states
|
|
44
|
+
if (result.data && result.status === 'success') {
|
|
45
|
+
onNotifications(result.data, 'polling_api')
|
|
46
|
+
} else if (result.error) {
|
|
47
|
+
getLogger().error(result.error, {
|
|
48
|
+
tags: { file: 'createPollingNotificationDataSource', function: 'subscribe' },
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stop = async (): Promise<void> => {
|
|
55
|
+
if (unsubscribe) {
|
|
56
|
+
unsubscribe()
|
|
57
|
+
unsubscribe = null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
observer = null
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
await queryClient.cancelQueries({ queryKey: queryOptions.queryKey })
|
|
64
|
+
} catch (error) {
|
|
65
|
+
getLogger().error(error, {
|
|
66
|
+
tags: { file: 'createPollingNotificationDataSource', function: 'stop' },
|
|
67
|
+
})
|
|
68
|
+
} finally {
|
|
69
|
+
isActive = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return createNotificationDataSource({ start, stop })
|
|
74
|
+
}
|