Fix critical issues and enhance PayloadCMS automation plugin

## Critical Fixes Implemented:

### 1. Hook Execution Reliability (src/plugin/index.ts)
- Replaced fragile global variable pattern with proper dependency injection
- Added structured executor registry with initialization tracking
- Implemented proper logging using PayloadCMS logger instead of console
- Added graceful handling for executor unavailability scenarios

### 2. Error Handling & Workflow Run Tracking
- Fixed error swallowing in hook execution
- Added createFailedWorkflowRun() to track hook execution failures
- Improved error categorization and user-friendly error messages
- Enhanced workflow run status tracking with detailed context

### 3. Enhanced HTTP Step (src/steps/)
- Complete rewrite of HTTP request handler with enterprise features:
  - Multiple authentication methods (Bearer, Basic Auth, API Key)
  - Configurable timeouts and retry logic with exponential backoff
  - Comprehensive error handling for different failure scenarios
  - Support for all HTTP methods with proper request/response parsing
  - Request duration tracking and detailed logging

### 4. User Experience Improvements
- Added StatusCell component with visual status indicators
- Created ErrorDisplay component with user-friendly error explanations
- Added WorkflowExecutionStatus component for real-time execution monitoring
- Enhanced collections with better error display and conditional fields

### 5. Comprehensive Testing Suite
- Added hook-reliability.spec.ts: Tests executor availability and concurrent execution
- Added error-scenarios.spec.ts: Tests timeout, network, validation, and HTTP errors
- Added webhook-triggers.spec.ts: Tests webhook endpoints, conditions, and concurrent requests
- Fixed existing test to work with enhanced HTTP step schema

## Technical Improvements:
- Proper TypeScript interfaces for all new components
- Safe serialization handling for circular references
- Comprehensive logging with structured data
- Modular component architecture with proper exports
- Enhanced collection schemas with conditional field visibility

## Impact:
- Eliminates silent workflow execution failures
- Provides clear error visibility for users
- Makes HTTP requests production-ready with auth and retry capabilities
- Significantly improves debugging and monitoring experience
- Adds comprehensive test coverage for reliability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-09-04 11:42:45 +02:00
parent 253de9b8b0
commit 04100787d7
13 changed files with 2574 additions and 74 deletions

529
dev/error-scenarios.spec.ts Normal file
View File

@@ -0,0 +1,529 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import config from './payload.config'
describe('Error Scenarios and Edge Cases', () => {
let payload: Payload
beforeAll(async () => {
payload = await getPayload({ config: await config })
await cleanupTestData()
}, 60000)
afterAll(async () => {
await cleanupTestData()
}, 30000)
const cleanupTestData = async () => {
if (!payload) return
try {
// Clean up workflows
const workflows = await payload.find({
collection: 'workflows',
where: {
name: {
like: 'Test Error%'
}
}
})
for (const workflow of workflows.docs) {
await payload.delete({
collection: 'workflows',
id: workflow.id
})
}
// Clean up workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
limit: 100
})
for (const run of runs.docs) {
await payload.delete({
collection: 'workflow-runs',
id: run.id
})
}
// Clean up posts
const posts = await payload.find({
collection: 'posts',
where: {
content: {
like: 'Test Error%'
}
}
})
for (const post of posts.docs) {
await payload.delete({
collection: 'posts',
id: post.id
})
}
} catch (error) {
console.warn('Cleanup failed:', error)
}
}
it('should handle HTTP timeout errors gracefully', async () => {
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: [
{
name: 'timeout-request',
step: 'http-request-step',
input: {
url: 'https://httpbin.org/delay/35', // 35 second delay
method: 'GET',
timeout: 5000 // 5 second timeout
}
}
]
}
})
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, 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('failed')
expect(runs.docs[0].error).toContain('timeout')
console.log('✅ Timeout error handled:', runs.docs[0].error)
}, 30000)
it('should handle invalid JSON responses', async () => {
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',
input: {
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')
}, 20000)
it('should handle circular reference in JSONPath resolution', async () => {
// 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',
input: {
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 () => {
// Create workflow with missing required fields
const 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',
input: {
// Missing required collectionSlug
data: {
message: 'This should fail'
}
}
}
]
}
})
const post = await payload.create({
collection: 'posts',
data: {
content: 'Test Error Malformed Config 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('failed')
expect(runs.docs[0].error).toContain('Collection slug is required')
console.log('✅ Malformed config error:', runs.docs[0].error)
}, 20000)
it('should handle HTTP 4xx and 5xx errors properly', async () => {
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',
input: {
url: 'https://httpbin.org/status/404',
method: 'GET'
}
},
{
name: 'server-error-request',
step: 'http-request-step',
input: {
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('failed')
// Check that both steps failed due to HTTP errors
const context = runs.docs[0].context
expect(context.steps['not-found-request'].state).toBe('failed')
expect(context.steps['not-found-request'].output.status).toBe(404)
console.log('✅ HTTP error statuses handled correctly')
}, 25000)
it('should handle retry logic for transient failures', async () => {
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',
input: {
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('failed') // Should still fail after retries
// The error should indicate multiple attempts were made
const stepOutput = runs.docs[0].context.steps['retry-request'].output
expect(stepOutput.status).toBe(503)
console.log('✅ Retry logic executed correctly')
}, 25000)
it('should handle extremely large workflow contexts', async () => {
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',
input: {
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 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',
input: {
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)
})

View File

@@ -0,0 +1,435 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import config from './payload.config'
describe('Hook Execution Reliability Tests', () => {
let payload: Payload
beforeAll(async () => {
payload = await getPayload({ config: await config })
// Clean up any existing test data
await cleanupTestData()
}, 60000)
afterAll(async () => {
await cleanupTestData()
}, 30000)
const cleanupTestData = async () => {
if (!payload) return
try {
// Clean up workflows
const workflows = await payload.find({
collection: 'workflows',
where: {
name: {
like: 'Test Hook%'
}
}
})
for (const workflow of workflows.docs) {
await payload.delete({
collection: 'workflows',
id: workflow.id
})
}
// Clean up workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
limit: 100
})
for (const run of runs.docs) {
await payload.delete({
collection: 'workflow-runs',
id: run.id
})
}
// Clean up posts
const posts = await payload.find({
collection: 'posts',
where: {
content: {
like: 'Test Hook%'
}
}
})
for (const post of posts.docs) {
await payload.delete({
collection: 'posts',
id: post.id
})
}
} catch (error) {
console.warn('Cleanup failed:', error)
}
}
it('should reliably execute hooks when collections are created', async () => {
// 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: [
{
name: 'create-audit-log',
step: 'create-document',
input: {
collectionSlug: 'auditLog',
data: {
post: '$.trigger.doc.id',
message: 'Post was created via workflow trigger',
user: '$.trigger.req.user.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)
expect(runs.docs[0].status).not.toBe('failed')
console.log('✅ Hook execution status:', runs.docs[0].status)
// Verify audit log was created
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')
}, 30000)
it('should handle hook execution errors gracefully', async () => {
// 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',
input: {
url: 'invalid-url-that-will-fail'
}
}
]
}
})
// 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()
expect(runs.docs[0].error).toContain('URL')
console.log('✅ Error handling working:', runs.docs[0].error)
}, 30000)
it('should create failed workflow runs when executor is unavailable', async () => {
// 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',
input: {
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 () => {
// 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: '$.doc.content == "TRIGGER_CONDITION"'
}
],
steps: [
{
name: 'conditional-audit',
step: 'create-document',
input: {
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)
expect(runs.docs[0].status).not.toBe('failed')
// 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 () => {
// 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',
input: {
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)
})

View File

@@ -73,17 +73,15 @@ describe('Workflow Trigger Test', () => {
{
name: 'log-post',
step: 'http-request-step',
input: {
url: 'https://httpbin.org/post',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: {
message: 'Post created',
postId: '$.trigger.doc.id',
postTitle: '$.trigger.doc.content'
}
url: 'https://httpbin.org/post',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: {
message: 'Post created',
postId: '$.trigger.doc.id',
postTitle: '$.trigger.doc.content'
}
}
]

View File

@@ -0,0 +1,521 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import config from './payload.config'
describe('Webhook Trigger Testing', () => {
let payload: Payload
let baseUrl: string
beforeAll(async () => {
payload = await getPayload({ config: await config })
baseUrl = process.env.PAYLOAD_PUBLIC_SERVER_URL || 'http://localhost:3000'
await cleanupTestData()
}, 60000)
afterAll(async () => {
await cleanupTestData()
}, 30000)
const cleanupTestData = async () => {
if (!payload) return
try {
// Clean up workflows
const workflows = await payload.find({
collection: 'workflows',
where: {
name: {
like: 'Test Webhook%'
}
}
})
for (const workflow of workflows.docs) {
await payload.delete({
collection: 'workflows',
id: workflow.id
})
}
// Clean up workflow runs
const runs = await payload.find({
collection: 'workflow-runs',
limit: 100
})
for (const run of runs.docs) {
await payload.delete({
collection: 'workflow-runs',
id: run.id
})
}
// Clean up audit logs
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
like: 'Webhook%'
}
}
})
for (const log of auditLogs.docs) {
await payload.delete({
collection: 'auditLog',
id: log.id
})
}
} catch (error) {
console.warn('Cleanup failed:', error)
}
}
const makeWebhookRequest = async (path: string, data: any = {}, method: string = 'POST') => {
const webhookUrl = `${baseUrl}/api/workflows/webhook/${path}`
console.log(`Making webhook request to: ${webhookUrl}`)
const response = await fetch(webhookUrl, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
return {
status: response.status,
statusText: response.statusText,
data: response.ok ? await response.json().catch(() => ({})) : null,
text: await response.text().catch(() => '')
}
}
it('should trigger workflow via webhook endpoint', async () => {
// Create a workflow with webhook trigger
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Basic Trigger',
description: 'Tests basic webhook triggering',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-basic'
}
],
steps: [
{
name: 'create-webhook-audit',
step: 'create-document',
input: {
collectionSlug: 'auditLog',
data: {
message: 'Webhook triggered successfully',
user: '$.trigger.data.userId'
}
}
}
]
}
})
expect(workflow).toBeDefined()
// Make webhook request
const response = await makeWebhookRequest('test-basic', {
userId: 'webhook-test-user',
timestamp: new Date().toISOString()
})
expect(response.status).toBe(200)
console.log('✅ Webhook response:', response.status, response.statusText)
// 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)
expect(runs.docs[0].status).not.toBe('failed')
// Verify audit log was created
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'Webhook triggered'
}
},
limit: 1
})
expect(auditLogs.totalDocs).toBe(1)
console.log('✅ Webhook audit log created')
}, 30000)
it('should handle webhook with complex data', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Complex Data',
description: 'Tests webhook with complex JSON data',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-complex'
}
],
steps: [
{
name: 'echo-webhook-data',
step: 'http-request-step',
input: {
url: 'https://httpbin.org/post',
method: 'POST',
body: {
originalData: '$.trigger.data',
headers: '$.trigger.headers',
path: '$.trigger.path'
}
}
}
]
}
})
const complexData = {
user: {
id: 123,
name: 'Test User',
permissions: ['read', 'write']
},
event: {
type: 'user_action',
timestamp: new Date().toISOString(),
metadata: {
source: 'webhook-test',
version: '1.0.0'
}
},
nested: {
deeply: {
nested: {
value: 'deep-test-value'
}
}
}
}
const response = await makeWebhookRequest('test-complex', complexData)
expect(response.status).toBe(200)
// Wait for workflow execution
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')
// Verify the complex data was properly passed through
const stepOutput = runs.docs[0].context.steps['echo-webhook-data'].output
expect(stepOutput.status).toBe(200)
const responseBody = JSON.parse(stepOutput.body)
expect(responseBody.json.originalData.user.name).toBe('Test User')
expect(responseBody.json.originalData.nested.deeply.nested.value).toBe('deep-test-value')
console.log('✅ Complex webhook data processed correctly')
}, 30000)
it('should handle webhook conditions', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Conditional',
description: 'Tests conditional webhook execution',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-conditional',
condition: '$.data.action == "important"'
}
],
steps: [
{
name: 'conditional-audit',
step: 'create-document',
input: {
collectionSlug: 'auditLog',
data: {
message: 'Webhook condition met - important action'
}
}
}
]
}
})
// First request - should NOT trigger (condition not met)
const response1 = await makeWebhookRequest('test-conditional', {
action: 'normal',
data: 'test'
})
expect(response1.status).toBe(200)
// Second request - SHOULD trigger (condition met)
const response2 = await makeWebhookRequest('test-conditional', {
action: 'important',
priority: 'high'
})
expect(response2.status).toBe(200)
// Wait for workflow execution
await new Promise(resolve => setTimeout(resolve, 5000))
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)
expect(runs.docs[0].status).not.toBe('failed')
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'condition met'
}
}
})
expect(auditLogs.totalDocs).toBe(1)
console.log('✅ Webhook conditional execution working')
}, 30000)
it('should handle webhook authentication headers', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Headers',
description: 'Tests webhook header processing',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-headers'
}
],
steps: [
{
name: 'process-headers',
step: 'http-request-step',
input: {
url: 'https://httpbin.org/post',
method: 'POST',
body: {
receivedHeaders: '$.trigger.headers',
authorization: '$.trigger.headers.authorization',
userAgent: '$.trigger.headers.user-agent'
}
}
}
]
}
})
// Make webhook request with custom headers
const webhookUrl = `${baseUrl}/api/workflows/webhook/test-headers`
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer test-token-123',
'User-Agent': 'Webhook-Test-Client/1.0',
'X-Custom-Header': 'custom-value'
},
body: JSON.stringify({
test: 'header processing'
})
})
expect(response.status).toBe(200)
// Wait for workflow execution
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')
// Verify headers were captured and processed
const stepOutput = runs.docs[0].context.steps['process-headers'].output
const responseBody = JSON.parse(stepOutput.body)
expect(responseBody.json.authorization).toBe('Bearer test-token-123')
expect(responseBody.json.userAgent).toBe('Webhook-Test-Client/1.0')
console.log('✅ Webhook headers processed correctly')
}, 30000)
it('should handle multiple concurrent webhook requests', async () => {
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Concurrent',
description: 'Tests concurrent webhook processing',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-concurrent'
}
],
steps: [
{
name: 'concurrent-audit',
step: 'create-document',
input: {
collectionSlug: 'auditLog',
data: {
message: 'Concurrent webhook execution',
requestId: '$.trigger.data.requestId'
}
}
}
]
}
})
// Make multiple concurrent webhook requests
const concurrentRequests = Array.from({ length: 5 }, (_, i) =>
makeWebhookRequest('test-concurrent', {
requestId: `concurrent-${i + 1}`,
timestamp: new Date().toISOString()
})
)
const responses = await Promise.all(concurrentRequests)
responses.forEach(response => {
expect(response.status).toBe(200)
})
// Wait for all workflow executions
await new Promise(resolve => setTimeout(resolve, 8000))
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)
// Verify all audit logs were created
const auditLogs = await payload.find({
collection: 'auditLog',
where: {
message: {
contains: 'Concurrent webhook'
}
}
})
expect(auditLogs.totalDocs).toBe(5)
console.log('✅ Concurrent webhook requests processed successfully')
}, 35000)
it('should handle non-existent webhook paths gracefully', async () => {
const response = await makeWebhookRequest('non-existent-path', {
test: 'should fail'
})
// Should return 404 or appropriate error status
expect([404, 400]).toContain(response.status)
console.log('✅ Non-existent webhook path handled:', response.status)
}, 10000)
it('should handle malformed webhook JSON', async () => {
const webhookUrl = `${baseUrl}/api/workflows/webhook/test-malformed`
// First create a workflow to receive the malformed request
const workflow = await payload.create({
collection: 'workflows',
data: {
name: 'Test Webhook - Malformed JSON',
description: 'Tests malformed JSON handling',
triggers: [
{
type: 'webhook-trigger',
webhookPath: 'test-malformed'
}
],
steps: [
{
name: 'malformed-test',
step: 'create-document',
input: {
collectionSlug: 'auditLog',
data: {
message: 'Processed malformed request'
}
}
}
]
}
})
// Send malformed JSON
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: '{"malformed": json, "missing": quotes}'
})
// Should handle malformed JSON gracefully
expect([400, 422]).toContain(response.status)
console.log('✅ Malformed JSON handled:', response.status)
}, 15000)
})