Files
plunk/apps/api/src/services/__tests__/WorkflowService.test.ts
T

867 lines
29 KiB
TypeScript

import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {WorkflowService} from '../WorkflowService';
import {Keys} from '../keys';
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 unknown as {clear: () => void}).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 = Keys.Workflow.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.data).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.data.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.data.find(w => w.id === workflow.id) as
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
| undefined;
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
expect(found?._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 = Keys.Workflow.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 = Keys.Workflow.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();
});
});
});