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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user