import { jest } from '@jest/globals'; import CampaignSchedulerService from '../../../../services/scheduler/src/services/campaignSchedulerService.js'; import ScheduledCampaign from '../../../../services/scheduler/src/models/ScheduledCampaign.js'; import ScheduleJob from '../../../../services/scheduler/src/models/ScheduleJob.js'; import { createScheduledCampaign } from '../../../helpers/factories.js'; // Mock dependencies jest.mock('../../../../services/scheduler/src/models/ScheduledCampaign.js'); jest.mock('../../../../services/scheduler/src/models/ScheduleJob.js'); jest.mock('node-cron'); jest.mock('bull'); describe('CampaignSchedulerService', () => { let schedulerService; let mockQueue; beforeEach(() => { mockQueue = { add: jest.fn(), process: jest.fn(), on: jest.fn() }; schedulerService = new CampaignSchedulerService(mockQueue); jest.clearAllMocks(); }); describe('createSchedule', () => { it('should create one-time schedule', async () => { const scheduleData = createScheduledCampaign('camp123', { type: 'one-time', schedule: { startDateTime: new Date('2024-06-01T10:00:00Z') } }); const savedSchedule = { ...scheduleData, _id: 'sched123', save: jest.fn().mockResolvedValue(scheduleData) }; ScheduledCampaign.mockImplementation(() => savedSchedule); const result = await schedulerService.createSchedule(scheduleData); expect(ScheduledCampaign).toHaveBeenCalledWith(scheduleData); expect(savedSchedule.save).toHaveBeenCalled(); expect(mockQueue.add).toHaveBeenCalledWith( expect.objectContaining({ scheduleId: 'sched123', campaignId: 'camp123' }), expect.objectContaining({ delay: expect.any(Number) }) ); }); it('should create recurring schedule', async () => { const scheduleData = createScheduledCampaign('camp123', { type: 'recurring', schedule: { startDateTime: new Date(), recurring: { pattern: 'daily', frequency: { interval: 1, unit: 'day' }, time: '09:00', timezone: 'America/New_York' } } }); const savedSchedule = { ...scheduleData, _id: 'sched123', save: jest.fn().mockResolvedValue(scheduleData), calculateNextRunTime: jest.fn().mockReturnValue(new Date()) }; ScheduledCampaign.mockImplementation(() => savedSchedule); const result = await schedulerService.createSchedule(scheduleData); expect(result.type).toBe('recurring'); expect(savedSchedule.calculateNextRunTime).toHaveBeenCalled(); }); it('should validate schedule data', async () => { const invalidData = { campaignId: 'camp123', name: 'Test Schedule' // Missing required fields }; await expect(schedulerService.createSchedule(invalidData)) .rejects.toThrow('Schedule type is required'); }); it('should handle timezone conversion', async () => { const scheduleData = createScheduledCampaign('camp123', { type: 'recurring', schedule: { recurring: { time: '09:00', timezone: 'Europe/London' } } }); const savedSchedule = { ...scheduleData, _id: 'sched123', save: jest.fn().mockResolvedValue(scheduleData) }; ScheduledCampaign.mockImplementation(() => savedSchedule); await schedulerService.createSchedule(scheduleData); // Verify timezone handling expect(savedSchedule.schedule.recurring.timezone).toBe('Europe/London'); }); }); describe('pauseSchedule', () => { it('should pause active schedule', async () => { const schedule = { _id: 'sched123', status: 'active', save: jest.fn() }; ScheduledCampaign.findById.mockResolvedValue(schedule); await schedulerService.pauseSchedule('sched123'); expect(schedule.status).toBe('paused'); expect(schedule.save).toHaveBeenCalled(); }); it('should cancel pending jobs when pausing', async () => { const schedule = { _id: 'sched123', status: 'active', save: jest.fn() }; const pendingJobs = [ { _id: 'job1', status: 'pending', cancel: jest.fn() }, { _id: 'job2', status: 'pending', cancel: jest.fn() } ]; ScheduledCampaign.findById.mockResolvedValue(schedule); ScheduleJob.find.mockResolvedValue(pendingJobs); await schedulerService.pauseSchedule('sched123'); expect(ScheduleJob.find).toHaveBeenCalledWith({ scheduleId: 'sched123', status: 'pending' }); expect(pendingJobs[0].cancel).toHaveBeenCalled(); expect(pendingJobs[1].cancel).toHaveBeenCalled(); }); it('should handle already paused schedule', async () => { const schedule = { _id: 'sched123', status: 'paused' }; ScheduledCampaign.findById.mockResolvedValue(schedule); await expect(schedulerService.pauseSchedule('sched123')) .rejects.toThrow('Schedule is already paused'); }); }); describe('resumeSchedule', () => { it('should resume paused schedule', async () => { const schedule = { _id: 'sched123', status: 'paused', type: 'recurring', campaignId: 'camp123', save: jest.fn(), calculateNextRunTime: jest.fn().mockReturnValue(new Date()) }; ScheduledCampaign.findById.mockResolvedValue(schedule); await schedulerService.resumeSchedule('sched123'); expect(schedule.status).toBe('active'); expect(schedule.save).toHaveBeenCalled(); expect(schedule.calculateNextRunTime).toHaveBeenCalled(); expect(mockQueue.add).toHaveBeenCalled(); }); }); describe('processJob', () => { it('should execute campaign for job', async () => { const job = { _id: 'job123', scheduleId: 'sched123', campaignId: 'camp123', status: 'pending', acquireLock: jest.fn().mockResolvedValue(true), save: jest.fn() }; const schedule = { _id: 'sched123', type: 'recurring', recordExecution: jest.fn(), calculateNextRunTime: jest.fn().mockReturnValue(new Date()) }; ScheduleJob.findById.mockResolvedValue(job); ScheduledCampaign.findById.mockResolvedValue(schedule); // Mock campaign execution const mockCampaignExecution = jest.fn().mockResolvedValue({ success: true, executionId: 'exec123' }); schedulerService.executeCampaign = mockCampaignExecution; await schedulerService.processJob('job123'); expect(job.acquireLock).toHaveBeenCalled(); expect(mockCampaignExecution).toHaveBeenCalledWith('camp123'); expect(job.status).toBe('completed'); expect(schedule.recordExecution).toHaveBeenCalled(); }); it('should handle job lock failure', async () => { const job = { _id: 'job123', acquireLock: jest.fn().mockResolvedValue(false) }; ScheduleJob.findById.mockResolvedValue(job); await schedulerService.processJob('job123'); expect(job.acquireLock).toHaveBeenCalled(); expect(job.save).not.toHaveBeenCalled(); }); it('should handle campaign execution failure', async () => { const job = { _id: 'job123', scheduleId: 'sched123', campaignId: 'camp123', status: 'pending', attempts: 0, acquireLock: jest.fn().mockResolvedValue(true), save: jest.fn() }; ScheduleJob.findById.mockResolvedValue(job); // Mock campaign execution failure const mockCampaignExecution = jest.fn().mockRejectedValue(new Error('Campaign failed')); schedulerService.executeCampaign = mockCampaignExecution; await schedulerService.processJob('job123'); expect(job.status).toBe('failed'); expect(job.lastError).toContain('Campaign failed'); expect(job.attempts).toBe(1); }); it('should retry failed jobs', async () => { const job = { _id: 'job123', scheduleId: 'sched123', campaignId: 'camp123', status: 'failed', attempts: 1, maxRetries: 3, acquireLock: jest.fn().mockResolvedValue(true), save: jest.fn() }; ScheduleJob.findById.mockResolvedValue(job); await schedulerService.processJob('job123'); expect(mockQueue.add).toHaveBeenCalledWith( expect.objectContaining({ jobId: 'job123' }), expect.objectContaining({ delay: expect.any(Number) }) ); }); }); describe('getSchedulePreview', () => { it('should generate preview for recurring schedule', async () => { const schedule = { _id: 'sched123', type: 'recurring', schedule: { recurring: { pattern: 'weekly', daysOfWeek: [1, 3, 5], // Mon, Wed, Fri time: '10:00', timezone: 'America/New_York' } } }; ScheduledCampaign.findById.mockResolvedValue(schedule); const preview = await schedulerService.getSchedulePreview('sched123', 5); expect(preview).toHaveLength(5); preview.forEach(item => { expect(item).toHaveProperty('scheduledFor'); expect(item).toHaveProperty('dayOfWeek'); expect(item).toHaveProperty('localTime'); }); }); it('should handle one-time schedule preview', async () => { const startDate = new Date('2024-06-01T14:00:00Z'); const schedule = { _id: 'sched123', type: 'one-time', schedule: { startDateTime: startDate } }; ScheduledCampaign.findById.mockResolvedValue(schedule); const preview = await schedulerService.getSchedulePreview('sched123', 5); expect(preview).toHaveLength(1); expect(preview[0].scheduledFor).toEqual(startDate); }); }); describe('deleteSchedule', () => { it('should delete schedule and associated jobs', async () => { const schedule = { _id: 'sched123', status: 'paused', deleteOne: jest.fn() }; ScheduledCampaign.findById.mockResolvedValue(schedule); ScheduleJob.deleteMany.mockResolvedValue({ deletedCount: 3 }); await schedulerService.deleteSchedule('sched123'); expect(schedule.deleteOne).toHaveBeenCalled(); expect(ScheduleJob.deleteMany).toHaveBeenCalledWith({ scheduleId: 'sched123', status: { $in: ['pending', 'failed'] } }); }); it('should not delete active schedule', async () => { const schedule = { _id: 'sched123', status: 'active' }; ScheduledCampaign.findById.mockResolvedValue(schedule); await expect(schedulerService.deleteSchedule('sched123')) .rejects.toThrow('Cannot delete active schedule'); }); }); });