Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
@@ -0,0 +1,423 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {EmailSourceType} from '@plunk/db';
import {BillingLimitService} from '../BillingLimitService';
import {EmailService} from '../EmailService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
describe('BillingLimitService - Critical Enforcement', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
});
describe('Revenue Protection - Limit Enforcement', () => {
it('should BLOCK transactional emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitTransactional: 5},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Attempt to send 6th email should fail
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK campaign emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 3},
});
// Create 3 campaign emails
for (let i = 0; i < 3; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// 4th should fail
const campaign = await factories.createCampaign({projectId});
await expect(
EmailService.sendCampaignEmail({
projectId,
contactId,
campaignId: campaign.id,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should BLOCK workflow emails when limit exceeded', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitWorkflows: 2},
});
// Create 2 workflow emails
for (let i = 0; i < 2; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
}
await BillingLimitService.invalidateCache(projectId);
// 3rd should fail
await expect(
EmailService.sendWorkflowEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
).rejects.toThrow(/billing limit/i);
});
it('should ALLOW emails when limit is null (unlimited)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitTransactional: null},
});
// Create 100 emails
for (let i = 0; i < 100; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Should still allow more
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(result.allowed).toBe(true);
expect(result.limit).toBeNull();
});
it('should enforce limits independently per source type', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitTransactional: 5,
billingLimitCampaigns: 5,
billingLimitWorkflows: 5,
},
});
// Max out transactional
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.TRANSACTIONAL,
});
}
await BillingLimitService.invalidateCache(projectId);
// Transactional should be blocked
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
expect(transactionalCheck.allowed).toBe(false);
// Campaign should still be allowed
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(campaignCheck.allowed).toBe(true);
// Workflow should still be allowed
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
expect(workflowCheck.allowed).toBe(true);
});
});
describe('Warning Threshold (80%)', () => {
it('should warn when usage reaches 80% of limit', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(true);
expect(result.percentage).toBeGreaterThanOrEqual(80);
expect(result.message).toMatch(/warning/i);
});
it('should not warn when usage is below 80%', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
for (let i = 0; i < 5; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.warning).toBe(false);
expect(result.message).toBeUndefined();
});
});
describe('Cache Performance & Fallback', () => {
it('should use cached usage counts to avoid DB queries', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// First call - should query DB and cache
const usage1 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Add email directly to DB (bypassing cache increment)
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
// Second call within cache TTL - should return cached value (not reflect new email)
const usage2 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(usage2).toBe(usage1); // Same as cached value
});
it('should increment cache after successful email send', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
await BillingLimitService.invalidateCache(projectId);
const initialUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Send email (should increment cache)
await EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
const finalUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
expect(finalUsage).toBe(initialUsage + 1);
});
it('should handle Redis failure gracefully without blocking emails', async () => {
// Mock Redis failure
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('Redis connection failed'));
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 100},
});
// Should fall back to DB and still work
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
});
it('should handle invalid project ID gracefully', async () => {
// Non-existent project should not crash, should allow email (fail-open)
const result = await BillingLimitService.checkLimit('non-existent-project', EmailSourceType.CAMPAIGN);
expect(result.allowed).toBe(true);
expect(result.usage).toBe(0);
expect(result.limit).toBeNull();
});
});
describe('Monthly Reset Behavior', () => {
it('should only count emails from current calendar month', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
const lastMonth = new Date();
lastMonth.setMonth(lastMonth.getMonth() - 1);
await prisma.email.create({
data: {
projectId,
contactId,
subject: 'Old',
body: 'Old',
from: 'test@example.com',
sourceType: EmailSourceType.CAMPAIGN,
status: 'SENT',
createdAt: lastMonth,
},
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const usage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
// Should only count this month's email
expect(usage).toBe(1);
});
});
describe('Complete Limits Overview', () => {
it('should return all category limits and usage', async () => {
await prisma.project.update({
where: {id: projectId},
data: {
billingLimitWorkflows: 100,
billingLimitCampaigns: 200,
billingLimitTransactional: null, // Unlimited
},
});
// Create various emails
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.WORKFLOW,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
await BillingLimitService.invalidateCache(projectId);
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
expect(limits.workflows.limit).toBe(100);
expect(limits.workflows.usage).toBe(1);
expect(limits.workflows.percentage).toBe(1);
expect(limits.workflows.isWarning).toBe(false);
expect(limits.workflows.isBlocked).toBe(false);
expect(limits.campaigns.limit).toBe(200);
expect(limits.campaigns.usage).toBe(2);
expect(limits.campaigns.percentage).toBe(1);
expect(limits.transactional.limit).toBeNull();
expect(limits.transactional.usage).toBe(0);
expect(limits.transactional.percentage).toBe(0);
});
});
describe('Race Condition Behavior', () => {
it('should check limits before each email send (may allow some concurrent sends)', async () => {
await prisma.project.update({
where: {id: projectId},
data: {billingLimitCampaigns: 10},
});
// Create 8 existing emails (80% of limit)
for (let i = 0; i < 8; i++) {
await factories.createEmail({
projectId,
contactId,
sourceType: EmailSourceType.CAMPAIGN,
});
}
await BillingLimitService.invalidateCache(projectId);
// Try to send 5 emails simultaneously
const promises = Array.from({length: 5}, () =>
EmailService.sendCampaignEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
}),
);
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
// Note: Due to race conditions, multiple may succeed before limit is enforced
// The limit check happens at send time, not atomically
// At least some should succeed (we're under limit when we start)
expect(successful).toBeGreaterThan(0);
// Eventually, some should fail once limit is hit
// Total emails created should not greatly exceed limit
const totalEmails = await prisma.email.count({
where: {projectId, sourceType: EmailSourceType.CAMPAIGN},
});
// Should be close to limit (8 existing + some new <= ~13 due to races)
expect(totalEmails).toBeLessThanOrEqual(15);
});
});
});
@@ -0,0 +1,359 @@
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);
});
});
});
@@ -0,0 +1,442 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {ContactService} from '../ContactService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('ContactService - Duplicate Prevention & Data Merging', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Duplicate Email Prevention', () => {
it('should REJECT creating duplicate email in same project', async () => {
const email = 'test@example.com';
await ContactService.create(projectId, {email});
await expect(ContactService.create(projectId, {email})).rejects.toThrow(/already exists/i);
});
it('should ALLOW same email in different projects (multi-tenancy)', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const email = 'test@example.com';
const contact1 = await ContactService.create(project1.id, {email});
const contact2 = await ContactService.create(project2.id, {email});
expect(contact1.id).not.toBe(contact2.id);
expect(contact1.email).toBe(email);
expect(contact2.email).toBe(email);
});
it('should REJECT updating contact to duplicate email in same project', async () => {
const email1 = 'user1@example.com';
const email2 = 'user2@example.com';
await ContactService.create(projectId, {email: email1});
const contact2 = await ContactService.create(projectId, {email: email2});
await expect(ContactService.update(projectId, contact2.id, {email: email1})).rejects.toThrow(/already exists/i);
});
it('should ALLOW updating contact to same email (no-op)', async () => {
const email = 'test@example.com';
const contact = await ContactService.create(projectId, {email});
const updated = await ContactService.update(projectId, contact.id, {
email,
data: {firstName: 'John'},
});
expect(updated.email).toBe(email);
});
it('should handle race condition when creating same email simultaneously', async () => {
const email = 'race@example.com';
const promises = Array.from({length: 10}, () => ContactService.create(projectId, {email}));
const results = await Promise.allSettled(promises);
const successful = results.filter(r => r.status === 'fulfilled').length;
const failed = results.filter(r => r.status === 'rejected').length;
expect(successful).toBe(1);
expect(failed).toBe(9);
const contacts = await prisma.contact.findMany({
where: {projectId, email},
});
expect(contacts).toHaveLength(1);
});
});
describe('Upsert Data Merging Logic', () => {
it('should merge new data with existing data without losing fields', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
expect(contact.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
});
const updated = await ContactService.upsert(projectId, email, {
lastName: 'Doe',
company: 'Acme Inc',
});
expect(updated.data).toMatchObject({
firstName: 'John',
plan: 'free',
signupDate: '2024-01-01',
lastName: 'Doe',
company: 'Acme Inc',
});
});
it('should overwrite existing fields with new values', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {
plan: 'free',
credits: 100,
});
const updated = await ContactService.upsert(projectId, email, {
plan: 'pro',
credits: 1000,
});
expect(updated.data).toMatchObject({
plan: 'pro',
credits: 1000,
});
});
it('should NOT persist non-persistent data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
tempToken: {value: 'abc123', persistent: false},
oneTimeCode: {value: '123456', persistent: false},
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('tempToken');
expect(contact.data).not.toHaveProperty('oneTimeCode');
});
it('should ignore reserved fields (plunk_id, plunk_email)', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
plunk_id: 'malicious-id',
plunk_email: 'hacker@evil.com',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).not.toHaveProperty('plunk_id');
expect(contact.data).not.toHaveProperty('plunk_email');
});
it('should handle null data gracefully', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, undefined);
expect(contact.email).toBe(email);
});
it('should handle empty object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {});
expect(contact.email).toBe(email);
});
it('should update subscription status independently of data', async () => {
const email = 'test@example.com';
await ContactService.upsert(projectId, email, {firstName: 'John'}, true);
const subscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(subscribed?.subscribed).toBe(true);
await ContactService.upsert(projectId, email, {lastName: 'Doe'}, false);
const unsubscribed = await prisma.contact.findFirst({
where: {projectId, email},
});
expect(unsubscribed?.subscribed).toBe(false);
expect(unsubscribed?.data).toMatchObject({
firstName: 'John',
lastName: 'Doe',
});
});
});
describe('getMergedData - Template Rendering', () => {
it('should include reserved plunk_id and plunk_email fields', async () => {
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
const merged = ContactService.getMergedData(contact);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('test@example.com');
});
it('should merge persistent contact data', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John', plan: 'pro'},
});
const merged = ContactService.getMergedData(contact);
expect(merged.firstName).toBe('John');
expect(merged.plan).toBe('pro');
});
it('should merge temporary (non-persistent) data for rendering', async () => {
const contact = await factories.createContact({
projectId,
data: {firstName: 'John'},
});
const temporaryData = {
resetToken: {value: 'temp123', persistent: false},
resetUrl: {value: 'https://app.com/reset?token=temp123', persistent: false},
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.firstName).toBe('John');
expect(merged.resetToken).toBe('temp123');
expect(merged.resetUrl).toBe('https://app.com/reset?token=temp123');
});
it('should override persistent data with temporary data', async () => {
const contact = await factories.createContact({
projectId,
data: {name: 'Stored Name'},
});
const temporaryData = {
name: 'Override Name',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.name).toBe('Override Name');
});
it('should not allow overriding reserved fields via temporary data', async () => {
const contact = await factories.createContact({
projectId,
email: 'real@example.com',
});
const temporaryData = {
plunk_id: 'fake-id',
plunk_email: 'fake@example.com',
};
const merged = ContactService.getMergedData(contact, temporaryData);
expect(merged.plunk_id).toBe(contact.id);
expect(merged.plunk_email).toBe('real@example.com');
});
});
describe('Data Integrity Edge Cases', () => {
it('should handle deeply nested object data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
expect(contact.data).toMatchObject({
profile: {
name: 'John',
address: {
city: 'NYC',
zip: '10001',
},
},
});
});
it('should handle array data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
tags: ['vip', 'beta-tester', 'early-adopter'],
});
expect(contact.data).toMatchObject({
tags: ['vip', 'beta-tester', 'early-adopter'],
});
});
it('should handle special characters in field names', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
'custom-field': 'value',
'field.with.dots': 'value2',
'field with spaces': 'value3',
});
expect(contact.data).toHaveProperty('custom-field', 'value');
expect(contact.data).toHaveProperty('field.with.dots', 'value2');
expect(contact.data).toHaveProperty('field with spaces', 'value3');
});
it('should handle boolean, number, and string values', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
expect(contact.data).toMatchObject({
isPremium: true,
credits: 100,
name: 'John Doe',
discount: 0.15,
});
});
it('should handle null values in data', async () => {
const email = 'test@example.com';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: null,
lastName: 'Doe',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('middleName', null);
expect(contact.data).toHaveProperty('lastName', 'Doe');
});
});
describe('Contact CRUD Operations', () => {
it('should find contact by email', async () => {
const email = 'find@example.com';
const created = await ContactService.create(projectId, {email});
const found = await ContactService.findByEmail(projectId, email);
expect(found?.id).toBe(created.id);
expect(found?.email).toBe(email);
});
it('should return null when contact not found by email', async () => {
const found = await ContactService.findByEmail(projectId, 'nonexistent@example.com');
expect(found).toBeNull();
});
it('should get contact count for project', async () => {
await factories.createContact({projectId});
await factories.createContact({projectId});
await factories.createContact({projectId});
const count = await ContactService.count(projectId);
expect(count).toBe(3);
});
it('should delete contact', async () => {
const contact = await factories.createContact({projectId});
await ContactService.delete(projectId, contact.id);
const deleted = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(deleted).toBeNull();
});
it('should throw 404 when deleting non-existent contact', async () => {
await expect(ContactService.delete(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
it('should throw 404 when getting non-existent contact', async () => {
await expect(ContactService.get(projectId, 'non-existent')).rejects.toThrow(/not found/i);
});
});
describe('Public Contact Operations (Unsubscribe)', () => {
it('should get contact by ID without project authentication', async () => {
const contact = await factories.createContact({projectId});
const fetched = await ContactService.getById(contact.id);
expect(fetched.id).toBe(contact.id);
expect(fetched.email).toBe(contact.email);
});
it('should subscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false,
});
await ContactService.subscribe(contact.id);
const subscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(subscribed?.subscribed).toBe(true);
});
it('should unsubscribe contact', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
await ContactService.unsubscribe(contact.id);
const unsubscribed = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribed?.subscribed).toBe(false);
});
});
});
@@ -0,0 +1,564 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {DomainService} from '../DomainService.js';
import {HttpException} from '../../exceptions/index.js';
import * as SESService from '../SESService.js';
/**
* Unit tests for DomainService
* Focuses on business logic, validation, and edge cases
*/
describe('DomainService', () => {
const prisma = getPrismaClient();
beforeEach(() => {
// Mock SES service calls to avoid AWS API calls
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
status: 'Success',
tokens: ['token1', 'token2', 'token3'],
});
});
// ========================================
// ADD DOMAIN
// ========================================
describe('addDomain', () => {
it('should add a domain and initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'example.com';
const result = await DomainService.addDomain(project.id, domain);
expect(result.domain).toBe(domain);
expect(result.projectId).toBe(project.id);
expect(result.verified).toBe(false);
expect(result.dkimTokens).toEqual(['token1', 'token2', 'token3']);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
it('should call AWS SES to initiate verification', async () => {
const {project} = await factories.createUserWithProject();
const domain = 'test-domain.com';
await DomainService.addDomain(project.id, domain);
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
});
});
// ========================================
// CHECK DOMAIN OWNERSHIP
// ========================================
describe('checkDomainOwnership', () => {
it('should return exists: false for non-existent domain', async () => {
const result = await DomainService.checkDomainOwnership('non-existent.com', 'user-id');
expect(result).toEqual({exists: false});
});
it('should return project info when domain exists', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'existing.com');
const result = await DomainService.checkDomainOwnership('existing.com', user.id);
expect(result.exists).toBe(true);
expect(result.projectId).toBe(project.id);
expect(result.projectName).toBe(project.name);
expect(result.isMember).toBe(true);
});
it('should indicate isMember: false when user is not a member', async () => {
const {project: project1} = await factories.createUserWithProject();
const {user: user2} = await factories.createUserWithProject();
await DomainService.addDomain(project1.id, 'restricted.com');
const result = await DomainService.checkDomainOwnership('restricted.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(false);
});
it('should indicate isMember: true when user is a member', async () => {
const {project} = await factories.createUserWithProject();
const user2 = await factories.createUser();
await prisma.membership.create({
data: {
userId: user2.id,
projectId: project.id,
role: 'MEMBER',
},
});
await DomainService.addDomain(project.id, 'team-domain.com');
const result = await DomainService.checkDomainOwnership('team-domain.com', user2.id);
expect(result.exists).toBe(true);
expect(result.isMember).toBe(true);
});
});
// ========================================
// VERIFY EMAIL DOMAIN
// ========================================
describe('verifyEmailDomain', () => {
it('should throw error for invalid email format', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
HttpException,
);
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should throw error when domain is not registered', async () => {
const {project} = await factories.createUserWithProject();
await expect(
DomainService.verifyEmailDomain('sender@unregistered.com', project.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@unregistered.com', project.id),
).rejects.toThrow(/not registered/i);
});
it('should throw error when domain belongs to different project', async () => {
const {project: project1} = await factories.createUserWithProject();
const {project: project2} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project1.id, 'project1.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
await expect(
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@project1.com', project2.id),
).rejects.toThrow(/belongs to a different project/i);
});
it('should throw error when domain is not verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified.com');
await expect(
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
).rejects.toThrow(HttpException);
await expect(
DomainService.verifyEmailDomain('sender@unverified.com', project.id),
).rejects.toThrow(/not verified/i);
});
it('should return domain when all checks pass', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'verified.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@verified.com', project.id);
expect(result.domain).toBe('verified.com');
expect(result.verified).toBe(true);
expect(result.projectId).toBe(project.id);
});
it('should extract domain correctly from various email formats', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Test various email formats
const result1 = await DomainService.verifyEmailDomain('user@example.com', project.id);
const result2 = await DomainService.verifyEmailDomain('admin@example.com', project.id);
const result3 = await DomainService.verifyEmailDomain('support+tag@example.com', project.id);
expect(result1.domain).toBe('example.com');
expect(result2.domain).toBe('example.com');
expect(result3.domain).toBe('example.com');
});
});
// ========================================
// GET DOMAIN BY ID
// ========================================
describe('id', () => {
it('should return domain by id', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'test.com');
const result = await DomainService.id(domain.id);
expect(result).not.toBeNull();
expect(result?.id).toBe(domain.id);
expect(result?.domain).toBe('test.com');
});
it('should return null for non-existent id', async () => {
const result = await DomainService.id('00000000-0000-0000-0000-000000000000');
expect(result).toBeNull();
});
});
// ========================================
// GET PROJECT DOMAINS
// ========================================
describe('getProjectDomains', () => {
it('should return all domains for a project', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'domain1.com');
await DomainService.addDomain(project.id, 'domain2.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toHaveLength(2);
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com']);
});
it('should return domains ordered by creation date (newest first)', async () => {
const {project} = await factories.createUserWithProject();
// Add domains with slight delay to ensure different timestamps
const domain1 = await DomainService.addDomain(project.id, 'first.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain2 = await DomainService.addDomain(project.id, 'second.com');
await new Promise(resolve => setTimeout(resolve, 10));
const domain3 = await DomainService.addDomain(project.id, 'third.com');
const domains = await DomainService.getProjectDomains(project.id);
expect(domains[0].id).toBe(domain3.id); // Newest first
expect(domains[1].id).toBe(domain2.id);
expect(domains[2].id).toBe(domain1.id);
});
it('should return empty array for project with no domains', async () => {
const {project} = await factories.createUserWithProject();
const domains = await DomainService.getProjectDomains(project.id);
expect(domains).toEqual([]);
});
});
// ========================================
// GET VERIFIED DOMAINS
// ========================================
describe('getVerifiedDomains', () => {
it('should return only verified domains', async () => {
const {project} = await factories.createUserWithProject();
const domain1 = await DomainService.addDomain(project.id, 'verified1.com');
await DomainService.addDomain(project.id, 'unverified.com');
const domain3 = await DomainService.addDomain(project.id, 'verified2.com');
// Mark two as verified
await prisma.domain.update({
where: {id: domain1.id},
data: {verified: true},
});
await prisma.domain.update({
where: {id: domain3.id},
data: {verified: true},
});
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toHaveLength(2);
expect(verifiedDomains.map(d => d.domain).sort()).toEqual(['verified1.com', 'verified2.com']);
});
it('should return empty array when no domains are verified', async () => {
const {project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'unverified1.com');
await DomainService.addDomain(project.id, 'unverified2.com');
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
expect(verifiedDomains).toEqual([]);
});
});
// ========================================
// CHECK VERIFICATION
// ========================================
describe('checkVerification', () => {
it('should check verification status with AWS SES', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'check-verification.com');
const result = await DomainService.checkVerification(domain.id);
expect(result.domain).toBe('check-verification.com');
expect(result.tokens).toEqual(['token1', 'token2', 'token3']);
expect(result.status).toBe('Success');
expect(result.verified).toBe(true);
expect(SESService.getDomainVerificationAttributes).toHaveBeenCalledWith('check-verification.com');
});
it('should update domain to verified when SES returns Success', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'newly-verified.com');
expect(domain.verified).toBe(false);
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(true);
});
it('should update domain to unverified when SES returns Pending', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'pending-domain.com');
// Manually mark as verified
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
// Mock SES to return Pending
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValueOnce({
status: 'Pending',
tokens: ['token1', 'token2', 'token3'],
});
await DomainService.checkVerification(domain.id);
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
expect(updated?.verified).toBe(false);
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
});
});
// ========================================
// REMOVE DOMAIN
// ========================================
describe('removeDomain', () => {
it('should remove domain when not in use', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'remove-me.com');
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error when domain is used in templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-template.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender@used-in-template.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*template/i,
);
});
it('should throw error when domain is used in active campaigns', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'used-in-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@used-in-campaign.com',
status: 'DRAFT',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
/used in.*campaign/i,
);
});
it('should allow removal when campaign is SENT (completed)', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'completed-campaign.com');
await factories.createCampaign({
projectId: project.id,
from: 'campaign@completed-campaign.com',
status: 'SENT',
});
// Should not throw
await DomainService.removeDomain(domain.id);
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
expect(deleted).toBeNull();
});
it('should throw error for non-existent domain', async () => {
await expect(
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
).rejects.toThrow(/domain not found/i);
});
it('should check usage in multiple templates', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'multi-use.com');
await factories.createTemplate({
projectId: project.id,
from: 'sender1@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender2@multi-use.com',
});
await factories.createTemplate({
projectId: project.id,
from: 'sender3@multi-use.com',
});
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/3 template/i);
});
});
// ========================================
// EDGE CASES AND ERROR HANDLING
// ========================================
describe('Edge Cases', () => {
it('should handle emails with plus addressing', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('user+tag@example.com', project.id);
expect(result.domain).toBe('example.com');
});
it('should handle subdomain correctly', async () => {
const {project} = await factories.createUserWithProject();
const domain = await DomainService.addDomain(project.id, 'mail.example.com');
await prisma.domain.update({
where: {id: domain.id},
data: {verified: true},
});
const result = await DomainService.verifyEmailDomain('sender@mail.example.com', project.id);
expect(result.domain).toBe('mail.example.com');
// Different subdomain should fail
await expect(
DomainService.verifyEmailDomain('sender@other.example.com', project.id),
).rejects.toThrow(/not registered/i);
});
it('should handle email with no @ sign', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should handle email with multiple @ signs', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('user@@example.com', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
it('should handle empty email string', async () => {
const {project} = await factories.createUserWithProject();
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
/invalid email format/i,
);
});
});
// ========================================
// CONCURRENCY AND RACE CONDITIONS
// ========================================
describe('Concurrency', () => {
it('should handle concurrent domain additions to same project', async () => {
const {project} = await factories.createUserWithProject();
// Add multiple domains concurrently
const results = await Promise.all([
DomainService.addDomain(project.id, 'concurrent1.com'),
DomainService.addDomain(project.id, 'concurrent2.com'),
DomainService.addDomain(project.id, 'concurrent3.com'),
]);
expect(results).toHaveLength(3);
expect(results.map(d => d.domain).sort()).toEqual([
'concurrent1.com',
'concurrent2.com',
'concurrent3.com',
]);
});
it('should handle concurrent ownership checks', async () => {
const {user, project} = await factories.createUserWithProject();
await DomainService.addDomain(project.id, 'concurrent-check.com');
// Multiple concurrent ownership checks
const results = await Promise.all([
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
]);
// All should return consistent results
expect(results.every(r => r.exists)).toBe(true);
expect(results.every(r => r.isMember)).toBe(true);
});
});
});
@@ -0,0 +1,788 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {ActionSchemas} from '@plunk/shared';
import {EmailService} from '../EmailService';
import {sendRawEmail} from '../SESService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock SES service
vi.mock('../SESService', () => ({
sendRawEmail: vi.fn(),
}));
describe('EmailService', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
// Mock successful SES send by default
vi.mocked(sendRawEmail).mockResolvedValue({
messageId: 'ses-message-123',
});
});
// ========================================
// SUBSCRIPTION ENFORCEMENT (GDPR)
// ========================================
describe('Subscription Enforcement (GDPR Compliance)', () => {
describe('Marketing Email Protection', () => {
it('should send campaign emails only to subscribed contacts', async () => {
const subscribedContact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: subscribedContact.id,
subject: 'Newsletter',
body: 'Marketing content',
from: 'news@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
});
it('should NOT send workflow marketing emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: 'MARKETING',
});
// Create a workflow and execution for the foreign key
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: marketingTemplate.id,
subject: 'Marketing Email',
body: 'Content',
from: 'test@example.com',
workflowExecutionId: execution.id,
});
expect(email.status).toBe(EmailStatus.FAILED);
expect(email.error).toMatch(/unsubscribed/i);
});
it('should REJECT sending MARKETING template via transactional API to unsubscribed contact', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: 'MARKETING',
});
await expect(
EmailService.sendTransactionalEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: marketingTemplate.id,
subject: 'Marketing disguised as transactional',
body: 'Buy now!',
from: 'test@example.com',
}),
).rejects.toThrow(/cannot send marketing template to unsubscribed contact/i);
});
});
describe('Transactional Email Exemption', () => {
it('should ALLOW transactional emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: transactionalTemplate.id,
subject: 'Password Reset',
body: 'Reset your password',
from: 'noreply@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
it('should ALLOW workflow transactional emails to unsubscribed contacts', async () => {
const unsubscribedContact = await factories.createContact({
projectId,
subscribed: false,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
// Create workflow and execution for the foreign key
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: unsubscribedContact.id,
templateId: transactionalTemplate.id,
subject: 'Account Verification',
body: 'Verify your account',
from: 'noreply@example.com',
workflowExecutionId: execution.id,
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
});
describe('Template Type Determines Email Type', () => {
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
const campaign = await factories.createCampaign({projectId});
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
templateId: transactionalTemplate.id,
subject: 'Receipt',
body: 'Your receipt',
from: 'billing@example.com',
});
expect(email.sourceType).toBe(EmailSourceType.TRANSACTIONAL);
});
});
describe('Unsubscribe via Complaint Webhook', () => {
it('should track when contact unsubscribes via complaint webhook', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'complained');
const unsubscribedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(unsubscribedContact?.subscribed).toBe(false);
const complainedEmail = await prisma.email.findUnique({
where: {id: email.id},
});
expect(complainedEmail?.status).toBe(EmailStatus.COMPLAINED);
});
});
});
// ========================================
// STATUS TRANSITIONS
// ========================================
describe('Status Transitions & Lifecycle', () => {
describe('Email Creation', () => {
it('should create email with PENDING status', async () => {
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.sentAt).toBeNull();
expect(email.messageId).toBeNull();
});
});
describe('PENDING → SENDING → SENT', () => {
it('should transition correctly on successful send', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await EmailService.sendEmail(email.id);
const sent = await prisma.email.findUnique({
where: {id: email.id},
});
expect(sent?.status).toBe(EmailStatus.SENT);
expect(sent?.sentAt).not.toBeNull();
expect(sent?.messageId).toBe('ses-message-123');
});
it('should create email.sent event after successful send', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await EmailService.sendEmail(email.id);
const event = await prisma.event.findFirst({
where: {
projectId,
contactId,
emailId: email.id,
name: 'email.sent',
},
});
expect(event).toBeDefined();
expect(event?.data).toHaveProperty('messageId', 'ses-message-123');
});
});
describe('PENDING → SENDING → FAILED', () => {
it('should mark as FAILED on SES error', async () => {
vi.mocked(sendRawEmail).mockRejectedValue(new Error('SES rate limit exceeded'));
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.PENDING,
});
await expect(EmailService.sendEmail(email.id)).rejects.toThrow();
const failed = await prisma.email.findUnique({
where: {id: email.id},
});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toContain('rate limit');
});
});
describe('Idempotency - Prevent Re-sending', () => {
it('should NOT re-send email if already SENT', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: 'already-sent-123',
});
const sesSpy = vi.mocked(sendRawEmail);
sesSpy.mockClear();
await EmailService.sendEmail(email.id);
expect(sesSpy).not.toHaveBeenCalled();
});
});
describe('Webhook Status Updates', () => {
it('should transition SENT → DELIVERED on delivery webhook', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'delivered');
const delivered = await prisma.email.findUnique({
where: {id: email.id},
});
expect(delivered?.status).toBe(EmailStatus.DELIVERED);
expect(delivered?.deliveredAt).not.toBeNull();
});
it('should transition to OPENED on first open webhook', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'opened');
const opened = await prisma.email.findUnique({
where: {id: email.id},
});
expect(opened?.status).toBe(EmailStatus.OPENED);
expect(opened?.openedAt).not.toBeNull();
expect(opened?.opens).toBe(1);
});
it('should increment opens counter on subsequent opens', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.OPENED,
openedAt: new Date(),
opens: 1,
});
const firstOpenedAt = email.openedAt;
await EmailService.handleWebhookEvent(email.id, 'opened');
const reopened = await prisma.email.findUnique({
where: {id: email.id},
});
expect(reopened?.opens).toBe(2);
expect(reopened?.openedAt).toEqual(firstOpenedAt);
});
it('should transition to CLICKED and track clicks', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'clicked');
const clicked = await prisma.email.findUnique({
where: {id: email.id},
});
expect(clicked?.status).toBe(EmailStatus.CLICKED);
expect(clicked?.clickedAt).not.toBeNull();
expect(clicked?.clicks).toBe(1);
});
it('should transition to BOUNCED on bounce webhook', async () => {
const email = await factories.createEmail({
projectId,
contactId,
status: EmailStatus.SENT,
});
await EmailService.handleWebhookEvent(email.id, 'bounced');
const bounced = await prisma.email.findUnique({
where: {id: email.id},
});
expect(bounced?.status).toBe(EmailStatus.BOUNCED);
expect(bounced?.bouncedAt).not.toBeNull();
});
});
});
// ========================================
// EMAIL STATISTICS
// ========================================
describe('Email Statistics', () => {
it('should calculate accurate email stats', async () => {
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
await factories.createEmail({projectId, contactId, status: EmailStatus.DELIVERED});
await factories.createEmail({projectId, contactId, status: EmailStatus.OPENED});
await factories.createEmail({projectId, contactId, status: EmailStatus.CLICKED});
await factories.createEmail({projectId, contactId, status: EmailStatus.BOUNCED});
await factories.createEmail({projectId, contactId, status: EmailStatus.FAILED});
const stats = await EmailService.getStats(projectId);
expect(stats.total).toBe(7);
expect(stats.sent).toBe(2);
expect(stats.delivered).toBe(1);
expect(stats.opened).toBe(1);
expect(stats.clicked).toBe(1);
expect(stats.bounced).toBe(1);
expect(stats.failed).toBe(1);
});
it('should calculate open rate correctly', async () => {
// Create 10 SENT emails, 5 of which are OPENED
// OPENED status counts as both sent and opened
for (let i = 0; i < 5; i++) {
await factories.createEmail({projectId, contactId, status: EmailStatus.SENT});
}
for (let i = 0; i < 5; i++) {
await factories.createEmail({projectId, contactId, status: EmailStatus.OPENED});
}
const stats = await EmailService.getStats(projectId);
// Total sent = 5 (SENT) + 5 (OPENED) = 10
// Total opened = 5 (OPENED)
// Open rate = 5/10 * 100 = 50%
// BUT: EmailService counts SENT separately from OPENED
// So opened/sent = 5/5 = 100%
// This is a quirk of how EmailStatus works - OPENED doesn't include SENT count
expect(stats.sent).toBe(5); // Only EmailStatus.SENT
expect(stats.opened).toBe(5); // Only EmailStatus.OPENED
expect(stats.total).toBe(10);
});
it('should handle zero sent emails without division by zero', async () => {
await factories.createEmail({projectId, contactId, status: EmailStatus.PENDING});
const stats = await EmailService.getStats(projectId);
expect(stats.openRate).toBe(0);
expect(stats.clickRate).toBe(0);
expect(stats.bounceRate).toBe(0);
});
});
// ========================================
// EMAIL ATTACHMENTS
// ========================================
describe('Email Attachments', () => {
it('should send email with a single attachment', async () => {
const attachment = {
filename: 'invoice.pdf',
content: Buffer.from('PDF content here').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Your Invoice',
body: 'Please find your invoice attached',
from: 'billing@example.com',
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
const attachments = email.attachments as unknown as Array<{
filename: string;
contentType: string;
}>;
expect(Array.isArray(attachments)).toBe(true);
expect(attachments).toHaveLength(1);
expect(attachments[0]).toMatchObject({
filename: 'invoice.pdf',
contentType: 'application/pdf',
});
});
it('should send email with multiple attachments', async () => {
const attachments = [
{
filename: 'document1.pdf',
content: Buffer.from('PDF 1').toString('base64'),
contentType: 'application/pdf',
},
{
filename: 'image.png',
content: Buffer.from('PNG data').toString('base64'),
contentType: 'image/png',
},
{
filename: 'data.csv',
content: Buffer.from('CSV content').toString('base64'),
contentType: 'text/csv',
},
];
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Multiple Files',
body: 'Here are your files',
from: 'support@example.com',
attachments,
});
expect(email.status).toBe(EmailStatus.PENDING);
const storedAttachments = email.attachments as unknown as Array<{filename: string}>;
expect(storedAttachments).toHaveLength(3);
expect(storedAttachments[0].filename).toBe('document1.pdf');
expect(storedAttachments[1].filename).toBe('image.png');
expect(storedAttachments[2].filename).toBe('data.csv');
});
it('should send email without attachments', async () => {
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'No Attachments',
body: 'Simple email',
from: 'test@example.com',
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeNull();
});
it('should pass attachments to SES when sending', async () => {
const attachment = {
filename: 'test.txt',
content: Buffer.from('Test content').toString('base64'),
contentType: 'text/plain',
};
const email = await EmailService.sendTransactionalEmail({
projectId,
contactId,
subject: 'Test',
body: 'Test',
from: 'test@example.com',
attachments: [attachment],
});
// Send the email
await EmailService.sendEmail(email.id);
// Verify SES was called with attachments
expect(vi.mocked(sendRawEmail)).toHaveBeenCalledWith(
expect.objectContaining({
attachments: [attachment],
}),
);
});
it('should handle attachments in campaign emails', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
const campaign = await factories.createCampaign({projectId});
const attachment = {
filename: 'newsletter.pdf',
content: Buffer.from('Newsletter content').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendCampaignEmail({
projectId,
contactId: contact.id,
campaignId: campaign.id,
subject: 'Monthly Newsletter',
body: 'See attachment',
from: 'news@example.com',
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
const storedAttachments = email.attachments as unknown as Array<{filename: string}>;
expect(storedAttachments[0].filename).toBe('newsletter.pdf');
});
it('should handle attachments in workflow emails', async () => {
const contact = await factories.createContact({projectId, subscribed: true});
const workflow = await factories.createWorkflow({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
const attachment = {
filename: 'report.pdf',
content: Buffer.from('Report content').toString('base64'),
contentType: 'application/pdf',
};
const email = await EmailService.sendWorkflowEmail({
projectId,
contactId: contact.id,
subject: 'Your Report',
body: 'Report attached',
from: 'reports@example.com',
workflowExecutionId: execution.id,
attachments: [attachment],
});
expect(email.status).toBe(EmailStatus.PENDING);
expect(email.attachments).toBeDefined();
});
});
// ========================================
// ATTACHMENT SCHEMA VALIDATION
// ========================================
describe('Attachment Schema Validation', () => {
it('should validate attachment count limit (max 10)', () => {
const tooManyAttachments = Array.from({length: 11}, (_, i) => ({
filename: `file${i}.txt`,
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
}));
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: tooManyAttachments,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors.some(e => e.path.includes('attachments'))).toBe(true);
}
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
const largeContent = 'A'.repeat(14000000);
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'huge.txt',
content: largeContent,
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should accept attachments within size limit', () => {
const validContent = Buffer.from('Small file content').toString('base64');
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'small.txt',
content: validContent,
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(true);
});
it('should reject attachment with missing required fields', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'test.txt',
// Missing content and contentType
},
],
});
expect(result.success).toBe(false);
});
it('should reject attachment with empty filename', () => {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: '',
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should reject attachment with filename exceeding 255 chars', () => {
const tooLongFilename = 'a'.repeat(256) + '.pdf';
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: tooLongFilename,
content: Buffer.from('content').toString('base64'),
contentType: 'text/plain',
},
],
});
expect(result.success).toBe(false);
});
it('should accept valid attachment with various content types', () => {
const contentTypes = [
'application/pdf',
'image/png',
'image/jpeg',
'text/plain',
'application/zip',
];
for (const contentType of contentTypes) {
const result = ActionSchemas.send.safeParse({
to: 'test@example.com',
subject: 'Test',
body: 'Test',
attachments: [
{
filename: 'file.ext',
content: Buffer.from('data').toString('base64'),
contentType,
},
],
});
expect(result.success).toBe(true);
}
});
});
});
@@ -0,0 +1,665 @@
import {describe, it, expect, beforeEach, vi, afterEach} from 'vitest';
import {WorkflowTriggerType, WorkflowExecutionStatus} from '@plunk/db';
import {EventService} from '../EventService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
vi.mock('../../database/redis', () => {
const store = new Map<string, {value: string; expiry?: number}>();
return {
redis: {
get: vi.fn(async (key: string) => {
const item = store.get(key);
if (!item) return null;
if (item.expiry && Date.now() > item.expiry) {
store.delete(key);
return null;
}
return item.value;
}),
set: vi.fn(async (key: string, value: string) => {
store.set(key, {value});
return 'OK';
}),
setex: vi.fn(async (key: string, seconds: number, value: string) => {
store.set(key, {value, expiry: Date.now() + seconds * 1000});
return 'OK';
}),
del: vi.fn(async (key: string) => {
store.delete(key);
return 1;
}),
incr: vi.fn(async (key: string) => {
const current = store.get(key);
const newValue = current ? parseInt(current.value) + 1 : 1;
store.set(key, {value: String(newValue)});
return newValue;
}),
expire: vi.fn(async (key: string, seconds: number) => {
const item = store.get(key);
if (!item) return 0;
store.set(key, {...item, expiry: Date.now() + seconds * 1000});
return 1;
}),
clear: () => store.clear(),
},
};
});
describe('EventService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(async () => {
// Clear Redis mock
const {redis} = await import('../../database/redis');
if ('clear' in redis) {
(redis as any).clear();
}
});
// ========================================
// EVENT TRACKING
// ========================================
describe('trackEvent', () => {
it('should create an event record', async () => {
const contact = await factories.createContact({projectId});
const event = await EventService.trackEvent(projectId, 'user.signup', contact.id, undefined, {
source: 'web',
plan: 'free',
});
expect(event.projectId).toBe(projectId);
expect(event.contactId).toBe(contact.id);
expect(event.name).toBe('user.signup');
expect(event.data).toEqual({source: 'web', plan: 'free'});
});
it('should track event without contact (project-level event)', async () => {
const event = await EventService.trackEvent(projectId, 'project.created', undefined, undefined, {
plan: 'pro',
});
expect(event.projectId).toBe(projectId);
expect(event.contactId).toBeNull();
expect(event.name).toBe('project.created');
});
it('should track event with email reference', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
const event = await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
userAgent: 'Mozilla/5.0',
});
expect(event.emailId).toBe(email.id);
expect(event.contactId).toBe(contact.id);
});
it('should trigger workflows listening for the event', async () => {
const contact = await factories.createContact({projectId});
// Create workflow triggered by 'purchase.completed' event
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'purchase.completed'},
});
// Add a delay step so workflow doesn't complete immediately
const triggerStep = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: 'TRIGGER'},
});
const delayStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: 'DELAY',
name: 'Wait',
position: {x: 100, y: 0},
config: {amount: 24, unit: 'hours'},
},
});
// Connect steps
await prisma.workflowTransition.create({
data: {
fromStepId: triggerStep!.id,
toStepId: delayStep.id,
},
});
// Track the event
await EventService.trackEvent(projectId, 'purchase.completed', contact.id, undefined, {
amount: 99.99,
product: 'Premium Plan',
});
// Verify workflow execution was created
const executions = await prisma.workflowExecution.findMany({
where: {
workflowId: workflow.id,
contactId: contact.id,
},
});
expect(executions).toHaveLength(1);
// Workflow should be in COMPLETED status since DELAY step completes and has no next step
// (DELAY sets to WAITING then processNextSteps sees no transitions and completes it)
expect([WorkflowExecutionStatus.WAITING, WorkflowExecutionStatus.COMPLETED]).toContain(executions[0].status);
});
it('should NOT trigger disabled workflows', async () => {
const contact = await factories.createContact({projectId});
// Create disabled workflow
const workflow = await factories.createWorkflow({
projectId,
enabled: false, // Disabled
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
await EventService.trackEvent(projectId, 'test.event', contact.id);
// No execution should be created
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id},
});
expect(executions).toHaveLength(0);
});
it('should NOT trigger workflows for different event names', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'user.signup'},
});
// Track different event
await EventService.trackEvent(projectId, 'user.login', contact.id);
// No execution should be created
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id},
});
expect(executions).toHaveLength(0);
});
it('should respect workflow re-entry settings', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: false, // Do not allow re-entry
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'repeat.event'},
});
// First event - should create execution
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
let executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(1);
// Second event - should NOT create execution (re-entry not allowed)
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(1); // Still only 1
});
it('should allow re-entry when enabled', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: true, // Allow re-entry
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'repeat.event'},
});
// First event
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
// Complete first execution
const firstExecution = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow.id, contactId: contact.id},
});
await prisma.workflowExecution.update({
where: {id: firstExecution!.id},
data: {status: WorkflowExecutionStatus.COMPLETED},
});
// Second event - should create new execution
await EventService.trackEvent(projectId, 'repeat.event', contact.id);
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(2);
});
it('should trigger multiple workflows listening for same event', async () => {
const contact = await factories.createContact({projectId});
const workflow1 = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'shared.event'},
});
const workflow2 = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'shared.event'},
});
await EventService.trackEvent(projectId, 'shared.event', contact.id);
const execution1 = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow1.id},
});
const execution2 = await prisma.workflowExecution.findFirst({
where: {workflowId: workflow2.id},
});
expect(execution1).toBeDefined();
expect(execution2).toBeDefined();
});
});
// ========================================
// EVENT RETRIEVAL
// ========================================
describe('getContactEvents', () => {
it('should get events for a specific contact', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.1', contact1.id);
await EventService.trackEvent(projectId, 'event.2', contact1.id);
await EventService.trackEvent(projectId, 'event.3', contact2.id);
const events = await EventService.getContactEvents(projectId, contact1.id);
expect(events).toHaveLength(2);
expect(events.every(e => e.contactId === contact1.id)).toBe(true);
});
it('should return events in reverse chronological order (newest first)', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'first', contact.id);
await new Promise(resolve => setTimeout(resolve, 10));
await EventService.trackEvent(projectId, 'second', contact.id);
await new Promise(resolve => setTimeout(resolve, 10));
await EventService.trackEvent(projectId, 'third', contact.id);
const events = await EventService.getContactEvents(projectId, contact.id);
expect(events[0].name).toBe('third'); // Newest
expect(events[1].name).toBe('second');
expect(events[2].name).toBe('first'); // Oldest
});
it('should respect limit parameter', async () => {
const contact = await factories.createContact({projectId});
// Create 100 events
for (let i = 0; i < 100; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getContactEvents(projectId, contact.id, 25);
expect(events).toHaveLength(25);
});
it('should default to 50 events limit', async () => {
const contact = await factories.createContact({projectId});
// Create 60 events
for (let i = 0; i < 60; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getContactEvents(projectId, contact.id);
expect(events).toHaveLength(50); // Default limit
});
});
describe('getProjectEvents', () => {
it('should get all events for a project', async () => {
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.1', contact1.id);
await EventService.trackEvent(projectId, 'event.2', contact2.id);
await EventService.trackEvent(projectId, 'event.3');
const events = await EventService.getProjectEvents(projectId);
expect(events).toHaveLength(3);
expect(events.every(e => e.projectId === projectId)).toBe(true);
});
it('should filter by event name', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.login', contact.id);
await EventService.trackEvent(projectId, 'user.signup', contact.id);
const events = await EventService.getProjectEvents(projectId, 'user.signup');
expect(events).toHaveLength(2);
expect(events.every(e => e.name === 'user.signup')).toBe(true);
});
it('should include contact email in results', async () => {
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
await EventService.trackEvent(projectId, 'test.event', contact.id);
const events = await EventService.getProjectEvents(projectId);
expect(events[0].contact?.email).toBe('test@example.com');
});
it('should respect limit parameter', async () => {
const contact = await factories.createContact({projectId});
for (let i = 0; i < 150; i++) {
await EventService.trackEvent(projectId, `event.${i}`, contact.id);
}
const events = await EventService.getProjectEvents(projectId, undefined, 50);
expect(events).toHaveLength(50);
});
});
// ========================================
// EVENT STATISTICS
// ========================================
describe('getEventStats', () => {
it('should return event counts grouped by type', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.signup', contact.id);
await EventService.trackEvent(projectId, 'user.login', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
await EventService.trackEvent(projectId, 'purchase.completed', contact.id);
const stats = await EventService.getEventStats(projectId);
expect(stats).toHaveLength(3);
// Should be ordered by count (desc)
expect(stats[0].name).toBe('purchase.completed');
expect(stats[0].count).toBe(3);
expect(stats[1].name).toBe('user.signup');
expect(stats[1].count).toBe(2);
expect(stats[2].name).toBe('user.login');
expect(stats[2].count).toBe(1);
});
it('should filter by date range', async () => {
const contact = await factories.createContact({projectId});
const oldDate = new Date('2024-01-01');
const recentDate = new Date('2024-06-01');
// Create old event directly
await prisma.event.create({
data: {
projectId,
contactId: contact.id,
name: 'old.event',
createdAt: oldDate,
},
});
// Create recent events
await EventService.trackEvent(projectId, 'recent.event', contact.id);
const startDate = new Date('2024-05-01');
const stats = await EventService.getEventStats(projectId, startDate);
expect(stats).toHaveLength(1);
expect(stats[0].name).toBe('recent.event');
});
it('should handle empty result', async () => {
const stats = await EventService.getEventStats(projectId);
expect(stats).toHaveLength(0);
});
});
describe('getUniqueEventNames', () => {
it('should return unique event names ordered by frequency', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'event.a', contact.id);
await EventService.trackEvent(projectId, 'event.b', contact.id);
await EventService.trackEvent(projectId, 'event.b', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
await EventService.trackEvent(projectId, 'event.c', contact.id);
const names = await EventService.getUniqueEventNames(projectId);
expect(names).toHaveLength(3);
expect(names[0]).toBe('event.c'); // Most frequent
expect(names[1]).toBe('event.b');
expect(names[2]).toBe('event.a'); // Least frequent
});
it('should return empty array when no events exist', async () => {
const names = await EventService.getUniqueEventNames(projectId);
expect(names).toHaveLength(0);
});
});
// ========================================
// WORKFLOW CACHE MANAGEMENT
// ========================================
describe('invalidateWorkflowCache', () => {
it('should invalidate workflow cache for project', async () => {
const {redis} = await import('../../database/redis');
// Set cache
const cacheKey = `workflows:enabled:${projectId}`;
await redis.set(cacheKey, JSON.stringify([{id: 'test'}]));
// Verify cache exists
const cached = await redis.get(cacheKey);
expect(cached).toBeTruthy();
// Invalidate
await EventService.invalidateWorkflowCache(projectId);
// Verify cache deleted
const afterInvalidation = await redis.get(cacheKey);
expect(afterInvalidation).toBeNull();
});
it('should not throw error if cache does not exist', async () => {
await expect(EventService.invalidateWorkflowCache(projectId)).resolves.not.toThrow();
});
});
// ========================================
// EDGE CASES
// ========================================
describe('edge cases', () => {
it('should handle events with complex data structures', async () => {
const contact = await factories.createContact({projectId});
const complexData = {
user: {
id: 123,
profile: {
name: 'John Doe',
preferences: ['email', 'sms'],
},
},
metadata: {
source: 'mobile_app',
version: '2.0.1',
},
items: [
{id: 1, name: 'Item 1', price: 19.99},
{id: 2, name: 'Item 2', price: 29.99},
],
};
const event = await EventService.trackEvent(projectId, 'complex.event', contact.id, undefined, complexData);
expect(event.data).toEqual(complexData);
// Verify data persists correctly
const retrieved = await prisma.event.findUnique({
where: {id: event.id},
});
expect(retrieved?.data).toEqual(complexData);
});
it('should handle events with null data', async () => {
const contact = await factories.createContact({projectId});
const event = await EventService.trackEvent(projectId, 'simple.event', contact.id);
expect(event.data).toBeNull();
});
it('should handle event names with special characters', async () => {
const contact = await factories.createContact({projectId});
const eventName = 'user:action@domain.com/path-123';
const event = await EventService.trackEvent(projectId, eventName, contact.id);
expect(event.name).toBe(eventName);
});
it('should not trigger workflows for events without contact when workflow expects contact', async () => {
await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
// Track event without contact
await EventService.trackEvent(projectId, 'test.event');
// No execution should be created (event is not contact-specific)
const executions = await prisma.workflowExecution.findMany({
where: {workflow: {projectId}},
});
expect(executions).toHaveLength(0);
});
});
describe('Event Data - Persistent vs Non-Persistent', () => {
it('should store all event data (persistent + non-persistent) in event record', async () => {
const contact = await factories.createContact({projectId});
const eventData = {
totalSpent: 599.99, // Persistent
orderId: {value: 'ORD-123', persistent: false}, // Non-persistent
items: {value: [{name: 'Widget', qty: 2}], persistent: false}, // Non-persistent
};
const event = await EventService.trackEvent(projectId, 'purchase', contact.id, undefined, eventData);
// Event should store ALL data for workflow access
expect(event.data).toMatchObject({
totalSpent: 599.99,
orderId: {value: 'ORD-123', persistent: false},
items: {value: [{name: 'Widget', qty: 2}], persistent: false},
});
});
it('should pass all event data to workflow execution context', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'order_placed'},
});
await factories.createWorkflowStep({
workflowId: workflow.id,
type: 'TRIGGER',
name: 'Order Trigger',
position: {x: 0, y: 0},
config: {},
});
const eventData = {
amount: 99.99, // Persistent
confirmationCode: {value: 'CONF-456', persistent: false}, // Non-persistent
};
await EventService.trackEvent(projectId, 'order_placed', contact.id, undefined, eventData);
// Wait for async workflow creation
await new Promise(resolve => setTimeout(resolve, 50));
const execution = await prisma.workflowExecution.findFirst({
where: {
workflowId: workflow.id,
contactId: contact.id,
},
});
// Execution context should have ALL event data
expect(execution).toBeDefined();
expect(execution?.context).toMatchObject({
amount: 99.99,
confirmationCode: {value: 'CONF-456', persistent: false},
});
});
});
});
@@ -0,0 +1,893 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { SegmentService } from '../SegmentService';
import { factories, getPrismaClient } from '../../../../../test/helpers';
/**
* Comprehensive Operator Tests for Segment Filtering
*
* This file systematically tests ALL supported operators across different
* data types to ensure complete coverage of filtering logic.
*
* Supported Operators:
* - String: equals, notEquals, contains, notContains
* - Numeric: greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual
* - Existence: exists, notExists
* - Temporal: within
*/
describe('SegmentService - Comprehensive Operator Tests', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const { project } = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// STRING OPERATORS
// ========================================
describe('String Operators', () => {
describe('equals operator', () => {
it('should match exact string values in JSON data fields', async () => {
const match = await factories.createContact({
projectId,
data: { plan: 'premium' },
});
await factories.createContact({
projectId,
data: { plan: 'basic' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.plan', operator: 'equals', value: 'premium' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match exact string values in standard fields (case-insensitive)', async () => {
const match = await factories.createContact({
projectId,
email: 'user@example.com',
});
await factories.createContact({
projectId,
email: 'other@example.com',
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'email', operator: 'equals', value: 'USER@EXAMPLE.COM' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match boolean values', async () => {
const match = await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'subscribed', operator: 'equals', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match numeric values as strings in JSON fields', async () => {
const match = await factories.createContact({
projectId,
data: { userId: '12345' },
});
await factories.createContact({
projectId,
data: { userId: '67890' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.userId', operator: 'equals', value: '12345' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('notEquals operator', () => {
it('should exclude exact matches in JSON data fields', async () => {
const match = await factories.createContact({
projectId,
data: { plan: 'premium' },
});
await factories.createContact({
projectId,
data: { plan: 'basic' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.plan', operator: 'notEquals', value: 'basic' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should exclude boolean false values', async () => {
const match = await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'subscribed', operator: 'notEquals', value: false }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
const withMatchingField = await factories.createContact({
projectId,
data: { plan: 'basic' },
});
const withDifferentValue = await factories.createContact({
projectId,
data: { plan: 'premium' },
});
const withoutField = await factories.createContact({
projectId,
data: { other: 'value' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.plan', operator: 'notEquals', value: 'basic' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
// notEquals only matches where field exists and has different value
expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingField.id);
expect(ids).not.toContain(withoutField.id);
});
});
describe('contains operator', () => {
it('should match substring in JSON data fields', async () => {
const match = await factories.createContact({
projectId,
data: { company: 'Acme Corporation' },
});
await factories.createContact({
projectId,
data: { company: 'Other Industries' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'contains', value: 'Acme' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should match substring in email field (case-insensitive)', async () => {
const match1 = await factories.createContact({
projectId,
email: 'user@company.com',
});
const match2 = await factories.createContact({
projectId,
email: 'admin@COMPANY.org',
});
await factories.createContact({
projectId,
email: 'user@example.com',
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'email', operator: 'contains', value: 'company' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(match1.id);
expect(ids).toContain(match2.id);
expect(result.contacts).toHaveLength(2);
});
it('should not match when field does not exist', async () => {
await factories.createContact({
projectId,
data: { other: 'value' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'contains', value: 'Acme' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should match partial domain in email', async () => {
const gmailUser = await factories.createContact({
projectId,
email: 'user@gmail.com',
});
await factories.createContact({
projectId,
email: 'user@hotmail.com',
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'email', operator: 'contains', value: 'gmail' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(gmailUser.id);
});
});
describe('notContains operator', () => {
it('should exclude substring matches in JSON data fields', async () => {
const match = await factories.createContact({
projectId,
data: { company: 'Other Industries' },
});
await factories.createContact({
projectId,
data: { company: 'Acme Corporation' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'notContains', value: 'Acme' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
const withoutField = await factories.createContact({
projectId,
data: { other: 'value' },
});
const withMatchingSubstring = await factories.createContact({
projectId,
data: { company: 'Acme Corporation' },
});
const withDifferentValue = await factories.createContact({
projectId,
data: { company: 'Other Industries' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'notContains', value: 'Acme' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
// notContains only matches where field exists and doesn't contain substring
expect(ids).toContain(withDifferentValue.id);
expect(ids).not.toContain(withMatchingSubstring.id);
expect(ids).not.toContain(withoutField.id);
});
it('should exclude email domains (case-insensitive)', async () => {
const match = await factories.createContact({
projectId,
email: 'user@example.com',
});
await factories.createContact({
projectId,
email: 'user@GMAIL.com',
});
await factories.createContact({
projectId,
email: 'admin@gmail.org',
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'email', operator: 'notContains', value: 'gmail' }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
});
// ========================================
// NUMERIC OPERATORS
// ========================================
describe('Numeric Operators', () => {
describe('greaterThan operator', () => {
it('should match values greater than threshold', async () => {
const high = await factories.createContact({
projectId,
data: { score: 100 },
});
const veryHigh = await factories.createContact({
projectId,
data: { score: 200 },
});
await factories.createContact({
projectId,
data: { score: 50 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'greaterThan', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(high.id);
expect(ids).toContain(veryHigh.id);
expect(result.contacts).toHaveLength(2);
});
it('should exclude values equal to threshold', async () => {
await factories.createContact({
projectId,
data: { score: 50 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'greaterThan', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should work with negative numbers', async () => {
const match = await factories.createContact({
projectId,
data: { temperature: 5 },
});
await factories.createContact({
projectId,
data: { temperature: -10 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.temperature', operator: 'greaterThan', value: 0 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should work with decimal values', async () => {
const match = await factories.createContact({
projectId,
data: { rating: 4.5 },
});
await factories.createContact({
projectId,
data: { rating: 3.2 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.rating', operator: 'greaterThan', value: 4.0 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
describe('greaterThanOrEqual operator', () => {
it('should match values greater than or equal to threshold', async () => {
const equal = await factories.createContact({
projectId,
data: { score: 50 },
});
const greater = await factories.createContact({
projectId,
data: { score: 100 },
});
await factories.createContact({
projectId,
data: { score: 25 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'greaterThanOrEqual', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(equal.id);
expect(ids).toContain(greater.id);
expect(result.contacts).toHaveLength(2);
});
});
describe('lessThan operator', () => {
it('should match values less than threshold', async () => {
const low = await factories.createContact({
projectId,
data: { score: 25 },
});
const veryLow = await factories.createContact({
projectId,
data: { score: 10 },
});
await factories.createContact({
projectId,
data: { score: 50 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'lessThan', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(low.id);
expect(ids).toContain(veryLow.id);
expect(result.contacts).toHaveLength(2);
});
it('should exclude values equal to threshold', async () => {
await factories.createContact({
projectId,
data: { score: 50 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'lessThan', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
});
describe('lessThanOrEqual operator', () => {
it('should match values less than or equal to threshold', async () => {
const equal = await factories.createContact({
projectId,
data: { score: 50 },
});
const less = await factories.createContact({
projectId,
data: { score: 25 },
});
await factories.createContact({
projectId,
data: { score: 100 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'lessThanOrEqual', value: 50 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(equal.id);
expect(ids).toContain(less.id);
expect(result.contacts).toHaveLength(2);
});
});
describe('Numeric edge cases', () => {
it('should handle zero values correctly', async () => {
const zero = await factories.createContact({
projectId,
data: { balance: 0 },
});
const positive = await factories.createContact({
projectId,
data: { balance: 100 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.balance', operator: 'greaterThan', value: 0 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(positive.id);
});
it('should handle very large numbers', async () => {
const match = await factories.createContact({
projectId,
data: { views: 1000000 },
});
await factories.createContact({
projectId,
data: { views: 500000 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.views', operator: 'greaterThanOrEqual', value: 1000000 }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
});
// ========================================
// EXISTENCE OPERATORS
// ========================================
describe('Existence Operators', () => {
describe('exists operator', () => {
it('should match contacts where field exists and is not null', async () => {
const withField = await factories.createContact({
projectId,
data: { company: 'Acme Inc' },
});
await factories.createContact({
projectId,
data: { name: 'John' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'exists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
});
it('should exclude contacts where field is null', async () => {
const withValue = await factories.createContact({
projectId,
data: { company: 'Acme Inc' },
});
await factories.createContact({
projectId,
data: { company: null },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'exists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withValue.id);
});
it('should match fields with empty string values', async () => {
const withEmptyString = await factories.createContact({
projectId,
data: { notes: '' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.notes', operator: 'exists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withEmptyString.id);
});
it('should match fields with zero values', async () => {
const withZero = await factories.createContact({
projectId,
data: { score: 0 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'exists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withZero.id);
});
it('should match fields with boolean false values', async () => {
const withFalse = await factories.createContact({
projectId,
data: { verified: false },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.verified', operator: 'exists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withFalse.id);
});
});
describe('notExists operator', () => {
it('should match contacts where field does not exist', async () => {
const withoutField = await factories.createContact({
projectId,
data: { name: 'John' },
});
await factories.createContact({
projectId,
data: { company: 'Acme Inc' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'notExists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withoutField.id);
});
it('should match contacts where field is null', async () => {
const withNull = await factories.createContact({
projectId,
data: { company: null },
});
await factories.createContact({
projectId,
data: { company: 'Acme Inc' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.company', operator: 'notExists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withNull.id);
});
it('should exclude fields with empty string values', async () => {
await factories.createContact({
projectId,
data: { notes: '' },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.notes', operator: 'notExists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
it('should exclude fields with zero values', async () => {
await factories.createContact({
projectId,
data: { score: 0 },
});
const segment = await factories.createSegment(projectId, {
filters: [{ field: 'data.score', operator: 'notExists', value: true }],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
});
});
// ========================================
// TEMPORAL OPERATORS
// ========================================
describe('Temporal Operators', () => {
describe('within operator', () => {
it('should match contacts created within specified days', async () => {
const recent = await factories.createContact({ projectId });
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'within',
value: 1,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(recent.id);
});
it('should match contacts created within specified hours', async () => {
const veryRecent = await factories.createContact({ projectId });
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'within',
value: 24,
unit: 'hours',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(veryRecent.id);
});
it('should match contacts created within specified minutes', async () => {
const justNow = await factories.createContact({ projectId });
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'within',
value: 60,
unit: 'minutes',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(justNow.id);
});
});
});
// ========================================
// DATE COMPARISON OPERATORS
// ========================================
describe('Date Comparison Operators', () => {
it('should support greaterThan for dates', async () => {
const older = await factories.createContact({ projectId });
await new Promise((resolve) => setTimeout(resolve, 10));
const newer = await factories.createContact({ projectId });
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'greaterThan',
value: older.createdAt.toISOString(),
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(newer.id);
expect(ids).not.toContain(older.id);
});
it('should support lessThanOrEqual for dates', async () => {
const first = await factories.createContact({ projectId });
await new Promise((resolve) => setTimeout(resolve, 10));
const second = await factories.createContact({ projectId });
await new Promise((resolve) => setTimeout(resolve, 10));
const third = await factories.createContact({ projectId });
const segment = await factories.createSegment(projectId, {
filters: [
{
field: 'createdAt',
operator: 'lessThanOrEqual',
value: second.createdAt.toISOString(),
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map((c) => c.id);
expect(ids).toContain(first.id);
expect(ids).toContain(second.id);
expect(ids).not.toContain(third.id);
});
});
// ========================================
// COMBINED OPERATORS (AND logic)
// ========================================
describe('Multiple Operators Combined', () => {
it('should apply AND logic across different operator types', async () => {
const match = await factories.createContact({
projectId,
subscribed: true,
data: {
plan: 'premium',
score: 85,
company: 'Acme Inc',
},
});
await factories.createContact({
projectId,
subscribed: false,
data: { plan: 'premium', score: 85, company: 'Acme Inc' },
});
await factories.createContact({
projectId,
subscribed: true,
data: { plan: 'basic', score: 85, company: 'Acme Inc' },
});
await factories.createContact({
projectId,
subscribed: true,
data: { plan: 'premium', score: 50, company: 'Acme Inc' },
});
const segment = await factories.createSegment(projectId, {
filters: [
{ field: 'subscribed', operator: 'equals', value: true },
{ field: 'data.plan', operator: 'equals', value: 'premium' },
{ field: 'data.score', operator: 'greaterThanOrEqual', value: 80 },
{ field: 'data.company', operator: 'contains', value: 'Acme' },
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should combine existence checks with value comparisons', async () => {
const match = await factories.createContact({
projectId,
data: {
company: 'Tech Corp',
revenue: 100000,
},
});
await factories.createContact({
projectId,
data: { company: 'Tech Corp' }, // Missing revenue
});
await factories.createContact({
projectId,
data: { revenue: 100000 }, // Missing company
});
const segment = await factories.createSegment(projectId, {
filters: [
{ field: 'data.company', operator: 'exists', value: true },
{ field: 'data.revenue', operator: 'greaterThanOrEqual', value: 100000 },
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
});
});
@@ -0,0 +1,676 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {SegmentService} from '../SegmentService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('SegmentService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('Segment Filtering', () => {
it('should filter contacts by subscribed status', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const _unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Subscribed Users',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(subscribed.id);
});
it('should filter contacts by custom data fields', async () => {
const proUser = await factories.createContact({
projectId,
data: {plan: 'pro', tier: 'premium'},
});
const _freeUser = await factories.createContact({
projectId,
data: {plan: 'free', tier: 'basic'},
});
const segment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(proUser.id);
});
it('should filter contacts with multiple conditions', async () => {
const target = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'pro', active: true},
});
const _notSubscribed = await factories.createContact({
projectId,
subscribed: false,
data: {plan: 'pro', active: true},
});
const _notPro = await factories.createContact({
projectId,
subscribed: true,
data: {plan: 'free', active: true},
});
const segment = await factories.createSegment(projectId, {
name: 'Active Pro Subscribers',
filters: [
{field: 'subscribed', operator: 'equals', value: true},
{field: 'data.plan', operator: 'equals', value: 'pro'},
{field: 'data.active', operator: 'equals', value: true},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(target.id);
});
it('should support notEquals operator', async () => {
const pro = await factories.createContact({
projectId,
data: {plan: 'pro'},
});
const _free = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Non-Free Users',
filters: [{field: 'data.plan', operator: 'notEquals', value: 'free'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(pro.id);
});
it('should support contains operator for strings', async () => {
const match = await factories.createContact({
projectId,
email: 'user@company.com',
});
const _noMatch = await factories.createContact({
projectId,
email: 'user@example.com',
});
const segment = await factories.createSegment(projectId, {
name: 'Company Emails',
filters: [{field: 'email', operator: 'contains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(match.id);
});
it('should support exists operator for custom fields', async () => {
const withField = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
await factories.createContact({
projectId,
data: {name: 'John'},
});
const segment = await factories.createSegment(projectId, {
name: 'Has Company',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(withField.id);
});
it('should handle empty segments', async () => {
await factories.createContact({
projectId,
data: {plan: 'free'},
});
const segment = await factories.createSegment(projectId, {
name: 'Enterprise Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'enterprise'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
expect(result.total).toBe(0);
});
});
describe('Segment Membership', () => {
it('should return correct member count', async () => {
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: true,
});
await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.total).toBe(2);
expect(result.contacts).toHaveLength(2);
});
it('should support pagination', async () => {
// Create 25 contacts
for (let i = 0; i < 25; i++) {
await factories.createContact({
projectId,
subscribed: true,
});
}
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
expect(page1.contacts).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
expect(page2.contacts).toHaveLength(10);
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
expect(page3.contacts).toHaveLength(5);
});
});
describe('Segment Management', () => {
it('should create segment with filters', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [
{field: 'data.vip', operator: 'equals', value: true},
{field: 'subscribed', operator: 'equals', value: true},
],
});
expect(segment.name).toBe('VIP Customers');
expect(segment.projectId).toBe(projectId);
expect(Array.isArray(segment.filters)).toBe(true);
});
it('should list all segments for a project', async () => {
await factories.createSegment(projectId, {name: 'Segment 1'});
await factories.createSegment(projectId, {name: 'Segment 2'});
await factories.createSegment(projectId, {name: 'Segment 3'});
const segments = await SegmentService.list(projectId);
expect(segments).toHaveLength(3);
});
it('should get specific segment by id', async () => {
const segment = await factories.createSegment(projectId, {
name: 'Test Segment',
});
const retrieved = await SegmentService.get(projectId, segment.id);
expect(retrieved.id).toBe(segment.id);
expect(retrieved.name).toBe('Test Segment');
});
it('should throw error when segment not found', async () => {
await expect(SegmentService.get(projectId, 'non-existent-id')).rejects.toThrow('Segment not found');
});
});
describe('Dynamic Segment Updates', () => {
it('should reflect in segment when contact data changes', async () => {
const contact = await factories.createContact({
projectId,
data: {plan: 'free'},
});
const proSegment = await factories.createSegment(projectId, {
name: 'Pro Users',
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
});
// Initially not in segment
let result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(0);
// Update contact to pro plan
await prisma.contact.update({
where: {id: contact.id},
data: {data: {plan: 'pro'}},
});
// Should now be in segment
result = await SegmentService.getContacts(projectId, proSegment.id);
expect(result.contacts).toHaveLength(1);
expect(result.contacts[0].id).toBe(contact.id);
});
it('should be removed from segment when criteria no longer met', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const segment = await factories.createSegment(projectId, {
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
// Initially in segment
let result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(1);
// Unsubscribe contact
await prisma.contact.update({
where: {id: contact.id},
data: {subscribed: false},
});
// Should no longer be in segment
result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts).toHaveLength(0);
});
});
describe('Delete Protection for Active Campaigns', () => {
it('should BLOCK deleting segment used in DRAFT campaigns', async () => {
const segment = await factories.createSegment(projectId, {
name: 'VIP Customers',
filters: [{field: 'subscribed', operator: 'equals', value: true}],
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should BLOCK deleting segment used in SCHEDULED campaigns', async () => {
const segment = await factories.createSegment(projectId);
await factories.createScheduledCampaign({
projectId,
segmentId: segment.id,
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(
/cannot delete segment.*active campaign/i,
);
});
it('should ALLOW deleting segment if all campaigns are SENT', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SENT',
});
await SegmentService.delete(projectId, segment.id);
const deleted = await prisma.segment.findUnique({
where: {id: segment.id},
});
expect(deleted).toBeNull();
});
it('should show count of blocking campaigns in error message', async () => {
const segment = await factories.createSegment(projectId);
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'DRAFT',
});
await factories.createCampaign({
projectId,
segmentId: segment.id,
status: 'SCHEDULED',
});
await expect(SegmentService.delete(projectId, segment.id)).rejects.toThrow(/2.*active campaign/i);
});
});
describe('Filter Validation', () => {
it('should REJECT empty filters array', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
filters: [],
}),
).rejects.toThrow(/at least one filter/i);
});
it('should REJECT filter without field', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
filters: [
{
// Intentionally missing field, cast to any to bypass compile-time validation
operator: 'equals',
value: 'test',
} as any,
],
}),
).rejects.toThrow(/field is required/i);
});
it('should REJECT invalid operators', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
filters: [
{
field: 'email',
// Intentionally invalid operator, cast to any
operator: 'DROP TABLE contacts;',
value: 'test',
} as any,
],
}),
).rejects.toThrow(/invalid operator/i);
});
it('should REJECT operators that need values without values', async () => {
await expect(
SegmentService.create(projectId, {
name: 'Invalid Segment',
filters: [
{
field: 'email',
operator: 'equals',
// Value intentionally omitted, cast to any
} as any,
],
}),
).rejects.toThrow(/requires a value/i);
});
it('should ACCEPT valid filters', async () => {
const segment = await SegmentService.create(projectId, {
name: 'Valid Segment',
filters: [
{
field: 'subscribed',
operator: 'equals',
value: true,
},
],
});
expect(segment.id).toBeDefined();
expect(segment.name).toBe('Valid Segment');
});
});
describe('Operator behavior and edge cases', () => {
it('should support notContains operator for email strings', async () => {
const match = await factories.createContact({
projectId,
email: 'user@company.com',
});
const other = await factories.createContact({
projectId,
email: 'user@example.com',
});
const segment = await factories.createSegment(projectId, {
name: 'Non-company Emails',
filters: [{field: 'email', operator: 'notContains', value: 'company'}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
});
it('should support case-insensitive equals/contains for email strings', async () => {
const lower = await factories.createContact({
projectId,
email: 'user@company.com',
});
const upper = await factories.createContact({
projectId,
email: 'USER@COMPANY.COM',
});
const equalsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive equals',
filters: [{field: 'email', operator: 'equals', value: 'USER@COMPANY.COM'}],
});
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
const equalsIds = equalsResult.contacts.map(c => c.id);
expect(equalsIds).toContain(lower.id);
expect(equalsIds).toContain(upper.id);
const containsSegment = await factories.createSegment(projectId, {
name: 'Case-insensitive contains',
filters: [{field: 'email', operator: 'contains', value: 'COMPANY.COM'}],
});
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
const containsIds = containsResult.contacts.map(c => c.id);
expect(containsIds).toContain(lower.id);
expect(containsIds).toContain(upper.id);
});
it('should support notEquals for boolean subscribed field', async () => {
const subscribed = await factories.createContact({
projectId,
subscribed: true,
});
const unsubscribed = await factories.createContact({
projectId,
subscribed: false,
});
const segment = await factories.createSegment(projectId, {
name: 'Not subscribed users',
filters: [{field: 'subscribed', operator: 'notEquals', value: true}],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(unsubscribed.id);
expect(ids).not.toContain(subscribed.id);
});
it('should support notContains and notEquals for JSON data fields', async () => {
const acme = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const other = await factories.createContact({
projectId,
data: {company: 'Other Corp'},
});
const notContainsSegment = await factories.createSegment(projectId, {
name: 'Company not containing "Acme"',
filters: [{field: 'data.company', operator: 'notContains', value: 'Acme'}],
});
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
const notContainsIds = notContainsResult.contacts.map(c => c.id);
expect(notContainsIds).toContain(other.id);
expect(notContainsIds).not.toContain(acme.id);
const notEqualsSegment = await factories.createSegment(projectId, {
name: 'Company not equal to "Acme Inc"',
filters: [{field: 'data.company', operator: 'notEquals', value: 'Acme Inc'}],
});
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
expect(notEqualsIds).toContain(other.id);
expect(notEqualsIds).not.toContain(acme.id);
});
it('should support exists and notExists for JSON data fields', async () => {
const withCompany = await factories.createContact({
projectId,
data: {company: 'Acme Inc'},
});
const withNullCompany = await factories.createContact({
projectId,
data: {company: null},
});
const existsSegment = await factories.createSegment(projectId, {
name: 'Has company (non-null)',
filters: [{field: 'data.company', operator: 'exists', value: true}],
});
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
const existsIds = new Set(existsResult.contacts.map(c => c.id));
expect(existsIds.has(withCompany.id)).toBe(true);
expect(existsIds.has(withNullCompany.id)).toBe(false);
const notExistsSegment = await factories.createSegment(projectId, {
name: 'No company (null)',
filters: [{field: 'data.company', operator: 'notExists', value: true}],
});
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
expect(notExistsIds.has(withCompany.id)).toBe(false);
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
});
it('should support numeric comparison operators on JSON data fields', async () => {
const low = await factories.createContact({
projectId,
data: {score: 10},
});
const mid = await factories.createContact({
projectId,
data: {score: 50},
});
const high = await factories.createContact({
projectId,
data: {score: 100},
});
const greaterThanSegment = await factories.createSegment(projectId, {
name: 'Score > 10',
filters: [{field: 'data.score', operator: 'greaterThan', value: 10}],
});
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
const gtIds = greaterThanResult.contacts.map(c => c.id);
expect(gtIds).toContain(mid.id);
expect(gtIds).toContain(high.id);
expect(gtIds).not.toContain(low.id);
const lessThanOrEqualSegment = await factories.createSegment(projectId, {
name: 'Score <= 50',
filters: [{field: 'data.score', operator: 'lessThanOrEqual', value: 50}],
});
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
expect(lteIds).toContain(low.id);
expect(lteIds).toContain(mid.id);
expect(lteIds).not.toContain(high.id);
});
it('should support date comparison operators on createdAt field', async () => {
const older = await factories.createContact({projectId});
// Ensure a small delay so createdAt differs
await new Promise(resolve => setTimeout(resolve, 10));
const newer = await factories.createContact({projectId});
const gtSegment = await factories.createSegment(projectId, {
name: 'Created after first',
filters: [{field: 'createdAt', operator: 'greaterThan', value: older.createdAt.toISOString()}],
});
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
const gtIds = gtResult.contacts.map(c => c.id);
expect(gtIds).toContain(newer.id);
expect(gtIds).not.toContain(older.id);
const lteSegment = await factories.createSegment(projectId, {
name: 'Created on or before second',
filters: [{field: 'createdAt', operator: 'lessThanOrEqual', value: newer.createdAt.toISOString()}],
});
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
const lteIds = lteResult.contacts.map(c => c.id);
expect(lteIds).toContain(older.id);
expect(lteIds).toContain(newer.id);
});
it('should support within operator for recent contacts', async () => {
const recent = await factories.createContact({projectId});
const segment = await factories.createSegment(projectId, {
name: 'Created within last day',
filters: [
{
field: 'createdAt',
operator: 'within',
value: 1,
unit: 'days',
},
],
});
const result = await SegmentService.getContacts(projectId, segment.id);
const ids = result.contacts.map(c => c.id);
expect(ids).toContain(recent.id);
});
});
});
@@ -0,0 +1,570 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {TemplateType} from '@plunk/db';
import {TemplateService} from '../TemplateService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('TemplateService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// CRUD OPERATIONS
// ========================================
describe('create', () => {
it('should create a template with all fields', async () => {
const template = await TemplateService.create(projectId, {
name: 'Welcome Email',
description: 'Sent to new users',
subject: 'Welcome to {{company}}!',
body: '<h1>Hello {{firstName}}</h1>',
from: 'hello@example.com',
fromName: 'Company Team',
replyTo: 'support@example.com',
type: TemplateType.TRANSACTIONAL,
});
expect(template.name).toBe('Welcome Email');
expect(template.description).toBe('Sent to new users');
expect(template.subject).toBe('Welcome to {{company}}!');
expect(template.body).toBe('<h1>Hello {{firstName}}</h1>');
expect(template.from).toBe('hello@example.com');
expect(template.fromName).toBe('Company Team');
expect(template.replyTo).toBe('support@example.com');
expect(template.type).toBe(TemplateType.TRANSACTIONAL);
expect(template.projectId).toBe(projectId);
});
it('should create template with minimal required fields', async () => {
const template = await TemplateService.create(projectId, {
name: 'Basic Template',
subject: 'Test',
body: 'Test body',
from: 'test@example.com',
});
expect(template.name).toBe('Basic Template');
expect(template.type).toBe(TemplateType.MARKETING); // Default type
expect(template.description).toBeNull();
expect(template.fromName).toBeNull();
expect(template.replyTo).toBeNull();
});
it('should default to MARKETING type when not specified', async () => {
const template = await TemplateService.create(projectId, {
name: 'Newsletter',
subject: 'Monthly Update',
body: 'Content',
from: 'news@example.com',
});
expect(template.type).toBe(TemplateType.MARKETING);
});
});
describe('get', () => {
it('should retrieve a template by ID', async () => {
const created = await factories.createTemplate({
projectId,
name: 'Test Template',
});
const retrieved = await TemplateService.get(projectId, created.id);
expect(retrieved.id).toBe(created.id);
expect(retrieved.name).toBe('Test Template');
});
it('should throw 404 when template not found', async () => {
await expect(TemplateService.get(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
it('should throw 404 when template belongs to different project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({
projectId: otherProject.id,
});
await expect(TemplateService.get(projectId, template.id)).rejects.toThrow('Template not found');
});
});
describe('list', () => {
it('should list templates with pagination', async () => {
// Create 25 templates
for (let i = 0; i < 25; i++) {
await factories.createTemplate({
projectId,
name: `Template ${i}`,
});
}
const page1 = await TemplateService.list(projectId, 1, 10);
expect(page1.templates).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.page).toBe(1);
expect(page1.pageSize).toBe(10);
expect(page1.totalPages).toBe(3);
const page2 = await TemplateService.list(projectId, 2, 10);
expect(page2.templates).toHaveLength(10);
expect(page2.page).toBe(2);
const page3 = await TemplateService.list(projectId, 3, 10);
expect(page3.templates).toHaveLength(5);
expect(page3.page).toBe(3);
});
it('should filter templates by search query (name)', async () => {
await factories.createTemplate({projectId, name: 'Welcome Email'});
await factories.createTemplate({projectId, name: 'Password Reset'});
await factories.createTemplate({projectId, name: 'Welcome Message'});
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should filter templates by search query (description)', async () => {
await prisma.template.create({
data: {
projectId,
name: 'Template 1',
description: 'For new users',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
await prisma.template.create({
data: {
projectId,
name: 'Template 2',
description: 'For existing customers',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
await prisma.template.create({
data: {
projectId,
name: 'Template 3',
description: 'For new subscribers',
subject: 'Subject',
body: 'Body',
from: 'test@example.com',
},
});
const result = await TemplateService.list(projectId, 1, 20, 'new');
expect(result.total).toBe(2);
expect(result.templates.map(t => t.description)).toEqual(
expect.arrayContaining([expect.stringContaining('new')]),
);
});
it('should filter templates by search query (subject)', async () => {
await factories.createTemplate({
projectId,
subject: 'Welcome to our platform',
});
await factories.createTemplate({
projectId,
subject: 'Reset your password',
});
await factories.createTemplate({
projectId,
subject: 'Welcome back!',
});
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
});
it('should filter templates by type', async () => {
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
expect(marketingResult.total).toBe(2);
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
expect(transactionalResult.total).toBe(1);
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
});
it('should combine search and type filters', async () => {
await factories.createTemplate({
projectId,
name: 'Welcome Email',
type: TemplateType.MARKETING,
});
await factories.createTemplate({
projectId,
name: 'Welcome SMS',
type: TemplateType.TRANSACTIONAL,
});
await factories.createTemplate({
projectId,
name: 'Newsletter',
type: TemplateType.MARKETING,
});
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
expect(result.total).toBe(1);
expect(result.templates[0].name).toBe('Welcome Email');
});
it('should return templates ordered by creation date (newest first)', async () => {
const template1 = await factories.createTemplate({projectId, name: 'First'});
// Small delay to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 10));
const template2 = await factories.createTemplate({projectId, name: 'Second'});
await new Promise(resolve => setTimeout(resolve, 10));
const template3 = await factories.createTemplate({projectId, name: 'Third'});
const result = await TemplateService.list(projectId, 1, 20);
expect(result.templates[0].id).toBe(template3.id); // Newest
expect(result.templates[1].id).toBe(template2.id);
expect(result.templates[2].id).toBe(template1.id); // Oldest
});
it('should only return templates for the specified project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
await factories.createTemplate({projectId});
await factories.createTemplate({projectId});
await factories.createTemplate({projectId: otherProject.id});
const result = await TemplateService.list(projectId);
expect(result.total).toBe(2);
});
});
describe('update', () => {
it('should update template name', async () => {
const template = await factories.createTemplate({
projectId,
name: 'Old Name',
});
const updated = await TemplateService.update(projectId, template.id, {
name: 'New Name',
});
expect(updated.name).toBe('New Name');
});
it('should update template body and subject', async () => {
const template = await factories.createTemplate({projectId});
const updated = await TemplateService.update(projectId, template.id, {
subject: 'New Subject',
body: '<p>New body content</p>',
});
expect(updated.subject).toBe('New Subject');
expect(updated.body).toBe('<p>New body content</p>');
});
it('should update template type', async () => {
const template = await factories.createTemplate({
projectId,
type: TemplateType.MARKETING,
});
const updated = await TemplateService.update(projectId, template.id, {
type: TemplateType.TRANSACTIONAL,
});
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
});
it('should update email fields (from, fromName, replyTo)', async () => {
const template = await factories.createTemplate({projectId});
const updated = await TemplateService.update(projectId, template.id, {
from: 'new@example.com',
fromName: 'New Name',
replyTo: 'reply@example.com',
});
expect(updated.from).toBe('new@example.com');
expect(updated.fromName).toBe('New Name');
expect(updated.replyTo).toBe('reply@example.com');
});
it('should throw 404 when updating non-existent template', async () => {
await expect(TemplateService.update(projectId, 'non-existent-id', {name: 'New Name'})).rejects.toThrow(
'Template not found',
);
});
it('should throw 404 when updating template from different project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({projectId: otherProject.id});
await expect(TemplateService.update(projectId, template.id, {name: 'New Name'})).rejects.toThrow(
'Template not found',
);
});
});
describe('delete', () => {
it('should delete a template', async () => {
const template = await factories.createTemplate({projectId});
await TemplateService.delete(projectId, template.id);
const deleted = await prisma.template.findUnique({
where: {id: template.id},
});
expect(deleted).toBeNull();
});
it('should throw 404 when deleting non-existent template', async () => {
await expect(TemplateService.delete(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
it('should BLOCK deleting template used in workflow steps', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
await expect(TemplateService.delete(projectId, template.id)).rejects.toThrow(/currently used in workflow steps/i);
});
it('should ALLOW deleting template that was used but no longer in workflows', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
// Remove template from workflow step
await prisma.workflowStep.update({
where: {id: step.id},
data: {templateId: null},
});
// Should now be deletable
await TemplateService.delete(projectId, template.id);
const deleted = await prisma.template.findUnique({
where: {id: template.id},
});
expect(deleted).toBeNull();
});
});
describe('duplicate', () => {
it('should duplicate a template with (Copy) suffix', async () => {
const original = await factories.createTemplate({
projectId,
name: 'Original Template',
description: 'Original description',
subject: 'Original subject',
body: 'Original body',
from: 'original@example.com',
fromName: 'Original Name',
replyTo: 'reply@example.com',
type: TemplateType.TRANSACTIONAL,
});
const duplicate = await TemplateService.duplicate(projectId, original.id);
expect(duplicate.id).not.toBe(original.id);
expect(duplicate.name).toBe('Original Template (Copy)');
expect(duplicate.description).toBe(original.description);
expect(duplicate.subject).toBe(original.subject);
expect(duplicate.body).toBe(original.body);
expect(duplicate.from).toBe(original.from);
expect(duplicate.fromName).toBe(original.fromName);
expect(duplicate.replyTo).toBe(original.replyTo);
expect(duplicate.type).toBe(original.type);
expect(duplicate.projectId).toBe(projectId);
});
it('should handle duplicating template with null optional fields', async () => {
const original = await prisma.template.create({
data: {
projectId,
name: 'Minimal Template',
subject: 'Subject',
body: 'Body',
from: 'from@example.com',
description: null,
fromName: null,
replyTo: null,
},
});
const duplicate = await TemplateService.duplicate(projectId, original.id);
expect(duplicate.name).toBe('Minimal Template (Copy)');
expect(duplicate.description).toBeNull();
expect(duplicate.fromName).toBeNull();
expect(duplicate.replyTo).toBeNull();
});
it('should throw 404 when duplicating non-existent template', async () => {
await expect(TemplateService.duplicate(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
});
// ========================================
// TEMPLATE USAGE TRACKING
// ========================================
describe('getUsage', () => {
it('should return usage statistics for template', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId});
// Create workflow steps using this template
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
await factories.createWorkflowStep({
workflowId: workflow.id,
templateId: template.id,
type: 'SEND_EMAIL',
});
// Create emails sent using this template
const contact = await factories.createContact({projectId});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
await factories.createEmail(projectId, contact.id, {templateId: template.id});
const usage = await TemplateService.getUsage(projectId, template.id);
expect(usage.workflowSteps).toBe(2);
expect(usage.emailsSent).toBe(3);
});
it('should return zero usage for unused template', async () => {
const template = await factories.createTemplate({projectId});
const usage = await TemplateService.getUsage(projectId, template.id);
expect(usage.workflowSteps).toBe(0);
expect(usage.emailsSent).toBe(0);
});
it('should only count usage within the project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const template = await factories.createTemplate({projectId});
// Create workflow steps in different project (shouldn't count)
const otherWorkflow = await factories.createWorkflow({projectId: otherProject.id});
await factories.createWorkflowStep({
workflowId: otherWorkflow.id,
templateId: template.id, // Using same template ID (cross-project reference)
type: 'SEND_EMAIL',
});
const usage = await TemplateService.getUsage(projectId, template.id);
// Should not count workflow step from other project
expect(usage.workflowSteps).toBe(0);
});
it('should throw 404 when getting usage for non-existent template', async () => {
await expect(TemplateService.getUsage(projectId, 'non-existent-id')).rejects.toThrow('Template not found');
});
});
// ========================================
// EDGE CASES & DATA INTEGRITY
// ========================================
describe('edge cases', () => {
it('should handle templates with HTML content', async () => {
const html = `
<!DOCTYPE html>
<html>
<body>
<h1>Hello {{firstName}}</h1>
<p>Welcome to {{company}}</p>
</body>
</html>
`;
const template = await TemplateService.create(projectId, {
name: 'HTML Template',
subject: 'Welcome',
body: html,
from: 'test@example.com',
});
expect(template.body).toBe(html);
const retrieved = await TemplateService.get(projectId, template.id);
expect(retrieved.body).toBe(html);
});
it('should handle templates with variable placeholders', async () => {
const body = 'Hello {{firstName}} {{lastName}}, your code is {{verificationCode}}';
const subject = 'Welcome {{firstName}} - {{company}}';
const template = await TemplateService.create(projectId, {
name: 'Variables Test',
subject,
body,
from: 'test@example.com',
});
expect(template.subject).toBe(subject);
expect(template.body).toBe(body);
});
it('should handle templates with special characters', async () => {
const template = await TemplateService.create(projectId, {
name: 'Special Chars: @#$%^&*()',
subject: 'Émojis 🎉 & Spëcial Çhars',
body: '<p>Price: $100 • Discount: 20%</p>',
from: 'test@example.com',
});
expect(template.name).toBe('Special Chars: @#$%^&*()');
expect(template.subject).toContain('🎉');
expect(template.body).toContain('$100');
});
it('should handle very long template content', async () => {
const longBody = '<p>' + 'Lorem ipsum '.repeat(1000) + '</p>';
const template = await TemplateService.create(projectId, {
name: 'Long Template',
subject: 'Test',
body: longBody,
from: 'test@example.com',
});
expect(template.body.length).toBeGreaterThan(10000);
});
});
});
@@ -0,0 +1,724 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {WorkflowStepType, StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Comprehensive Operator Tests for Workflow CONDITION Steps
*
* This file systematically tests ALL supported operators in workflow
* conditional branching to ensure complete coverage.
*
* Supported Operators (same as segments except 'within'):
* - String: equals, notEquals, contains, notContains
* - Numeric: greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual
* - Existence: exists, notExists
*/
// Mock QueueService to prevent actual job queueing
vi.mock('../QueueService', () => ({
QueueService: {
queueWorkflowStep: vi.fn(async () => ({id: 'mock-job-id'})),
queueEmail: vi.fn(async () => ({id: 'mock-email-job-id'})),
queueWorkflowTimeout: vi.fn(async () => ({id: 'mock-timeout-job-id'})),
},
}));
describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
/**
* Helper function to create a workflow with a condition step and two exit paths
*/
async function createConditionalWorkflow(
contactData: Record<string, unknown>,
conditionConfig: {field: string; operator: string; value: unknown},
) {
const contact = await factories.createContact({
projectId,
data: contactData,
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const conditionStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Test Condition',
position: {x: 100, y: 0},
config: conditionConfig,
},
});
const yesExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'YES Path',
position: {x: 200, y: -50},
config: {reason: 'yes'},
},
});
const noExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'NO Path',
position: {x: 200, y: 50},
config: {reason: 'no'},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: yesExit.id,
condition: {branch: 'yes'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: noExit.id,
condition: {branch: 'no'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
return {execution, triggerStep, conditionStep, contact};
}
/**
* Helper to get the branch result from a condition execution
*/
async function getConditionBranch(executionId: string, conditionStepId: string): Promise<string> {
const stepExecution = await prisma.workflowStepExecution.findFirst({
where: {
executionId,
stepId: conditionStepId,
},
});
return (stepExecution?.output as any)?.branch || 'unknown';
}
// ========================================
// STRING OPERATORS
// ========================================
describe('String Operators', () => {
describe('equals operator', () => {
it('should branch YES when string values match exactly', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{plan: 'premium'},
{field: 'data.plan', operator: 'equals', value: 'premium'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when string values do not match', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{plan: 'basic'},
{field: 'data.plan', operator: 'equals', value: 'premium'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should match boolean true values', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true,
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const conditionStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Subscribed',
position: {x: 100, y: 0},
config: {field: 'contact.subscribed', operator: 'equals', value: true},
},
});
const yesExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'YES',
position: {x: 200, y: 0},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: yesExit.id,
condition: {branch: 'yes'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
});
describe('notEquals operator', () => {
it('should branch YES when values do not match', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{plan: 'premium'},
{field: 'data.plan', operator: 'notEquals', value: 'basic'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when values match', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{plan: 'basic'},
{field: 'data.plan', operator: 'notEquals', value: 'basic'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
describe('contains operator', () => {
it('should branch YES when field contains substring', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Acme Corporation'},
{field: 'data.company', operator: 'contains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when field does not contain substring', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Other Industries'},
{field: 'data.company', operator: 'contains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when field does not exist', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{other: 'value'},
{field: 'data.company', operator: 'contains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
describe('notContains operator', () => {
it('should branch YES when field does not contain substring', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Other Industries'},
{field: 'data.company', operator: 'notContains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when field contains substring', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Acme Corporation'},
{field: 'data.company', operator: 'notContains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when field does not exist (consistent with SegmentService)', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{other: 'value'},
{field: 'data.company', operator: 'notContains', value: 'Acme'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
// notContains only matches when field EXISTS and doesn't contain substring
expect(branch).toBe('no');
});
});
});
// ========================================
// NUMERIC OPERATORS
// ========================================
describe('Numeric Operators', () => {
describe('greaterThan operator', () => {
it('should branch YES when value is greater than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 100},
{field: 'data.score', operator: 'greaterThan', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when value equals threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 50},
{field: 'data.score', operator: 'greaterThan', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when value is less than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 25},
{field: 'data.score', operator: 'greaterThan', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should handle negative numbers correctly', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{temperature: 5},
{field: 'data.temperature', operator: 'greaterThan', value: 0},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should handle decimal values correctly', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{rating: 4.7},
{field: 'data.rating', operator: 'greaterThan', value: 4.5},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
});
describe('greaterThanOrEqual operator', () => {
it('should branch YES when value is greater than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 100},
{field: 'data.score', operator: 'greaterThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch YES when value equals threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 50},
{field: 'data.score', operator: 'greaterThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when value is less than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 25},
{field: 'data.score', operator: 'greaterThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
describe('lessThan operator', () => {
it('should branch YES when value is less than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 25},
{field: 'data.score', operator: 'lessThan', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when value equals threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 50},
{field: 'data.score', operator: 'lessThan', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
describe('lessThanOrEqual operator', () => {
it('should branch YES when value is less than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 25},
{field: 'data.score', operator: 'lessThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch YES when value equals threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 50},
{field: 'data.score', operator: 'lessThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when value is greater than threshold', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 100},
{field: 'data.score', operator: 'lessThanOrEqual', value: 50},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
});
// ========================================
// EXISTENCE OPERATORS
// ========================================
describe('Existence Operators', () => {
describe('exists operator', () => {
it('should branch YES when field exists and has value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Acme Inc'},
{field: 'data.company', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when field does not exist', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{other: 'value'},
{field: 'data.company', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when field is null', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: null},
{field: 'data.company', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch YES when field has empty string value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{notes: ''},
{field: 'data.notes', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch YES when field has zero value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 0},
{field: 'data.score', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch YES when field has boolean false value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{verified: false},
{field: 'data.verified', operator: 'exists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
});
describe('notExists operator', () => {
it('should branch YES when field does not exist', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{other: 'value'},
{field: 'data.company', operator: 'notExists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch YES when field is null', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: null},
{field: 'data.company', operator: 'notExists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should branch NO when field exists with value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{company: 'Acme Inc'},
{field: 'data.company', operator: 'notExists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when field has empty string', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{notes: ''},
{field: 'data.notes', operator: 'notExists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should branch NO when field has zero value', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{score: 0},
{field: 'data.score', operator: 'notExists', value: true},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
});
});
// ========================================
// EDGE CASES
// ========================================
describe('Edge Cases', () => {
it('should handle undefined/missing fields gracefully in equals', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{other: 'value'},
{field: 'data.missingField', operator: 'equals', value: 'something'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('no');
});
it('should handle very large numbers in comparisons', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{views: 1000000},
{field: 'data.views', operator: 'greaterThanOrEqual', value: 1000000},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should handle special characters in string comparisons', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{notes: 'Price: $99.99 (50% off!)'},
{field: 'data.notes', operator: 'contains', value: '$99.99'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
it('should handle nested field paths correctly', async () => {
const {execution, triggerStep, conditionStep} = await createConditionalWorkflow(
{profile: {tier: 'gold'}},
{field: 'data.profile.tier', operator: 'equals', value: 'gold'},
);
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const branch = await getConditionBranch(execution.id, conditionStep.id);
expect(branch).toBe('yes');
});
});
});
@@ -0,0 +1,957 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {WorkflowStepType, StepExecutionStatus, WorkflowExecutionStatus, TemplateType, Prisma} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
/**
* Integration Tests: Workflow Execution Engine
*
* These tests verify the actual execution logic of workflows,
* testing real step processing, conditional branching, event handling,
* and complex multi-step scenarios.
*/
// Mock QueueService to prevent actual job queueing
vi.mock('../QueueService', () => ({
QueueService: {
queueWorkflowStep: vi.fn(async () => ({id: 'mock-job-id'})),
queueEmail: vi.fn(async () => ({id: 'mock-email-job-id'})),
queueWorkflowTimeout: vi.fn(async () => ({id: 'mock-timeout-job-id'})),
cancelWorkflowTimeout: vi.fn(async () => true),
},
}));
// Mock SES for email sending
vi.mock('../../services/ses', () => ({
ses: {
sendEmail: vi.fn(async () => ({MessageId: 'mock-message-id'})),
},
}));
describe('WorkflowExecutionService - Integration Tests', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
// ========================================
// CONDITIONAL BRANCHING (CONDITION STEPS)
// ========================================
describe('Conditional Branching', () => {
it('should follow YES branch when condition evaluates to true', async () => {
const contact = await factories.createContact({
projectId,
data: {isPremium: true},
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
// Create condition step
const conditionStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Premium Status',
position: {x: 100, y: 0},
config: {
field: 'data.isPremium',
operator: 'equals',
value: true,
},
},
});
// Create YES and NO branches
const yesStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Premium Path',
position: {x: 200, y: -50},
config: {reason: 'Premium customer'},
},
});
const noStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Standard Path',
position: {x: 200, y: 50},
config: {reason: 'Standard customer'},
},
});
// Create transitions
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
priority: 1,
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
priority: 2,
},
});
// Create execution
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
// Process trigger step
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
// Process condition step
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
// Verify YES branch was taken
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
include: {step: true},
orderBy: {createdAt: 'asc'},
});
// Should have: TRIGGER, CONDITION, and YES step
expect(stepExecutions.length).toBeGreaterThanOrEqual(2);
const conditionExec = stepExecutions.find(se => se.step.type === WorkflowStepType.CONDITION);
expect(conditionExec).toBeDefined();
expect(conditionExec?.status).toBe(StepExecutionStatus.COMPLETED);
expect((conditionExec?.output as any)?.branch).toBe('yes');
});
it('should follow NO branch when condition evaluates to false', async () => {
const contact = await factories.createContact({
projectId,
data: {isPremium: false},
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const conditionStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Premium',
position: {x: 100, y: 0},
config: {
field: 'data.isPremium',
operator: 'equals',
value: true,
},
},
});
const yesStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Premium',
position: {x: 200, y: -50},
config: {},
},
});
const noStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Standard',
position: {x: 200, y: 50},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
include: {step: true},
orderBy: {createdAt: 'asc'},
});
const conditionExec = stepExecutions.find(se => se.step.type === WorkflowStepType.CONDITION);
expect(conditionExec).toBeDefined();
expect((conditionExec?.output as any)?.branch).toBe('no');
});
it('should handle complex nested conditions', async () => {
const contact = await factories.createContact({
projectId,
data: {country: 'US', isPremium: true},
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
// First condition: Check country
const condition1 = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Country',
position: {x: 100, y: 0},
config: {field: 'data.country', operator: 'equals', value: 'US'},
},
});
// Second condition (nested): Check premium status (only for US)
const condition2 = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Premium (US)',
position: {x: 200, y: -50},
config: {field: 'data.isPremium', operator: 'equals', value: true},
},
});
const usPremiumExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'US Premium',
position: {x: 300, y: -75},
config: {},
},
});
const usStandardExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'US Standard',
position: {x: 300, y: -25},
config: {},
},
});
const nonUsExit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Non-US',
position: {x: 200, y: 50},
config: {},
},
});
// Create transitions
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: condition1.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition1.id,
toStepId: condition2.id,
condition: {branch: 'yes'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition1.id,
toStepId: nonUsExit.id,
condition: {branch: 'no'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition2.id,
toStepId: usPremiumExit.id,
condition: {branch: 'yes'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition2.id,
toStepId: usStandardExit.id,
condition: {branch: 'no'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, condition1.id);
await WorkflowExecutionService.processStepExecution(execution.id, condition2.id);
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
include: {step: true},
orderBy: {createdAt: 'asc'},
});
// Should have executed: TRIGGER → CONDITION (US) → CONDITION (Premium) → EXIT (US Premium)
expect(stepExecutions.length).toBeGreaterThanOrEqual(3);
const conditions = stepExecutions.filter(se => se.step.type === WorkflowStepType.CONDITION);
expect(conditions).toHaveLength(2);
expect((conditions[0].output as any)?.branch).toBe('yes'); // US = yes
expect((conditions[1].output as any)?.branch).toBe('yes'); // Premium = yes
});
});
// ========================================
// WAIT_FOR_EVENT STEPS
// ========================================
describe('Wait for Event', () => {
it('should pause workflow execution when waiting for event', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const waitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Purchase',
position: {x: 100, y: 0},
config: {
eventName: 'purchase.completed',
timeout: 3600, // 1 hour
},
},
});
const exitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 200, y: 0},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: waitStep.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: waitStep.id, toStepId: exitStep.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, waitStep.id);
// Verify step is in WAITING status
const waitStepExecution = await prisma.workflowStepExecution.findFirst({
where: {
executionId: execution.id,
stepId: waitStep.id,
},
});
expect(waitStepExecution?.status).toBe(StepExecutionStatus.WAITING);
// Verify workflow execution is in WAITING status
const updatedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(updatedExecution?.status).toBe(WorkflowExecutionStatus.WAITING);
});
it('should resume workflow when expected event arrives', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const waitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.WAIT_FOR_EVENT,
name: 'Wait for Event',
position: {x: 100, y: 0},
config: {
eventName: 'user.verified',
timeout: 3600,
},
},
});
const exitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Done',
position: {x: 200, y: 0},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: waitStep.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: waitStep.id, toStepId: exitStep.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, waitStep.id);
// Verify waiting state
const waitingExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(waitingExecution?.status).toBe(WorkflowExecutionStatus.WAITING);
// Simulate event arrival by calling handleEvent
await WorkflowExecutionService.handleEvent(projectId, 'user.verified', contact.id, {verified: true});
// Verify step execution was resumed
const waitStepExecution = await prisma.workflowStepExecution.findFirst({
where: {
executionId: execution.id,
stepId: waitStep.id,
},
});
// Step should be completed after event arrives
expect(waitStepExecution?.status).toBe(StepExecutionStatus.COMPLETED);
});
});
// ========================================
// COMPLEX MULTI-STEP WORKFLOWS
// ========================================
describe('Complex Multi-Step Workflows', () => {
it('should execute linear workflow end-to-end', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
// Build: TRIGGER → DELAY → CONDITION → EXIT
const delay = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.DELAY,
name: 'Wait 1 day',
position: {x: 100, y: 0},
config: {amount: 1, unit: 'days'},
},
});
const condition = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Status',
position: {x: 200, y: 0},
config: {field: 'contact.subscribed', operator: 'equals', value: true},
},
});
const exit = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Complete',
position: {x: 300, y: 0},
config: {},
},
});
// Create transitions
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: delay.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: delay.id, toStepId: condition.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition.id,
toStepId: exit.id,
condition: {branch: 'yes'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
// Execute through workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
// Verify step executions were created
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
include: {step: true},
orderBy: {createdAt: 'asc'},
});
// Should have at least TRIGGER step
expect(stepExecutions.length).toBeGreaterThanOrEqual(1);
// Verify trigger step completed
const triggerExec = stepExecutions.find(se => se.step.type === WorkflowStepType.TRIGGER);
expect(triggerExec?.status).toBe(StepExecutionStatus.COMPLETED);
});
it('should handle workflows with multiple branches that converge', async () => {
const contact = await factories.createContact({
projectId,
data: {segment: 'A'},
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
// Split into A/B paths, then merge
const condition = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'A/B Split',
position: {x: 100, y: 0},
config: {field: 'data.segment', operator: 'equals', value: 'A'},
},
});
const pathA = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.DELAY,
name: 'Path A Delay',
position: {x: 200, y: -50},
config: {amount: 1, unit: 'hours'},
},
});
const pathB = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.DELAY,
name: 'Path B Delay',
position: {x: 200, y: 50},
config: {amount: 2, unit: 'hours'},
},
});
const merge = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Merge Point',
position: {x: 300, y: 0},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: condition.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition.id,
toStepId: pathA.id,
condition: {branch: 'yes'},
},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition.id,
toStepId: pathB.id,
condition: {branch: 'no'},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: pathA.id, toStepId: merge.id},
});
await prisma.workflowTransition.create({
data: {fromStepId: pathB.id, toStepId: merge.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, condition.id);
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
include: {step: true},
orderBy: {createdAt: 'asc'},
});
// Should have: TRIGGER, CONDITION, and Path A (since segment = 'A')
expect(stepExecutions.length).toBeGreaterThanOrEqual(2);
const conditionExec = stepExecutions.find(se => se.step.type === WorkflowStepType.CONDITION);
expect((conditionExec?.output as any)?.branch).toBe('yes');
});
});
// ========================================
// ERROR HANDLING
// ========================================
describe('Error Handling', () => {
it('should mark workflow as FAILED when step execution fails', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
// Create a CONDITION step with invalid config (will fail)
const badStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Bad Condition',
position: {x: 100, y: 0},
config: {}, // Invalid - missing required fields
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: badStep.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
// Processing the trigger step will automatically try to process the bad step
// due to the transition, which will throw an error
await expect(WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id)).rejects.toThrow();
// Verify workflow execution is marked as FAILED
const failedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(failedExecution?.status).toBe(WorkflowExecutionStatus.FAILED);
});
it('should handle missing contact data gracefully in CONDITION steps', async () => {
const contact = await factories.createContact({
projectId,
data: {}, // No fields
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const condition = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
name: 'Check Missing Field',
position: {x: 100, y: 0},
config: {
field: 'data.nonExistentField',
operator: 'equals',
value: 'something',
},
},
});
const noStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Exit',
position: {x: 200, y: 0},
config: {},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: condition.id},
});
await prisma.workflowTransition.create({
data: {
fromStepId: condition.id,
toStepId: noStep.id,
condition: {branch: 'no'},
},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, condition.id);
// Verify condition evaluated to 'no' when field doesn't exist
const conditionExec = await prisma.workflowStepExecution.findFirst({
where: {executionId: execution.id, stepId: condition.id},
});
expect(conditionExec?.status).toBe(StepExecutionStatus.COMPLETED);
expect((conditionExec?.output as any)?.branch).toBe('no');
});
});
// ========================================
// EXIT STEPS
// ========================================
describe('Exit Steps', () => {
it('should complete workflow when EXIT step is reached', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const exitStep = await prisma.workflowStep.create({
data: {
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'Early Exit',
position: {x: 100, y: 0},
config: {reason: 'User already converted'},
},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: exitStep.id},
});
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: {},
},
});
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, exitStep.id);
// Verify workflow completed
const completedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(completedExecution?.status).toBe(WorkflowExecutionStatus.COMPLETED);
expect(completedExecution?.completedAt).toBeDefined();
});
});
describe('Non-Persistent Data in Workflow Context', () => {
it('should make non-persistent event data available throughout entire workflow execution', async () => {
const contact = await factories.createContact({
projectId,
data: {
firstName: 'Alice',
plan: 'enterprise',
},
});
const workflow = await factories.createWorkflow({projectId});
const triggerStep = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.TRIGGER,
name: 'Start',
position: {x: 0, y: 0},
config: {},
});
const exitStep = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.EXIT,
name: 'End',
position: {x: 100, y: 0},
config: {},
});
await prisma.workflowTransition.create({
data: {fromStepId: triggerStep.id, toStepId: exitStep.id},
});
// Create execution with context containing both persistent and non-persistent data
const contextData = {
totalSpent: 999.99, // Persistent
orderId: {value: 'ORD-789', persistent: false}, // Non-persistent
trackingUrl: {value: 'https://track.example.com/ORD-789', persistent: false}, // Non-persistent
};
const execution = await prisma.workflowExecution.create({
data: {
workflowId: workflow.id,
contactId: contact.id,
status: WorkflowExecutionStatus.RUNNING,
currentStepId: triggerStep.id,
context: contextData as Prisma.InputJsonValue,
},
});
// Verify context persists in execution record
const savedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(savedExecution?.context).toMatchObject({
totalSpent: 999.99,
orderId: {value: 'ORD-789', persistent: false},
trackingUrl: {value: 'https://track.example.com/ORD-789', persistent: false},
});
// Process workflow steps
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
await WorkflowExecutionService.processStepExecution(execution.id, exitStep.id);
// Verify context still available after workflow completes
const completedExecution = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(completedExecution?.status).toBe(WorkflowExecutionStatus.COMPLETED);
expect(completedExecution?.context).toMatchObject({
totalSpent: 999.99,
orderId: {value: 'ORD-789', persistent: false},
trackingUrl: {value: 'https://track.example.com/ORD-789', persistent: false},
});
// Verify contact data was NOT polluted with non-persistent data
const savedContact = await prisma.contact.findUnique({
where: {id: contact.id},
});
expect(savedContact?.data).toMatchObject({
firstName: 'Alice',
plan: 'enterprise',
});
expect(savedContact?.data).not.toHaveProperty('orderId');
expect(savedContact?.data).not.toHaveProperty('trackingUrl');
});
});
});
@@ -0,0 +1,381 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {
WorkflowStepType,
StepExecutionStatus,
WorkflowExecutionStatus,
TemplateType,
WorkflowTriggerType,
} from '@plunk/db';
import {WorkflowExecutionService} from '../WorkflowExecutionService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
describe('WorkflowExecutionService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
describe('processTimeout', () => {
it('should timeout a WAIT_FOR_EVENT step when event does not arrive', async () => {
// Create workflow with WAIT_FOR_EVENT step
const _template = await factories.createTemplate({projectId});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.WAIT_FOR_EVENT, timeout: 3600}, // 1 hour timeout
{type: WorkflowStepType.SEND_EMAIL, templateId: _template.id},
]);
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
// Create step execution in WAITING state
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.WAITING,
startedAt: new Date(),
},
});
// Process timeout
await WorkflowExecutionService.processTimeout(execution.id, steps[0].id, stepExecution.id);
// Verify step execution was completed with timeout
const updatedStepExecution = await prisma.workflowStepExecution.findUnique({
where: {id: stepExecution.id},
});
expect(updatedStepExecution?.status).toBe(StepExecutionStatus.COMPLETED);
expect(updatedStepExecution?.completedAt).toBeDefined();
});
it('should not timeout if event arrives before timeout', async () => {
await factories.createTemplate({projectId});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.WAIT_FOR_EVENT, timeout: 3600},
]);
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.WAITING,
startedAt: new Date(),
},
});
// Event arrives - mark step as completed
await prisma.workflowStepExecution.update({
where: {id: stepExecution.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
},
});
// Try to process timeout - should be no-op
await WorkflowExecutionService.processTimeout(execution.id, steps[0].id, stepExecution.id);
// Verify step execution is still completed (not reprocessed)
const updatedStepExecution = await prisma.workflowStepExecution.findUnique({
where: {id: stepExecution.id},
});
expect(updatedStepExecution?.status).toBe(StepExecutionStatus.COMPLETED);
});
});
describe('workflow execution status', () => {
it('should track workflow execution from start to completion', async () => {
const template = await factories.createTemplate({projectId});
const workflow = await factories.createWorkflow({projectId, enabled: true});
await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.SEND_EMAIL,
templateId: template.id,
});
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id, {
status: WorkflowExecutionStatus.RUNNING,
});
expect(execution.status).toBe(WorkflowExecutionStatus.RUNNING);
expect(execution.completedAt).toBeNull();
// Complete execution
await prisma.workflowExecution.update({
where: {id: execution.id},
data: {
status: WorkflowExecutionStatus.COMPLETED,
completedAt: new Date(),
},
});
const completed = await prisma.workflowExecution.findUnique({
where: {id: execution.id},
});
expect(completed?.status).toBe(WorkflowExecutionStatus.COMPLETED);
expect(completed?.completedAt).toBeDefined();
});
});
describe('step execution tracking', () => {
it('should track individual step executions', async () => {
const template = await factories.createTemplate({projectId});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id},
{type: WorkflowStepType.DELAY, delay: 3600},
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id},
]);
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
// Execute first step
const stepExecution1 = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.RUNNING,
startedAt: new Date(),
},
});
// Complete first step
await prisma.workflowStepExecution.update({
where: {id: stepExecution1.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
},
});
// Verify tracking
const allStepExecutions = await prisma.workflowStepExecution.findMany({
where: {executionId: execution.id},
});
expect(allStepExecutions).toHaveLength(1);
expect(allStepExecutions[0].status).toBe(StepExecutionStatus.COMPLETED);
});
});
describe('Workflow + Subscription Status', () => {
it('should skip marketing workflow emails for unsubscribed contacts', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false, // Unsubscribed
});
const marketingTemplate = await factories.createTemplate({
projectId,
type: TemplateType.MARKETING,
});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.SEND_EMAIL, templateId: marketingTemplate.id},
]);
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
// Process the send email step - should be skipped for unsubscribed
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.RUNNING,
startedAt: new Date(),
},
});
// Mark as completed with skip output
await prisma.workflowStepExecution.update({
where: {id: stepExecution.id},
data: {
status: StepExecutionStatus.COMPLETED,
completedAt: new Date(),
output: {skipped: true, reason: 'Contact is unsubscribed from marketing emails'},
},
});
// No email should be created
const emails = await prisma.email.findMany({
where: {workflowExecutionId: execution.id},
});
expect(emails).toHaveLength(0);
// Step should be marked as completed
const updatedStepExecution = await prisma.workflowStepExecution.findUnique({
where: {id: stepExecution.id},
});
expect(updatedStepExecution?.status).toBe(StepExecutionStatus.COMPLETED);
expect((updatedStepExecution?.output as Record<string, unknown>)?.skipped).toBe(true);
});
it('should send transactional workflow emails to unsubscribed contacts', async () => {
const contact = await factories.createContact({
projectId,
subscribed: false, // Unsubscribed
});
const transactionalTemplate = await factories.createTemplate({
projectId,
type: TemplateType.TRANSACTIONAL,
});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.SEND_EMAIL, templateId: transactionalTemplate.id},
]);
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
// Transactional emails should be allowed
const stepExecution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.COMPLETED,
startedAt: new Date(),
completedAt: new Date(),
},
});
expect(stepExecution.status).toBe(StepExecutionStatus.COMPLETED);
});
it('should handle contact unsubscribing mid-workflow', async () => {
const contact = await factories.createContact({
projectId,
subscribed: true, // Initially subscribed
});
const template = await factories.createTemplate({
projectId,
type: TemplateType.MARKETING,
});
const {workflow, steps} = await factories.createWorkflowWithSteps(projectId, [
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id},
{type: WorkflowStepType.DELAY, delay: 3600},
{type: WorkflowStepType.SEND_EMAIL, templateId: template.id}, // Should be skipped
]);
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
// First email succeeds
await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[0].id,
status: StepExecutionStatus.COMPLETED,
startedAt: new Date(),
completedAt: new Date(),
},
});
// Contact unsubscribes during delay
await prisma.contact.update({
where: {id: contact.id},
data: {subscribed: false},
});
// Third step (after delay) should detect unsubscribe and skip
const step3Execution = await prisma.workflowStepExecution.create({
data: {
executionId: execution.id,
stepId: steps[2].id,
status: StepExecutionStatus.COMPLETED,
startedAt: new Date(),
completedAt: new Date(),
output: {skipped: true, reason: 'Contact unsubscribed'},
},
});
expect((step3Execution.output as Record<string, unknown>)?.skipped).toBe(true);
});
});
describe('Workflow Trigger Conditions', () => {
it('should not trigger disabled workflows', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: false, // Disabled
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
// No execution should be created for disabled workflow
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(0);
});
it('should respect allowReentry setting', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: false, // Do not allow reentry
triggerType: WorkflowTriggerType.EVENT,
triggerConfig: {eventName: 'test.event'},
});
// Create first execution
const execution1 = await factories.createWorkflowExecution(workflow.id, contact.id, {
status: WorkflowExecutionStatus.COMPLETED,
});
expect(execution1).toBeDefined();
// Attempting to create second execution should be prevented by allowReentry=false
// In real implementation, the trigger logic would check for existing executions
const existingExecutions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
// If allowReentry is false and there's a completed execution, don't allow reentry
const shouldAllowReentry = workflow.allowReentry || existingExecutions.length === 0;
expect(shouldAllowReentry).toBe(false);
});
it('should allow reentry when allowReentry is true', async () => {
const contact = await factories.createContact({projectId});
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: true, // Allow reentry
});
await factories.createWorkflowExecution(workflow.id, contact.id, {
status: WorkflowExecutionStatus.COMPLETED,
});
await factories.createWorkflowExecution(workflow.id, contact.id, {
status: WorkflowExecutionStatus.RUNNING,
});
const executions = await prisma.workflowExecution.findMany({
where: {workflowId: workflow.id, contactId: contact.id},
});
expect(executions).toHaveLength(2);
});
});
});
@@ -0,0 +1,863 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
import {WorkflowTriggerType, WorkflowStepType, WorkflowExecutionStatus} from '@plunk/db';
import {WorkflowService} from '../WorkflowService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
// Mock Redis for caching tests - must be inline to avoid hoisting issues
vi.mock('../../database/redis', () => {
const store = new Map<string, {value: string; expiry?: number}>();
return {
redis: {
get: vi.fn(async (key: string) => {
const item = store.get(key);
if (!item) return null;
if (item.expiry && Date.now() > item.expiry) {
store.delete(key);
return null;
}
return item.value;
}),
set: vi.fn(async (key: string, value: string) => {
store.set(key, {value});
return 'OK';
}),
setex: vi.fn(async (key: string, seconds: number, value: string) => {
store.set(key, {value, expiry: Date.now() + seconds * 1000});
return 'OK';
}),
del: vi.fn(async (key: string) => {
store.delete(key);
return 1;
}),
incr: vi.fn(async (key: string) => {
const current = store.get(key);
const newValue = current ? parseInt(current.value) + 1 : 1;
store.set(key, {value: String(newValue)});
return newValue;
}),
expire: vi.fn(async (key: string, seconds: number) => {
const item = store.get(key);
if (!item) return 0;
store.set(key, {...item, expiry: Date.now() + seconds * 1000});
return 1;
}),
clear: () => store.clear(),
},
};
});
describe('WorkflowService', () => {
let projectId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(async () => {
const {redis} = await import('../../database/redis');
if ('clear' in redis) {
(redis as any).clear();
}
});
// ========================================
// WORKFLOW CRUD
// ========================================
describe('create', () => {
it('should create a workflow with event trigger', async () => {
const workflow = await WorkflowService.create(projectId, {
name: 'Welcome Workflow',
description: 'Send welcome emails to new users',
eventName: 'user.signup',
enabled: true,
allowReentry: false,
});
expect(workflow.name).toBe('Welcome Workflow');
expect(workflow.description).toBe('Send welcome emails to new users');
expect(workflow.triggerType).toBe(WorkflowTriggerType.EVENT);
expect(workflow.triggerConfig).toEqual({eventName: 'user.signup'});
expect(workflow.enabled).toBe(true);
expect(workflow.allowReentry).toBe(false);
expect(workflow.projectId).toBe(projectId);
});
it('should create trigger step automatically', async () => {
const workflow = await WorkflowService.create(projectId, {
name: 'Test Workflow',
eventName: 'test.event',
});
const steps = await prisma.workflowStep.findMany({
where: {workflowId: workflow.id},
});
expect(steps).toHaveLength(1);
expect(steps[0].type).toBe(WorkflowStepType.TRIGGER);
expect(steps[0].name).toBe('Trigger: test.event');
expect(steps[0].config).toEqual({eventName: 'test.event'});
});
it('should default to disabled and no re-entry', async () => {
const workflow = await WorkflowService.create(projectId, {
name: 'Default Settings',
eventName: 'test.event',
});
expect(workflow.enabled).toBe(false);
expect(workflow.allowReentry).toBe(false);
});
it('should trim event name', async () => {
const workflow = await WorkflowService.create(projectId, {
name: 'Test',
eventName: ' user.signup ',
});
expect(workflow.triggerConfig).toEqual({eventName: 'user.signup'});
});
it('should throw error when event name is empty', async () => {
await expect(
WorkflowService.create(projectId, {
name: 'Invalid',
eventName: ' ',
}),
).rejects.toThrow('Event name is required');
});
it('should invalidate cache when creating enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
// Set cache
await redis.set(cacheKey, JSON.stringify([{id: 'old'}]));
// Create enabled workflow
await WorkflowService.create(projectId, {
name: 'Test',
eventName: 'test.event',
enabled: true,
});
// Cache should be invalidated
const cached = await redis.get(cacheKey);
expect(cached).toBeNull();
});
});
describe('get', () => {
it('should get workflow with steps and transitions', async () => {
const workflow = await factories.createWorkflow({projectId});
const template = await factories.createTemplate({projectId});
const step1 = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.SEND_EMAIL,
templateId: template.id,
});
const step2 = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.DELAY,
});
// Create transition
await prisma.workflowTransition.create({
data: {
fromStepId: step1.id,
toStepId: step2.id,
},
});
const retrieved = await WorkflowService.get(projectId, workflow.id);
expect(retrieved.id).toBe(workflow.id);
expect(retrieved.steps).toHaveLength(3); // TRIGGER + 2 created
expect(retrieved.steps.some(s => s.type === WorkflowStepType.SEND_EMAIL)).toBe(true);
expect(retrieved.steps.some(s => s.type === WorkflowStepType.DELAY)).toBe(true);
const emailStep = retrieved.steps.find(s => s.id === step1.id);
expect(emailStep?.outgoingTransitions).toHaveLength(1);
expect(emailStep?.template?.id).toBe(template.id);
});
it('should throw 404 when workflow not found', async () => {
await expect(WorkflowService.get(projectId, 'non-existent')).rejects.toThrow('Workflow not found');
});
it('should throw 404 when workflow belongs to different project', async () => {
const {project: otherProject} = await factories.createUserWithProject();
const workflow = await factories.createWorkflow({projectId: otherProject.id});
await expect(WorkflowService.get(projectId, workflow.id)).rejects.toThrow('Workflow not found');
});
});
describe('list', () => {
it('should list workflows with pagination', async () => {
for (let i = 0; i < 25; i++) {
await factories.createWorkflow({projectId, name: `Workflow ${i}`});
}
const page1 = await WorkflowService.list(projectId, 1, 10);
expect(page1.workflows).toHaveLength(10);
expect(page1.total).toBe(25);
expect(page1.totalPages).toBe(3);
});
it('should filter by search query', async () => {
await factories.createWorkflow({projectId, name: 'Welcome Sequence'});
await factories.createWorkflow({projectId, name: 'Onboarding Flow'});
await factories.createWorkflow({projectId, name: 'Welcome Email'});
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
expect(result.total).toBe(2);
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
});
it('should include step and execution counts', async () => {
const workflow = await factories.createWorkflow({projectId});
const contact = await factories.createContact({projectId});
// Add steps
await factories.createWorkflowStep({workflowId: workflow.id});
await factories.createWorkflowStep({workflowId: workflow.id});
// Add executions
await factories.createWorkflowExecution(workflow.id, contact.id);
const result = await WorkflowService.list(projectId);
const found = result.workflows.find(w => w.id === workflow.id);
expect((found as any)._count.steps).toBe(3); // TRIGGER + 2 added
expect((found as any)._count.executions).toBe(1);
});
});
describe('update', () => {
it('should update workflow name and description', async () => {
const workflow = await factories.createWorkflow({
projectId,
name: 'Old Name',
description: 'Old description',
});
const updated = await WorkflowService.update(projectId, workflow.id, {
name: 'New Name',
description: 'New description',
});
expect(updated.name).toBe('New Name');
expect(updated.description).toBe('New description');
});
it('should update enabled status', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: false,
});
const updated = await WorkflowService.update(projectId, workflow.id, {
enabled: true,
});
expect(updated.enabled).toBe(true);
});
it('should update allowReentry setting', async () => {
const workflow = await factories.createWorkflow({
projectId,
allowReentry: false,
});
const updated = await WorkflowService.update(projectId, workflow.id, {
allowReentry: true,
});
expect(updated.allowReentry).toBe(true);
});
it('should invalidate cache when enabling workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const workflow = await factories.createWorkflow({
projectId,
enabled: false,
});
// Set cache
await redis.set(cacheKey, JSON.stringify([]));
await WorkflowService.update(projectId, workflow.id, {enabled: true});
const cached = await redis.get(cacheKey);
expect(cached).toBeNull();
});
});
describe('delete', () => {
it('should delete a workflow and its steps', async () => {
const workflow = await factories.createWorkflow({projectId});
await factories.createWorkflowStep({workflowId: workflow.id});
await WorkflowService.delete(projectId, workflow.id);
const deleted = await prisma.workflow.findUnique({
where: {id: workflow.id},
});
expect(deleted).toBeNull();
// Steps should be deleted too (cascade)
const steps = await prisma.workflowStep.findMany({
where: {workflowId: workflow.id},
});
expect(steps).toHaveLength(0);
});
it('should invalidate cache when deleting enabled workflow', async () => {
const {redis} = await import('../../database/redis');
const cacheKey = `workflows:enabled:${projectId}`;
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
});
await redis.set(cacheKey, JSON.stringify([{id: workflow.id}]));
await WorkflowService.delete(projectId, workflow.id);
const cached = await redis.get(cacheKey);
expect(cached).toBeNull();
});
});
// ========================================
// WORKFLOW STEPS
// ========================================
describe('addStep', () => {
it('should add a step to workflow', async () => {
const workflow = await factories.createWorkflow({projectId});
const step = await WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.DELAY,
name: 'Wait 1 hour',
position: {x: 200, y: 100},
config: {delay: 3600},
});
expect(step.workflowId).toBe(workflow.id);
expect(step.type).toBe(WorkflowStepType.DELAY);
expect(step.name).toBe('Wait 1 hour');
expect(step.config).toEqual({delay: 3600});
});
it('should add SEND_EMAIL step with template reference', async () => {
const workflow = await factories.createWorkflow({projectId});
const template = await factories.createTemplate({projectId});
const step = await WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.SEND_EMAIL,
name: 'Send welcome email',
position: {x: 200, y: 100},
config: {},
templateId: template.id,
});
expect(step.templateId).toBe(template.id);
});
it('should auto-connect to previous step by default', async () => {
const workflow = await factories.createWorkflow({projectId});
// Workflow starts with a TRIGGER step
const triggerStep = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
const step1 = await WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.DELAY,
name: 'Step 1',
position: {x: 100, y: 100},
config: {},
});
// Verify transition created from TRIGGER to step1
const transitions1 = await prisma.workflowTransition.findMany({
where: {fromStepId: triggerStep!.id, toStepId: step1.id},
});
expect(transitions1).toHaveLength(1);
const step2 = await WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.DELAY,
name: 'Step 2',
position: {x: 200, y: 100},
config: {},
});
// Verify transition created from step1 to step2
const transitions2 = await prisma.workflowTransition.findMany({
where: {fromStepId: step1.id, toStepId: step2.id},
});
expect(transitions2).toHaveLength(1);
});
it('should NOT auto-connect when autoConnect is false', async () => {
const workflow = await factories.createWorkflow({projectId});
const step = await WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.DELAY,
name: 'Isolated Step',
position: {x: 100, y: 100},
config: {},
autoConnect: false,
});
const transitions = await prisma.workflowTransition.findMany({
where: {toStepId: step.id},
});
expect(transitions).toHaveLength(0);
});
it('should prevent adding duplicate TRIGGER steps', async () => {
const workflow = await factories.createWorkflow({projectId});
await expect(
WorkflowService.addStep(projectId, workflow.id, {
type: WorkflowStepType.TRIGGER,
name: 'Second Trigger',
position: {x: 100, y: 100},
config: {},
}),
).rejects.toThrow(/already has a trigger step/i);
});
});
describe('updateStep', () => {
it('should update step name and config', async () => {
const workflow = await factories.createWorkflow({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
name: 'Old Name',
config: {delay: 60},
});
const updated = await WorkflowService.updateStep(projectId, workflow.id, step.id, {
name: 'New Name',
config: {delay: 120},
});
expect(updated.name).toBe('New Name');
expect(updated.config).toEqual({delay: 120});
});
it('should update step position', async () => {
const workflow = await factories.createWorkflow({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
});
const updated = await WorkflowService.updateStep(projectId, workflow.id, step.id, {
position: {x: 500, y: 300},
});
expect(updated.position).toEqual({x: 500, y: 300});
});
it('should update template reference', async () => {
const workflow = await factories.createWorkflow({projectId});
const template1 = await factories.createTemplate({projectId});
const template2 = await factories.createTemplate({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.SEND_EMAIL,
templateId: template1.id,
});
const updated = await WorkflowService.updateStep(projectId, workflow.id, step.id, {
templateId: template2.id,
});
expect(updated.templateId).toBe(template2.id);
});
it('should remove template reference when set to null', async () => {
const workflow = await factories.createWorkflow({projectId});
const template = await factories.createTemplate({projectId});
const step = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.SEND_EMAIL,
templateId: template.id,
});
const updated = await WorkflowService.updateStep(projectId, workflow.id, step.id, {
templateId: null,
});
expect(updated.templateId).toBeNull();
});
it('should throw 404 when step not found', async () => {
const workflow = await factories.createWorkflow({projectId});
await expect(WorkflowService.updateStep(projectId, workflow.id, 'non-existent', {name: 'New'})).rejects.toThrow(
'Workflow step not found',
);
});
});
describe('deleteStep', () => {
it('should delete a workflow step', async () => {
const workflow = await factories.createWorkflow({projectId});
const step = await factories.createWorkflowStep({workflowId: workflow.id});
await WorkflowService.deleteStep(projectId, workflow.id, step.id);
const deleted = await prisma.workflowStep.findUnique({
where: {id: step.id},
});
expect(deleted).toBeNull();
});
it('should prevent deleting TRIGGER steps', async () => {
const workflow = await factories.createWorkflow({projectId});
const trigger = await prisma.workflowStep.findFirst({
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
});
await expect(WorkflowService.deleteStep(projectId, workflow.id, trigger!.id)).rejects.toThrow(
/Cannot delete the trigger step/i,
);
});
});
// ========================================
// WORKFLOW TRANSITIONS
// ========================================
describe('createTransition', () => {
it('should create a transition between steps', async () => {
const workflow = await factories.createWorkflow({projectId});
const step1 = await factories.createWorkflowStep({workflowId: workflow.id});
const step2 = await factories.createWorkflowStep({workflowId: workflow.id});
const transition = await WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: step1.id,
toStepId: step2.id,
});
expect(transition.fromStepId).toBe(step1.id);
expect(transition.toStepId).toBe(step2.id);
});
it('should create transition with condition', async () => {
const workflow = await factories.createWorkflow({projectId});
const step1 = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
});
const step2 = await factories.createWorkflowStep({workflowId: workflow.id});
const transition = await WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: step1.id,
toStepId: step2.id,
condition: {branch: 'yes'},
priority: 1,
});
expect(transition.condition).toEqual({branch: 'yes'});
expect(transition.priority).toBe(1);
});
it('should prevent duplicate branch transitions from CONDITION steps', async () => {
const workflow = await factories.createWorkflow({projectId});
const conditionStep = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
});
const step2 = await factories.createWorkflowStep({workflowId: workflow.id});
const step3 = await factories.createWorkflowStep({workflowId: workflow.id});
// Create first 'yes' branch
await WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: conditionStep.id,
toStepId: step2.id,
condition: {branch: 'yes'},
});
// Try to create second 'yes' branch - should fail
await expect(
WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: conditionStep.id,
toStepId: step3.id,
condition: {branch: 'yes'},
}),
).rejects.toThrow(/already exists/i);
});
it('should allow different branches from CONDITION steps', async () => {
const workflow = await factories.createWorkflow({projectId});
const conditionStep = await factories.createWorkflowStep({
workflowId: workflow.id,
type: WorkflowStepType.CONDITION,
});
const yesStep = await factories.createWorkflowStep({workflowId: workflow.id});
const noStep = await factories.createWorkflowStep({workflowId: workflow.id});
const yesTransition = await WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: conditionStep.id,
toStepId: yesStep.id,
condition: {branch: 'yes'},
});
const noTransition = await WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: conditionStep.id,
toStepId: noStep.id,
condition: {branch: 'no'},
});
expect(yesTransition.condition).toEqual({branch: 'yes'});
expect(noTransition.condition).toEqual({branch: 'no'});
});
it('should throw 404 when steps not found', async () => {
const workflow = await factories.createWorkflow({projectId});
await expect(
WorkflowService.createTransition(projectId, workflow.id, {
fromStepId: 'non-existent',
toStepId: 'non-existent-2',
}),
).rejects.toThrow('One or both steps not found');
});
});
describe('deleteTransition', () => {
it('should delete a transition', async () => {
const workflow = await factories.createWorkflow({projectId});
const step1 = await factories.createWorkflowStep({workflowId: workflow.id});
const step2 = await factories.createWorkflowStep({workflowId: workflow.id});
const transition = await prisma.workflowTransition.create({
data: {
fromStepId: step1.id,
toStepId: step2.id,
},
});
await WorkflowService.deleteTransition(projectId, workflow.id, transition.id);
const deleted = await prisma.workflowTransition.findUnique({
where: {id: transition.id},
});
expect(deleted).toBeNull();
});
});
// ========================================
// WORKFLOW EXECUTION
// ========================================
describe('startExecution', () => {
it('should start workflow execution for a contact', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
});
const contact = await factories.createContact({projectId});
const execution = await WorkflowService.startExecution(projectId, workflow.id, contact.id);
expect(execution.workflowId).toBe(workflow.id);
expect(execution.contactId).toBe(contact.id);
expect(execution.status).toBe(WorkflowExecutionStatus.RUNNING);
});
it('should throw error when workflow is disabled', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: false,
});
const contact = await factories.createContact({projectId});
await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow(
'Workflow is not enabled',
);
});
it('should throw error when contact not found', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
});
await expect(WorkflowService.startExecution(projectId, workflow.id, 'non-existent')).rejects.toThrow(
'Contact not found',
);
});
it('should prevent re-entry when allowReentry is false', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: false,
});
const contact = await factories.createContact({projectId});
// First execution
await WorkflowService.startExecution(projectId, workflow.id, contact.id);
// Second execution should fail
await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow(
/does not allow re-entry/i,
);
});
it('should allow re-entry when allowReentry is true and previous execution completed', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: true,
});
const contact = await factories.createContact({projectId});
// First execution
const exec1 = await WorkflowService.startExecution(projectId, workflow.id, contact.id);
// Complete it
await prisma.workflowExecution.update({
where: {id: exec1.id},
data: {status: WorkflowExecutionStatus.COMPLETED},
});
// Second execution should succeed
const exec2 = await WorkflowService.startExecution(projectId, workflow.id, contact.id);
expect(exec2.id).not.toBe(exec1.id);
});
it('should prevent concurrent executions even with allowReentry=true', async () => {
const workflow = await factories.createWorkflow({
projectId,
enabled: true,
allowReentry: true,
});
const contact = await factories.createContact({projectId});
// Start first execution (still running)
await WorkflowService.startExecution(projectId, workflow.id, contact.id);
// Second execution should fail (first still running)
await expect(WorkflowService.startExecution(projectId, workflow.id, contact.id)).rejects.toThrow(
/already running/i,
);
});
});
describe('listExecutions', () => {
it('should list workflow executions with pagination', async () => {
const workflow = await factories.createWorkflow({projectId});
const contacts = await factories.createContacts(projectId, 25);
for (const contact of contacts) {
await factories.createWorkflowExecution(workflow.id, contact.id);
}
const result = await WorkflowService.listExecutions(projectId, workflow.id, 1, 10);
expect(result.executions).toHaveLength(10);
expect(result.total).toBe(25);
expect(result.totalPages).toBe(3);
});
it('should filter executions by status', async () => {
const workflow = await factories.createWorkflow({projectId});
const contact1 = await factories.createContact({projectId});
const contact2 = await factories.createContact({projectId});
const contact3 = await factories.createContact({projectId});
await factories.createWorkflowExecution(workflow.id, contact1.id, {
status: WorkflowExecutionStatus.RUNNING,
});
await factories.createWorkflowExecution(workflow.id, contact2.id, {
status: WorkflowExecutionStatus.COMPLETED,
});
await factories.createWorkflowExecution(workflow.id, contact3.id, {
status: WorkflowExecutionStatus.FAILED,
});
const running = await WorkflowService.listExecutions(
projectId,
workflow.id,
1,
20,
WorkflowExecutionStatus.RUNNING,
);
expect(running.total).toBe(1);
expect(running.executions[0].status).toBe(WorkflowExecutionStatus.RUNNING);
});
it('should include contact email in results', async () => {
const workflow = await factories.createWorkflow({projectId});
const contact = await factories.createContact({
projectId,
email: 'test@example.com',
});
await factories.createWorkflowExecution(workflow.id, contact.id);
const result = await WorkflowService.listExecutions(projectId, workflow.id);
expect(result.executions[0].contact.email).toBe('test@example.com');
});
});
describe('getExecution', () => {
it('should get execution with full details', async () => {
const workflow = await factories.createWorkflow({projectId});
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id);
const retrieved = await WorkflowService.getExecution(projectId, workflow.id, execution.id);
expect(retrieved.id).toBe(execution.id);
expect(retrieved.workflow.id).toBe(workflow.id);
expect(retrieved.contact.id).toBe(contact.id);
});
it('should throw 404 when execution not found', async () => {
const workflow = await factories.createWorkflow({projectId});
await expect(WorkflowService.getExecution(projectId, workflow.id, 'non-existent')).rejects.toThrow(
'Workflow execution not found',
);
});
});
describe('cancelExecution', () => {
it('should cancel a running execution', async () => {
const workflow = await factories.createWorkflow({projectId});
const contact = await factories.createContact({projectId});
const execution = await factories.createWorkflowExecution(workflow.id, contact.id, {
status: WorkflowExecutionStatus.RUNNING,
});
const cancelled = await WorkflowService.cancelExecution(projectId, workflow.id, execution.id);
expect(cancelled.status).toBe(WorkflowExecutionStatus.CANCELLED);
expect(cancelled.completedAt).toBeDefined();
});
});
});