Files
plunk/apps/api/src/services/__tests__/CampaignService.test.ts
T
2025-12-01 09:56:56 +01:00

360 lines
12 KiB
TypeScript

import {describe, it, expect, beforeEach} from 'vitest';
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
import {CampaignService} from '../CampaignService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('CampaignService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('create', () => {
it('should create a campaign with ALL audience type', async () => {
const campaign = await CampaignService.create(projectId, {
name: 'Test Campaign',
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.ALL,
});
expect(campaign).toBeDefined();
expect(campaign.name).toBe('Test Campaign');
expect(campaign.status).toBe(CampaignStatus.DRAFT);
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
});
it('should create a campaign with SEGMENT audience type', async () => {
const segment = await factories.createSegment(projectId, {name: 'VIP Users'});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.segmentId).toBe(segment.id);
});
it('should throw error when creating SEGMENT campaign without segmentId', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
}),
).rejects.toThrow('Segment ID is required');
});
it('should throw error when segment does not exist', async () => {
await expect(
CampaignService.create(projectId, {
name: 'Invalid Campaign',
subject: 'Test',
body: '<p>Test</p>',
from: 'test@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: 'non-existent-segment',
}),
).rejects.toThrow('Segment not found');
});
});
describe('update', () => {
it('should update a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
name: 'Original Name',
status: CampaignStatus.DRAFT,
});
const updated = await CampaignService.update(projectId, campaign.id, {
name: 'Updated Name',
subject: 'Updated Subject',
});
expect(updated.name).toBe('Updated Name');
expect(updated.subject).toBe('Updated Subject');
});
it('should throw error when updating non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.update(projectId, campaign.id, {name: 'New Name'})).rejects.toThrow(
'Cannot update campaign that is sending or has been sent',
);
});
});
describe('delete', () => {
it('should delete a draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.DRAFT,
});
await CampaignService.delete(projectId, campaign.id);
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
expect(deleted).toBeNull();
});
it('should not delete a non-draft campaign', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENT,
});
await expect(CampaignService.delete(projectId, campaign.id)).rejects.toThrow('Can only delete draft campaigns');
});
});
describe('duplicate', () => {
it('should duplicate a campaign with (Copy) suffix', async () => {
const original = await factories.createCampaign({
projectId,
name: 'Original Campaign',
});
const duplicate = await CampaignService.duplicate(projectId, original.id);
expect(duplicate.name).toBe('Original Campaign (Copy)');
expect(duplicate.subject).toBe(original.subject);
expect(duplicate.body).toBe(original.body);
expect(duplicate.status).toBe(CampaignStatus.DRAFT);
expect(duplicate.id).not.toBe(original.id);
});
});
describe('list', () => {
it('should list campaigns with pagination', async () => {
// Create 25 campaigns using bulk insert to avoid memory issues
const campaignData = Array.from({length: 25}, (_, i) => ({
projectId,
name: `Campaign ${i}`,
subject: 'Test Subject',
body: '<p>Test Body</p>',
from: 'test@example.com',
status: 'DRAFT' as const,
}));
await prisma.campaign.createMany({data: campaignData});
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
expect(result.campaigns).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
expect(result.page).toBe(1);
});
it('should filter campaigns by status', async () => {
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
expect(result.campaigns).toHaveLength(2);
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
});
});
describe('get', () => {
it('should get a campaign by id', async () => {
const campaign = await factories.createCampaign({projectId, name: 'Test Campaign'});
const retrieved = await CampaignService.get(projectId, campaign.id);
expect(retrieved.id).toBe(campaign.id);
expect(retrieved.name).toBe('Test Campaign');
});
it('should throw error when campaign does not exist', async () => {
await expect(CampaignService.get(projectId, 'non-existent-id')).rejects.toThrow('Campaign not found');
});
});
describe('Campaign + Segment Integration', () => {
it('should create campaign targeting a segment', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const campaign = await CampaignService.create(projectId, {
name: 'VIP Campaign',
subject: 'Exclusive Offer',
body: '<p>For VIP users only</p>',
from: 'vip@example.com',
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.SEGMENT);
expect(campaign.segmentId).toBe(segment.id);
});
it('should only send to contacts matching segment criteria', async () => {
// Create contacts - some match segment, some don't
const vipContact1 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const vipContact2 = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const regularContact = await factories.createContact({
projectId,
subscribed: true,
data: {vip: false},
});
const segment = await factories.createSegment(projectId, {
name: 'VIP Users',
filters: [{field: 'data.vip', operator: 'equals', value: true}],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// In a real scenario, the campaign processor would create emails
// For this test, we manually check which contacts match the segment filters
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const contacts = allContacts.filter(c => (c.data as Record<string, unknown>)?.vip === true);
// Should only include VIP contacts
expect(contacts).toHaveLength(2);
const contactIds = contacts.map(c => c.id);
expect(contactIds).toContain(vipContact1.id);
expect(contactIds).toContain(vipContact2.id);
expect(contactIds).not.toContain(regularContact.id);
});
it('should exclude unsubscribed contacts from segment campaigns', async () => {
const subscribedVip = await factories.createContact({
projectId,
subscribed: true,
data: {vip: true},
});
const _unsubscribedVip = await factories.createContact({
projectId,
subscribed: false,
data: {vip: true},
});
// Segment that requires BOTH vip AND subscribed
const segment = await factories.createSegment(projectId, {
name: 'Subscribed VIP Users',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
const _campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
// Verify only subscribed VIP is targeted
const allContacts = await prisma.contact.findMany({
where: {projectId},
});
const matching = allContacts.filter(
c => (c.data as Record<string, unknown>)?.vip === true && c.subscribed === true,
);
expect(matching).toHaveLength(1);
expect(matching[0].id).toBe(subscribedVip.id);
});
it('should handle campaigns for ALL audience type', async () => {
// Create mix of contacts
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: true});
await factories.createContact({projectId, subscribed: false}); // Should be excluded
const campaign = await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.ALL,
});
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
expect(campaign.segmentId).toBeNull();
// Verify ALL campaigns should target subscribed contacts only
const subscribedContacts = await prisma.contact.findMany({
where: {projectId, subscribed: true},
});
expect(subscribedContacts).toHaveLength(2);
});
});
describe('Campaign Audience Validation', () => {
it('should calculate correct recipient count for segment campaigns', async () => {
// Create 5 contacts matching segment
for (let i = 0; i < 5; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro'},
});
}
// Create 3 contacts not matching
for (let i = 0; i < 3; i++) {
await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free'},
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
await factories.createCampaign({
projectId,
audienceType: CampaignAudienceType.SEGMENT,
segmentId: segment.id,
});
const matching = await prisma.contact.count({
where: {
projectId,
data: {
path: ['plan'],
equals: 'pro',
},
},
});
expect(matching).toBe(5);
});
});
});