|
1 | 1 | import { describe, it, expect, vi, beforeEach } from 'vitest';
|
2 | 2 |
|
3 |
| -import { NotificationConnector } from '@/connectors/notification/notification-connector'; |
4 |
| -import type { ITelegramConnector } from '@/connectors/telegram/interfaces/telegram-connector.interface'; |
| 3 | +import { NotificationConnector } from '../notification-connector'; |
| 4 | +import type { INotificationAdapter } from '../../core/interfaces/notification'; |
| 5 | +import { NotificationStatus } from '../../core/interfaces/notification'; |
| 6 | +import type { ILogger } from '../../core/interfaces/logger'; |
| 7 | +import type { IEventBus } from '../../core/interfaces/event-bus'; |
| 8 | +import type { IKeyValueStore } from '../../core/interfaces/storage'; |
5 | 9 |
|
6 | 10 | describe('NotificationConnector', () => {
|
7 |
| - let notificationConnector: NotificationConnector; |
8 |
| - let mockTelegramConnector: ITelegramConnector; |
| 11 | + let connector: NotificationConnector; |
| 12 | + let mockAdapter: INotificationAdapter; |
| 13 | + let mockLogger: ILogger; |
| 14 | + let mockEventBus: IEventBus; |
| 15 | + let mockStorage: IKeyValueStore; |
9 | 16 |
|
10 | 17 | beforeEach(() => {
|
11 | 18 | vi.clearAllMocks();
|
12 | 19 |
|
13 |
| - mockTelegramConnector = { |
14 |
| - sendMessage: vi.fn().mockResolvedValue(undefined), |
15 |
| - } as unknown as ITelegramConnector; |
16 |
| - |
17 |
| - notificationConnector = new NotificationConnector(mockTelegramConnector); |
| 20 | + mockAdapter = { |
| 21 | + formatMessage: vi.fn().mockResolvedValue({ text: 'formatted message' }), |
| 22 | + deliver: vi.fn().mockResolvedValue(undefined), |
| 23 | + checkReachability: vi.fn().mockResolvedValue(true), |
| 24 | + isRetryableError: vi.fn().mockReturnValue(true), |
| 25 | + getUserInfo: vi.fn().mockResolvedValue({ locale: 'en' }), |
| 26 | + }; |
| 27 | + |
| 28 | + mockLogger = { |
| 29 | + debug: vi.fn(), |
| 30 | + info: vi.fn(), |
| 31 | + warn: vi.fn(), |
| 32 | + error: vi.fn(), |
| 33 | + child: vi.fn().mockReturnThis(), |
| 34 | + }; |
| 35 | + |
| 36 | + mockEventBus = { |
| 37 | + emit: vi.fn(), |
| 38 | + on: vi.fn(), |
| 39 | + off: vi.fn(), |
| 40 | + once: vi.fn(), |
| 41 | + }; |
| 42 | + |
| 43 | + mockStorage = { |
| 44 | + get: vi.fn().mockResolvedValue(null), |
| 45 | + put: vi.fn().mockResolvedValue(undefined), |
| 46 | + delete: vi.fn().mockResolvedValue(undefined), |
| 47 | + list: vi.fn().mockResolvedValue({ keys: [] }), |
| 48 | + }; |
| 49 | + |
| 50 | + connector = new NotificationConnector({ |
| 51 | + adapter: mockAdapter, |
| 52 | + storage: mockStorage, |
| 53 | + logger: mockLogger, |
| 54 | + eventBus: mockEventBus, |
| 55 | + }); |
18 | 56 | });
|
19 | 57 |
|
20 |
| - describe('sendNotification', () => { |
| 58 | + describe('send', () => { |
21 | 59 | it('should send notification successfully', async () => {
|
22 |
| - const result = await notificationConnector.sendNotification(123456, 'Test message'); |
23 |
| - |
24 |
| - expect(result).toBe(true); |
25 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledWith(123456, 'Test message'); |
26 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(1); |
| 60 | + const message = { |
| 61 | + id: 'msg_123', |
| 62 | + recipientId: 'user_123', |
| 63 | + template: 'welcome', |
| 64 | + category: 'system' as const, |
| 65 | + priority: 'medium' as const, |
| 66 | + params: { name: 'John' }, |
| 67 | + }; |
| 68 | + |
| 69 | + const result = await connector.send(message); |
| 70 | + |
| 71 | + expect(result.status).toBe(NotificationStatus.SENT); |
| 72 | + expect(result.messageId).toBe('msg_123'); |
| 73 | + expect(result.deliveredAt).toBeDefined(); |
| 74 | + expect(mockAdapter.deliver).toHaveBeenCalledWith('user_123', { text: 'formatted message' }); |
| 75 | + expect(mockEventBus.emit).toHaveBeenCalledWith('notification:sent', expect.any(Object)); |
27 | 76 | });
|
28 | 77 |
|
29 |
| - it('should retry on failure', async () => { |
30 |
| - vi.mocked(mockTelegramConnector.sendMessage) |
31 |
| - .mockRejectedValueOnce(new Error('Temporary error')) |
32 |
| - .mockRejectedValueOnce(new Error('Temporary error')) |
33 |
| - .mockResolvedValueOnce(undefined); |
34 |
| - |
35 |
| - const result = await notificationConnector.sendNotification(123456, 'Test message'); |
36 |
| - |
37 |
| - expect(result).toBe(true); |
38 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(3); |
39 |
| - }); |
| 78 | + it('should handle unreachable recipient', async () => { |
| 79 | + vi.mocked(mockAdapter.checkReachability).mockResolvedValueOnce(false); |
40 | 80 |
|
41 |
| - it('should return false after max retries', async () => { |
42 |
| - vi.mocked(mockTelegramConnector.sendMessage).mockRejectedValue(new Error('Persistent error')); |
| 81 | + const message = { |
| 82 | + id: 'msg_123', |
| 83 | + recipientId: 'user_123', |
| 84 | + template: 'welcome', |
| 85 | + category: 'system' as const, |
| 86 | + priority: 'medium' as const, |
| 87 | + }; |
43 | 88 |
|
44 |
| - const result = await notificationConnector.sendNotification(123456, 'Test message'); |
| 89 | + const result = await connector.send(message); |
45 | 90 |
|
46 |
| - expect(result).toBe(false); |
47 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(3); |
| 91 | + expect(result.status).toBe(NotificationStatus.BLOCKED); |
| 92 | + expect(result.error).toBe('Recipient is not reachable'); |
| 93 | + expect(mockAdapter.deliver).not.toHaveBeenCalled(); |
| 94 | + expect(mockEventBus.emit).toHaveBeenCalledWith('notification:blocked', expect.any(Object)); |
48 | 95 | });
|
49 | 96 |
|
50 |
| - it('should not retry if user blocked the bot', async () => { |
51 |
| - vi.mocked(mockTelegramConnector.sendMessage).mockRejectedValueOnce( |
52 |
| - new Error('Bot was blocked by the user'), |
| 97 | + it('should retry on retryable error', async () => { |
| 98 | + const error = new Error('Temporary error'); |
| 99 | + vi.mocked(mockAdapter.deliver).mockRejectedValueOnce(error); |
| 100 | + vi.mocked(mockAdapter.isRetryableError).mockReturnValueOnce(true); |
| 101 | + |
| 102 | + const message = { |
| 103 | + id: 'msg_123', |
| 104 | + recipientId: 'user_123', |
| 105 | + template: 'welcome', |
| 106 | + category: 'system' as const, |
| 107 | + priority: 'medium' as const, |
| 108 | + }; |
| 109 | + |
| 110 | + const result = await connector.send(message); |
| 111 | + |
| 112 | + expect(result.status).toBe(NotificationStatus.RETRY); |
| 113 | + expect(result.retryCount).toBe(1); |
| 114 | + expect(result.nextRetryAt).toBeDefined(); |
| 115 | + expect(mockEventBus.emit).toHaveBeenCalledWith( |
| 116 | + 'notification:failed', |
| 117 | + expect.objectContaining({ |
| 118 | + willRetry: true, |
| 119 | + }), |
53 | 120 | );
|
54 |
| - |
55 |
| - const result = await notificationConnector.sendNotification(123456, 'Test message'); |
56 |
| - |
57 |
| - expect(result).toBe(false); |
58 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(1); |
59 | 121 | });
|
60 | 122 |
|
61 |
| - it('should detect various blocked user errors', async () => { |
62 |
| - const blockedErrors = [ |
63 |
| - 'Bot was blocked by the user', |
64 |
| - 'User is deactivated', |
65 |
| - 'Chat not found', |
66 |
| - 'Forbidden: bot was blocked', |
67 |
| - ]; |
68 |
| - |
69 |
| - for (const errorMessage of blockedErrors) { |
70 |
| - vi.clearAllMocks(); |
71 |
| - vi.mocked(mockTelegramConnector.sendMessage).mockRejectedValueOnce(new Error(errorMessage)); |
72 |
| - |
73 |
| - const result = await notificationConnector.sendNotification(123456, 'Test message'); |
74 |
| - |
75 |
| - expect(result).toBe(false); |
76 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(1); |
77 |
| - } |
| 123 | + it('should fail after max retries', async () => { |
| 124 | + const error = new Error('Persistent error'); |
| 125 | + vi.mocked(mockAdapter.deliver).mockRejectedValue(error); |
| 126 | + vi.mocked(mockAdapter.isRetryableError).mockReturnValue(false); |
| 127 | + |
| 128 | + const message = { |
| 129 | + id: 'msg_123', |
| 130 | + recipientId: 'user_123', |
| 131 | + template: 'welcome', |
| 132 | + category: 'system' as const, |
| 133 | + priority: 'medium' as const, |
| 134 | + metadata: { retryCount: 3 }, |
| 135 | + }; |
| 136 | + |
| 137 | + const result = await connector.send(message); |
| 138 | + |
| 139 | + expect(result.status).toBe(NotificationStatus.FAILED); |
| 140 | + expect(result.error).toBe('Persistent error'); |
| 141 | + expect(mockEventBus.emit).toHaveBeenCalledWith( |
| 142 | + 'notification:failed', |
| 143 | + expect.objectContaining({ |
| 144 | + willRetry: false, |
| 145 | + }), |
| 146 | + ); |
78 | 147 | });
|
79 | 148 | });
|
80 | 149 |
|
81 |
| - describe('sendBatchNotifications', () => { |
| 150 | + describe('sendBatch', () => { |
82 | 151 | it('should send notifications in batches', async () => {
|
83 |
| - const notifications = Array.from({ length: 75 }, (_, i) => ({ |
84 |
| - userId: 100 + i, |
85 |
| - message: `Message ${i}`, |
| 152 | + const messages = Array.from({ length: 25 }, (_, i) => ({ |
| 153 | + id: `msg_${i}`, |
| 154 | + recipientId: `user_${i}`, |
| 155 | + template: 'announcement', |
| 156 | + category: 'system' as const, |
| 157 | + priority: 'low' as const, |
86 | 158 | }));
|
87 | 159 |
|
88 |
| - await notificationConnector.sendBatchNotifications(notifications); |
89 |
| - |
90 |
| - // Should be called 75 times (once for each notification) |
91 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(75); |
92 |
| - |
93 |
| - // Check first and last calls |
94 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenNthCalledWith(1, 100, 'Message 0'); |
95 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenNthCalledWith(75, 174, 'Message 74'); |
| 160 | + const results = await connector.sendBatch(messages, { |
| 161 | + batchSize: 10, |
| 162 | + delayBetweenBatches: 100, |
| 163 | + }); |
| 164 | + |
| 165 | + expect(results).toHaveLength(25); |
| 166 | + expect(results.every((r) => r.status === NotificationStatus.SENT)).toBe(true); |
| 167 | + expect(mockAdapter.deliver).toHaveBeenCalledTimes(25); |
| 168 | + expect(mockEventBus.emit).toHaveBeenCalledWith( |
| 169 | + 'notification:batch:started', |
| 170 | + expect.any(Object), |
| 171 | + ); |
| 172 | + expect(mockEventBus.emit).toHaveBeenCalledWith( |
| 173 | + 'notification:batch:completed', |
| 174 | + expect.any(Object), |
| 175 | + ); |
96 | 176 | });
|
97 | 177 |
|
98 |
| - it('should handle failures gracefully in batch', async () => { |
99 |
| - // Set up mixed success/failure responses |
100 |
| - vi.mocked(mockTelegramConnector.sendMessage) |
101 |
| - .mockResolvedValueOnce(undefined) // First message success |
102 |
| - .mockRejectedValue(new Error('Failed')); // All others fail |
| 178 | + it('should handle failures in batch gracefully', async () => { |
| 179 | + vi.mocked(mockAdapter.deliver) |
| 180 | + .mockResolvedValueOnce(undefined) |
| 181 | + .mockRejectedValueOnce(new Error('Failed')) |
| 182 | + .mockResolvedValueOnce(undefined); |
103 | 183 |
|
104 |
| - const notifications = [ |
105 |
| - { userId: 111, message: 'Message 1' }, |
106 |
| - { userId: 222, message: 'Message 2' }, |
107 |
| - { userId: 333, message: 'Message 3' }, |
| 184 | + const messages = [ |
| 185 | + { |
| 186 | + id: 'msg_1', |
| 187 | + recipientId: 'user_1', |
| 188 | + template: 'test', |
| 189 | + category: 'system' as const, |
| 190 | + priority: 'low' as const, |
| 191 | + }, |
| 192 | + { |
| 193 | + id: 'msg_2', |
| 194 | + recipientId: 'user_2', |
| 195 | + template: 'test', |
| 196 | + category: 'system' as const, |
| 197 | + priority: 'low' as const, |
| 198 | + }, |
| 199 | + { |
| 200 | + id: 'msg_3', |
| 201 | + recipientId: 'user_3', |
| 202 | + template: 'test', |
| 203 | + category: 'system' as const, |
| 204 | + priority: 'low' as const, |
| 205 | + }, |
108 | 206 | ];
|
109 | 207 |
|
110 |
| - // Just verify it completes without throwing |
111 |
| - await expect( |
112 |
| - notificationConnector.sendBatchNotifications(notifications), |
113 |
| - ).resolves.toBeUndefined(); |
| 208 | + const results = await connector.sendBatch(messages, { |
| 209 | + batchSize: 10, |
| 210 | + delayBetweenBatches: 0, |
| 211 | + }); |
114 | 212 |
|
115 |
| - // Verify at least the first successful call was made |
116 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledWith(111, 'Message 1'); |
| 213 | + expect(results).toHaveLength(3); |
| 214 | + expect(results[0].status).toBe(NotificationStatus.SENT); |
| 215 | + expect(results[1].status).toBe(NotificationStatus.RETRY); |
| 216 | + expect(results[2].status).toBe(NotificationStatus.SENT); |
117 | 217 | });
|
| 218 | + }); |
118 | 219 |
|
119 |
| - it('should delay between batches', async () => { |
120 |
| - const notifications = Array.from({ length: 31 }, (_, i) => ({ |
121 |
| - userId: 100 + i, |
122 |
| - message: `Message ${i}`, |
123 |
| - })); |
| 220 | + describe('isReachable', () => { |
| 221 | + it('should return adapter reachability check result', async () => { |
| 222 | + const result = await connector.isReachable('user_123'); |
| 223 | + expect(result).toBe(true); |
| 224 | + expect(mockAdapter.checkReachability).toHaveBeenCalledWith('user_123'); |
| 225 | + }); |
| 226 | + |
| 227 | + it('should handle adapter errors gracefully', async () => { |
| 228 | + vi.mocked(mockAdapter.checkReachability).mockRejectedValueOnce(new Error('Check failed')); |
| 229 | + |
| 230 | + const result = await connector.isReachable('user_123'); |
| 231 | + expect(result).toBe(false); |
| 232 | + expect(mockLogger.error).toHaveBeenCalled(); |
| 233 | + }); |
| 234 | + }); |
124 | 235 |
|
125 |
| - const start = Date.now(); |
126 |
| - await notificationConnector.sendBatchNotifications(notifications); |
127 |
| - const duration = Date.now() - start; |
| 236 | + describe('getStatus', () => { |
| 237 | + it('should retrieve stored notification status', async () => { |
| 238 | + const storedResult = { |
| 239 | + messageId: 'msg_123', |
| 240 | + status: NotificationStatus.SENT, |
| 241 | + deliveredAt: new Date(), |
| 242 | + }; |
| 243 | + vi.mocked(mockStorage.get).mockResolvedValueOnce(JSON.stringify(storedResult)); |
| 244 | + |
| 245 | + const result = await connector.getStatus('msg_123'); |
| 246 | + expect(result).toEqual(storedResult); |
| 247 | + }); |
128 | 248 |
|
129 |
| - // Should have delayed at least 1000ms between batches |
130 |
| - expect(duration).toBeGreaterThanOrEqual(1000); |
131 |
| - expect(mockTelegramConnector.sendMessage).toHaveBeenCalledTimes(31); |
| 249 | + it('should return null when status not found', async () => { |
| 250 | + const result = await connector.getStatus('msg_123'); |
| 251 | + expect(result).toBeNull(); |
132 | 252 | });
|
133 | 253 |
|
134 |
| - it('should handle empty notifications array', async () => { |
135 |
| - await notificationConnector.sendBatchNotifications([]); |
| 254 | + it('should return null when storage not available', async () => { |
| 255 | + const connectorWithoutStorage = new NotificationConnector({ |
| 256 | + adapter: mockAdapter, |
| 257 | + logger: mockLogger, |
| 258 | + eventBus: mockEventBus, |
| 259 | + }); |
136 | 260 |
|
137 |
| - expect(mockTelegramConnector.sendMessage).not.toHaveBeenCalled(); |
| 261 | + const result = await connectorWithoutStorage.getStatus('msg_123'); |
| 262 | + expect(result).toBeNull(); |
138 | 263 | });
|
139 | 264 | });
|
140 | 265 | });
|
0 commit comments