Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
377 lines
11 KiB
JavaScript
377 lines
11 KiB
JavaScript
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');
|
|
});
|
|
});
|
|
}); |