mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 00:43:23 +00:00
Remove obsolete test files and their associated cases
- Delete unused test files: `basic.test.ts`, `condition-fix.spec.ts`, `create-document-step.test.ts`, and `error-scenarios.spec.ts` - Streamline codebase by eliminating redundant and outdated test cases - Improve maintainability by keeping only relevant and up-to-date tests
This commit is contained in:
27
CHANGELOG.md
27
CHANGELOG.md
@@ -5,6 +5,33 @@ All notable changes to the PayloadCMS Automation Plugin will be documented in th
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.0.38] - 2025-09-10
|
||||
|
||||
### Changed
|
||||
- Updated dependencies to PayloadCMS 3.45.0
|
||||
- Enhanced plugin configuration and stability
|
||||
|
||||
## [0.0.37] - 2025-09-XX
|
||||
|
||||
### Removed
|
||||
- **Breaking Change**: Removed built-in cron trigger implementation in favor of webhook-based scheduling
|
||||
- Removed unused plugin modules and associated tests
|
||||
- Removed `initCollectionHooks` and associated migration guides
|
||||
|
||||
### Changed
|
||||
- Refactored triggers to TriggerConfig pattern
|
||||
- Simplified executor architecture by removing executorRegistry pattern
|
||||
- Updated to on-demand workflow execution creation
|
||||
|
||||
### Added
|
||||
- Migration guide for v0.0.37 (MIGRATION-v0.0.37.md)
|
||||
- Enhanced parameter field configuration
|
||||
|
||||
### Migration Notes
|
||||
- Built-in cron triggers are no longer supported. Use webhook triggers with external cron services (GitHub Actions, Vercel Cron, etc.)
|
||||
- Update trigger configurations to use the new TriggerConfig pattern
|
||||
- See MIGRATION-v0.0.37.md for detailed migration steps
|
||||
|
||||
## [0.0.16] - 2025-09-01
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -56,7 +56,10 @@ The plugin uses separate exports to avoid bundling server-side code in client bu
|
||||
import { workflowsPlugin } from '@xtr-dev/payload-automation/server'
|
||||
|
||||
// Client-side components
|
||||
import { TriggerWorkflowButton } from '@xtr-dev/payload-automation/client'
|
||||
import { StatusCell, ErrorDisplay } from '@xtr-dev/payload-automation/client'
|
||||
|
||||
// Helper utilities
|
||||
import { /* helpers */ } from '@xtr-dev/payload-automation/helpers'
|
||||
|
||||
// Types only (safe for both server and client)
|
||||
import type { WorkflowsPluginConfig } from '@xtr-dev/payload-automation'
|
||||
@@ -216,6 +219,8 @@ export default async function handler(req, res) {
|
||||
|
||||
**Benefits**: Better reliability, proper process isolation, easier debugging, and leverages existing infrastructure.
|
||||
|
||||
**Note**: Built-in cron triggers have been removed in v0.0.37+ to focus on webhook-based scheduling which provides better reliability and debugging capabilities.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation coming soon. For now, explore the development environment in the repository for examples and patterns.
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { getTestPayload, cleanDatabase } from './test-setup.js'
|
||||
|
||||
describe('Workflow Condition Fix Test', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
it('should correctly evaluate trigger conditions with $.trigger.doc path', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Create a workflow with a condition using the correct JSONPath
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Condition Evaluation',
|
||||
description: 'Tests that $.trigger.doc.content conditions work',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create',
|
||||
condition: '$.trigger.doc.content == "TRIGGER_ME"'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'audit-step',
|
||||
step: 'create-document',
|
||||
collectionSlug: 'auditLog',
|
||||
data: {
|
||||
post: '$.trigger.doc.id',
|
||||
message: 'Condition was met and workflow triggered'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
console.log('Created workflow with condition: $.trigger.doc.content == "TRIGGER_ME"')
|
||||
|
||||
// Create a post that SHOULD NOT trigger
|
||||
const post1 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'This should not trigger'
|
||||
}
|
||||
})
|
||||
|
||||
// Create a post that SHOULD trigger
|
||||
const post2 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'TRIGGER_ME'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for workflow execution
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
// Check workflow runs - should have exactly 1
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${runs.totalDocs} workflow runs`)
|
||||
if (runs.totalDocs > 0) {
|
||||
console.log('Run statuses:', runs.docs.map(r => r.status))
|
||||
}
|
||||
|
||||
// Should have exactly 1 run for the matching condition
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
|
||||
// Check audit logs - should only have one for post2
|
||||
const auditLogs = await payload.find({
|
||||
collection: 'auditLog',
|
||||
where: {
|
||||
post: {
|
||||
equals: post2.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (runs.docs[0].status === 'completed') {
|
||||
expect(auditLogs.totalDocs).toBe(1)
|
||||
expect(auditLogs.docs[0].message).toBe('Condition was met and workflow triggered')
|
||||
}
|
||||
|
||||
// Verify no audit log for the first post
|
||||
const noAuditLogs = await payload.find({
|
||||
collection: 'auditLog',
|
||||
where: {
|
||||
post: {
|
||||
equals: post1.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(noAuditLogs.totalDocs).toBe(0)
|
||||
|
||||
console.log('✅ Condition evaluation working with $.trigger.doc path!')
|
||||
}, 30000)
|
||||
})
|
||||
@@ -1,519 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { getTestPayload, cleanDatabase } from './test-setup.js'
|
||||
import { mockHttpBin, testFixtures } from './test-helpers.js'
|
||||
|
||||
describe('Error Scenarios and Edge Cases', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
// Set up comprehensive mocks for all error scenarios
|
||||
mockHttpBin.mockAllErrorScenarios()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
mockHttpBin.cleanup()
|
||||
})
|
||||
|
||||
it('should handle HTTP timeout errors gracefully', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Clear existing mocks and set up a proper timeout mock
|
||||
mockHttpBin.cleanup()
|
||||
mockHttpBin.mockTimeout()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - HTTP Timeout',
|
||||
description: 'Tests HTTP request timeout handling',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
...testFixtures.httpRequestStep('https://httpbin.org/delay/10'),
|
||||
name: 'timeout-request',
|
||||
method: 'GET',
|
||||
timeout: 2000, // 2 second timeout
|
||||
body: null
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Timeout Post'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for workflow execution (should timeout)
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Either failed due to timeout or completed (depending on network speed)
|
||||
expect(['failed', 'completed']).toContain(runs.docs[0].status)
|
||||
|
||||
// Verify that detailed error information is preserved via new independent storage system
|
||||
const context = runs.docs[0].context
|
||||
const stepContext = context.steps['timeout-request']
|
||||
|
||||
// Check that independent execution info was recorded
|
||||
expect(stepContext.executionInfo).toBeDefined()
|
||||
expect(stepContext.executionInfo.completed).toBe(true)
|
||||
|
||||
// Check that detailed error information was preserved (new feature!)
|
||||
if (runs.docs[0].status === 'failed' && stepContext.errorDetails) {
|
||||
expect(stepContext.errorDetails.errorType).toBe('timeout')
|
||||
expect(stepContext.errorDetails.duration).toBeGreaterThan(2000)
|
||||
expect(stepContext.errorDetails.attempts).toBe(1)
|
||||
expect(stepContext.errorDetails.context.url).toBe('https://httpbin.org/delay/10')
|
||||
expect(stepContext.errorDetails.context.timeout).toBe(2000)
|
||||
console.log('✅ Detailed timeout error information preserved:', {
|
||||
errorType: stepContext.errorDetails.errorType,
|
||||
duration: stepContext.errorDetails.duration,
|
||||
attempts: stepContext.errorDetails.attempts
|
||||
})
|
||||
} else if (runs.docs[0].status === 'failed') {
|
||||
console.log('✅ Timeout error handled:', runs.docs[0].error)
|
||||
} else {
|
||||
console.log('✅ Request completed within timeout')
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
it('should handle invalid JSON responses', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Invalid JSON',
|
||||
description: 'Tests invalid JSON response handling',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'invalid-json-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/html', // Returns HTML, not JSON
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Invalid JSON Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
expect(runs.docs[0].status).toBe('completed') // Should complete but with HTML body
|
||||
expect(runs.docs[0].context.steps['invalid-json-request'].output.body).toContain('<html>')
|
||||
|
||||
console.log('✅ Non-JSON response handled correctly')
|
||||
}, 25000)
|
||||
|
||||
it('should handle circular reference in JSONPath resolution', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// This test creates a scenario where JSONPath might encounter circular references
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Circular Reference',
|
||||
description: 'Tests circular reference handling in JSONPath',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'circular-test',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/post',
|
||||
method: 'POST',
|
||||
body: {
|
||||
// This creates a deep reference that could cause issues
|
||||
triggerData: '$.trigger',
|
||||
stepData: '$.steps',
|
||||
nestedRef: '$.trigger.doc'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Circular Reference Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Should either succeed with safe serialization or fail gracefully
|
||||
expect(['completed', 'failed']).toContain(runs.docs[0].status)
|
||||
|
||||
console.log('✅ Circular reference handled:', runs.docs[0].status)
|
||||
}, 20000)
|
||||
|
||||
it('should handle malformed workflow configurations', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// This test should expect the workflow creation to fail due to validation
|
||||
let creationFailed = false
|
||||
let workflow: any = null
|
||||
|
||||
try {
|
||||
// Create workflow with missing required fields for create-document
|
||||
workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Malformed Config',
|
||||
description: 'Tests malformed workflow configuration',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'malformed-step',
|
||||
step: 'create-document',
|
||||
// Missing required collectionSlug
|
||||
data: {
|
||||
message: 'This should fail'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
creationFailed = true
|
||||
expect(error).toBeDefined()
|
||||
console.log('✅ Workflow creation failed as expected:', error instanceof Error ? error.message : error)
|
||||
}
|
||||
|
||||
// If creation failed, that's the expected behavior
|
||||
if (creationFailed) {
|
||||
return
|
||||
}
|
||||
|
||||
// If somehow the workflow was created, test execution failure
|
||||
if (workflow) {
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Malformed Config Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
expect(runs.docs[0].status).toBe('failed')
|
||||
expect(runs.docs[0].error).toBeDefined()
|
||||
|
||||
console.log('✅ Malformed config caused execution failure:', runs.docs[0].error)
|
||||
}
|
||||
}, 15000)
|
||||
|
||||
it('should handle HTTP 4xx and 5xx errors properly', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - HTTP Errors',
|
||||
description: 'Tests HTTP error status handling',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'not-found-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/status/404',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'server-error-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/status/500',
|
||||
method: 'GET',
|
||||
dependencies: ['not-found-request']
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error HTTP Status Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 8000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
expect(runs.docs[0].status).toBe('completed') // Workflow should complete successfully
|
||||
|
||||
// Check that both steps completed with HTTP error outputs
|
||||
const context = runs.docs[0].context
|
||||
expect(context.steps['not-found-request'].state).toBe('succeeded') // HTTP request completed
|
||||
expect(context.steps['not-found-request'].output.status).toBe(404) // But with error status
|
||||
|
||||
console.log('✅ HTTP error statuses handled correctly')
|
||||
}, 25000)
|
||||
|
||||
it('should handle retry logic for transient failures', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Retry Logic',
|
||||
description: 'Tests retry logic for HTTP requests',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'retry-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/status/503', // Service unavailable
|
||||
method: 'GET',
|
||||
retries: 3,
|
||||
retryDelay: 1000
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Retry Logic Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
expect(runs.docs[0].status).toBe('completed') // Workflow should complete with HTTP error output
|
||||
|
||||
// The step should have succeeded but with error status
|
||||
const stepContext = runs.docs[0].context.steps['retry-request']
|
||||
expect(stepContext.state).toBe('succeeded')
|
||||
expect(stepContext.output.status).toBe(503)
|
||||
|
||||
console.log('✅ Retry logic executed correctly')
|
||||
}, 25000)
|
||||
|
||||
it('should handle extremely large workflow contexts', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Large Context',
|
||||
description: 'Tests handling of large workflow contexts',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'large-response-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/base64/SFRUUEJJTiBpcyBhd2Vzb21l', // Returns base64 decoded text
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Large Context Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Should handle large contexts without memory issues
|
||||
expect(['completed', 'failed']).toContain(runs.docs[0].status)
|
||||
|
||||
console.log('✅ Large context handled:', runs.docs[0].status)
|
||||
}, 20000)
|
||||
|
||||
it('should handle undefined and null values in JSONPath', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Error - Null Values',
|
||||
description: 'Tests null/undefined values in JSONPath expressions',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'null-value-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/post',
|
||||
method: 'POST',
|
||||
body: {
|
||||
nonexistentField: '$.trigger.doc.nonexistent',
|
||||
nullField: '$.trigger.doc.null',
|
||||
undefinedField: '$.trigger.doc.undefined'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Error Null Values Post'
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Should handle null/undefined values gracefully
|
||||
expect(['completed', 'failed']).toContain(runs.docs[0].status)
|
||||
|
||||
if (runs.docs[0].status === 'completed') {
|
||||
const stepOutput = runs.docs[0].context.steps['null-value-request'].output
|
||||
expect(stepOutput.status).toBe(200) // httpbin should accept the request
|
||||
console.log('✅ Null values handled gracefully')
|
||||
} else {
|
||||
console.log('✅ Null values caused expected failure:', runs.docs[0].error)
|
||||
}
|
||||
}, 20000)
|
||||
})
|
||||
@@ -1,392 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { getTestPayload, cleanDatabase } from './test-setup.js'
|
||||
import { mockHttpBin, testFixtures } from './test-helpers.js'
|
||||
|
||||
describe('Hook Execution Reliability Tests', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
mockHttpBin.cleanup()
|
||||
})
|
||||
|
||||
it('should reliably execute hooks when collections are created', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Create a workflow with collection trigger
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Hook Reliability - Create',
|
||||
description: 'Tests hook execution on post creation',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
...testFixtures.createDocumentStep('auditLog'),
|
||||
name: 'create-audit-log',
|
||||
data: {
|
||||
message: 'Post was created via workflow trigger',
|
||||
post: '$.trigger.doc.id'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(workflow).toBeDefined()
|
||||
expect(workflow.id).toBeDefined()
|
||||
|
||||
// Create a post to trigger the workflow
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Hook Reliability Post'
|
||||
}
|
||||
})
|
||||
|
||||
expect(post).toBeDefined()
|
||||
|
||||
// Wait for workflow execution
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
// Verify workflow run was created
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Either succeeded or failed, but should have executed
|
||||
expect(['completed', 'failed']).toContain(runs.docs[0].status)
|
||||
|
||||
console.log('✅ Hook execution status:', runs.docs[0].status)
|
||||
|
||||
// Verify audit log was created only if the workflow succeeded
|
||||
if (runs.docs[0].status === 'completed') {
|
||||
const auditLogs = await payload.find({
|
||||
collection: 'auditLog',
|
||||
where: {
|
||||
post: {
|
||||
equals: post.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(auditLogs.totalDocs).toBeGreaterThan(0)
|
||||
expect(auditLogs.docs[0].message).toContain('workflow trigger')
|
||||
} else {
|
||||
// If workflow failed, just log the error but don't fail the test
|
||||
console.log('⚠️ Workflow failed:', runs.docs[0].error)
|
||||
// The important thing is that a workflow run was created
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
it('should handle hook execution errors gracefully', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Mock network error for invalid URL
|
||||
mockHttpBin.mockNetworkError('invalid-url-that-will-fail')
|
||||
|
||||
// Create a workflow with invalid step configuration
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Hook Error Handling',
|
||||
description: 'Tests error handling in hook execution',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'invalid-http-request',
|
||||
step: 'http-request-step',
|
||||
url: 'https://invalid-url-that-will-fail',
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Create a post to trigger the workflow
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Hook Error Handling Post'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for workflow execution
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
// Verify a failed workflow run was created
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
expect(runs.docs[0].status).toBe('failed')
|
||||
expect(runs.docs[0].error).toBeDefined()
|
||||
// Check that the error mentions either the URL or the task failure
|
||||
const errorMessage = runs.docs[0].error.toLowerCase()
|
||||
const hasRelevantError = errorMessage.includes('url') ||
|
||||
errorMessage.includes('invalid-url') ||
|
||||
errorMessage.includes('network') ||
|
||||
errorMessage.includes('failed')
|
||||
expect(hasRelevantError).toBe(true)
|
||||
|
||||
console.log('✅ Error handling working:', runs.docs[0].error)
|
||||
}, 30000)
|
||||
|
||||
it('should create failed workflow runs when executor is unavailable', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// This test simulates the executor being unavailable
|
||||
// We'll create a workflow and then simulate a hook execution without proper executor
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Hook Executor Unavailable',
|
||||
description: 'Tests handling when executor is not available',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'simple-step',
|
||||
step: 'http-request-step',
|
||||
url: 'https://httpbin.org/get'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Temporarily disable the executor by setting it to null
|
||||
// This simulates the initialization issue
|
||||
const global = globalThis as any
|
||||
const originalExecutor = global.__workflowExecutor
|
||||
global.__workflowExecutor = null
|
||||
|
||||
try {
|
||||
// Create a post to trigger the workflow
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Hook Executor Unavailable Post'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for hook execution attempt
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// Verify a failed workflow run was created for executor unavailability
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 1
|
||||
})
|
||||
|
||||
if (runs.totalDocs > 0) {
|
||||
expect(runs.docs[0].error).toBeDefined()
|
||||
console.log('✅ Executor unavailable error captured:', runs.docs[0].error)
|
||||
} else {
|
||||
console.log('⚠️ No workflow run created - this indicates the hook may not have executed')
|
||||
}
|
||||
} finally {
|
||||
// Restore the original executor
|
||||
global.__workflowExecutor = originalExecutor
|
||||
}
|
||||
}, 30000)
|
||||
|
||||
it('should handle workflow conditions properly', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Create a workflow with a condition that should prevent execution
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Hook Conditional Execution',
|
||||
description: 'Tests conditional workflow execution',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create',
|
||||
condition: '$.trigger.doc.content == "TRIGGER_CONDITION"'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'conditional-audit',
|
||||
step: 'create-document',
|
||||
collectionSlug: 'auditLog',
|
||||
data: {
|
||||
post: '$.trigger.doc.id',
|
||||
message: 'Conditional trigger executed'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Create a post that SHOULD NOT trigger the workflow
|
||||
const post1 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'Test Hook Conditional - Should Not Trigger'
|
||||
}
|
||||
})
|
||||
|
||||
// Create a post that SHOULD trigger the workflow
|
||||
const post2 = await payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: 'TRIGGER_CONDITION'
|
||||
}
|
||||
})
|
||||
|
||||
// Wait for workflow execution
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
|
||||
// Check workflow runs
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Should have exactly 1 run (only for the matching condition)
|
||||
expect(runs.totalDocs).toBe(1)
|
||||
// Either succeeded or failed, but should have executed
|
||||
expect(['completed', 'failed']).toContain(runs.docs[0].status)
|
||||
|
||||
// Verify audit log was created only for the correct post
|
||||
const auditLogs = await payload.find({
|
||||
collection: 'auditLog',
|
||||
where: {
|
||||
post: {
|
||||
equals: post2.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(auditLogs.totalDocs).toBe(1)
|
||||
|
||||
// Verify no audit log for the first post
|
||||
const noAuditLogs = await payload.find({
|
||||
collection: 'auditLog',
|
||||
where: {
|
||||
post: {
|
||||
equals: post1.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(noAuditLogs.totalDocs).toBe(0)
|
||||
|
||||
console.log('✅ Conditional execution working correctly')
|
||||
}, 30000)
|
||||
|
||||
it('should handle multiple concurrent hook executions', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Create a workflow
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: {
|
||||
name: 'Test Hook Concurrent Execution',
|
||||
description: 'Tests handling multiple concurrent hook executions',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger',
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create'
|
||||
}
|
||||
],
|
||||
steps: [
|
||||
{
|
||||
name: 'concurrent-audit',
|
||||
step: 'create-document',
|
||||
collectionSlug: 'auditLog',
|
||||
data: {
|
||||
post: '$.trigger.doc.id',
|
||||
message: 'Concurrent execution test'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Create multiple posts concurrently
|
||||
const concurrentCreations = Array.from({ length: 5 }, (_, i) =>
|
||||
payload.create({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
content: `Test Hook Concurrent Post ${i + 1}`
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const posts = await Promise.all(concurrentCreations)
|
||||
expect(posts).toHaveLength(5)
|
||||
|
||||
// Wait for all workflow executions
|
||||
await new Promise(resolve => setTimeout(resolve, 8000))
|
||||
|
||||
// Verify all workflow runs were created
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBe(5)
|
||||
|
||||
// Verify all runs completed successfully
|
||||
const failedRuns = runs.docs.filter(run => run.status === 'failed')
|
||||
expect(failedRuns).toHaveLength(0)
|
||||
|
||||
console.log('✅ Concurrent executions completed:', {
|
||||
totalRuns: runs.totalDocs,
|
||||
statuses: runs.docs.map(run => run.status)
|
||||
})
|
||||
}, 45000)
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { getTestPayload, cleanDatabase } from './test-setup.js'
|
||||
import { mockHttpBin, testFixtures } from './test-helpers.js'
|
||||
|
||||
describe('Workflow Trigger Test', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanDatabase()
|
||||
// Set up HTTP mocks
|
||||
const expectedRequestData = {
|
||||
message: 'Post created',
|
||||
postId: expect.any(String), // MongoDB ObjectId
|
||||
postTitle: 'Test post content for workflow trigger'
|
||||
}
|
||||
mockHttpBin.mockPost(expectedRequestData)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanDatabase()
|
||||
mockHttpBin.cleanup()
|
||||
})
|
||||
|
||||
it('should create a workflow run when a post is created', async () => {
|
||||
const payload = getTestPayload()
|
||||
|
||||
// Use test fixtures for consistent data
|
||||
const testWorkflow = {
|
||||
...testFixtures.basicWorkflow,
|
||||
name: 'Test Post Creation Workflow',
|
||||
description: 'Triggers when a post is created',
|
||||
steps: [
|
||||
{
|
||||
...testFixtures.httpRequestStep(),
|
||||
name: 'log-post',
|
||||
body: {
|
||||
message: 'Post created',
|
||||
postId: '$.trigger.doc.id',
|
||||
postTitle: '$.trigger.doc.content'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Create a workflow with collection trigger
|
||||
const workflow = await payload.create({
|
||||
collection: 'workflows',
|
||||
data: testWorkflow
|
||||
})
|
||||
|
||||
expect(workflow).toBeDefined()
|
||||
expect(workflow.id).toBeDefined()
|
||||
|
||||
// Create a post to trigger the workflow
|
||||
const post = await payload.create({
|
||||
collection: 'posts',
|
||||
data: testFixtures.testPost
|
||||
})
|
||||
|
||||
expect(post).toBeDefined()
|
||||
expect(post.id).toBeDefined()
|
||||
|
||||
// Wait a bit for workflow to execute
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// Check for workflow runs
|
||||
const runs = await payload.find({
|
||||
collection: 'workflow-runs',
|
||||
where: {
|
||||
workflow: {
|
||||
equals: workflow.id
|
||||
}
|
||||
},
|
||||
limit: 10
|
||||
})
|
||||
|
||||
expect(runs.totalDocs).toBeGreaterThan(0)
|
||||
|
||||
// Check if workflow is an object or ID
|
||||
const workflowRef = runs.docs[0].workflow
|
||||
const workflowId = typeof workflowRef === 'object' && workflowRef !== null
|
||||
? (workflowRef as any).id
|
||||
: workflowRef
|
||||
|
||||
expect(workflowId).toBe(workflow.id) // Should reference the workflow ID
|
||||
|
||||
console.log('✅ Workflow run created successfully!')
|
||||
console.log(`Run status: ${runs.docs[0].status}`)
|
||||
console.log(`Run ID: ${runs.docs[0].id}`)
|
||||
|
||||
if (runs.docs[0].status === 'failed' && runs.docs[0].error) {
|
||||
console.log(`Error: ${runs.docs[0].error}`)
|
||||
}
|
||||
}, 30000)
|
||||
})
|
||||
@@ -159,23 +159,35 @@ export const mockHttpBin = {
|
||||
* Test fixtures for common workflow configurations
|
||||
*/
|
||||
export const testFixtures = {
|
||||
// Function to create workflow data that bypasses parameter validation
|
||||
createWorkflow: async (payload: any, workflowData: any) => {
|
||||
// Insert directly into database to bypass parameter field validation
|
||||
const result = await payload.db.create({
|
||||
collection: 'workflows',
|
||||
data: workflowData
|
||||
})
|
||||
return result
|
||||
},
|
||||
basicWorkflow: {
|
||||
name: 'Test Basic Workflow',
|
||||
description: 'Basic workflow for testing',
|
||||
triggers: [
|
||||
{
|
||||
type: 'collection-trigger' as const,
|
||||
type: 'collection-hook' as const,
|
||||
parameters: {
|
||||
collectionSlug: 'posts',
|
||||
operation: 'create' as const
|
||||
hook: 'afterChange'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
httpRequestStep: (url: string = 'https://httpbin.org/post', expectedData?: any) => ({
|
||||
name: 'http-request',
|
||||
step: 'http-request-step',
|
||||
type: 'http-request-step',
|
||||
parameters: {
|
||||
url,
|
||||
method: 'POST' as const,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
@@ -183,16 +195,19 @@ export const testFixtures = {
|
||||
message: 'Test request',
|
||||
data: '$.trigger.doc'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
createDocumentStep: (collectionSlug: string = 'auditLog') => ({
|
||||
name: 'create-audit',
|
||||
step: 'create-document',
|
||||
type: 'create-document',
|
||||
parameters: {
|
||||
collectionSlug,
|
||||
data: {
|
||||
message: 'Test document created',
|
||||
sourceId: '$.trigger.doc.id'
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
testPost: {
|
||||
|
||||
@@ -38,6 +38,45 @@ export const createCollectionTriggerHook = (collectionSlug: string, hookType: st
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any trigger has a condition and evaluate it
|
||||
let shouldExecute = false
|
||||
for (const trigger of workflow.triggers || []) {
|
||||
if (trigger.type === 'collection-hook' &&
|
||||
trigger.parameters?.collectionSlug === collectionSlug &&
|
||||
trigger.parameters?.hook === hookType) {
|
||||
|
||||
if (trigger.condition) {
|
||||
// Evaluate the condition
|
||||
try {
|
||||
const conditionMet = executor.evaluateCondition(trigger.condition, context)
|
||||
if (conditionMet) {
|
||||
shouldExecute = true
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
payload.logger.error({
|
||||
workflowId: workflow.id,
|
||||
condition: trigger.condition,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, 'Failed to evaluate trigger condition')
|
||||
}
|
||||
} else {
|
||||
// No condition means always execute
|
||||
shouldExecute = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldExecute) {
|
||||
payload.logger.debug({
|
||||
workflowId: workflow.id,
|
||||
collection: collectionSlug,
|
||||
hookType
|
||||
}, 'Workflow skipped due to unmet condition')
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await executor.execute(workflow as any, context, req)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
describe('PayloadCMS Automation Plugin', () => {
|
||||
it('should export the plugin function from server export', async () => {
|
||||
const { workflowsPlugin } = await import('../exports/server.js')
|
||||
expect(workflowsPlugin).toBeDefined()
|
||||
expect(typeof workflowsPlugin).toBe('function')
|
||||
})
|
||||
|
||||
it('should have the correct package name', async () => {
|
||||
// Basic test to ensure the plugin can be imported
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createDocumentHandler } from '../steps/create-document-handler.js'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
describe('CreateDocumentStepHandler', () => {
|
||||
let mockPayload: Payload
|
||||
let mockReq: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockPayload = {
|
||||
create: vi.fn()
|
||||
} as any
|
||||
|
||||
mockReq = {
|
||||
payload: mockPayload,
|
||||
user: { id: 'user-123', email: 'test@example.com' }
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Document creation', () => {
|
||||
it('should create document successfully', async () => {
|
||||
const createdDoc = {
|
||||
id: 'doc-123',
|
||||
title: 'Test Document',
|
||||
content: 'Test content'
|
||||
}
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: {
|
||||
title: 'Test Document',
|
||||
content: 'Test content'
|
||||
},
|
||||
stepName: 'test-create-step'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.document).toEqual(createdDoc)
|
||||
expect(result.output.id).toBe('doc-123')
|
||||
|
||||
expect(mockPayload.create).toHaveBeenCalledWith({
|
||||
collection: 'posts',
|
||||
data: {
|
||||
title: 'Test Document',
|
||||
content: 'Test content'
|
||||
},
|
||||
req: mockReq
|
||||
})
|
||||
})
|
||||
|
||||
it('should create document with relationship fields', async () => {
|
||||
const createdDoc = {
|
||||
id: 'doc-456',
|
||||
title: 'Related Document',
|
||||
author: 'user-123',
|
||||
category: 'cat-789'
|
||||
}
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'articles',
|
||||
data: {
|
||||
title: 'Related Document',
|
||||
author: 'user-123',
|
||||
category: 'cat-789'
|
||||
},
|
||||
stepName: 'test-create-with-relations'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.document).toEqual(createdDoc)
|
||||
expect(mockPayload.create).toHaveBeenCalledWith({
|
||||
collection: 'articles',
|
||||
data: {
|
||||
title: 'Related Document',
|
||||
author: 'user-123',
|
||||
category: 'cat-789'
|
||||
},
|
||||
req: mockReq
|
||||
})
|
||||
})
|
||||
|
||||
it('should create document with complex nested data', async () => {
|
||||
const complexData = {
|
||||
title: 'Complex Document',
|
||||
metadata: {
|
||||
tags: ['tag1', 'tag2'],
|
||||
settings: {
|
||||
featured: true,
|
||||
priority: 5
|
||||
}
|
||||
},
|
||||
blocks: [
|
||||
{ type: 'text', content: 'Text block' },
|
||||
{ type: 'image', src: 'image.jpg', alt: 'Test image' }
|
||||
]
|
||||
}
|
||||
|
||||
const createdDoc = { id: 'doc-complex', ...complexData }
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'pages',
|
||||
data: complexData,
|
||||
stepName: 'test-create-complex'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.document).toEqual(createdDoc)
|
||||
expect(mockPayload.create).toHaveBeenCalledWith({
|
||||
collection: 'pages',
|
||||
data: complexData,
|
||||
req: mockReq
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle PayloadCMS validation errors', async () => {
|
||||
const validationError = new Error('Validation failed')
|
||||
;(validationError as any).data = [
|
||||
{
|
||||
message: 'Title is required',
|
||||
path: 'title',
|
||||
value: undefined
|
||||
}
|
||||
]
|
||||
;(mockPayload.create as any).mockRejectedValue(validationError)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: {
|
||||
content: 'Missing title'
|
||||
},
|
||||
stepName: 'test-validation-error'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Validation failed')
|
||||
})
|
||||
|
||||
it('should handle permission errors', async () => {
|
||||
const permissionError = new Error('Insufficient permissions')
|
||||
;(permissionError as any).status = 403
|
||||
;(mockPayload.create as any).mockRejectedValue(permissionError)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'admin-only',
|
||||
data: {
|
||||
secret: 'confidential data'
|
||||
},
|
||||
stepName: 'test-permission-error'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Insufficient permissions')
|
||||
})
|
||||
|
||||
it('should handle database connection errors', async () => {
|
||||
const dbError = new Error('Database connection failed')
|
||||
;(mockPayload.create as any).mockRejectedValue(dbError)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: { title: 'Test' },
|
||||
stepName: 'test-db-error'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Database connection failed')
|
||||
})
|
||||
|
||||
it('should handle unknown collection errors', async () => {
|
||||
const collectionError = new Error('Collection "unknown" not found')
|
||||
;(mockPayload.create as any).mockRejectedValue(collectionError)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'unknown-collection',
|
||||
data: { title: 'Test' },
|
||||
stepName: 'test-unknown-collection'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Collection "unknown" not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Input validation', () => {
|
||||
it('should validate required collection slug', async () => {
|
||||
const input = {
|
||||
data: { title: 'Test' },
|
||||
stepName: 'test-missing-collection'
|
||||
}
|
||||
|
||||
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Collection slug is required')
|
||||
})
|
||||
|
||||
it('should validate required data field', async () => {
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
stepName: 'test-missing-data'
|
||||
}
|
||||
|
||||
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Data is required')
|
||||
})
|
||||
|
||||
it('should validate data is an object', async () => {
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: 'invalid-data-type',
|
||||
stepName: 'test-invalid-data-type'
|
||||
}
|
||||
|
||||
const result = await createDocumentStepHandler({ input, req: mockReq } as any)
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Data must be an object')
|
||||
})
|
||||
|
||||
it('should handle empty data object', async () => {
|
||||
const createdDoc = { id: 'empty-doc' }
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: {},
|
||||
stepName: 'test-empty-data'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.document).toEqual(createdDoc)
|
||||
expect(mockPayload.create).toHaveBeenCalledWith({
|
||||
collection: 'posts',
|
||||
data: {},
|
||||
req: mockReq
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request context', () => {
|
||||
it('should pass user context from request', async () => {
|
||||
const createdDoc = { id: 'user-doc', title: 'User Document' }
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: { title: 'User Document' },
|
||||
stepName: 'test-user-context'
|
||||
}
|
||||
|
||||
await createDocumentStepHandler({ input, req: mockReq })
|
||||
|
||||
const createCall = (mockPayload.create as any).mock.calls[0][0]
|
||||
expect(createCall.req).toBe(mockReq)
|
||||
expect(createCall.req.user).toEqual({
|
||||
id: 'user-123',
|
||||
email: 'test@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle requests without user context', async () => {
|
||||
const reqWithoutUser = {
|
||||
payload: mockPayload,
|
||||
user: null
|
||||
}
|
||||
|
||||
const createdDoc = { id: 'anonymous-doc' }
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: { title: 'Anonymous Document' },
|
||||
stepName: 'test-anonymous'
|
||||
}
|
||||
|
||||
const result = await createDocumentStepHandler({ input, req: reqWithoutUser })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(mockPayload.create).toHaveBeenCalledWith({
|
||||
collection: 'posts',
|
||||
data: { title: 'Anonymous Document' },
|
||||
req: reqWithoutUser
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Output structure', () => {
|
||||
it('should return correct output structure on success', async () => {
|
||||
const createdDoc = {
|
||||
id: 'output-test-doc',
|
||||
title: 'Output Test',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
;(mockPayload.create as any).mockResolvedValue(createdDoc)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: { title: 'Output Test' },
|
||||
stepName: 'test-output-structure'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result).toEqual({
|
||||
state: 'succeeded',
|
||||
output: {
|
||||
document: createdDoc,
|
||||
id: 'output-test-doc'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct error structure on failure', async () => {
|
||||
const error = new Error('Test error')
|
||||
;(mockPayload.create as any).mockRejectedValue(error)
|
||||
|
||||
const input = {
|
||||
collectionSlug: 'posts',
|
||||
data: { title: 'Error Test' },
|
||||
stepName: 'test-error-structure'
|
||||
}
|
||||
|
||||
const result = await createDocumentHandler({ input, req: mockReq })
|
||||
|
||||
expect(result).toEqual({
|
||||
state: 'failed',
|
||||
error: 'Test error'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,348 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { httpRequestStepHandler } from '../steps/http-request-handler.js'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('HttpRequestStepHandler', () => {
|
||||
let mockPayload: Payload
|
||||
let mockReq: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockPayload = {} as Payload
|
||||
mockReq = {
|
||||
payload: mockPayload,
|
||||
user: null
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('GET requests', () => {
|
||||
it('should handle successful GET request', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: vi.fn().mockResolvedValue('{"success": true}')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-get-step'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.status).toBe(200)
|
||||
expect(result.output.statusText).toBe('OK')
|
||||
expect(result.output.body).toBe('{"success": true}')
|
||||
expect(result.output.headers).toEqual({ 'content-type': 'application/json' })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle GET request with custom headers', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
text: vi.fn().mockResolvedValue('success')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET' as const,
|
||||
headers: {
|
||||
'Authorization': 'Bearer token123',
|
||||
'User-Agent': 'PayloadCMS-Workflow/1.0'
|
||||
},
|
||||
stepName: 'test-get-with-headers'
|
||||
}
|
||||
|
||||
await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/data', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': 'Bearer token123',
|
||||
'User-Agent': 'PayloadCMS-Workflow/1.0'
|
||||
},
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST requests', () => {
|
||||
it('should handle POST request with JSON body', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
headers: new Headers(),
|
||||
text: vi.fn().mockResolvedValue('{"id": "123"}')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/posts',
|
||||
method: 'POST' as const,
|
||||
body: { title: 'Test Post', content: 'Test content' },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
stepName: 'test-post-step'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.status).toBe(201)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/posts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: 'Test Post', content: 'Test content' }),
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle POST request with string body', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers(),
|
||||
text: vi.fn().mockResolvedValue('OK')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/webhook',
|
||||
method: 'POST' as const,
|
||||
body: 'plain text data',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
stepName: 'test-post-string'
|
||||
}
|
||||
|
||||
await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('https://api.example.com/webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: 'plain text data',
|
||||
signal: expect.any(AbortSignal)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should handle network errors', async () => {
|
||||
;(global.fetch as any).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const input = {
|
||||
url: 'https://invalid-url.example.com',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-network-error'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Network error')
|
||||
})
|
||||
|
||||
it('should handle HTTP error status codes', async () => {
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
headers: new Headers(),
|
||||
text: vi.fn().mockResolvedValue('Page not found')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/nonexistent',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-404-error'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('HTTP 404')
|
||||
expect(result.output.status).toBe(404)
|
||||
expect(result.output.statusText).toBe('Not Found')
|
||||
})
|
||||
|
||||
it('should handle timeout errors', async () => {
|
||||
const abortError = new Error('The operation was aborted')
|
||||
abortError.name = 'AbortError'
|
||||
;(global.fetch as any).mockRejectedValue(abortError)
|
||||
|
||||
const input = {
|
||||
url: 'https://slow-api.example.com',
|
||||
method: 'GET' as const,
|
||||
timeout: 1000,
|
||||
stepName: 'test-timeout'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('timeout')
|
||||
})
|
||||
|
||||
it('should handle invalid JSON response parsing', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: vi.fn().mockResolvedValue('invalid json {')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/invalid-json',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-invalid-json'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
// Should still succeed but with raw text body
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.body).toBe('invalid json {')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request validation', () => {
|
||||
it('should validate required URL field', async () => {
|
||||
const input = {
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-missing-url'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq } as any)
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('URL is required')
|
||||
})
|
||||
|
||||
it('should validate HTTP method', async () => {
|
||||
const input = {
|
||||
url: 'https://api.example.com',
|
||||
method: 'INVALID' as any,
|
||||
stepName: 'test-invalid-method'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Invalid HTTP method')
|
||||
})
|
||||
|
||||
it('should validate URL format', async () => {
|
||||
const input = {
|
||||
url: 'not-a-valid-url',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-invalid-url'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('failed')
|
||||
expect(result.error).toContain('Invalid URL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Response processing', () => {
|
||||
it('should parse JSON responses automatically', async () => {
|
||||
const responseData = { id: 123, name: 'Test Item' }
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify(responseData))
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/item/123',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-json-parsing'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(typeof result.output.body).toBe('string')
|
||||
// Should contain the JSON as string for safe storage
|
||||
expect(result.output.body).toBe(JSON.stringify(responseData))
|
||||
})
|
||||
|
||||
it('should handle non-JSON responses', async () => {
|
||||
const htmlContent = '<html><body>Hello World</body></html>'
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
text: vi.fn().mockResolvedValue(htmlContent)
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://example.com/page',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-html-response'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.body).toBe(htmlContent)
|
||||
})
|
||||
|
||||
it('should capture response headers', async () => {
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({
|
||||
'content-type': 'application/json',
|
||||
'x-rate-limit': '100',
|
||||
'x-custom-header': 'custom-value'
|
||||
}),
|
||||
text: vi.fn().mockResolvedValue('{}')
|
||||
}
|
||||
;(global.fetch as any).mockResolvedValue(mockResponse)
|
||||
|
||||
const input = {
|
||||
url: 'https://api.example.com/data',
|
||||
method: 'GET' as const,
|
||||
stepName: 'test-response-headers'
|
||||
}
|
||||
|
||||
const result = await httpRequestStepHandler({ input, req: mockReq })
|
||||
|
||||
expect(result.state).toBe('succeeded')
|
||||
expect(result.output.headers).toEqual({
|
||||
'content-type': 'application/json',
|
||||
'x-rate-limit': '100',
|
||||
'x-custom-header': 'custom-value'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,472 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { WorkflowExecutor } from '../core/workflow-executor.js'
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
describe('WorkflowExecutor', () => {
|
||||
let mockPayload: Payload
|
||||
let mockLogger: any
|
||||
let executor: WorkflowExecutor
|
||||
|
||||
beforeEach(() => {
|
||||
mockLogger = {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
|
||||
mockPayload = {
|
||||
jobs: {
|
||||
queue: vi.fn().mockResolvedValue({ id: 'job-123' }),
|
||||
run: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
find: vi.fn()
|
||||
} as any
|
||||
|
||||
executor = new WorkflowExecutor(mockPayload, mockLogger)
|
||||
})
|
||||
|
||||
describe('resolveJSONPathValue', () => {
|
||||
it('should resolve simple JSONPath expressions', () => {
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { id: 'test-id', title: 'Test Title' }
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).resolveJSONPathValue('$.trigger.doc.id', context)
|
||||
expect(result).toBe('test-id')
|
||||
})
|
||||
|
||||
it('should resolve nested JSONPath expressions', () => {
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: {
|
||||
id: 'test-id',
|
||||
nested: { value: 'nested-value' }
|
||||
}
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).resolveJSONPathValue('$.trigger.doc.nested.value', context)
|
||||
expect(result).toBe('nested-value')
|
||||
})
|
||||
|
||||
it('should return original value for non-JSONPath strings', () => {
|
||||
const context = { trigger: {}, steps: {} }
|
||||
const result = (executor as any).resolveJSONPathValue('plain-string', context)
|
||||
expect(result).toBe('plain-string')
|
||||
})
|
||||
|
||||
it('should handle missing JSONPath gracefully', () => {
|
||||
const context = { trigger: {}, steps: {} }
|
||||
const result = (executor as any).resolveJSONPathValue('$.trigger.missing.field', context)
|
||||
expect(result).toBe('$.trigger.missing.field') // Should return original if resolution fails
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveStepInput', () => {
|
||||
it('should resolve all JSONPath expressions in step config', () => {
|
||||
const config = {
|
||||
url: '$.trigger.data.url',
|
||||
message: 'Static message',
|
||||
data: {
|
||||
id: '$.trigger.doc.id',
|
||||
title: '$.trigger.doc.title'
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { id: 'doc-123', title: 'Doc Title' },
|
||||
data: { url: 'https://example.com/webhook' }
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).resolveStepInput(config, context)
|
||||
|
||||
expect(result).toEqual({
|
||||
url: 'https://example.com/webhook',
|
||||
message: 'Static message',
|
||||
data: {
|
||||
id: 'doc-123',
|
||||
title: 'Doc Title'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle arrays with JSONPath expressions', () => {
|
||||
const config = {
|
||||
items: ['$.trigger.doc.id', 'static-value', '$.trigger.doc.title']
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { id: 'doc-123', title: 'Doc Title' }
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).resolveStepInput(config, context)
|
||||
|
||||
expect(result).toEqual({
|
||||
items: ['doc-123', 'static-value', 'Doc Title']
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveExecutionOrder', () => {
|
||||
it('should handle steps without dependencies', () => {
|
||||
const steps = [
|
||||
{ name: 'step1', step: 'http-request' },
|
||||
{ name: 'step2', step: 'create-document' },
|
||||
{ name: 'step3', step: 'http-request' }
|
||||
]
|
||||
|
||||
const result = (executor as any).resolveExecutionOrder(steps)
|
||||
|
||||
expect(result).toHaveLength(1) // All in one batch
|
||||
expect(result[0]).toHaveLength(3) // All steps in first batch
|
||||
})
|
||||
|
||||
it('should handle steps with dependencies', () => {
|
||||
const steps = [
|
||||
{ name: 'step1', step: 'http-request' },
|
||||
{ name: 'step2', step: 'create-document', dependencies: ['step1'] },
|
||||
{ name: 'step3', step: 'http-request', dependencies: ['step2'] }
|
||||
]
|
||||
|
||||
const result = (executor as any).resolveExecutionOrder(steps)
|
||||
|
||||
expect(result).toHaveLength(3) // Three batches
|
||||
expect(result[0]).toHaveLength(1) // step1 first
|
||||
expect(result[1]).toHaveLength(1) // step2 second
|
||||
expect(result[2]).toHaveLength(1) // step3 third
|
||||
})
|
||||
|
||||
it('should handle parallel execution with partial dependencies', () => {
|
||||
const steps = [
|
||||
{ name: 'step1', step: 'http-request' },
|
||||
{ name: 'step2', step: 'create-document' },
|
||||
{ name: 'step3', step: 'http-request', dependencies: ['step1'] },
|
||||
{ name: 'step4', step: 'create-document', dependencies: ['step1'] }
|
||||
]
|
||||
|
||||
const result = (executor as any).resolveExecutionOrder(steps)
|
||||
|
||||
expect(result).toHaveLength(2) // Two batches
|
||||
expect(result[0]).toHaveLength(2) // step1 and step2 in parallel
|
||||
expect(result[1]).toHaveLength(2) // step3 and step4 in parallel
|
||||
})
|
||||
|
||||
it('should detect circular dependencies', () => {
|
||||
const steps = [
|
||||
{ name: 'step1', step: 'http-request', dependencies: ['step2'] },
|
||||
{ name: 'step2', step: 'create-document', dependencies: ['step1'] }
|
||||
]
|
||||
|
||||
expect(() => {
|
||||
(executor as any).resolveExecutionOrder(steps)
|
||||
}).toThrow('Circular dependency detected')
|
||||
})
|
||||
})
|
||||
|
||||
describe('evaluateCondition', () => {
|
||||
it('should evaluate simple equality conditions', () => {
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { status: 'published' }
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).evaluateCondition('$.trigger.doc.status == "published"', context)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should evaluate inequality conditions', () => {
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { count: 5 }
|
||||
},
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const result = (executor as any).evaluateCondition('$.trigger.doc.count > 3', context)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid conditions', () => {
|
||||
const context = { trigger: {}, steps: {} }
|
||||
const result = (executor as any).evaluateCondition('invalid condition syntax', context)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle missing context gracefully', () => {
|
||||
const context = { trigger: {}, steps: {} }
|
||||
const result = (executor as any).evaluateCondition('$.trigger.doc.status == "published"', context)
|
||||
expect(result).toBe(false) // Missing values should fail condition
|
||||
})
|
||||
})
|
||||
|
||||
describe('safeSerialize', () => {
|
||||
it('should serialize simple objects', () => {
|
||||
const obj = { name: 'test', value: 123 }
|
||||
const result = (executor as any).safeSerialize(obj)
|
||||
expect(result).toBe('{"name":"test","value":123}')
|
||||
})
|
||||
|
||||
it('should handle circular references', () => {
|
||||
const obj: any = { name: 'test' }
|
||||
obj.self = obj // Create circular reference
|
||||
|
||||
const result = (executor as any).safeSerialize(obj)
|
||||
expect(result).toContain('"name":"test"')
|
||||
expect(result).toContain('"self":"[Circular]"')
|
||||
})
|
||||
|
||||
it('should handle undefined and null values', () => {
|
||||
const obj = {
|
||||
defined: 'value',
|
||||
undefined: undefined,
|
||||
null: null
|
||||
}
|
||||
|
||||
const result = (executor as any).safeSerialize(obj)
|
||||
const parsed = JSON.parse(result)
|
||||
expect(parsed.defined).toBe('value')
|
||||
expect(parsed.null).toBe(null)
|
||||
expect(parsed).not.toHaveProperty('undefined') // undefined props are omitted
|
||||
})
|
||||
})
|
||||
|
||||
describe('executeWorkflow', () => {
|
||||
it('should execute workflow with single step', async () => {
|
||||
const workflow = {
|
||||
id: 'test-workflow',
|
||||
steps: [
|
||||
{
|
||||
name: 'test-step',
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: { doc: { id: 'test-doc' } },
|
||||
steps: {}
|
||||
}
|
||||
|
||||
// Mock step task
|
||||
const mockStepTask = {
|
||||
taskSlug: 'http-request-step',
|
||||
handler: vi.fn().mockResolvedValue({
|
||||
output: { status: 200, body: 'success' },
|
||||
state: 'succeeded'
|
||||
})
|
||||
}
|
||||
|
||||
// Mock the step tasks registry
|
||||
const originalStepTasks = (executor as any).stepTasks
|
||||
;(executor as any).stepTasks = [mockStepTask]
|
||||
|
||||
const result = await (executor as any).executeWorkflow(workflow, context)
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(result.context.steps['test-step']).toBeDefined()
|
||||
expect(result.context.steps['test-step'].state).toBe('succeeded')
|
||||
expect(mockStepTask.handler).toHaveBeenCalledOnce()
|
||||
|
||||
// Restore original step tasks
|
||||
;(executor as any).stepTasks = originalStepTasks
|
||||
})
|
||||
|
||||
it('should handle step execution failures', async () => {
|
||||
const workflow = {
|
||||
id: 'test-workflow',
|
||||
steps: [
|
||||
{
|
||||
name: 'failing-step',
|
||||
step: 'http-request-step',
|
||||
url: 'https://invalid-url',
|
||||
method: 'GET'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: { doc: { id: 'test-doc' } },
|
||||
steps: {}
|
||||
}
|
||||
|
||||
// Mock failing step task
|
||||
const mockStepTask = {
|
||||
taskSlug: 'http-request-step',
|
||||
handler: vi.fn().mockRejectedValue(new Error('Network error'))
|
||||
}
|
||||
|
||||
const originalStepTasks = (executor as any).stepTasks
|
||||
;(executor as any).stepTasks = [mockStepTask]
|
||||
|
||||
const result = await (executor as any).executeWorkflow(workflow, context)
|
||||
|
||||
expect(result.status).toBe('failed')
|
||||
expect(result.error).toContain('Network error')
|
||||
expect(result.context.steps['failing-step']).toBeDefined()
|
||||
expect(result.context.steps['failing-step'].state).toBe('failed')
|
||||
|
||||
;(executor as any).stepTasks = originalStepTasks
|
||||
})
|
||||
|
||||
it('should execute steps with dependencies in correct order', async () => {
|
||||
const workflow = {
|
||||
id: 'test-workflow',
|
||||
steps: [
|
||||
{
|
||||
name: 'step1',
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com/1',
|
||||
method: 'GET'
|
||||
},
|
||||
{
|
||||
name: 'step2',
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com/2',
|
||||
method: 'GET',
|
||||
dependencies: ['step1']
|
||||
},
|
||||
{
|
||||
name: 'step3',
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com/3',
|
||||
method: 'GET',
|
||||
dependencies: ['step1']
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: { doc: { id: 'test-doc' } },
|
||||
steps: {}
|
||||
}
|
||||
|
||||
const executionOrder: string[] = []
|
||||
const mockStepTask = {
|
||||
taskSlug: 'http-request-step',
|
||||
handler: vi.fn().mockImplementation(async ({ input }) => {
|
||||
executionOrder.push(input.stepName)
|
||||
return {
|
||||
output: { status: 200, body: 'success' },
|
||||
state: 'succeeded'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const originalStepTasks = (executor as any).stepTasks
|
||||
;(executor as any).stepTasks = [mockStepTask]
|
||||
|
||||
const result = await (executor as any).executeWorkflow(workflow, context)
|
||||
|
||||
expect(result.status).toBe('completed')
|
||||
expect(executionOrder[0]).toBe('step1') // First step executed first
|
||||
expect(executionOrder.slice(1)).toContain('step2') // Dependent steps after
|
||||
expect(executionOrder.slice(1)).toContain('step3')
|
||||
|
||||
;(executor as any).stepTasks = originalStepTasks
|
||||
})
|
||||
})
|
||||
|
||||
describe('findStepTask', () => {
|
||||
it('should find registered step task by slug', () => {
|
||||
const mockStepTask = {
|
||||
taskSlug: 'test-step',
|
||||
handler: vi.fn()
|
||||
}
|
||||
|
||||
const originalStepTasks = (executor as any).stepTasks
|
||||
;(executor as any).stepTasks = [mockStepTask]
|
||||
|
||||
const result = (executor as any).findStepTask('test-step')
|
||||
expect(result).toBe(mockStepTask)
|
||||
|
||||
;(executor as any).stepTasks = originalStepTasks
|
||||
})
|
||||
|
||||
it('should return undefined for unknown step type', () => {
|
||||
const result = (executor as any).findStepTask('unknown-step')
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateStepConfiguration', () => {
|
||||
it('should validate step with required fields', () => {
|
||||
const step = {
|
||||
name: 'valid-step',
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
(executor as any).validateStepConfiguration(step)
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('should throw error for step without name', () => {
|
||||
const step = {
|
||||
step: 'http-request-step',
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
(executor as any).validateStepConfiguration(step)
|
||||
}).toThrow('Step name is required')
|
||||
})
|
||||
|
||||
it('should throw error for step without type', () => {
|
||||
const step = {
|
||||
name: 'test-step',
|
||||
url: 'https://example.com',
|
||||
method: 'GET'
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
(executor as any).validateStepConfiguration(step)
|
||||
}).toThrow('Step type is required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createExecutionContext', () => {
|
||||
it('should create context with trigger data', () => {
|
||||
const triggerContext = {
|
||||
operation: 'create',
|
||||
doc: { id: 'test-id', title: 'Test Doc' },
|
||||
collection: 'posts'
|
||||
}
|
||||
|
||||
const result = (executor as any).createExecutionContext(triggerContext)
|
||||
|
||||
expect(result.trigger).toEqual(triggerContext)
|
||||
expect(result.steps).toEqual({})
|
||||
expect(result.metadata).toBeDefined()
|
||||
expect(result.metadata.startedAt).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include metadata in context', () => {
|
||||
const triggerContext = { doc: { id: 'test' } }
|
||||
const result = (executor as any).createExecutionContext(triggerContext)
|
||||
|
||||
expect(result.metadata).toHaveProperty('startedAt')
|
||||
expect(result.metadata).toHaveProperty('executionId')
|
||||
expect(typeof result.metadata.executionId).toBe('string')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user