Initial push of Plunk Next
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user