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