mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 08:53:23 +00:00
Major Features: • Add persistent error tracking for timeout/network failures that bypasses PayloadCMS output limitations • Implement smart error classification (timeout, DNS, connection, network) with duration-based detection • Add comprehensive test infrastructure with MongoDB in-memory testing and enhanced mocking • Fix HTTP request handler error preservation with detailed context storage • Add independent execution tracking with success/failure status and duration metrics Technical Improvements: • Update JSONPath documentation to use correct $.trigger.doc syntax across all step types • Fix PayloadCMS job execution to use runByID instead of run() for reliable task processing • Add enhanced HTTP error handling that preserves outputs for 4xx/5xx status codes • Implement proper nock configuration with undici for Node.js 22 fetch interception • Add comprehensive unit tests for WorkflowExecutor with mocked PayloadCMS instances Developer Experience: • Add detailed error information in workflow context with URL, method, timeout, attempts • Update README with HTTP error handling patterns and enhanced error tracking examples • Add test helpers and setup infrastructure for reliable integration testing • Fix workflow step validation and JSONPath field descriptions Breaking Changes: None - fully backward compatible 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
519 lines
15 KiB
TypeScript
519 lines
15 KiB
TypeScript
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)
|
|
}) |