|
| 1 | +import { VintaSendFactory } from '../../index'; |
| 2 | +import type { DatabaseNotification, NotificationInput } from '../../types/notification'; |
| 3 | +import type { BaseLogger } from '../loggers/base-logger'; |
| 4 | +import type { BaseNotificationAdapter } from '../notification-adapters/base-notification-adapter'; |
| 5 | +import type { BaseNotificationBackend } from '../notification-backends/base-notification-backend'; |
| 6 | +import type { BaseEmailTemplateRenderer } from '../notification-template-renderers/base-email-template-renderer'; |
| 7 | + |
| 8 | +type ContextGenerators = { |
| 9 | + testContext: { |
| 10 | + generate: (params: { userId: string }) => Promise<{ userId: string }>; |
| 11 | + }; |
| 12 | +}; |
| 13 | + |
| 14 | +type Config = { |
| 15 | + ContextMap: ContextGenerators; |
| 16 | + NotificationIdType: string; |
| 17 | + UserIdType: string; |
| 18 | +}; |
| 19 | + |
| 20 | +type MockBackend = jest.Mocked<BaseNotificationBackend<Config>> & { |
| 21 | + injectLogger: jest.Mock; |
| 22 | + injectAttachmentManager: jest.Mock; |
| 23 | + getBackendIdentifier: jest.Mock<string, []>; |
| 24 | +}; |
| 25 | + |
| 26 | +const templateRenderer: jest.Mocked<BaseEmailTemplateRenderer<Config>> = { |
| 27 | + render: jest.fn(), |
| 28 | + renderFromTemplateContent: jest.fn(), |
| 29 | +}; |
| 30 | + |
| 31 | +const logger: jest.Mocked<BaseLogger> = { |
| 32 | + info: jest.fn(), |
| 33 | + error: jest.fn(), |
| 34 | + warn: jest.fn(), |
| 35 | +}; |
| 36 | + |
| 37 | +// biome-ignore lint/suspicious/noExplicitAny: test adapter mock |
| 38 | +const adapter: jest.Mocked<BaseNotificationAdapter<any, Config>> = { |
| 39 | + notificationType: 'EMAIL', |
| 40 | + key: 'adapter-1', |
| 41 | + enqueueNotifications: false, |
| 42 | + send: jest.fn(), |
| 43 | + injectBackend: jest.fn(), |
| 44 | + injectLogger: jest.fn(), |
| 45 | + backend: null, |
| 46 | + templateRenderer, |
| 47 | + logger, |
| 48 | + supportsAttachments: false, |
| 49 | + // biome-ignore lint/suspicious/noExplicitAny: test-only cast |
| 50 | +} as any; |
| 51 | + |
| 52 | +const contextGenerators: ContextGenerators = { |
| 53 | + testContext: { |
| 54 | + generate: jest.fn(), |
| 55 | + }, |
| 56 | +}; |
| 57 | + |
| 58 | +function createMockBackend(identifier: string): MockBackend { |
| 59 | + return { |
| 60 | + persistNotification: jest.fn(), |
| 61 | + persistNotificationUpdate: jest.fn(), |
| 62 | + getAllFutureNotifications: jest.fn(), |
| 63 | + getAllFutureNotificationsFromUser: jest.fn(), |
| 64 | + getFutureNotificationsFromUser: jest.fn(), |
| 65 | + getFutureNotifications: jest.fn(), |
| 66 | + getAllPendingNotifications: jest.fn(), |
| 67 | + getPendingNotifications: jest.fn(), |
| 68 | + getNotification: jest.fn(), |
| 69 | + markAsRead: jest.fn(), |
| 70 | + filterAllInAppUnreadNotifications: jest.fn(), |
| 71 | + cancelNotification: jest.fn(), |
| 72 | + markAsSent: jest.fn(), |
| 73 | + markAsFailed: jest.fn(), |
| 74 | + storeAdapterAndContextUsed: jest.fn(), |
| 75 | + getUserEmailFromNotification: jest.fn(), |
| 76 | + filterInAppUnreadNotifications: jest.fn(), |
| 77 | + filterNotifications: jest.fn(), |
| 78 | + bulkPersistNotifications: jest.fn(), |
| 79 | + getAllNotifications: jest.fn(), |
| 80 | + getNotifications: jest.fn(), |
| 81 | + persistOneOffNotification: jest.fn(), |
| 82 | + persistOneOffNotificationUpdate: jest.fn(), |
| 83 | + getOneOffNotification: jest.fn(), |
| 84 | + getAllOneOffNotifications: jest.fn(), |
| 85 | + getOneOffNotifications: jest.fn(), |
| 86 | + storeAttachmentFileRecord: jest.fn(), |
| 87 | + getAttachmentFileRecord: jest.fn(), |
| 88 | + getAttachmentFile: jest.fn(), |
| 89 | + findAttachmentFileByChecksum: jest.fn(), |
| 90 | + deleteAttachmentFile: jest.fn(), |
| 91 | + getOrphanedAttachmentFiles: jest.fn(), |
| 92 | + getAttachments: jest.fn(), |
| 93 | + deleteNotificationAttachment: jest.fn(), |
| 94 | + injectLogger: jest.fn(), |
| 95 | + injectAttachmentManager: jest.fn(), |
| 96 | + getBackendIdentifier: jest.fn(() => identifier), |
| 97 | + } as unknown as MockBackend; |
| 98 | +} |
| 99 | + |
| 100 | +describe('VintaSend multi-backend error handling (Phase 4)', () => { |
| 101 | + const createNotificationInput: Omit<NotificationInput<Config>, 'id'> = { |
| 102 | + userId: 'user-1', |
| 103 | + notificationType: 'EMAIL', |
| 104 | + title: 'Title', |
| 105 | + bodyTemplate: 'body', |
| 106 | + contextName: 'testContext', |
| 107 | + contextParameters: { userId: 'user-1' }, |
| 108 | + sendAfter: new Date(Date.now() + 60_000), |
| 109 | + subjectTemplate: 'subject', |
| 110 | + extraParams: null, |
| 111 | + }; |
| 112 | + |
| 113 | + const databaseNotification = { |
| 114 | + ...createNotificationInput, |
| 115 | + id: 'notif-1', |
| 116 | + status: 'PENDING_SEND', |
| 117 | + contextUsed: null, |
| 118 | + adapterUsed: null, |
| 119 | + sentAt: null, |
| 120 | + readAt: null, |
| 121 | + gitCommitSha: null, |
| 122 | + } as DatabaseNotification<Config>; |
| 123 | + |
| 124 | + beforeEach(() => { |
| 125 | + jest.clearAllMocks(); |
| 126 | + contextGenerators.testContext.generate = jest.fn().mockResolvedValue({ userId: 'user-1' }); |
| 127 | + }); |
| 128 | + |
| 129 | + it('continues when one additional backend replication fails', async () => { |
| 130 | + const primaryBackend = createMockBackend('primary'); |
| 131 | + const failingReplica = createMockBackend('replica-a'); |
| 132 | + const healthyReplica = createMockBackend('replica-b'); |
| 133 | + |
| 134 | + primaryBackend.persistNotification.mockResolvedValue(databaseNotification); |
| 135 | + failingReplica.persistNotification.mockRejectedValue(new Error('replication failed')); |
| 136 | + healthyReplica.persistNotification.mockResolvedValue(databaseNotification); |
| 137 | + |
| 138 | + const service = new VintaSendFactory<Config>().create({ |
| 139 | + adapters: [adapter], |
| 140 | + backend: primaryBackend, |
| 141 | + additionalBackends: [failingReplica, healthyReplica], |
| 142 | + logger, |
| 143 | + contextGeneratorsMap: contextGenerators, |
| 144 | + }); |
| 145 | + |
| 146 | + const result = await service.createNotification(createNotificationInput); |
| 147 | + |
| 148 | + expect(result.id).toBe('notif-1'); |
| 149 | + expect(healthyReplica.persistNotification).toHaveBeenCalledWith({ |
| 150 | + ...createNotificationInput, |
| 151 | + id: 'notif-1', |
| 152 | + }); |
| 153 | + expect(logger.error).toHaveBeenCalledWith( |
| 154 | + expect.stringContaining('Failed to replicate createNotification to backend replica-a'), |
| 155 | + ); |
| 156 | + }); |
| 157 | + |
| 158 | + it('continues processing send path when additional backend markAsSent fails', async () => { |
| 159 | + const primaryBackend = createMockBackend('primary'); |
| 160 | + const failingReplica = createMockBackend('replica-a'); |
| 161 | + |
| 162 | + adapter.send.mockResolvedValue(); |
| 163 | + |
| 164 | + primaryBackend.markAsSent.mockResolvedValue(databaseNotification); |
| 165 | + failingReplica.markAsSent.mockRejectedValue(new Error('markAsSent failed')); |
| 166 | + |
| 167 | + primaryBackend.storeAdapterAndContextUsed.mockResolvedValue(); |
| 168 | + failingReplica.storeAdapterAndContextUsed.mockResolvedValue(); |
| 169 | + |
| 170 | + const service = new VintaSendFactory<Config>().create({ |
| 171 | + adapters: [adapter], |
| 172 | + backend: primaryBackend, |
| 173 | + additionalBackends: [failingReplica], |
| 174 | + logger, |
| 175 | + contextGeneratorsMap: contextGenerators, |
| 176 | + }); |
| 177 | + |
| 178 | + await expect(service.send(databaseNotification)).resolves.toBeUndefined(); |
| 179 | + |
| 180 | + expect(primaryBackend.markAsSent).toHaveBeenCalledWith('notif-1', true); |
| 181 | + expect(failingReplica.markAsSent).toHaveBeenCalledWith('notif-1', true); |
| 182 | + expect(primaryBackend.storeAdapterAndContextUsed).toHaveBeenCalled(); |
| 183 | + expect(failingReplica.storeAdapterAndContextUsed).toHaveBeenCalled(); |
| 184 | + expect(logger.error).toHaveBeenCalledWith( |
| 185 | + expect.stringContaining('Failed to replicate markAsSent to backend replica-a'), |
| 186 | + ); |
| 187 | + }); |
| 188 | + |
| 189 | + it('logs all additional backend failures without failing operation', async () => { |
| 190 | + const primaryBackend = createMockBackend('primary'); |
| 191 | + const replicaA = createMockBackend('replica-a'); |
| 192 | + const replicaB = createMockBackend('replica-b'); |
| 193 | + |
| 194 | + primaryBackend.markAsRead.mockResolvedValue({ |
| 195 | + ...databaseNotification, |
| 196 | + status: 'SENT', |
| 197 | + readAt: new Date(), |
| 198 | + }); |
| 199 | + replicaA.markAsRead.mockRejectedValue(new Error('replica A fail')); |
| 200 | + replicaB.markAsRead.mockRejectedValue(new Error('replica B fail')); |
| 201 | + |
| 202 | + const service = new VintaSendFactory<Config>().create({ |
| 203 | + adapters: [adapter], |
| 204 | + backend: primaryBackend, |
| 205 | + additionalBackends: [replicaA, replicaB], |
| 206 | + logger, |
| 207 | + contextGeneratorsMap: contextGenerators, |
| 208 | + }); |
| 209 | + |
| 210 | + await expect(service.markRead('notif-1')).resolves.toMatchObject({ id: 'notif-1' }); |
| 211 | + |
| 212 | + expect(logger.error).toHaveBeenCalledWith( |
| 213 | + expect.stringContaining('Failed to replicate markRead to backend replica-a'), |
| 214 | + ); |
| 215 | + expect(logger.error).toHaveBeenCalledWith( |
| 216 | + expect.stringContaining('Failed to replicate markRead to backend replica-b'), |
| 217 | + ); |
| 218 | + }); |
| 219 | +}); |
0 commit comments