mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 00:43:23 +00:00
## 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>
521 lines
14 KiB
TypeScript
521 lines
14 KiB
TypeScript
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)
|
|
}) |