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
335 lines
9.6 KiB
TypeScript
335 lines
9.6 KiB
TypeScript
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',
|
|
}),
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|