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), }) ); }); }); });