228 lines
7.3 KiB
TypeScript
228 lines
7.3 KiB
TypeScript
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
import {CampaignStatus} from '@plunk/db';
|
|
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
|
|
|
|
describe('Scheduled Campaign Processor', () => {
|
|
let projectId: string;
|
|
const prisma = getPrismaClient();
|
|
const timeControl = createTimeControl();
|
|
|
|
beforeEach(async () => {
|
|
const {project} = await factories.createUserWithProject();
|
|
projectId = project.id;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Ensure time is always restored between tests
|
|
timeControl.restore();
|
|
});
|
|
|
|
describe('Campaign Scheduling', () => {
|
|
it('should execute campaign at scheduled time', async () => {
|
|
// Freeze time at a specific moment
|
|
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
|
|
|
// Schedule campaign for 1 hour from now
|
|
const scheduledTime = timeControl.helpers.relative(1, 'hour');
|
|
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
|
|
name: 'Scheduled Newsletter',
|
|
subject: 'Weekly Update',
|
|
});
|
|
|
|
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
|
expect(campaign.scheduledFor).toEqual(scheduledTime);
|
|
|
|
// Advance time to scheduled time
|
|
timeControl.advanceTo(scheduledTime);
|
|
|
|
// Verify we're at the right time
|
|
expect(timeControl.now()).toEqual(scheduledTime);
|
|
|
|
// At this point, the scheduled processor would pick up this campaign
|
|
// and change its status to SENDING
|
|
await prisma.campaign.update({
|
|
where: {id: campaign.id},
|
|
data: {status: CampaignStatus.SENDING},
|
|
});
|
|
|
|
const updatedCampaign = await prisma.campaign.findUnique({
|
|
where: {id: campaign.id},
|
|
});
|
|
|
|
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
|
|
|
|
timeControl.restore();
|
|
});
|
|
|
|
it('should not execute campaign before scheduled time', async () => {
|
|
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
|
|
|
// Schedule campaign for 2 hours from now
|
|
const scheduledTime = timeControl.helpers.relative(2, 'hour');
|
|
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
|
|
|
|
// Advance time by only 1 hour (before scheduled time)
|
|
timeControl.helpers.advanceHours(1);
|
|
|
|
// Campaign should still be scheduled
|
|
const stillScheduled = await prisma.campaign.findUnique({
|
|
where: {id: campaign.id},
|
|
});
|
|
|
|
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
|
|
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
|
|
|
|
timeControl.restore();
|
|
});
|
|
|
|
it('should handle multiple scheduled campaigns at different times', async () => {
|
|
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
|
|
|
// Create campaigns scheduled at different times
|
|
await factories.createScheduledCampaign(
|
|
projectId,
|
|
timeControl.helpers.relative(1, 'hour'), // 11:00
|
|
{name: 'Campaign 1'},
|
|
);
|
|
|
|
await factories.createScheduledCampaign(
|
|
projectId,
|
|
timeControl.helpers.relative(2, 'hour'), // 12:00
|
|
{name: 'Campaign 2'},
|
|
);
|
|
|
|
await factories.createScheduledCampaign(
|
|
projectId,
|
|
timeControl.helpers.relative(3, 'hour'), // 13:00
|
|
{name: 'Campaign 3'},
|
|
);
|
|
|
|
// Advance to 11:00 - first campaign should be processable
|
|
timeControl.helpers.advanceHours(1);
|
|
expect(timeControl.now().getUTCHours()).toBe(11);
|
|
|
|
// Advance to 12:00 - second campaign should be processable
|
|
timeControl.helpers.advanceHours(1);
|
|
expect(timeControl.now().getUTCHours()).toBe(12);
|
|
|
|
// Advance to 13:00 - third campaign should be processable
|
|
timeControl.helpers.advanceHours(1);
|
|
expect(timeControl.now().getUTCHours()).toBe(13);
|
|
|
|
// All campaigns should still be in scheduled state (until processor runs)
|
|
const campaigns = await prisma.campaign.findMany({
|
|
where: {projectId},
|
|
orderBy: {scheduledFor: 'asc'},
|
|
});
|
|
|
|
expect(campaigns).toHaveLength(3);
|
|
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
|
|
|
|
timeControl.restore();
|
|
});
|
|
});
|
|
|
|
describe('Campaign Status Transitions', () => {
|
|
it('should transition from SCHEDULED to SENDING', async () => {
|
|
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
|
const scheduledTime = timeControl.helpers.relative(30, 'minute');
|
|
|
|
const campaign = await factories.createCampaign({
|
|
projectId,
|
|
status: CampaignStatus.SCHEDULED,
|
|
scheduledFor: scheduledTime,
|
|
});
|
|
|
|
// Advance past scheduled time
|
|
timeControl.helpers.advanceMinutes(31);
|
|
|
|
// Processor would transition to SENDING
|
|
await prisma.campaign.update({
|
|
where: {id: campaign.id},
|
|
data: {status: CampaignStatus.SENDING},
|
|
});
|
|
|
|
const sending = await prisma.campaign.findUnique({
|
|
where: {id: campaign.id},
|
|
});
|
|
|
|
expect(sending?.status).toBe(CampaignStatus.SENDING);
|
|
|
|
timeControl.restore();
|
|
});
|
|
|
|
it('should eventually transition from SENDING to SENT', async () => {
|
|
const campaign = await factories.createCampaign({
|
|
projectId,
|
|
status: CampaignStatus.SENDING,
|
|
});
|
|
|
|
// Create some contacts and emails
|
|
const contact = await factories.createContact({projectId});
|
|
await factories.createEmail(projectId, contact.id, {
|
|
campaignId: campaign.id,
|
|
});
|
|
|
|
// Mark campaign as sent
|
|
await prisma.campaign.update({
|
|
where: {id: campaign.id},
|
|
data: {
|
|
status: CampaignStatus.SENT,
|
|
sentAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const sent = await prisma.campaign.findUnique({
|
|
where: {id: campaign.id},
|
|
});
|
|
|
|
expect(sent?.status).toBe(CampaignStatus.SENT);
|
|
expect(sent?.sentAt).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle campaigns scheduled in the past', async () => {
|
|
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
|
|
|
// Schedule campaign in the past
|
|
const pastTime = new Date('2025-01-19T10:00:00Z');
|
|
const campaign = await factories.createCampaign({
|
|
projectId,
|
|
status: CampaignStatus.SCHEDULED,
|
|
scheduledFor: pastTime,
|
|
});
|
|
|
|
// Processor should immediately pick this up
|
|
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
|
|
expect(shouldRun).toBe(true);
|
|
|
|
timeControl.restore();
|
|
});
|
|
|
|
it('should handle campaigns scheduled far in the future', async () => {
|
|
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
|
|
|
// Schedule campaign 30 days in the future
|
|
const futureTime = timeControl.helpers.relative(30, 'day');
|
|
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
|
|
|
|
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
|
|
|
// Campaign should not be processable yet
|
|
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
|
|
expect(shouldNotRun).toBe(true);
|
|
|
|
// Advance time by 29 days - still not ready
|
|
timeControl.helpers.advanceDays(29);
|
|
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
|
|
|
|
// Advance to exactly the scheduled time
|
|
timeControl.advanceTo(futureTime);
|
|
expect(timeControl.now()).toEqual(futureTime);
|
|
|
|
timeControl.restore();
|
|
});
|
|
});
|
|
});
|