Skip to main content
Glama
chat-handler.test.tsβ€’16.7 kB
/** * 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); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/czlonkowski/n8n-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server