"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const testing_1 = require("@nestjs/testing"); const typeorm_1 = require("@nestjs/typeorm"); const notification_queue_service_1 = require("../services/notification-queue.service"); const entities_1 = require("../entities"); describe('NotificationQueueService', () => { let service; let queueRepository; let logRepository; let notificationRepository; const mockNotificationId = '550e8400-e29b-41d4-a716-446655440000'; const mockQueueId = '550e8400-e29b-41d4-a716-446655440001'; const mockTenantId = '550e8400-e29b-41d4-a716-446655440002'; const mockUserId = '550e8400-e29b-41d4-a716-446655440003'; const mockNotification = { id: mockNotificationId, tenant_id: mockTenantId, user_id: mockUserId, type: 'info', channel: 'email', title: 'Test Notification', message: 'This is a test notification', is_read: false, delivery_status: 'pending', created_at: new Date(), }; const mockQueueItem = { id: mockQueueId, notification_id: mockNotificationId, channel: 'email', scheduled_for: new Date(), priority_value: 0, attempts: 0, max_attempts: 3, status: 'queued', error_count: 0, metadata: {}, created_at: new Date(), }; const createMockQueryBuilder = (overrides = {}) => ({ leftJoinAndSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue([]), select: jest.fn().mockReturnThis(), addSelect: jest.fn().mockReturnThis(), groupBy: jest.fn().mockReturnThis(), addGroupBy: jest.fn().mockReturnThis(), getRawMany: jest.fn().mockResolvedValue([]), delete: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 0 }), ...overrides, }); const mockQueueRepo = { create: jest.fn(), save: jest.fn(), findOne: jest.fn(), update: jest.fn(), createQueryBuilder: jest.fn(), }; const mockLogRepo = { save: jest.fn(), }; const mockNotificationRepo = { update: jest.fn(), }; beforeEach(async () => { jest.clearAllMocks(); mockQueueRepo.createQueryBuilder.mockReturnValue(createMockQueryBuilder()); const module = await testing_1.Test.createTestingModule({ providers: [ notification_queue_service_1.NotificationQueueService, { provide: (0, typeorm_1.getRepositoryToken)(entities_1.NotificationQueue), useValue: mockQueueRepo, }, { provide: (0, typeorm_1.getRepositoryToken)(entities_1.NotificationLog), useValue: mockLogRepo, }, { provide: (0, typeorm_1.getRepositoryToken)(entities_1.Notification), useValue: mockNotificationRepo, }, ], }).compile(); service = module.get(notification_queue_service_1.NotificationQueueService); queueRepository = module.get((0, typeorm_1.getRepositoryToken)(entities_1.NotificationQueue)); logRepository = module.get((0, typeorm_1.getRepositoryToken)(entities_1.NotificationLog)); notificationRepository = module.get((0, typeorm_1.getRepositoryToken)(entities_1.Notification)); }); afterEach(() => { jest.clearAllMocks(); }); describe('enqueue', () => { it('should enqueue notification with default priority', async () => { mockQueueRepo.create.mockReturnValue(mockQueueItem); mockQueueRepo.save.mockResolvedValue(mockQueueItem); const result = await service.enqueue(mockNotificationId, 'email'); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ notification_id: mockNotificationId, channel: 'email', priority_value: 0, status: 'queued', attempts: 0, max_attempts: 3, })); expect(mockQueueRepo.save).toHaveBeenCalled(); expect(result).toEqual(mockQueueItem); }); it('should enqueue notification with urgent priority', async () => { const urgentQueueItem = { ...mockQueueItem, priority_value: 10 }; mockQueueRepo.create.mockReturnValue(urgentQueueItem); mockQueueRepo.save.mockResolvedValue(urgentQueueItem); const result = await service.enqueue(mockNotificationId, 'email', 'urgent'); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority_value: 10, })); expect(result.priority_value).toBe(10); }); it('should enqueue notification with high priority', async () => { const highPriorityItem = { ...mockQueueItem, priority_value: 5 }; mockQueueRepo.create.mockReturnValue(highPriorityItem); mockQueueRepo.save.mockResolvedValue(highPriorityItem); const result = await service.enqueue(mockNotificationId, 'push', 'high'); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority_value: 5, })); expect(result.priority_value).toBe(5); }); it('should enqueue notification with low priority', async () => { const lowPriorityItem = { ...mockQueueItem, priority_value: -5 }; mockQueueRepo.create.mockReturnValue(lowPriorityItem); mockQueueRepo.save.mockResolvedValue(lowPriorityItem); const result = await service.enqueue(mockNotificationId, 'sms', 'low'); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority_value: -5, })); expect(result.priority_value).toBe(-5); }); it('should enqueue notification with scheduled time', async () => { const futureDate = new Date(Date.now() + 3600000); const scheduledItem = { ...mockQueueItem, scheduled_for: futureDate }; mockQueueRepo.create.mockReturnValue(scheduledItem); mockQueueRepo.save.mockResolvedValue(scheduledItem); const result = await service.enqueue(mockNotificationId, 'email', 'normal', futureDate); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ scheduled_for: futureDate, })); expect(result.scheduled_for).toEqual(futureDate); }); }); describe('enqueueBatch', () => { it('should enqueue notification for multiple channels', async () => { const channels = ['email', 'push', 'in_app']; const queueItems = channels.map((channel) => ({ ...mockQueueItem, channel, })); mockQueueRepo.create.mockImplementation((data) => data); mockQueueRepo.save.mockResolvedValue(queueItems); const result = await service.enqueueBatch(mockNotificationId, channels); expect(mockQueueRepo.create).toHaveBeenCalledTimes(3); expect(mockQueueRepo.save).toHaveBeenCalled(); expect(result).toHaveLength(3); }); it('should enqueue batch with specified priority', async () => { const channels = ['email', 'sms']; mockQueueRepo.create.mockImplementation((data) => data); mockQueueRepo.save.mockResolvedValue([]); await service.enqueueBatch(mockNotificationId, channels, 'urgent'); expect(mockQueueRepo.create).toHaveBeenCalledWith(expect.objectContaining({ priority_value: 10, })); }); }); describe('getPendingItems', () => { it('should return pending queue items', async () => { const pendingItems = [ { ...mockQueueItem, notification: mockNotification }, ]; const mockQueryBuilder = createMockQueryBuilder({ getMany: jest.fn().mockResolvedValue(pendingItems), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.getPendingItems(); expect(mockQueryBuilder.where).toHaveBeenCalledWith('q.status IN (:...statuses)', { statuses: ['queued', 'retrying'] }); expect(result).toEqual(pendingItems); }); it('should filter by channel when specified', async () => { const mockQueryBuilder = createMockQueryBuilder(); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); await service.getPendingItems(100, 'email'); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('q.channel = :channel', { channel: 'email' }); }); it('should limit results', async () => { const mockQueryBuilder = createMockQueryBuilder(); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); await service.getPendingItems(50); expect(mockQueryBuilder.take).toHaveBeenCalledWith(50); }); }); describe('markAsProcessing', () => { it('should update queue item status to processing', async () => { mockQueueRepo.update.mockResolvedValue({ affected: 1 }); await service.markAsProcessing(mockQueueId); expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { status: 'processing', last_attempt_at: expect.any(Date), }); }); }); describe('markAsSent', () => { it('should mark queue item as sent and update notification', async () => { mockQueueRepo.findOne.mockResolvedValue(mockQueueItem); mockQueueRepo.update.mockResolvedValue({ affected: 1 }); mockNotificationRepo.update.mockResolvedValue({ affected: 1 }); mockLogRepo.save.mockResolvedValue({}); await service.markAsSent(mockQueueId, 'sendgrid', 'msg-123', { status: 'ok' }); expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { status: 'sent', completed_at: expect.any(Date), attempts: 1, }); expect(mockNotificationRepo.update).toHaveBeenCalledWith(mockNotificationId, { delivery_status: 'sent', sent_at: expect.any(Date), }); expect(mockLogRepo.save).toHaveBeenCalledWith(expect.objectContaining({ notification_id: mockNotificationId, queue_id: mockQueueId, channel: 'email', status: 'sent', provider: 'sendgrid', provider_message_id: 'msg-123', })); }); it('should do nothing if queue item not found', async () => { mockQueueRepo.findOne.mockResolvedValue(null); await service.markAsSent(mockQueueId); expect(mockQueueRepo.update).not.toHaveBeenCalled(); expect(mockNotificationRepo.update).not.toHaveBeenCalled(); expect(mockLogRepo.save).not.toHaveBeenCalled(); }); }); describe('markAsFailed', () => { it('should retry on failure when attempts < max_attempts', async () => { const queueItemWithAttempts = { ...mockQueueItem, attempts: 0, max_attempts: 3 }; mockQueueRepo.findOne.mockResolvedValue(queueItemWithAttempts); mockQueueRepo.update.mockResolvedValue({ affected: 1 }); mockLogRepo.save.mockResolvedValue({}); await service.markAsFailed(mockQueueId, 'Connection timeout', 'sendgrid'); expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { status: 'retrying', attempts: 1, error_message: 'Connection timeout', error_count: 1, next_retry_at: expect.any(Date), }); }); it('should mark as failed permanently after max retries', async () => { const queueItemMaxRetries = { ...mockQueueItem, attempts: 2, max_attempts: 3, error_count: 2 }; mockQueueRepo.findOne.mockResolvedValue(queueItemMaxRetries); mockQueueRepo.update.mockResolvedValue({ affected: 1 }); mockNotificationRepo.update.mockResolvedValue({ affected: 1 }); mockLogRepo.save.mockResolvedValue({}); await service.markAsFailed(mockQueueId, 'Final failure', 'sendgrid'); expect(mockQueueRepo.update).toHaveBeenCalledWith(mockQueueId, { status: 'failed', attempts: 3, error_message: 'Final failure', error_count: 3, completed_at: expect.any(Date), }); expect(mockNotificationRepo.update).toHaveBeenCalledWith(mockNotificationId, { delivery_status: 'failed' }); }); it('should create log entry on failure', async () => { mockQueueRepo.findOne.mockResolvedValue(mockQueueItem); mockQueueRepo.update.mockResolvedValue({ affected: 1 }); mockLogRepo.save.mockResolvedValue({}); await service.markAsFailed(mockQueueId, 'Test error', 'twilio'); expect(mockLogRepo.save).toHaveBeenCalledWith(expect.objectContaining({ notification_id: mockNotificationId, queue_id: mockQueueId, channel: 'email', status: 'failed', provider: 'twilio', error_message: 'Test error', })); }); it('should do nothing if queue item not found', async () => { mockQueueRepo.findOne.mockResolvedValue(null); await service.markAsFailed(mockQueueId, 'Error'); expect(mockQueueRepo.update).not.toHaveBeenCalled(); }); it('should calculate exponential backoff for retry', async () => { const queueItemWithOneAttempt = { ...mockQueueItem, attempts: 1, max_attempts: 3, error_count: 1 }; mockQueueRepo.findOne.mockResolvedValue(queueItemWithOneAttempt); mockQueueRepo.update.mockResolvedValue({ affected: 1 }); mockLogRepo.save.mockResolvedValue({}); const beforeCall = Date.now(); await service.markAsFailed(mockQueueId, 'Retry error'); const afterCall = Date.now(); const updateCall = mockQueueRepo.update.mock.calls[0][1]; const nextRetryAt = new Date(updateCall.next_retry_at).getTime(); const expectedMinDelay = beforeCall + 120000; const expectedMaxDelay = afterCall + 120000; expect(nextRetryAt).toBeGreaterThanOrEqual(expectedMinDelay - 1000); expect(nextRetryAt).toBeLessThanOrEqual(expectedMaxDelay + 1000); }); }); describe('getStats', () => { it('should return queue statistics', async () => { const mockStats = [ { status: 'queued', count: '10' }, { status: 'processing', count: '5' }, { status: 'sent', count: '100' }, { status: 'failed', count: '3' }, { status: 'retrying', count: '2' }, ]; const mockQueryBuilder = createMockQueryBuilder({ getRawMany: jest.fn().mockResolvedValue(mockStats), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.getStats(); expect(result).toEqual({ queued: 10, processing: 5, sent: 100, failed: 3, retrying: 2, }); }); it('should return zero for missing statuses', async () => { const mockStats = [ { status: 'queued', count: '5' }, ]; const mockQueryBuilder = createMockQueryBuilder({ getRawMany: jest.fn().mockResolvedValue(mockStats), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.getStats(); expect(result).toEqual({ queued: 5, processing: 0, sent: 0, failed: 0, retrying: 0, }); }); it('should return all zeros for empty queue', async () => { const mockQueryBuilder = createMockQueryBuilder({ getRawMany: jest.fn().mockResolvedValue([]), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.getStats(); expect(result).toEqual({ queued: 0, processing: 0, sent: 0, failed: 0, retrying: 0, }); }); }); describe('getStatsByChannel', () => { it('should return stats grouped by channel and status', async () => { const mockStats = [ { channel: 'email', status: 'sent', count: 50 }, { channel: 'push', status: 'sent', count: 30 }, { channel: 'email', status: 'failed', count: 2 }, ]; const mockQueryBuilder = createMockQueryBuilder({ getRawMany: jest.fn().mockResolvedValue(mockStats), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.getStatsByChannel(); expect(result).toEqual(mockStats); expect(mockQueryBuilder.groupBy).toHaveBeenCalledWith('q.channel'); expect(mockQueryBuilder.addGroupBy).toHaveBeenCalledWith('q.status'); }); }); describe('cleanupOldItems', () => { it('should delete old completed queue items', async () => { const mockQueryBuilder = createMockQueryBuilder({ execute: jest.fn().mockResolvedValue({ affected: 50 }), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.cleanupOldItems(30); expect(result).toBe(50); expect(mockQueryBuilder.delete).toHaveBeenCalled(); expect(mockQueryBuilder.where).toHaveBeenCalledWith('status IN (:...statuses)', { statuses: ['sent', 'failed'] }); }); it('should use default 30 days if not specified', async () => { const mockQueryBuilder = createMockQueryBuilder(); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); await service.cleanupOldItems(); expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('completed_at < :cutoff', expect.objectContaining({ cutoff: expect.any(Date) })); }); it('should return 0 if no items to delete', async () => { const mockQueryBuilder = createMockQueryBuilder({ execute: jest.fn().mockResolvedValue({ affected: 0 }), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.cleanupOldItems(7); expect(result).toBe(0); }); it('should handle undefined affected count', async () => { const mockQueryBuilder = createMockQueryBuilder({ execute: jest.fn().mockResolvedValue({}), }); mockQueueRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder); const result = await service.cleanupOldItems(); expect(result).toBe(0); }); }); describe('cancelPending', () => { it('should cancel pending items for notification', async () => { mockQueueRepo.update.mockResolvedValue({ affected: 3 }); const result = await service.cancelPending(mockNotificationId); expect(result).toBe(3); expect(mockQueueRepo.update).toHaveBeenCalledWith(expect.objectContaining({ notification_id: mockNotificationId, }), expect.objectContaining({ status: 'failed', error_message: 'Cancelled', completed_at: expect.any(Date), })); }); it('should return 0 if no pending items', async () => { mockQueueRepo.update.mockResolvedValue({ affected: 0 }); const result = await service.cancelPending(mockNotificationId); expect(result).toBe(0); }); it('should handle undefined affected count', async () => { mockQueueRepo.update.mockResolvedValue({}); const result = await service.cancelPending(mockNotificationId); expect(result).toBe(0); }); }); }); //# sourceMappingURL=notification-queue.service.spec.js.map