867 lines
29 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|