business-as-code
Version:
Primitives for expressing business logic and processes as code
502 lines (423 loc) • 16.1 kB
text/typescript
/**
* Tests for workflow.ts - Workflow definition and automation sequences
*/
import { describe, it, expect } from 'vitest'
import {
Workflow,
getActionsInOrder,
getActionsByType,
getConditionalActions,
addAction,
removeAction,
updateAction,
isEventTrigger,
isScheduleTrigger,
isWebhookTrigger,
parseWaitDuration,
evaluateCondition,
fillTemplate,
validateWorkflow,
} from '../src/workflow.js'
import type { WorkflowDefinition, WorkflowAction, WorkflowTrigger } from '../src/types.js'
describe('Workflow', () => {
describe('Workflow()', () => {
it('should create a workflow with required fields', () => {
const workflow = Workflow({
name: 'Test Workflow',
trigger: { type: 'event', event: 'user.created' },
})
expect(workflow.name).toBe('Test Workflow')
expect(workflow.trigger.type).toBe('event')
expect(workflow.actions).toEqual([])
expect(workflow.metadata).toEqual({})
})
it('should create a workflow with all fields', () => {
const workflow = Workflow({
name: 'Welcome Flow',
description: 'Welcomes new users',
trigger: { type: 'event', event: 'user.created' },
actions: [
{ order: 1, type: 'send', description: 'Send email' },
{ order: 2, type: 'create', description: 'Create task' },
],
metadata: { version: 1 },
})
expect(workflow.name).toBe('Welcome Flow')
expect(workflow.description).toBe('Welcomes new users')
expect(workflow.actions).toHaveLength(2)
expect(workflow.metadata).toEqual({ version: 1 })
})
it('should throw error if name is missing', () => {
expect(() =>
Workflow({
name: '',
trigger: { type: 'event' },
})
).toThrow('Workflow name is required')
})
it('should throw error if trigger is missing', () => {
expect(() =>
Workflow({
name: 'Test',
trigger: undefined as unknown as WorkflowTrigger,
})
).toThrow('Workflow trigger is required')
})
})
describe('getActionsInOrder()', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 3, type: 'notify', description: 'Third' },
{ order: 1, type: 'send', description: 'First' },
{ order: 2, type: 'create', description: 'Second' },
],
})
it('should return actions sorted by order', () => {
const sorted = getActionsInOrder(workflow)
expect(sorted[0]?.order).toBe(1)
expect(sorted[0]?.description).toBe('First')
expect(sorted[1]?.order).toBe(2)
expect(sorted[2]?.order).toBe(3)
})
it('should handle workflow with no actions', () => {
const emptyWorkflow = Workflow({
name: 'Empty',
trigger: { type: 'manual' },
})
const sorted = getActionsInOrder(emptyWorkflow)
expect(sorted).toEqual([])
})
})
describe('getActionsByType()', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'send', description: 'Send 1' },
{ order: 2, type: 'create', description: 'Create 1' },
{ order: 3, type: 'send', description: 'Send 2' },
{ order: 4, type: 'notify', description: 'Notify 1' },
],
})
it('should filter actions by type', () => {
const sendActions = getActionsByType(workflow, 'send')
expect(sendActions).toHaveLength(2)
expect(sendActions[0]?.description).toBe('Send 1')
expect(sendActions[1]?.description).toBe('Send 2')
})
it('should return empty array for non-existent type', () => {
const deleteActions = getActionsByType(workflow, 'delete')
expect(deleteActions).toHaveLength(0)
})
})
describe('getConditionalActions()', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'send', description: 'Always runs' },
{
order: 2,
type: 'notify',
description: 'Conditional',
condition: 'user.premium === true',
},
{
order: 3,
type: 'create',
description: 'Also conditional',
condition: 'order.total > 100',
},
],
})
it('should return only conditional actions', () => {
const conditional = getConditionalActions(workflow)
expect(conditional).toHaveLength(2)
expect(conditional[0]?.description).toBe('Conditional')
expect(conditional[1]?.description).toBe('Also conditional')
})
it('should return empty array if no conditional actions', () => {
const simpleWorkflow = Workflow({
name: 'Simple',
trigger: { type: 'manual' },
actions: [{ order: 1, type: 'send', description: 'Always' }],
})
const conditional = getConditionalActions(simpleWorkflow)
expect(conditional).toHaveLength(0)
})
})
describe('addAction()', () => {
it('should add action to workflow', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [{ order: 1, type: 'send', description: 'First' }],
})
const updated = addAction(workflow, { order: 2, type: 'notify', description: 'Second' })
expect(updated.actions).toHaveLength(2)
expect(updated.actions?.[1]?.order).toBe(2)
})
it('should add action to empty workflow', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
})
const updated = addAction(workflow, { order: 1, type: 'send', description: 'First' })
expect(updated.actions).toHaveLength(1)
})
})
describe('removeAction()', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'send', description: 'First' },
{ order: 2, type: 'create', description: 'Second' },
{ order: 3, type: 'notify', description: 'Third' },
],
})
it('should remove action by order', () => {
const updated = removeAction(workflow, 2)
expect(updated.actions).toHaveLength(2)
expect(updated.actions?.find((a) => a.order === 2)).toBeUndefined()
})
it('should handle removing non-existent action', () => {
const updated = removeAction(workflow, 99)
expect(updated.actions).toHaveLength(3)
})
})
describe('updateAction()', () => {
const workflow = Workflow({
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'send', description: 'Original' },
{ order: 2, type: 'create', description: 'Second' },
],
})
it('should update action properties', () => {
const updated = updateAction(workflow, 1, { description: 'Updated', type: 'notify' })
const action = updated.actions?.find((a) => a.order === 1)
expect(action?.description).toBe('Updated')
expect(action?.type).toBe('notify')
})
it('should not affect other actions', () => {
const updated = updateAction(workflow, 1, { description: 'Updated' })
const secondAction = updated.actions?.find((a) => a.order === 2)
expect(secondAction?.description).toBe('Second')
})
it('should handle updating non-existent action', () => {
const updated = updateAction(workflow, 99, { description: 'Updated' })
expect(updated.actions).toHaveLength(2)
})
})
describe('Trigger type checkers', () => {
it('isEventTrigger should identify event triggers', () => {
expect(isEventTrigger({ type: 'event', event: 'user.created' })).toBe(true)
expect(isEventTrigger({ type: 'schedule' })).toBe(false)
})
it('isScheduleTrigger should identify schedule triggers', () => {
expect(isScheduleTrigger({ type: 'schedule', schedule: '0 9 * * *' })).toBe(true)
expect(isScheduleTrigger({ type: 'event' })).toBe(false)
})
it('isWebhookTrigger should identify webhook triggers', () => {
expect(isWebhookTrigger({ type: 'webhook', webhook: 'https://example.com' })).toBe(true)
expect(isWebhookTrigger({ type: 'manual' })).toBe(false)
})
})
describe('parseWaitDuration()', () => {
it('should parse milliseconds', () => {
expect(parseWaitDuration('100 ms')).toBe(100)
expect(parseWaitDuration('500 milliseconds')).toBe(500)
})
it('should parse seconds', () => {
expect(parseWaitDuration('30 seconds')).toBe(30000)
expect(parseWaitDuration('1 second')).toBe(1000)
expect(parseWaitDuration('5 s')).toBe(5000)
})
it('should parse minutes', () => {
expect(parseWaitDuration('5 minutes')).toBe(300000)
expect(parseWaitDuration('1 minute')).toBe(60000)
expect(parseWaitDuration('10 m')).toBe(600000)
})
it('should parse hours', () => {
expect(parseWaitDuration('2 hours')).toBe(7200000)
expect(parseWaitDuration('1 hour')).toBe(3600000)
expect(parseWaitDuration('24 h')).toBe(86400000)
})
it('should parse days', () => {
expect(parseWaitDuration('1 day')).toBe(86400000)
expect(parseWaitDuration('7 days')).toBe(604800000)
expect(parseWaitDuration('2 d')).toBe(172800000)
})
it('should return 0 for invalid format', () => {
expect(parseWaitDuration('invalid')).toBe(0)
expect(parseWaitDuration('')).toBe(0)
expect(parseWaitDuration('minutes')).toBe(0)
})
})
describe('evaluateCondition()', () => {
it('should evaluate simple equality', () => {
expect(evaluateCondition('active === true', { active: true })).toBe(true)
expect(evaluateCondition('active === false', { active: true })).toBe(false)
})
it('should evaluate comparisons', () => {
expect(evaluateCondition('amount > 100', { amount: 150 })).toBe(true)
expect(evaluateCondition('amount > 100', { amount: 50 })).toBe(false)
expect(evaluateCondition('count >= 5', { count: 5 })).toBe(true)
})
it('should handle missing context variables', () => {
expect(evaluateCondition('missing === true', {})).toBe(false)
})
it('should evaluate string comparisons', () => {
expect(evaluateCondition('status === "active"', { status: 'active' })).toBe(true)
})
it('should return false for invalid conditions', () => {
expect(evaluateCondition('invalid syntax {{}}', {})).toBe(false)
})
})
describe('fillTemplate()', () => {
it('should fill simple templates', () => {
const result = fillTemplate('Hello {{name}}!', { name: 'Alice' })
expect(result).toBe('Hello Alice!')
})
it('should fill multiple placeholders', () => {
const result = fillTemplate('{{greeting}} {{name}}, you have {{count}} messages', {
greeting: 'Hello',
name: 'Bob',
count: 5,
})
expect(result).toBe('Hello Bob, you have 5 messages')
})
it('should handle nested paths', () => {
const result = fillTemplate('Welcome {{user.name}}!', {
user: { name: 'Charlie' },
})
expect(result).toBe('Welcome Charlie!')
})
it('should handle deeply nested paths', () => {
const result = fillTemplate('Order: {{order.customer.name}}', {
order: { customer: { name: 'Acme Corp' } },
})
expect(result).toBe('Order: Acme Corp')
})
it('should replace missing values with empty string', () => {
const result = fillTemplate('Hello {{missing}}!', {})
expect(result).toBe('Hello !')
})
it('should handle template with no placeholders', () => {
const result = fillTemplate('No placeholders here', { value: 'test' })
expect(result).toBe('No placeholders here')
})
it('should handle whitespace in placeholders', () => {
const result = fillTemplate('Hello {{ name }}!', { name: 'Alice' })
expect(result).toBe('Hello Alice!')
})
})
describe('validateWorkflow()', () => {
it('should validate valid workflow', () => {
const workflow: WorkflowDefinition = {
name: 'Valid Workflow',
trigger: { type: 'event', event: 'user.created' },
actions: [
{ order: 1, type: 'send', description: 'Send email' },
{ order: 2, type: 'notify', description: 'Notify admin' },
],
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(true)
expect(result.errors).toHaveLength(0)
})
it('should fail if name is missing', () => {
const workflow: WorkflowDefinition = {
name: '',
trigger: { type: 'event' },
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Workflow name is required')
})
it('should fail if trigger is missing', () => {
const workflow = {
name: 'Test',
trigger: undefined,
} as unknown as WorkflowDefinition
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Workflow trigger is required')
})
it('should fail if event trigger has no event', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'event' },
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Event trigger must specify an event name')
})
it('should fail if schedule trigger has no schedule', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'schedule' },
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Schedule trigger must specify a schedule expression')
})
it('should fail if webhook trigger has no webhook URL', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'webhook' },
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Webhook trigger must specify a webhook URL')
})
it('should fail if action has no type', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'manual' },
actions: [{ order: 1, type: '' as WorkflowAction['type'], description: 'No type' }],
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Action at order 1 must have a type')
})
it('should fail for duplicate action orders', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'send', description: 'First' },
{ order: 1, type: 'notify', description: 'Duplicate' },
],
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Duplicate action order: 1')
})
it('should fail if wait action has no duration', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'manual' },
actions: [{ order: 1, type: 'wait', description: 'Wait without duration' }],
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(false)
expect(result.errors).toContain('Wait action at order 1 must specify duration')
})
it('should pass for wait action with duration', () => {
const workflow: WorkflowDefinition = {
name: 'Test',
trigger: { type: 'manual' },
actions: [
{ order: 1, type: 'wait', description: 'Wait', params: { duration: '5 minutes' } },
],
}
const result = validateWorkflow(workflow)
expect(result.valid).toBe(true)
})
})
})