mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-10 08:53:23 +00:00
Implement independent error storage system and comprehensive improvements
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>
This commit is contained in:
356
src/test/create-document-step.test.ts
Normal file
356
src/test/create-document-step.test.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
348
src/test/http-request-step.test.ts
Normal file
348
src/test/http-request-step.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
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'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
472
src/test/workflow-executor.test.ts
Normal file
472
src/test/workflow-executor.test.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
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.webhook.url',
|
||||
message: 'Static message',
|
||||
data: {
|
||||
id: '$.trigger.doc.id',
|
||||
title: '$.trigger.doc.title'
|
||||
}
|
||||
}
|
||||
|
||||
const context = {
|
||||
trigger: {
|
||||
doc: { id: 'doc-123', title: 'Doc Title' },
|
||||
webhook: { 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