- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8 - Actualizaciones de configuracion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
446 lines
21 KiB
JavaScript
446 lines
21 KiB
JavaScript
"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
|