mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 06:22:04 +00:00
feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support (#460)
* feat: rename n8n_trigger_webhook_workflow to n8n_test_workflow with multi-trigger support - Rename tool from n8n_trigger_webhook_workflow to n8n_test_workflow - Add support for webhook, form, and chat triggers (auto-detection) - Implement modular trigger system with registry pattern - Add trigger detector for automatic trigger type inference - Remove execute trigger type (n8n public API limitation) - Add comprehensive tests for trigger detection and handlers The tool now auto-detects trigger type from workflow structure and supports all externally-triggerable workflows via n8n's public API. Note: Direct workflow execution (Schedule/Manual triggers) requires n8n's instance-level MCP access, not available via REST API. Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: add SSRF protection to webhook handler and update tests - Add SSRF URL validation to webhook-handler.ts (critical security fix) Aligns with existing SSRF protection in form-handler.ts and chat-handler.ts - Update parameter-validation.test.ts to use new n8n_test_workflow tool name Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: n8n_test_workflow unified trigger tool (v2.28.0) Added new `n8n_test_workflow` tool replacing `n8n_trigger_webhook_workflow`: Features: - Auto-detects trigger type (webhook/form/chat) from workflow - Supports multiple trigger types with type-specific parameters - SSRF protection for all trigger handlers - Extensible handler architecture with registry pattern Changes: - Fixed Zod schema to remove invalid 'execute' trigger type - Updated README.md tool documentation - Added CHANGELOG entry for v2.28.0 - Bumped version to 2.28.0 Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * test: add comprehensive unit tests for trigger handlers Added 87 unit tests across 4 test files to improve code coverage: - base-handler.test.ts (19 tests) - 100% coverage - webhook-handler.test.ts (22 tests) - 100% coverage - chat-handler.test.ts (23 tests) - 100% coverage - form-handler.test.ts (23 tests) - 100% coverage Tests cover: - Input validation and parameter handling - SSRF protection integration - HTTP method handling and URL building - Error response formatting - Execution paths for all trigger types Conceived by Romuald Członkowski - www.aiadvisors.pl/en 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
ddf9556759
commit
33690c5650
@@ -535,12 +535,12 @@ describe('Parameter Validation', () => {
|
||||
{ name: 'n8n_validate_workflow', args: {}, expected: 'n8n_validate_workflow: Validation failed:\n • id: id is required' },
|
||||
];
|
||||
|
||||
// n8n_update_partial_workflow and n8n_trigger_webhook_workflow use legacy validation
|
||||
// n8n_update_partial_workflow and n8n_test_workflow use legacy validation
|
||||
await expect(server.testExecuteTool('n8n_update_partial_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_update_partial_workflow: id, operations');
|
||||
|
||||
await expect(server.testExecuteTool('n8n_trigger_webhook_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_trigger_webhook_workflow: webhookUrl');
|
||||
|
||||
await expect(server.testExecuteTool('n8n_test_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId');
|
||||
|
||||
for (const tool of n8nToolsWithRequiredParams) {
|
||||
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||
|
||||
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal file
335
tests/unit/triggers/handlers/base-handler.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Unit tests for BaseTriggerHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BaseTriggerHandler } from '../../../../src/triggers/handlers/base-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import { TriggerType, TriggerResponse, TriggerHandlerCapabilities, BaseTriggerInput } from '../../../../src/triggers/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Mock getN8nApiConfig
|
||||
vi.mock('../../../../src/config/n8n-api', () => ({
|
||||
getN8nApiConfig: vi.fn(() => ({
|
||||
baseUrl: 'https://env-n8n.example.com/api/v1',
|
||||
apiKey: 'env-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Create a concrete implementation for testing
|
||||
class TestHandler extends BaseTriggerHandler {
|
||||
readonly triggerType: TriggerType = 'webhook';
|
||||
readonly capabilities: TriggerHandlerCapabilities = {
|
||||
requiresActiveWorkflow: true,
|
||||
canPassInputData: true,
|
||||
};
|
||||
readonly inputSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
triggerType: z.literal('webhook'),
|
||||
});
|
||||
|
||||
async execute(
|
||||
input: BaseTriggerInput,
|
||||
workflow: Workflow
|
||||
): Promise<TriggerResponse> {
|
||||
return {
|
||||
success: true,
|
||||
triggerType: this.triggerType,
|
||||
workflowId: input.workflowId,
|
||||
data: { test: 'data' },
|
||||
metadata: { duration: 100 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create mock client
|
||||
const createMockClient = (): N8nApiClient => ({
|
||||
getWorkflow: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
triggerWebhook: vi.fn(),
|
||||
getExecution: vi.fn(),
|
||||
listExecutions: vi.fn(),
|
||||
deleteExecution: vi.fn(),
|
||||
} as unknown as N8nApiClient);
|
||||
|
||||
describe('BaseTriggerHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should initialize with client only', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should initialize with client and context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://test.n8n.com/api/v1',
|
||||
n8nApiKey: 'test-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
expect(handler).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate correct input', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should throw ZodError for invalid input', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
workflowId: 123, // Wrong type
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw ZodError for missing required fields', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input = {
|
||||
triggerType: 'webhook',
|
||||
// Missing workflowId
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBaseUrl', () => {
|
||||
it('should return base URL from context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
|
||||
it('should strip trailing slash and /api/v1 from context URL', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1/',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
|
||||
it('should return base URL from environment config when no context', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://env-n8n.example.com');
|
||||
});
|
||||
|
||||
it('should prefer context over environment config', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const baseUrl = (handler as any).getBaseUrl();
|
||||
expect(baseUrl).toBe('https://context.n8n.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getApiKey', () => {
|
||||
it('should return API key from context', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-api-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('context-api-key');
|
||||
});
|
||||
|
||||
it('should return API key from environment config when no context', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('env-api-key');
|
||||
});
|
||||
|
||||
it('should prefer context over environment config', () => {
|
||||
const context: InstanceContext = {
|
||||
n8nApiUrl: 'https://context.n8n.com/api/v1',
|
||||
n8nApiKey: 'context-key',
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
const handler = new TestHandler(mockClient, context);
|
||||
|
||||
const apiKey = (handler as any).getApiKey();
|
||||
expect(apiKey).toBe('context-key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeResponse', () => {
|
||||
it('should create normalized success response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 150;
|
||||
const result = { data: 'test-result' };
|
||||
|
||||
const response = (handler as any).normalizeResponse(result, input, startTime);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.triggerType).toBe('webhook');
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.data).toEqual(result);
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(150);
|
||||
});
|
||||
|
||||
it('should merge extra fields into response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now();
|
||||
const result = { data: 'test' };
|
||||
const extra = {
|
||||
executionId: 'exec-123',
|
||||
status: 200,
|
||||
};
|
||||
|
||||
const response = (handler as any).normalizeResponse(result, input, startTime, extra);
|
||||
|
||||
expect(response.executionId).toBe('exec-123');
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should calculate duration correctly', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 500;
|
||||
|
||||
const response = (handler as any).normalizeResponse({}, input, startTime);
|
||||
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(500);
|
||||
expect(response.metadata.duration).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorResponse', () => {
|
||||
it('should create error response', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 200;
|
||||
|
||||
const response = (handler as any).errorResponse(
|
||||
input,
|
||||
'Test error message',
|
||||
startTime
|
||||
);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.triggerType).toBe('webhook');
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.error).toBe('Test error message');
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it('should merge extra error details', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now();
|
||||
const extra = {
|
||||
code: 'ERR_TEST',
|
||||
details: { reason: 'test reason' },
|
||||
};
|
||||
|
||||
const response = (handler as any).errorResponse(
|
||||
input,
|
||||
'Error',
|
||||
startTime,
|
||||
extra
|
||||
);
|
||||
|
||||
expect(response.code).toBe('ERR_TEST');
|
||||
expect(response.details).toEqual({ reason: 'test reason' });
|
||||
});
|
||||
|
||||
it('should calculate error duration correctly', () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const startTime = Date.now() - 750;
|
||||
|
||||
const response = (handler as any).errorResponse(input, 'Error', startTime);
|
||||
|
||||
expect(response.metadata.duration).toBeGreaterThanOrEqual(750);
|
||||
expect(response.metadata.duration).toBeLessThan(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute successfully', async () => {
|
||||
const handler = new TestHandler(mockClient);
|
||||
const input: BaseTriggerInput = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
const workflow: Workflow = {
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.workflowId).toBe('workflow-123');
|
||||
expect(response.data).toEqual({ test: 'data' });
|
||||
});
|
||||
});
|
||||
});
|
||||
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal file
569
tests/unit/triggers/handlers/chat-handler.test.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* Unit tests for ChatHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ChatHandler } from '../../../../src/triggers/handlers/chat-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import { DetectedTrigger } from '../../../../src/triggers/types';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock getN8nApiConfig
|
||||
vi.mock('../../../../src/config/n8n-api', () => ({
|
||||
getN8nApiConfig: vi.fn(() => ({
|
||||
baseUrl: 'https://test.n8n.com/api/v1',
|
||||
apiKey: 'test-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock SSRFProtection
|
||||
vi.mock('../../../../src/utils/ssrf-protection', () => ({
|
||||
SSRFProtection: {
|
||||
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock buildTriggerUrl
|
||||
vi.mock('../../../../src/triggers/trigger-detector', () => ({
|
||||
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
|
||||
return `${baseUrl}/webhook/${trigger.webhookPath}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
|
||||
// Create mock client
|
||||
const createMockClient = (): N8nApiClient => ({
|
||||
getWorkflow: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
triggerWebhook: vi.fn(),
|
||||
getExecution: vi.fn(),
|
||||
listExecutions: vi.fn(),
|
||||
deleteExecution: vi.fn(),
|
||||
} as unknown as N8nApiClient);
|
||||
|
||||
// Create test workflow
|
||||
const createWorkflow = (): Workflow => ({
|
||||
id: 'workflow-123',
|
||||
name: 'Chat Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'chat-node',
|
||||
name: 'Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: 'ai-chat',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow);
|
||||
|
||||
describe('ChatHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
let handler: ChatHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = createMockClient();
|
||||
handler = new ChatHandler(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset SSRFProtection mock
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: true,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
// Reset axios mock
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { response: 'Chat response' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should have correct trigger type', () => {
|
||||
expect(handler.triggerType).toBe('chat');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should validate correct chat input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should validate minimal input without sessionId', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.workflowId).toBe('workflow-123');
|
||||
expect(result.message).toBe('Hello AI!');
|
||||
expect(result.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid trigger type', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
message: 'Hello',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject missing message', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
data: { context: 'value' },
|
||||
headers: { 'X-Custom': 'header' },
|
||||
timeout: 60000,
|
||||
waitForResponse: false,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.data).toEqual({ context: 'value' });
|
||||
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
||||
expect(result.timeout).toBe(60000);
|
||||
expect(result.waitForResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute chat with provided sessionId', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'custom-session',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
data: expect.objectContaining({
|
||||
action: 'sendMessage',
|
||||
sessionId: 'custom-session',
|
||||
chatInput: 'Hello AI!',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate sessionId when not provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.metadata?.sessionId).toMatch(/^session_\d+_[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
it('should use trigger info to build chat URL', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'custom-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/webhook/custom-chat'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use workflow ID as fallback when no trigger info', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/webhook/workflow-123'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
const handlerNoContext = new ChatHandler(mockClient, {} as InstanceContext);
|
||||
|
||||
// Mock getN8nApiConfig to return null
|
||||
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
|
||||
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handlerNoContext.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('Cannot determine n8n base URL');
|
||||
});
|
||||
|
||||
it('should handle SSRF protection rejection', async () => {
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: false,
|
||||
reason: 'Private IP address not allowed',
|
||||
});
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('SSRF protection');
|
||||
expect(response.error).toContain('Private IP address not allowed');
|
||||
});
|
||||
|
||||
it('should include additional data in payload', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
data: {
|
||||
userId: 'user-456',
|
||||
context: 'support',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'sendMessage',
|
||||
chatInput: 'Hello',
|
||||
userId: 'user-456',
|
||||
context: 'support',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom timeout when provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
timeout: 90000,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 90000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout of 120000ms when waiting for response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
waitForResponse: true,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 120000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use timeout of 30000ms when not waiting for response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
waitForResponse: false,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 30000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return response with status and metadata', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello AI!',
|
||||
sessionId: 'session-123',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { response: 'AI reply', tokens: 150 },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.statusText).toBe('OK');
|
||||
expect(response.data).toEqual({ response: 'AI reply', tokens: 150 });
|
||||
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(response.metadata?.sessionId).toBe('session-123');
|
||||
expect(response.metadata?.webhookPath).toBe('ai-chat');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError = new Error('Chat execution failed');
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBe('Chat execution failed');
|
||||
});
|
||||
|
||||
it('should extract execution ID from error response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Execution error');
|
||||
apiError.response = {
|
||||
data: {
|
||||
executionId: 'exec-789',
|
||||
error: 'Node failed',
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.executionId).toBe('exec-789');
|
||||
expect(response.details).toEqual({
|
||||
executionId: 'exec-789',
|
||||
error: 'Node failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error with code', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Timeout error');
|
||||
apiError.code = 'ETIMEDOUT';
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.code).toBe('ETIMEDOUT');
|
||||
});
|
||||
|
||||
it('should validate status codes less than 500', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat' as const,
|
||||
message: 'Hello',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'chat',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
validateStatus: expect.any(Function),
|
||||
})
|
||||
);
|
||||
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.validateStatus!(200)).toBe(true);
|
||||
expect(config.validateStatus!(404)).toBe(true);
|
||||
expect(config.validateStatus!(499)).toBe(true);
|
||||
expect(config.validateStatus!(500)).toBe(false);
|
||||
expect(config.validateStatus!(503)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
578
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
578
tests/unit/triggers/handlers/form-handler.test.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Unit tests for FormHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { FormHandler } from '../../../../src/triggers/handlers/form-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow } from '../../../../src/types/n8n-api';
|
||||
import { DetectedTrigger } from '../../../../src/triggers/types';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mock getN8nApiConfig
|
||||
vi.mock('../../../../src/config/n8n-api', () => ({
|
||||
getN8nApiConfig: vi.fn(() => ({
|
||||
baseUrl: 'https://test.n8n.com/api/v1',
|
||||
apiKey: 'test-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock SSRFProtection
|
||||
vi.mock('../../../../src/utils/ssrf-protection', () => ({
|
||||
SSRFProtection: {
|
||||
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios');
|
||||
|
||||
// Create mock client
|
||||
const createMockClient = (): N8nApiClient => ({
|
||||
getWorkflow: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
triggerWebhook: vi.fn(),
|
||||
getExecution: vi.fn(),
|
||||
listExecutions: vi.fn(),
|
||||
deleteExecution: vi.fn(),
|
||||
} as unknown as N8nApiClient);
|
||||
|
||||
// Create test workflow
|
||||
const createWorkflow = (): Workflow => ({
|
||||
id: 'workflow-123',
|
||||
name: 'Form Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'form-node',
|
||||
name: 'Form Trigger',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: 'contact-form',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow);
|
||||
|
||||
describe('FormHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
let handler: FormHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = createMockClient();
|
||||
handler = new FormHandler(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset SSRFProtection mock
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: true,
|
||||
reason: '',
|
||||
});
|
||||
|
||||
// Reset axios mock
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { success: true, message: 'Form submitted' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should have correct trigger type', () => {
|
||||
expect(handler.triggerType).toBe('form');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should validate correct form input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
},
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should validate minimal input without formData', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.workflowId).toBe('workflow-123');
|
||||
expect(result.triggerType).toBe('form');
|
||||
expect(result.formData).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject invalid trigger type', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: { field: 'value' },
|
||||
data: { extra: 'data' },
|
||||
headers: { 'X-Custom': 'header' },
|
||||
timeout: 60000,
|
||||
waitForResponse: false,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.formData).toEqual({ field: 'value' });
|
||||
expect(result.data).toEqual({ extra: 'data' });
|
||||
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
||||
expect(result.timeout).toBe(60000);
|
||||
expect(result.waitForResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute form with provided formData', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
message: 'Hello',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
data: {
|
||||
name: 'Jane Doe',
|
||||
email: 'jane@example.com',
|
||||
message: 'Hello',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use form path from trigger info', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: { field: 'value' },
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: {
|
||||
id: 'form-node',
|
||||
name: 'Form',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { path: 'custom-form' },
|
||||
},
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/form/custom-form'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use workflow ID as fallback path', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-456',
|
||||
triggerType: 'form' as const,
|
||||
formData: { field: 'value' },
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: expect.stringContaining('/form/workflow-456'),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge formData and data with formData taking precedence', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
data: {
|
||||
field1: 'from data',
|
||||
field2: 'from data',
|
||||
},
|
||||
formData: {
|
||||
field2: 'from formData',
|
||||
field3: 'from formData',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
field1: 'from data',
|
||||
field2: 'from formData',
|
||||
field3: 'from formData',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
const handlerNoContext = new FormHandler(mockClient, {} as InstanceContext);
|
||||
|
||||
// Mock getN8nApiConfig to return null
|
||||
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
|
||||
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handlerNoContext.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('Cannot determine n8n base URL');
|
||||
});
|
||||
|
||||
it('should handle SSRF protection rejection', async () => {
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: false,
|
||||
reason: 'Private IP address not allowed',
|
||||
});
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('SSRF protection');
|
||||
expect(response.error).toContain('Private IP address not allowed');
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: { field: 'value' },
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use custom timeout when provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
timeout: 90000,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 90000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default timeout of 120000ms when waiting for response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
waitForResponse: true,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 120000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use timeout of 30000ms when not waiting for response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
waitForResponse: false,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
timeout: 30000,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return response with status and metadata', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: { name: 'Test' },
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
vi.mocked(axios.request).mockResolvedValue({
|
||||
status: 201,
|
||||
statusText: 'Created',
|
||||
data: { id: 'submission-123', status: 'processed' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.statusText).toBe('Created');
|
||||
expect(response.data).toEqual({ id: 'submission-123', status: 'processed' });
|
||||
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
const apiError = new Error('Form submission failed');
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBe('Form submission failed');
|
||||
});
|
||||
|
||||
it('should extract execution ID from error response', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Execution error');
|
||||
apiError.response = {
|
||||
data: {
|
||||
id: 'exec-111',
|
||||
error: 'Validation failed',
|
||||
},
|
||||
};
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.executionId).toBe('exec-111');
|
||||
expect(response.details).toEqual({
|
||||
id: 'exec-111',
|
||||
error: 'Validation failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error with code', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
const apiError: any = new Error('Connection timeout');
|
||||
apiError.code = 'ECONNABORTED';
|
||||
vi.mocked(axios.request).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.code).toBe('ECONNABORTED');
|
||||
});
|
||||
|
||||
it('should validate status codes less than 500', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
validateStatus: expect.any(Function),
|
||||
})
|
||||
);
|
||||
|
||||
const config = vi.mocked(axios.request).mock.calls[0][0];
|
||||
expect(config.validateStatus!(200)).toBe(true);
|
||||
expect(config.validateStatus!(400)).toBe(true);
|
||||
expect(config.validateStatus!(499)).toBe(true);
|
||||
expect(config.validateStatus!(500)).toBe(false);
|
||||
expect(config.validateStatus!(502)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty formData', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: {},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle complex form data types', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'form' as const,
|
||||
formData: {
|
||||
name: 'Test User',
|
||||
age: 30,
|
||||
active: true,
|
||||
tags: ['tag1', 'tag2'],
|
||||
metadata: { key: 'value' },
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'form',
|
||||
node: workflow.nodes[0],
|
||||
};
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(axios.request).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
name: 'Test User',
|
||||
age: 30,
|
||||
active: true,
|
||||
tags: ['tag1', 'tag2'],
|
||||
metadata: { key: 'value' },
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal file
525
tests/unit/triggers/handlers/webhook-handler.test.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
/**
|
||||
* Unit tests for WebhookHandler
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { WebhookHandler } from '../../../../src/triggers/handlers/webhook-handler';
|
||||
import { N8nApiClient } from '../../../../src/services/n8n-api-client';
|
||||
import { InstanceContext } from '../../../../src/types/instance-context';
|
||||
import { Workflow, WebhookRequest } from '../../../../src/types/n8n-api';
|
||||
import { DetectedTrigger } from '../../../../src/triggers/types';
|
||||
|
||||
// Mock getN8nApiConfig
|
||||
vi.mock('../../../../src/config/n8n-api', () => ({
|
||||
getN8nApiConfig: vi.fn(() => ({
|
||||
baseUrl: 'https://test.n8n.com/api/v1',
|
||||
apiKey: 'test-api-key',
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock SSRFProtection
|
||||
vi.mock('../../../../src/utils/ssrf-protection', () => ({
|
||||
SSRFProtection: {
|
||||
validateWebhookUrl: vi.fn(async () => ({ valid: true, reason: '' })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock buildTriggerUrl
|
||||
vi.mock('../../../../src/triggers/trigger-detector', () => ({
|
||||
buildTriggerUrl: vi.fn((baseUrl: string, trigger: any, mode: string) => {
|
||||
return `${baseUrl}/webhook/${trigger.webhookPath}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Create mock client
|
||||
const createMockClient = (): N8nApiClient => ({
|
||||
getWorkflow: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
triggerWebhook: vi.fn(),
|
||||
getExecution: vi.fn(),
|
||||
listExecutions: vi.fn(),
|
||||
deleteExecution: vi.fn(),
|
||||
} as unknown as N8nApiClient);
|
||||
|
||||
// Create test workflow
|
||||
const createWorkflow = (): Workflow => ({
|
||||
id: 'workflow-123',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'webhook-node',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {
|
||||
path: 'test-webhook',
|
||||
httpMethod: 'POST',
|
||||
},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow);
|
||||
|
||||
describe('WebhookHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
let handler: WebhookHandler;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockClient = createMockClient();
|
||||
handler = new WebhookHandler(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import and reset mock
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: true,
|
||||
reason: '',
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should have correct trigger type', () => {
|
||||
expect(handler.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should have correct capabilities', () => {
|
||||
expect(handler.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler.capabilities.canPassInputData).toBe(true);
|
||||
expect(handler.capabilities.supportedMethods).toEqual(['GET', 'POST', 'PUT', 'DELETE']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('input validation', () => {
|
||||
it('should validate correct webhook input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
httpMethod: 'POST' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result).toEqual(input);
|
||||
});
|
||||
|
||||
it('should validate minimal input', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.workflowId).toBe('workflow-123');
|
||||
expect(result.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should reject invalid trigger type', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'chat',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should reject invalid HTTP method', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook',
|
||||
httpMethod: 'PATCH',
|
||||
};
|
||||
|
||||
expect(() => handler.validate(input)).toThrow();
|
||||
});
|
||||
|
||||
it('should accept optional fields', () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
data: { key: 'value' },
|
||||
headers: { 'X-Custom': 'header' },
|
||||
timeout: 60000,
|
||||
waitForResponse: false,
|
||||
};
|
||||
|
||||
const result = handler.validate(input);
|
||||
expect(result.data).toEqual({ key: 'value' });
|
||||
expect(result.headers).toEqual({ 'X-Custom': 'header' });
|
||||
expect(result.timeout).toBe(60000);
|
||||
expect(result.waitForResponse).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute webhook with provided path', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'custom-path',
|
||||
httpMethod: 'POST' as const,
|
||||
data: { test: 'data' },
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'success' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
webhookUrl: expect.stringContaining('/webhook/custom-path'),
|
||||
httpMethod: 'POST',
|
||||
data: { test: 'data' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use trigger info when no explicit path provided', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'GET',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'success' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error when no webhook path available', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('No webhook path available');
|
||||
});
|
||||
|
||||
it('should return error when base URL not available', async () => {
|
||||
const handlerNoContext = new WebhookHandler(mockClient, {} as InstanceContext);
|
||||
|
||||
// Mock getN8nApiConfig to return null
|
||||
const { getN8nApiConfig } = await import('../../../../src/config/n8n-api');
|
||||
vi.mocked(getN8nApiConfig).mockReturnValue(null as any);
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handlerNoContext.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('Cannot determine n8n base URL');
|
||||
});
|
||||
|
||||
it('should handle SSRF protection rejection', async () => {
|
||||
const { SSRFProtection } = await import('../../../../src/utils/ssrf-protection');
|
||||
vi.mocked(SSRFProtection.validateWebhookUrl).mockResolvedValue({
|
||||
valid: false,
|
||||
reason: 'Private IP address not allowed',
|
||||
});
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toContain('SSRF protection');
|
||||
expect(response.error).toContain('Private IP address not allowed');
|
||||
});
|
||||
|
||||
it('should use default POST method when not specified', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass custom headers', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'X-Custom-Header': 'custom-value',
|
||||
'Authorization': 'Bearer token',
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should set waitForResponse from input', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
waitForResponse: false,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 202,
|
||||
statusText: 'Accepted',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
waitForResponse: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should default waitForResponse to true', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
waitForResponse: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return response with status and metadata', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: 'POST' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: { result: 'webhook response' },
|
||||
});
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.statusText).toBe('OK');
|
||||
expect(response.data).toEqual({ status: 200, statusText: 'OK', data: { result: 'webhook response' } });
|
||||
expect(response.metadata?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(response.metadata?.webhookPath).toBe('test-path');
|
||||
expect(response.metadata?.httpMethod).toBe('POST');
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const apiError = new Error('Webhook execution failed');
|
||||
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.error).toBe('Webhook execution failed');
|
||||
});
|
||||
|
||||
it('should extract execution ID from error details', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
|
||||
const apiError: any = new Error('Execution error');
|
||||
apiError.details = {
|
||||
executionId: 'exec-456',
|
||||
message: 'Node execution failed',
|
||||
};
|
||||
vi.mocked(mockClient.triggerWebhook).mockRejectedValue(apiError);
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.executionId).toBe('exec-456');
|
||||
expect(response.details).toEqual({
|
||||
executionId: 'exec-456',
|
||||
message: 'Node execution failed',
|
||||
});
|
||||
});
|
||||
|
||||
it('should support all HTTP methods', async () => {
|
||||
const workflow = createWorkflow();
|
||||
const methods: Array<'GET' | 'POST' | 'PUT' | 'DELETE'> = ['GET', 'POST', 'PUT', 'DELETE'];
|
||||
|
||||
for (const method of methods) {
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: method,
|
||||
};
|
||||
|
||||
const response = await handler.execute(input, workflow);
|
||||
|
||||
expect(response.success).toBe(true);
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: method,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should use httpMethod from trigger info when not in input', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'PUT',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'PUT',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should prefer input httpMethod over trigger info', async () => {
|
||||
const input = {
|
||||
workflowId: 'workflow-123',
|
||||
triggerType: 'webhook' as const,
|
||||
webhookPath: 'test-path',
|
||||
httpMethod: 'DELETE' as const,
|
||||
};
|
||||
const workflow = createWorkflow();
|
||||
const triggerInfo: DetectedTrigger = {
|
||||
type: 'webhook',
|
||||
node: workflow.nodes[0],
|
||||
webhookPath: 'detected-path',
|
||||
httpMethod: 'GET',
|
||||
};
|
||||
|
||||
vi.mocked(mockClient.triggerWebhook).mockResolvedValue({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
data: {},
|
||||
});
|
||||
|
||||
await handler.execute(input, workflow, triggerInfo);
|
||||
|
||||
expect(mockClient.triggerWebhook).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
httpMethod: 'DELETE',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
330
tests/unit/triggers/trigger-detector.test.ts
Normal file
330
tests/unit/triggers/trigger-detector.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Unit tests for trigger detection
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectTriggerFromWorkflow, buildTriggerUrl, describeTrigger } from '../../../src/triggers/trigger-detector';
|
||||
import type { Workflow } from '../../../src/types/n8n-api';
|
||||
|
||||
// Helper to create a workflow with a specific trigger node
|
||||
function createWorkflowWithTrigger(triggerType: string, params: Record<string, unknown> = {}): Workflow {
|
||||
return {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-node',
|
||||
name: 'Trigger',
|
||||
type: triggerType,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: params,
|
||||
},
|
||||
{
|
||||
id: 'action-node',
|
||||
name: 'Action',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
}
|
||||
|
||||
describe('Trigger Detector', () => {
|
||||
describe('detectTriggerFromWorkflow', () => {
|
||||
describe('webhook detection', () => {
|
||||
it('should detect n8n-nodes-base.webhook as webhook trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'my-webhook',
|
||||
httpMethod: 'POST',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
expect(result.trigger?.webhookPath).toBe('my-webhook');
|
||||
expect(result.trigger?.httpMethod).toBe('POST');
|
||||
});
|
||||
|
||||
it('should detect webhook node with httpMethod from parameters', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'get-data',
|
||||
httpMethod: 'GET',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
expect(result.trigger?.httpMethod).toBe('GET');
|
||||
});
|
||||
|
||||
it('should default httpMethod to POST when not specified', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.webhook', {
|
||||
path: 'test-path',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('webhook');
|
||||
// Default is POST when not specified
|
||||
expect(result.trigger?.httpMethod).toBe('POST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('form detection', () => {
|
||||
it('should detect n8n-nodes-base.formTrigger as form trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.formTrigger', {
|
||||
path: 'my-form',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('form');
|
||||
expect(result.trigger?.node?.parameters?.path).toBe('my-form');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chat detection', () => {
|
||||
it('should detect @n8n/n8n-nodes-langchain.chatTrigger as chat trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('@n8n/n8n-nodes-langchain.chatTrigger', {
|
||||
path: 'chat-endpoint',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('chat');
|
||||
});
|
||||
|
||||
it('should detect n8n-nodes-langchain.chatTrigger as chat trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-langchain.chatTrigger', {
|
||||
webhookPath: 'ai-chat',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(true);
|
||||
expect(result.trigger?.type).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-triggerable workflows', () => {
|
||||
it('should return not detected for schedule trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.scheduleTrigger', {
|
||||
rule: { interval: [{ field: 'hours', value: 1 }] },
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
// Fallback reason may be undefined for non-input triggers
|
||||
});
|
||||
|
||||
it('should return not detected for manual trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.manualTrigger', {});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
|
||||
it('should return not detected for email trigger', () => {
|
||||
const workflow = createWorkflowWithTrigger('n8n-nodes-base.emailReadImap', {
|
||||
mailbox: 'INBOX',
|
||||
});
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflows without triggers', () => {
|
||||
it('should return not detected for workflow with no trigger node', () => {
|
||||
const workflow: Workflow = {
|
||||
id: 'test-workflow',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
nodes: [
|
||||
{
|
||||
id: 'action-node',
|
||||
name: 'Action',
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
],
|
||||
connections: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
settings: {},
|
||||
staticData: undefined,
|
||||
} as Workflow;
|
||||
|
||||
const result = detectTriggerFromWorkflow(workflow);
|
||||
|
||||
expect(result.detected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTriggerUrl', () => {
|
||||
it('should build webhook URL correctly', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-webhook' },
|
||||
},
|
||||
webhookPath: 'my-webhook',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook/my-webhook');
|
||||
});
|
||||
|
||||
it('should build test webhook URL correctly', () => {
|
||||
const baseUrl = 'https://n8n.example.com/';
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'test-path' },
|
||||
},
|
||||
webhookPath: 'test-path',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'test');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook-test/test-path');
|
||||
});
|
||||
|
||||
it('should build form URL with node ID when webhookPath not set', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'form' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Form',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-form' },
|
||||
},
|
||||
// webhookPath is undefined - should use node.id
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
// When webhookPath is not set, uses node.id as fallback
|
||||
expect(url).toContain('/form/');
|
||||
});
|
||||
|
||||
it('should build chat URL correctly', () => {
|
||||
const baseUrl = 'https://n8n.example.com';
|
||||
const trigger = {
|
||||
type: 'chat' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'ai-chat' },
|
||||
},
|
||||
webhookPath: 'ai-chat',
|
||||
};
|
||||
|
||||
const url = buildTriggerUrl(baseUrl, trigger, 'production');
|
||||
|
||||
expect(url).toBe('https://n8n.example.com/webhook/ai-chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeTrigger', () => {
|
||||
it('should describe webhook trigger', () => {
|
||||
const trigger = {
|
||||
type: 'webhook' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'My Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'my-webhook' },
|
||||
},
|
||||
webhookPath: 'my-webhook',
|
||||
httpMethod: 'POST' as const,
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('webhook');
|
||||
expect(description).toContain('POST');
|
||||
expect(description).toContain('my-webhook');
|
||||
});
|
||||
|
||||
it('should describe form trigger', () => {
|
||||
const trigger = {
|
||||
type: 'form' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'Contact Form',
|
||||
type: 'n8n-nodes-base.formTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: { path: 'contact' },
|
||||
},
|
||||
webhookPath: 'contact',
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('form');
|
||||
});
|
||||
|
||||
it('should describe chat trigger', () => {
|
||||
const trigger = {
|
||||
type: 'chat' as const,
|
||||
node: {
|
||||
id: 'trigger',
|
||||
name: 'AI Chat',
|
||||
type: '@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
typeVersion: 1,
|
||||
position: [0, 0] as [number, number],
|
||||
parameters: {},
|
||||
},
|
||||
};
|
||||
|
||||
const description = describeTrigger(trigger);
|
||||
|
||||
// Case-insensitive check
|
||||
expect(description.toLowerCase()).toContain('chat');
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
156
tests/unit/triggers/trigger-registry.test.ts
Normal file
156
tests/unit/triggers/trigger-registry.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Unit tests for trigger registry
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { TriggerRegistry, initializeTriggerRegistry, ensureRegistryInitialized } from '../../../src/triggers/trigger-registry';
|
||||
import type { N8nApiClient } from '../../../src/services/n8n-api-client';
|
||||
|
||||
// Mock N8nApiClient
|
||||
const createMockClient = (): N8nApiClient => ({
|
||||
getWorkflow: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
deleteWorkflow: vi.fn(),
|
||||
triggerWebhook: vi.fn(),
|
||||
getExecution: vi.fn(),
|
||||
listExecutions: vi.fn(),
|
||||
deleteExecution: vi.fn(),
|
||||
} as unknown as N8nApiClient);
|
||||
|
||||
describe('TriggerRegistry', () => {
|
||||
describe('initialization', () => {
|
||||
it('should initialize with all handlers registered', async () => {
|
||||
await initializeTriggerRegistry();
|
||||
|
||||
const registeredTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
expect(registeredTypes).toContain('webhook');
|
||||
expect(registeredTypes).toContain('form');
|
||||
expect(registeredTypes).toContain('chat');
|
||||
expect(registeredTypes.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should not register duplicate handlers on multiple init calls', async () => {
|
||||
await initializeTriggerRegistry();
|
||||
const firstTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
await initializeTriggerRegistry();
|
||||
const secondTypes = TriggerRegistry.getRegisteredTypes();
|
||||
|
||||
expect(firstTypes.length).toBe(secondTypes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHandler', () => {
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
});
|
||||
|
||||
it('should return true for webhook handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('webhook')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for form handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('form')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for chat handler', () => {
|
||||
expect(TriggerRegistry.hasHandler('chat')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for unknown trigger type', () => {
|
||||
expect(TriggerRegistry.hasHandler('unknown' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHandler', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it('should return a webhook handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('webhook', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should return a form handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('form', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('form');
|
||||
});
|
||||
|
||||
it('should return a chat handler', () => {
|
||||
const handler = TriggerRegistry.getHandler('chat', mockClient);
|
||||
|
||||
expect(handler).toBeDefined();
|
||||
expect(handler?.triggerType).toBe('chat');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown trigger type', () => {
|
||||
const handler = TriggerRegistry.getHandler('unknown' as any, mockClient);
|
||||
|
||||
expect(handler).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler capabilities', () => {
|
||||
let mockClient: N8nApiClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
await ensureRegistryInitialized();
|
||||
mockClient = createMockClient();
|
||||
});
|
||||
|
||||
it('webhook handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('webhook', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
|
||||
it('form handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('form', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
|
||||
it('chat handler should require active workflow', () => {
|
||||
const handler = TriggerRegistry.getHandler('chat', mockClient);
|
||||
|
||||
expect(handler?.capabilities.requiresActiveWorkflow).toBe(true);
|
||||
expect(handler?.capabilities.canPassInputData).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureRegistryInitialized', () => {
|
||||
it('should be safe to call multiple times', async () => {
|
||||
await ensureRegistryInitialized();
|
||||
await ensureRegistryInitialized();
|
||||
await ensureRegistryInitialized();
|
||||
|
||||
const types = TriggerRegistry.getRegisteredTypes();
|
||||
expect(types.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle concurrent initialization calls', async () => {
|
||||
const promises = [
|
||||
ensureRegistryInitialized(),
|
||||
ensureRegistryInitialized(),
|
||||
ensureRegistryInitialized(),
|
||||
];
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
const types = TriggerRegistry.getRegisteredTypes();
|
||||
expect(types.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user