Add integration tests for notification services (FRE-4522)

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
This commit is contained in:
2026-05-02 13:22:41 -04:00
parent bdf8ad30b6
commit 67622a2f11
4 changed files with 1614 additions and 0 deletions

View File

@@ -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: '<h1>Test</h1>',
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: '<p>Test</p>',
};
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: '<p>Test</p>',
};
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: '<p>Test</p>',
};
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: '<p>Test</p>',
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: '<p>Test</p>',
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: '<p>Test</p>',
};
await emailService.send(notification);
expect(mockResend.emails.send).toHaveBeenCalledWith(
expect.objectContaining({
from: 'ShieldAI <noreply@shieldai.com>',
})
);
});
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: '<p>Test</p>',
};
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: '<h1>HTML</h1>',
textBody: 'Plain text',
};
await emailService.send(notification);
expect(mockResend.emails.send).toHaveBeenCalledWith(
expect.objectContaining({
html: '<h1>HTML</h1>',
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: '<p>Test</p>',
};
// 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: '<p>Test 1</p>',
},
{
channel: 'email',
to: 'user2@example.com',
subject: 'Batch 2',
htmlBody: '<p>Test 2</p>',
},
{
channel: 'email',
to: 'user3@example.com',
subject: 'Batch 3',
htmlBody: '<p>Test 3</p>',
},
];
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: '<p>Valid</p>',
},
{
channel: 'email',
to: 'invalid-email',
subject: 'Invalid',
htmlBody: '<p>Invalid</p>',
},
];
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);
});
});
});

View File

@@ -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: '<p>Test</p>',
};
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: '<p>Test</p>',
};
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: '<p>Test</p>',
};
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: '<p>Test</p>',
};
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();
});
});
});

View File

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

View File

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