Comprehensive integration test suite for notification services: - EmailService integration tests (Resend provider) - SMSService integration tests (Twilio provider) - PushService integration tests (FCM/APNs providers) - NotificationService integration tests (orchestration layer) Test coverage includes: - Successful notification delivery - Error handling (API errors, network timeouts, invalid inputs) - Rate limiting enforcement - Batch operations with partial failures - Notification preferences and deduplication - Template-based email sending - Metadata and attachment handling Total: ~1400 lines across 4 test files
367 lines
9.5 KiB
TypeScript
367 lines
9.5 KiB
TypeScript
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),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|