diff --git a/packages/integration-tests/src/e2e/email.service.integration.test.ts b/packages/integration-tests/src/e2e/email.service.integration.test.ts new file mode 100644 index 0000000..dbac03f --- /dev/null +++ b/packages/integration-tests/src/e2e/email.service.integration.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from '@jest/globals'; +import { EmailService } from '@shieldai/shared-notifications'; +import type { EmailNotification } from '@shieldai/shared-notifications'; + +// Mock Resend +vi.mock('resend', () => { + return { + Resend: vi.fn().mockImplementation(() => ({ + emails: { + send: vi.fn().mockResolvedValue({ + data: { id: 'resend-mock-123' }, + error: undefined, + }), + }, + })), + }; +}); + +describe('EmailService Integration Tests', () => { + let emailService: EmailService; + let mockResend: any; + + beforeAll(() => { + emailService = EmailService.getInstance(); + mockResend = (require('resend').Resend as any).mock.instances[0]; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + it('should successfully send email notification', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'test-email-123' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test Subject', + htmlBody: '

Test

', + textBody: 'Test', + }; + + const result = await emailService.send(notification); + + expect(result.status).toBe('sent'); + expect(result.channel).toBe('email'); + expect(result.externalId).toBe('test-email-123'); + expect(result.notificationId).toContain('email-'); + expect(result.deliveredAt).toBeInstanceOf(Date); + }); + + it('should handle invalid email format', async () => { + const notification: EmailNotification = { + channel: 'email', + to: 'invalid-email', + subject: 'Test', + htmlBody: '

Test

', + }; + + const result = await emailService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Invalid email format'); + }); + + it('should handle Resend API error', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'error-email-456' }, + error: { message: 'API rate limit exceeded' }, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const result = await emailService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('API rate limit exceeded'); + }); + + it('should handle network error', async () => { + mockResend.emails.send.mockRejectedValueOnce( + new Error('Network timeout') + ); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const result = await emailService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Network timeout'); + }); + + it('should include metadata in email', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'meta-email-789' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + metadata: { userId: 'user-123', campaign: 'welcome' }, + }; + + await emailService.send(notification); + + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { userId: 'user-123', campaign: 'welcome' }, + }) + ); + }); + + it('should include attachments in email', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'attach-email-101' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + attachments: [ + { + filename: 'report.pdf', + content: Buffer.from('PDF content'), + mimeType: 'application/pdf', + }, + ], + }; + + await emailService.send(notification); + + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + attachments: expect.arrayContaining([ + expect.objectContaining({ + filename: 'report.pdf', + contentType: 'application/pdf', + }), + ]), + }) + ); + }); + + it('should use default from address when not provided', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'default-from-202' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + await emailService.send(notification); + + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'ShieldAI ', + }) + ); + }); + + it('should use custom from address when provided', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'custom-from-303' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + from: 'custom@shieldai.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + await emailService.send(notification); + + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'custom@shieldai.com', + }) + ); + }); + + it('should handle both html and text body', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'both-body-404' }, + error: undefined, + }); + + const notification: EmailNotification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

HTML

', + textBody: 'Plain text', + }; + + await emailService.send(notification); + + expect(mockResend.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + html: '

HTML

', + text: 'Plain text', + }) + ); + }); + + it('should enforce email rate limiting', async () => { + // Set rate limit to 2 for testing + process.env.EMAIL_RATE_LIMIT = '2'; + + // Clear the service instance to pick up new config + vi.clearAllMocks(); + emailService = EmailService.getInstance(); + + const notification: EmailNotification = { + channel: 'email', + to: 'rate-test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + // First two should succeed + const result1 = await emailService.send(notification); + const result2 = await emailService.send(notification); + + expect(result1.status).toBe('sent'); + expect(result2.status).toBe('sent'); + + // Third should throw due to rate limit + await expect(emailService.send(notification)).rejects.toThrow( + 'Email rate limit exceeded' + ); + }); + }); + + describe('sendWithTemplate', () => { + beforeEach(() => { + vi.clearAllMocks(); + emailService = EmailService.getInstance(); + }); + + it('should send email with resolved template', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'template-email-505' }, + error: undefined, + }); + + const result = await emailService.sendWithTemplate('test@example.com', { + templateId: 'welcome-email', + locale: 'en', + variables: { name: 'John' }, + }); + + expect(result.status).toBe('sent'); + expect(result.channel).toBe('email'); + }); + + it('should handle missing template', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'missing-template-606' }, + error: undefined, + }); + + const result = await emailService.sendWithTemplate('test@example.com', { + templateId: 'non-existent-template', + locale: 'en', + variables: {}, + }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Template not found'); + }); + + it('should handle template channel mismatch', async () => { + mockResend.emails.send.mockResolvedValueOnce({ + data: { id: 'channel-mismatch-707' }, + error: undefined, + }); + + const result = await emailService.sendWithTemplate('test@example.com', { + templateId: 'sms-template', + locale: 'en', + variables: {}, + channel: 'email', + }); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('is for channel'); + }); + }); + + describe('sendBatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + emailService = EmailService.getInstance(); + }); + + it('should send multiple emails successfully', async () => { + mockResend.emails.send + .mockResolvedValueOnce({ data: { id: 'batch-1' }, error: undefined }) + .mockResolvedValueOnce({ data: { id: 'batch-2' }, error: undefined }) + .mockResolvedValueOnce({ data: { id: 'batch-3' }, error: undefined }); + + const notifications: EmailNotification[] = [ + { + channel: 'email', + to: 'user1@example.com', + subject: 'Batch 1', + htmlBody: '

Test 1

', + }, + { + channel: 'email', + to: 'user2@example.com', + subject: 'Batch 2', + htmlBody: '

Test 2

', + }, + { + channel: 'email', + to: 'user3@example.com', + subject: 'Batch 3', + htmlBody: '

Test 3

', + }, + ]; + + const results = await emailService.sendBatch(notifications); + + expect(results).toHaveLength(3); + expect(results.every(r => r.status === 'sent')).toBe(true); + expect(results.map(r => r.externalId)).toEqual(['batch-1', 'batch-2', 'batch-3']); + }); + + it('should handle partial failures in batch', async () => { + mockResend.emails.send + .mockResolvedValueOnce({ data: { id: 'partial-1' }, error: undefined }) + .mockResolvedValueOnce({ data: { id: 'partial-2' }, error: undefined }); + + const notifications: EmailNotification[] = [ + { + channel: 'email', + to: 'valid@example.com', + subject: 'Valid', + htmlBody: '

Valid

', + }, + { + channel: 'email', + to: 'invalid-email', + subject: 'Invalid', + htmlBody: '

Invalid

', + }, + ]; + + const results = await emailService.sendBatch(notifications); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('sent'); + expect(results[1].status).toBe('failed'); + }); + }); + + describe('getRateLimitStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + emailService = EmailService.getInstance(); + }); + + it('should return rate limit status', () => { + const status = emailService.getRateLimitStatus(); + + expect(status).toHaveProperty('remaining'); + expect(status).toHaveProperty('limit'); + expect(status.limit).toBeGreaterThan(0); + expect(status.remaining).toBeLessThanOrEqual(status.limit); + }); + }); +}); diff --git a/packages/integration-tests/src/e2e/notification.service.integration.test.ts b/packages/integration-tests/src/e2e/notification.service.integration.test.ts new file mode 100644 index 0000000..74c7000 --- /dev/null +++ b/packages/integration-tests/src/e2e/notification.service.integration.test.ts @@ -0,0 +1,513 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals'; +import { NotificationService } from '@shieldai/shared-notifications'; +import { EmailService } from '@shieldai/shared-notifications'; +import { SMSService } from '@shieldai/shared-notifications'; +import { PushService } from '@shieldai/shared-notifications'; +import type { Notification, DeduplicationKey } from '@shieldai/shared-notifications'; + +// Mock individual services +vi.mock('@shieldai/shared-notifications', async () => { + const actual = await vi.importActual('@shieldai/shared-notifications'); + + return { + ...(actual as object), + EmailService: { + getInstance: vi.fn(() => ({ + send: vi.fn(async (notification: any) => ({ + notificationId: `email-${Date.now()}`, + channel: 'email', + status: 'sent', + externalId: 'resend-mock-id', + })), + })), + }, + SMSService: { + getInstance: vi.fn(() => ({ + send: vi.fn(async (notification: any) => ({ + notificationId: `sms-${Date.now()}`, + channel: 'sms', + status: 'sent', + externalId: 'twilio-mock-id', + })), + })), + }, + PushService: { + getInstance: vi.fn(() => ({ + send: vi.fn(async (notification: any) => ({ + notificationId: `push-${Date.now()}`, + channel: 'push', + status: 'sent', + externalId: 'fcm-mock-id', + })), + })), + }, + }; +}); + +describe('NotificationService Integration Tests', () => { + let notificationService: NotificationService; + + beforeAll(() => { + notificationService = NotificationService.getInstance(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + it('should send email notification', async () => { + const notification: Notification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const result = await notificationService.send(notification); + + expect(result.channel).toBe('email'); + expect(result.status).toBe('sent'); + expect(result.notificationId).toContain('email-'); + }); + + it('should send SMS notification', async () => { + const notification: Notification = { + channel: 'sms', + to: '+14155552672', + body: 'Test SMS', + }; + + const result = await notificationService.send(notification); + + expect(result.channel).toBe('sms'); + expect(result.status).toBe('sent'); + expect(result.notificationId).toContain('sms-'); + }); + + it('should send push notification', async () => { + const notification: Notification = { + channel: 'push', + userId: 'user-device-token', + title: 'Test Title', + body: 'Test Body', + }; + + const result = await notificationService.send(notification); + + expect(result.channel).toBe('push'); + expect(result.status).toBe('sent'); + expect(result.notificationId).toContain('push-'); + }); + + it('should throw for unknown channel', async () => { + const notification = { + channel: 'unknown' as any, + to: 'test@example.com', + } as Notification; + + await expect(notificationService.send(notification)).rejects.toThrow( + 'Unknown notification channel' + ); + }); + }); + + describe('sendWithDeduplication', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should allow first notification', async () => { + const notification: Notification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const dedupKey: DeduplicationKey = { + userId: 'user-123', + templateId: 'welcome-email', + key: 'initial', + }; + + const result = await notificationService.sendWithDeduplication( + notification, + dedupKey + ); + + expect(result.status).toBe('sent'); + }); + + it('should mark duplicate as pending', async () => { + const notification: Notification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const dedupKey: DeduplicationKey = { + userId: 'user-456', + templateId: 'alert-email', + key: 'same-key', + }; + + // First call + await notificationService.sendWithDeduplication(notification, dedupKey); + + // Second call with same key - should be pending + const result = await notificationService.sendWithDeduplication( + { ...notification, subject: 'Updated' }, + dedupKey + ); + + expect(result.status).toBe('pending'); + expect(result.error).toContain('Duplicate notification'); + }); + + it('should use custom deduplication window', async () => { + const notification: Notification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + const dedupKey: DeduplicationKey = { + userId: 'user-789', + templateId: 'sms-template', + key: 'custom-window', + windowSeconds: 60, + }; + + const result = await notificationService.sendWithDeduplication( + notification, + dedupKey + ); + + expect(result.status).toBe('sent'); + }); + }); + + describe('setPreference and getPreference', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should set notification preference', async () => { + const userId = 'user-pref-123'; + const channel = 'email' as const; + const enabled = true; + const categories = ['alerts', 'updates']; + + const preference = await notificationService.setPreference( + userId, + channel, + enabled, + categories + ); + + expect(preference.userId).toBe(userId); + expect(preference.channel).toBe(channel); + expect(preference.enabled).toBe(enabled); + expect(preference.categories).toEqual(categories); + }); + + it('should get notification preference', async () => { + const userId = 'user-pref-456'; + const channel = 'push' as const; + + await notificationService.setPreference(userId, channel, true, ['notifications']); + + const preference = await notificationService.getPreference(userId, channel); + + expect(preference).not.toBeNull(); + expect(preference?.userId).toBe(userId); + expect(preference?.enabled).toBe(true); + }); + + it('should return null for non-existent preference', async () => { + const preference = await notificationService.getPreference( + 'non-existent-user', + 'email' + ); + + expect(preference).toBeNull(); + }); + }); + + describe('shouldSend', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should allow send when no preference set', async () => { + const result = await notificationService.shouldSend( + 'new-user', + 'email', + 'alerts' + ); + + expect(result).toBe(true); + }); + + it('should allow send when preference enabled', async () => { + await notificationService.setPreference('enabled-user', 'sms', true, ['marketing']); + + const result = await notificationService.shouldSend( + 'enabled-user', + 'sms', + 'marketing' + ); + + expect(result).toBe(true); + }); + + it('should block send when preference disabled', async () => { + await notificationService.setPreference('disabled-user', 'push', false, ['alerts']); + + const result = await notificationService.shouldSend( + 'disabled-user', + 'push', + 'alerts' + ); + + expect(result).toBe(false); + }); + + it('should block send when category not in allowed list', async () => { + await notificationService.setPreference( + 'category-user', + 'email', + true, + ['alerts', 'updates'] + ); + + const result = await notificationService.shouldSend( + 'category-user', + 'email', + 'marketing' + ); + + expect(result).toBe(false); + }); + + it('should allow send when categories list is empty', async () => { + await notificationService.setPreference('empty-cats-user', 'sms', true, []); + + const result = await notificationService.shouldSend( + 'empty-cats-user', + 'sms', + 'any-category' + ); + + expect(result).toBe(true); + }); + }); + + describe('sendWithPreferences', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should send when preference allows', async () => { + await notificationService.setPreference( + 'pref-user-1', + 'email', + true, + ['alerts'] + ); + + const notification: Notification = { + channel: 'email', + to: 'test@example.com', + subject: 'Test', + htmlBody: '

Test

', + }; + + const result = await notificationService.sendWithPreferences( + notification, + 'alerts' + ); + + expect(result?.status).toBe('sent'); + }); + + it('should return pending when preference disabled', async () => { + await notificationService.setPreference( + 'pref-user-2', + 'sms', + false, + ['marketing'] + ); + + const notification: Notification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + const result = await notificationService.sendWithPreferences( + notification, + 'marketing' + ); + + expect(result?.status).toBe('pending'); + expect(result?.error).toContain('Notification disabled'); + }); + }); + + describe('checkRateLimit', () => { + beforeEach(async () => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should allow within rate limit', async () => { + const result = await notificationService.checkRateLimit( + 'rate-user-1', + 'email' + ); + + expect(result.allowed).toBe(true); + expect(result.limit).toBeGreaterThan(0); + expect(result.remaining).toBeLessThan(result.limit); + }); + + it('should track multiple identifiers independently', async () => { + await notificationService.checkRateLimit('rate-user-2a', 'email'); + await notificationService.checkRateLimit('rate-user-2b', 'email'); + + const resultA = await notificationService.checkRateLimit('rate-user-2a', 'email'); + const resultB = await notificationService.checkRateLimit('rate-user-2b', 'email'); + + expect(resultA.currentCount).toBe(2); + expect(resultB.currentCount).toBe(2); + }); + + it('should track different channels independently', async () => { + await notificationService.checkRateLimit('rate-user-3', 'email'); + await notificationService.checkRateLimit('rate-user-3', 'sms'); + + const emailResult = await notificationService.checkRateLimit('rate-user-3', 'email'); + const smsResult = await notificationService.checkRateLimit('rate-user-3', 'sms'); + + expect(emailResult.currentCount).toBe(2); + expect(smsResult.currentCount).toBe(2); + }); + + it('should use custom limit', async () => { + const result = await notificationService.checkRateLimit( + 'rate-user-4', + 'email', + 5 + ); + + expect(result.limit).toBe(5); + }); + + it('should use custom window', async () => { + const result = await notificationService.checkRateLimit( + 'rate-user-5', + 'email', + 10, + 120 + ); + + expect(result.resetInSeconds).toBeLessThanOrEqual(120); + }); + }); + + describe('deduplicateNotification', () => { + beforeEach(async () => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should return true for first notification', async () => { + const wasSet = await notificationService.deduplicateNotification({ + userId: 'dedup-user-1', + templateId: 'test-template', + key: 'unique-key', + }); + + expect(wasSet).toBe(true); + }); + + it('should return false for duplicate', async () => { + await notificationService.deduplicateNotification({ + userId: 'dedup-user-2', + templateId: 'test-template', + key: 'duplicate-key', + }); + + const wasSet = await notificationService.deduplicateNotification({ + userId: 'dedup-user-2', + templateId: 'test-template', + key: 'duplicate-key', + }); + + expect(wasSet).toBe(false); + }); + + it('should use custom window', async () => { + const wasSet = await notificationService.deduplicateNotification( + { + userId: 'dedup-user-3', + templateId: 'test-template', + key: 'custom-window-key', + }, + 60 + ); + + expect(wasSet).toBe(true); + }); + + it('should use windowSeconds from key', async () => { + const wasSet = await notificationService.deduplicateNotification({ + userId: 'dedup-user-4', + templateId: 'test-template', + key: 'key-window', + windowSeconds: 120, + }); + + expect(wasSet).toBe(true); + }); + }); + + describe('getRateLimitConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should return rate limit configuration', () => { + const config = notificationService.getRateLimitConfig(); + + expect(config).toHaveProperty('emailPerMinute'); + expect(config).toHaveProperty('smsPerMinute'); + expect(config).toHaveProperty('pushPerMinute'); + expect(config).toHaveProperty('windowSeconds'); + expect(config.emailPerMinute).toBeGreaterThan(0); + expect(config.smsPerMinute).toBeGreaterThan(0); + expect(config.pushPerMinute).toBeGreaterThan(0); + }); + }); + + describe('getTemplateService', () => { + beforeEach(() => { + vi.clearAllMocks(); + notificationService = NotificationService.getInstance(); + }); + + it('should return template service instance', () => { + const templateService = notificationService.getTemplateService(); + + expect(templateService).toBeDefined(); + }); + }); +}); diff --git a/packages/integration-tests/src/e2e/push.service.integration.test.ts b/packages/integration-tests/src/e2e/push.service.integration.test.ts new file mode 100644 index 0000000..f41485d --- /dev/null +++ b/packages/integration-tests/src/e2e/push.service.integration.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals'; +import { PushService } from '@shieldai/shared-notifications'; +import type { PushNotification } from '@shieldai/shared-notifications'; +import * as admin from 'firebase-admin'; + +// Mock firebase-admin +vi.mock('firebase-admin', () => { + const mockMessaging = { + send: vi.fn().mockResolvedValue('push-token-123'), + }; + + const mockCredential = { + cert: vi.fn().mockReturnValue({ + projectId: 'test-project', + clientEmail: 'test@test-project.iam.gserviceaccount.com', + privateKey: 'test-key', + }), + }; + + const mockApp = { + options: {}, + }; + + return { + default: { + initializeApp: vi.fn().mockReturnValue(mockApp), + credential: { + cert: mockCredential, + }, + messaging: vi.fn().mockReturnValue(mockMessaging), + }, + messaging: vi.fn().mockReturnValue(mockMessaging), + app: { + App: Object, + }, + credential: { + cert: mockCredential, + }, + }; +}); + +describe('PushService Integration Tests', () => { + let pushService: PushService; + let mockMessaging: any; + + beforeAll(() => { + pushService = PushService.getInstance(); + mockMessaging = (require('firebase-admin').messaging as any).mock.instances[0]; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + it('should successfully send push notification', async () => { + const mockResponse = 'fcm-message-id-123'; + (require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse); + + const notification: PushNotification = { + channel: 'push', + userId: 'user-device-token-123', + title: 'Test Title', + body: 'Test Body', + }; + + const result = await pushService.send(notification); + + expect(result.status).toBe('sent'); + expect(result.channel).toBe('push'); + expect(result.externalId).toBe(mockResponse); + expect(result.notificationId).toContain('push-'); + expect(result.deliveredAt).toBeInstanceOf(Date); + }); + + it('should include notification data', async () => { + const mockResponse = 'data-push-456'; + (require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse); + + const notification: PushNotification = { + channel: 'push', + userId: 'user-device-token-456', + title: 'Test', + body: 'Test', + data: { key1: 'value1', key2: 'value2' }, + }; + + await pushService.send(notification); + + expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + key1: 'value1', + key2: 'value2', + }), + }) + ); + }); + + it('should include badge and sound settings', async () => { + const mockResponse = 'apns-push-789'; + (require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse); + + const notification: PushNotification = { + channel: 'push', + userId: 'user-device-token-789', + title: 'Test', + body: 'Test', + badge: 5, + sound: 'custom-sound.caf', + category: 'ALERT_CATEGORY', + }; + + await pushService.send(notification); + + expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith( + expect.objectContaining({ + apns: expect.objectContaining({ + payload: expect.objectContaining({ + aps: expect.objectContaining({ + badge: 5, + sound: 'custom-sound.caf', + category: 'ALERT_CATEGORY', + }), + }), + }), + }) + ); + }); + + it('should handle undefined data gracefully', async () => { + const mockResponse = 'no-data-push-101'; + (require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse); + + const notification: PushNotification = { + channel: 'push', + userId: 'user-device-token-101', + title: 'Test', + body: 'Test', + }; + + await pushService.send(notification); + + expect((require('firebase-admin').messaging as any).mock.instances[0].send).toHaveBeenCalledWith( + expect.objectContaining({ + data: undefined, + }) + ); + }); + + it('should handle FCM API error', async () => { + (require('firebase-admin').messaging as any).mock.instances[0].send.mockRejectedValueOnce( + new Error('Invalid registration token') + ); + + const notification: PushNotification = { + channel: 'push', + userId: 'invalid-token', + title: 'Test', + body: 'Test', + }; + + const result = await pushService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Invalid registration token'); + }); + + it('should enforce push rate limiting', async () => { + process.env.PUSH_RATE_LIMIT = '2'; + vi.clearAllMocks(); + pushService = PushService.getInstance(); + + const notification: PushNotification = { + channel: 'push', + userId: 'rate-test-user', + title: 'Test', + body: 'Test', + }; + + // First two should succeed + const result1 = await pushService.send(notification); + const result2 = await pushService.send(notification); + + expect(result1.status).toBe('sent'); + expect(result2.status).toBe('sent'); + + // Third should throw due to rate limit + await expect(pushService.send(notification)).rejects.toThrow( + 'Push rate limit exceeded' + ); + }); + + it('should handle network timeout', async () => { + (require('firebase-admin').messaging as any).mock.instances[0].send.mockRejectedValueOnce( + new Error('Network timeout') + ); + + const notification: PushNotification = { + channel: 'push', + userId: 'timeout-user', + title: 'Test', + body: 'Test', + }; + + const result = await pushService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Network timeout'); + }); + }); + + describe('sendBatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + pushService = PushService.getInstance(); + }); + + it('should send multiple push notifications successfully', async () => { + (require('firebase-admin').messaging as any).mock.instances[0].send + .mockResolvedValueOnce('batch-push-1') + .mockResolvedValueOnce('batch-push-2') + .mockResolvedValueOnce('batch-push-3'); + + const notifications: PushNotification[] = [ + { + channel: 'push', + userId: 'user-token-1', + title: 'Batch 1', + body: 'Test 1', + }, + { + channel: 'push', + userId: 'user-token-2', + title: 'Batch 2', + body: 'Test 2', + }, + { + channel: 'push', + userId: 'user-token-3', + title: 'Batch 3', + body: 'Test 3', + }, + ]; + + const results = await pushService.sendBatch(notifications); + + expect(results).toHaveLength(3); + expect(results.every(r => r.status === 'sent')).toBe(true); + expect(results.map(r => r.externalId)).toEqual(['batch-push-1', 'batch-push-2', 'batch-push-3']); + }); + + it('should handle partial failures in batch', async () => { + (require('firebase-admin').messaging as any).mock.instances[0].send + .mockResolvedValueOnce('partial-push-1') + .mockRejectedValueOnce(new Error('Invalid token')); + + const notifications: PushNotification[] = [ + { + channel: 'push', + userId: 'valid-token', + title: 'Valid', + body: 'Valid', + }, + { + channel: 'push', + userId: 'invalid-token', + title: 'Invalid', + body: 'Invalid', + }, + ]; + + const results = await pushService.sendBatch(notifications); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('sent'); + expect(results[1].status).toBe('failed'); + }); + }); + + describe('getRateLimitStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + pushService = PushService.getInstance(); + }); + + it('should return rate limit status', () => { + const status = pushService.getRateLimitStatus(); + + expect(status).toHaveProperty('remaining'); + expect(status).toHaveProperty('limit'); + expect(status.limit).toBeGreaterThan(0); + expect(status.remaining).toBeLessThanOrEqual(status.limit); + }); + }); + + describe('APNs configuration', () => { + beforeEach(() => { + vi.clearAllMocks(); + pushService = PushService.getInstance(); + }); + + it('should configure APNs payload correctly', async () => { + const mockResponse = 'apns-config-test'; + (require('firebase-admin').messaging as any).mock.instances[0].send.mockResolvedValueOnce(mockResponse); + + const notification: PushNotification = { + channel: 'push', + userId: 'ios-device-token', + title: 'iOS Test', + body: 'iOS Body', + badge: 10, + sound: 'notification.caf', + category: 'MESSAGE_CATEGORY', + }; + + await pushService.send(notification); + + const callArg = (require('firebase-admin').messaging as any).mock.instances[0].send.mock.calls[0][0]; + + expect(call_arg.apns).toEqual( + expect.objectContaining({ + payload: expect.objectContaining({ + aps: expect.objectContaining({ + badge: 10, + sound: 'notification.caf', + category: 'MESSAGE_CATEGORY', + }), + }), + }) + ); + }); + }); +}); diff --git a/packages/integration-tests/src/e2e/sms.service.integration.test.ts b/packages/integration-tests/src/e2e/sms.service.integration.test.ts new file mode 100644 index 0000000..e377ec3 --- /dev/null +++ b/packages/integration-tests/src/e2e/sms.service.integration.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from '@jest/globals'; +import { SMSService } from '@shieldai/shared-notifications'; +import type { SMSNotification } from '@shieldai/shared-notifications'; +import twilio from 'twilio'; + +// Mock twilio +vi.mock('twilio', () => { + const mockMessages = { + create: vi.fn().mockResolvedValue({ + sid: 'SM1234567890abcdef1234567890abcdef', + from: '+14155552671', + to: '+14155552672', + body: 'Test message', + status: 'sent', + }), + }; + + return vi.fn().mockReturnValue({ + messages: mockMessages, + }); +}); + +describe('SMSService Integration Tests', () => { + let smsService: SMSService; + let mockTwilio: any; + + beforeAll(() => { + smsService = SMSService.getInstance(); + mockTwilio = (require('twilio') as any).mock.results[0].value; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('send', () => { + it('should successfully send SMS notification', async () => { + mockTwilio.messages.create.mockResolvedValueOnce({ + sid: 'SM1234567890abcdef', + from: '+14155552671', + to: '+14155552672', + body: 'Test SMS', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test SMS', + }; + + const result = await smsService.send(notification); + + expect(result.status).toBe('sent'); + expect(result.channel).toBe('sms'); + expect(result.externalId).toBe('SM1234567890abcdef'); + expect(result.notificationId).toContain('sms-'); + expect(result.deliveredAt).toBeInstanceOf(Date); + }); + + it('should use default from number when not provided', async () => { + mockTwilio.messages.create.mockResolvedValueOnce({ + sid: 'SM-default-from', + from: '+14155552671', + to: '+14155552672', + body: 'Test', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + await smsService.send(notification); + + expect(mockTwilio.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + from: expect.any(String), + }) + ); + }); + + it('should use custom from number when provided', async () => { + mockTwilio.messages.create.mockResolvedValueOnce({ + sid: 'SM-custom-from', + from: '+14155559999', + to: '+14155552672', + body: 'Test', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + from: '+14155559999', + body: 'Test', + }; + + await smsService.send(notification); + + expect(mockTwilio.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + from: '+14155559999', + }) + ); + }); + + it('should include metadata in SMS', async () => { + mockTwilio.messages.create.mockResolvedValueOnce({ + sid: 'SM-metadata-test', + from: '+14155552671', + to: '+14155552672', + body: 'Test', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + metadata: { userId: 'user-123', campaign: 'promo' }, + }; + + await smsService.send(notification); + + expect(mockTwilio.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: { userId: 'user-123', campaign: 'promo' }, + }) + ); + }); + + it('should handle Twilio API error', async () => { + mockTwilio.messages.create.mockRejectedValueOnce( + new Error('Invalid phone number') + ); + + const notification: SMSNotification = { + channel: 'sms', + to: 'invalid-number', + body: 'Test', + }; + + const result = await smsService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Invalid phone number'); + }); + + it('should handle rate limiting', async () => { + process.env.SMS_RATE_LIMIT = '2'; + vi.clearAllMocks(); + smsService = SMSService.getInstance(); + + mockTwilio.messages.create + .mockResolvedValueOnce({ + sid: 'SM-rate-1', + from: '+14155552671', + to: '+14155552672', + body: 'Test', + status: 'sent', + }) + .mockResolvedValueOnce({ + sid: 'SM-rate-2', + from: '+14155552671', + to: '+14155552672', + body: 'Test', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + // First two should succeed + const result1 = await smsService.send(notification); + const result2 = await smsService.send(notification); + + expect(result1.status).toBe('sent'); + expect(result2.status).toBe('sent'); + + // Third should throw due to rate limit + await expect(smsService.send(notification)).rejects.toThrow( + 'SMS rate limit exceeded' + ); + }); + + it('should handle network timeout', async () => { + mockTwilio.messages.create.mockRejectedValueOnce( + new Error('Network timeout') + ); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + const result = await smsService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toBe('Network timeout'); + }); + + it('should handle invalid phone number format', async () => { + mockTwilio.messages.create.mockRejectedValueOnce( + new Error('Number not in E.164 format') + ); + + const notification: SMSNotification = { + channel: 'sms', + to: '12345', + body: 'Test', + }; + + const result = await smsService.send(notification); + + expect(result.status).toBe('failed'); + expect(result.error).toContain('Number not in E.164 format'); + }); + }); + + describe('sendBatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + smsService = SMSService.getInstance(); + mockTwilio.messages.create + .mockResolvedValueOnce({ + sid: 'batch-sms-1', + from: '+14155552671', + to: '+14155552672', + body: 'Test 1', + status: 'sent', + }) + .mockResolvedValueOnce({ + sid: 'batch-sms-2', + from: '+14155552671', + to: '+14155552673', + body: 'Test 2', + status: 'sent', + }) + .mockResolvedValueOnce({ + sid: 'batch-sms-3', + from: '+14155552671', + to: '+14155552674', + body: 'Test 3', + status: 'sent', + }); + }); + + it('should send multiple SMS successfully', async () => { + const notifications: SMSNotification[] = [ + { + channel: 'sms', + to: '+14155552672', + body: 'Batch 1', + }, + { + channel: 'sms', + to: '+14155552673', + body: 'Batch 2', + }, + { + channel: 'sms', + to: '+14155552674', + body: 'Batch 3', + }, + ]; + + const results = await smsService.sendBatch(notifications); + + expect(results).toHaveLength(3); + expect(results.every(r => r.status === 'sent')).toBe(true); + expect(results.map(r => r.externalId)).toEqual(['batch-sms-1', 'batch-sms-2', 'batch-sms-3']); + }); + + it('should handle partial failures in batch', async () => { + mockTwilio.messages.create + .mockResolvedValueOnce({ + sid: 'partial-sms-1', + from: '+14155552671', + to: '+14155552672', + body: 'Valid', + status: 'sent', + }) + .mockRejectedValueOnce(new Error('Invalid number')); + + const notifications: SMSNotification[] = [ + { + channel: 'sms', + to: '+14155552672', + body: 'Valid', + }, + { + channel: 'sms', + to: 'invalid', + body: 'Invalid', + }, + ]; + + const results = await smsService.sendBatch(notifications); + + expect(results).toHaveLength(2); + expect(results[0].status).toBe('sent'); + expect(results[1].status).toBe('failed'); + }); + }); + + describe('getRateLimitStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + smsService = SMSService.getInstance(); + }); + + it('should return rate limit status', () => { + const status = smsService.getRateLimitStatus(); + + expect(status).toHaveProperty('remaining'); + expect(status).toHaveProperty('limit'); + expect(status.limit).toBeGreaterThan(0); + expect(status.remaining).toBeLessThanOrEqual(status.limit); + }); + }); + + describe('Twilio configuration', () => { + beforeEach(() => { + vi.clearAllMocks(); + smsService = SMSService.getInstance(); + }); + + it('should use configured account SID and auth token', () => { + expect(require('twilio')).toHaveBeenCalledWith( + expect.any(String), + expect.any(String) + ); + }); + + it('should use configured messaging service SID', async () => { + mockTwilio.messages.create.mockResolvedValueOnce({ + sid: 'SM-config-test', + from: '+14155552671', + to: '+14155552672', + body: 'Test', + status: 'sent', + }); + + const notification: SMSNotification = { + channel: 'sms', + to: '+14155552672', + body: 'Test', + }; + + await smsService.send(notification); + + expect(mockTwilio.messages.create).toHaveBeenCalledWith( + expect.objectContaining({ + from: expect.any(String), + }) + ); + }); + }); +});