From 2b54710fda4eb9ef80f520d55072e87a4ed00975 Mon Sep 17 00:00:00 2001 From: czlonkowski <56956555+czlonkowski@users.noreply.github.com> Date: Mon, 28 Jul 2025 18:15:21 +0200 Subject: [PATCH] test: add unit tests for n8n manager and workflow diff handlers --- src/services/workflow-validator.ts | 4 +- tests/unit/mcp/handlers-n8n-manager.test.ts | 1094 +++++++++++++++++ tests/unit/mcp/handlers-workflow-diff.test.ts | 590 +++++++++ .../enhanced-config-validator.test.ts | 13 +- tests/unit/services/n8n-api-client.test.ts | 39 +- tests/unit/services/n8n-validation.test.ts | 22 +- .../services/workflow-diff-engine.test.ts | 28 +- .../workflow-validator-comprehensive.test.ts | 32 +- 8 files changed, 1758 insertions(+), 64 deletions(-) create mode 100644 tests/unit/mcp/handlers-n8n-manager.test.ts create mode 100644 tests/unit/mcp/handlers-workflow-diff.test.ts diff --git a/src/services/workflow-validator.ts b/src/services/workflow-validator.ts index 52d6b3d..9281746 100644 --- a/src/services/workflow-validator.ts +++ b/src/services/workflow-validator.ts @@ -401,7 +401,7 @@ export class WorkflowValidator { type: 'error', nodeId: node.id, nodeName: node.name, - message: error + message: typeof error === 'string' ? error : error.message || String(error) }); }); @@ -410,7 +410,7 @@ export class WorkflowValidator { type: 'warning', nodeId: node.id, nodeName: node.name, - message: warning + message: typeof warning === 'string' ? warning : warning.message || String(warning) }); }); diff --git a/tests/unit/mcp/handlers-n8n-manager.test.ts b/tests/unit/mcp/handlers-n8n-manager.test.ts new file mode 100644 index 0000000..ebf829b --- /dev/null +++ b/tests/unit/mcp/handlers-n8n-manager.test.ts @@ -0,0 +1,1094 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + handleCreateWorkflow, + handleGetWorkflow, + handleGetWorkflowDetails, + handleGetWorkflowStructure, + handleGetWorkflowMinimal, + handleUpdateWorkflow, + handleDeleteWorkflow, + handleListWorkflows, + handleValidateWorkflow, + handleTriggerWebhookWorkflow, + handleGetExecution, + handleListExecutions, + handleDeleteExecution, + handleHealthCheck, + handleListAvailableTools, + handleDiagnostic, + getN8nApiClient, +} from '@/mcp/handlers-n8n-manager'; +import { N8nApiClient } from '@/services/n8n-api-client'; +import { WorkflowValidator } from '@/services/workflow-validator'; +import { NodeRepository } from '@/database/node-repository'; +import { + N8nApiError, + N8nAuthenticationError, + N8nNotFoundError, + N8nValidationError, + N8nRateLimitError, + N8nServerError, +} from '@/utils/n8n-errors'; +import { ExecutionStatus } from '@/types/n8n-api'; +import { z } from 'zod'; + +// Mock all dependencies +vi.mock('@/services/n8n-api-client'); +vi.mock('@/services/workflow-validator'); +vi.mock('@/database/node-repository'); +vi.mock('@/config/n8n-api'); +vi.mock('@/services/n8n-validation'); +vi.mock('@/utils/logger'); + +// Import mocked modules +import { getN8nApiConfig } from '@/config/n8n-api'; +import * as n8nValidation from '@/services/n8n-validation'; +import { logger } from '@/utils/logger'; + +describe('handlers-n8n-manager', () => { + let mockApiClient: any; + let mockRepository: any; + let mockValidator: any; + + // Helper function to create test data + const createTestWorkflow = (overrides = {}) => ({ + id: 'test-workflow-id', + name: 'Test Workflow', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + ], + connections: {}, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + tags: [], + settings: {}, + ...overrides, + }); + + const createTestExecution = (overrides = {}) => ({ + id: 'exec-123', + workflowId: 'test-workflow-id', + status: ExecutionStatus.SUCCESS, + startedAt: '2024-01-01T00:00:00Z', + stoppedAt: '2024-01-01T00:01:00Z', + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mock API client + mockApiClient = { + createWorkflow: vi.fn(), + getWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + deleteWorkflow: vi.fn(), + listWorkflows: vi.fn(), + triggerWebhook: vi.fn(), + getExecution: vi.fn(), + listExecutions: vi.fn(), + deleteExecution: vi.fn(), + healthCheck: vi.fn(), + }; + + // Setup mock repository + mockRepository = { + getNodeByType: vi.fn(), + getAllNodes: vi.fn(), + }; + + // Setup mock validator + mockValidator = { + validateWorkflow: vi.fn(), + }; + + // Mock the API config + vi.mocked(getN8nApiConfig).mockReturnValue({ + baseUrl: 'https://n8n.test.com', + apiKey: 'test-key', + timeout: 30000, + maxRetries: 3, + }); + + // Mock validation functions + vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([]); + vi.mocked(n8nValidation.hasWebhookTrigger).mockReturnValue(false); + vi.mocked(n8nValidation.getWebhookUrl).mockReturnValue(null); + + // Mock the N8nApiClient constructor + vi.mocked(N8nApiClient).mockImplementation(() => mockApiClient); + + // Mock WorkflowValidator constructor + vi.mocked(WorkflowValidator).mockImplementation(() => mockValidator); + + // Mock NodeRepository constructor + vi.mocked(NodeRepository).mockImplementation(() => mockRepository); + }); + + afterEach(() => { + // Clear the singleton API client + const handler = require('../../../src/mcp/handlers-n8n-manager'); + handler.apiClient = null; + handler.lastConfigUrl = null; + }); + + describe('getN8nApiClient', () => { + it('should create new client when config is available', () => { + const client = getN8nApiClient(); + expect(client).toBe(mockApiClient); + expect(N8nApiClient).toHaveBeenCalledWith({ + baseUrl: 'https://n8n.test.com', + apiKey: 'test-key', + timeout: 30000, + maxRetries: 3, + }); + }); + + it('should return null when config is not available', () => { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + const client = getN8nApiClient(); + expect(client).toBeNull(); + }); + + it('should reuse existing client when config has not changed', () => { + const client1 = getN8nApiClient(); + const client2 = getN8nApiClient(); + expect(client1).toBe(client2); + expect(N8nApiClient).toHaveBeenCalledTimes(1); + }); + + it('should create new client when config URL changes', () => { + const client1 = getN8nApiClient(); + + vi.mocked(getN8nApiConfig).mockReturnValue({ + baseUrl: 'https://different.test.com', + apiKey: 'test-key', + timeout: 30000, + maxRetries: 3, + }); + + const client2 = getN8nApiClient(); + expect(N8nApiClient).toHaveBeenCalledTimes(2); + }); + }); + + describe('handleCreateWorkflow', () => { + it('should create workflow successfully', async () => { + const testWorkflow = createTestWorkflow(); + const input = { + name: 'Test Workflow', + nodes: testWorkflow.nodes, + connections: testWorkflow.connections, + }; + + mockApiClient.createWorkflow.mockResolvedValue(testWorkflow); + + const result = await handleCreateWorkflow(input); + + expect(result).toEqual({ + success: true, + data: testWorkflow, + message: 'Workflow "Test Workflow" created successfully with ID: test-workflow-id', + }); + expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(input); + expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith(input); + }); + + it('should handle validation errors', async () => { + const input = { invalid: 'data' }; + + const result = await handleCreateWorkflow(input); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + }); + + it('should handle workflow structure validation failures', async () => { + const input = { + name: 'Test Workflow', + nodes: [], + connections: {}, + }; + + vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([ + 'Workflow must have at least one node', + ]); + + const result = await handleCreateWorkflow(input); + + expect(result).toEqual({ + success: false, + error: 'Workflow validation failed', + details: { errors: ['Workflow must have at least one node'] }, + }); + }); + + it('should handle API errors', async () => { + const input = { + name: 'Test Workflow', + nodes: [{ id: 'node1', name: 'Start', type: 'n8n-nodes-base.start' }], + connections: {}, + }; + + const apiError = new N8nValidationError('Invalid workflow data', { + field: 'nodes', + message: 'Node configuration invalid', + }); + mockApiClient.createWorkflow.mockRejectedValue(apiError); + + const result = await handleCreateWorkflow(input); + + expect(result).toEqual({ + success: false, + error: 'Invalid workflow data', + code: 'VALIDATION_ERROR', + details: { field: 'nodes', message: 'Node configuration invalid' }, + }); + }); + + it('should handle API not configured error', async () => { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + + const result = await handleCreateWorkflow({ name: 'Test', nodes: [], connections: {} }); + + expect(result).toEqual({ + success: false, + error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', + }); + }); + }); + + describe('handleGetWorkflow', () => { + it('should get workflow successfully', async () => { + const testWorkflow = createTestWorkflow(); + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + + const result = await handleGetWorkflow({ id: 'test-workflow-id' }); + + expect(result).toEqual({ + success: true, + data: testWorkflow, + }); + expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('test-workflow-id'); + }); + + it('should handle not found error', async () => { + const notFoundError = new N8nNotFoundError('Workflow not found'); + mockApiClient.getWorkflow.mockRejectedValue(notFoundError); + + const result = await handleGetWorkflow({ id: 'non-existent' }); + + expect(result).toEqual({ + success: false, + error: 'Workflow not found', + code: 'NOT_FOUND', + }); + }); + + it('should handle invalid input', async () => { + const result = await handleGetWorkflow({ notId: 'test' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + }); + }); + + describe('handleGetWorkflowDetails', () => { + it('should get workflow details with execution stats', async () => { + const testWorkflow = createTestWorkflow(); + const testExecutions = [ + createTestExecution({ status: ExecutionStatus.SUCCESS }), + createTestExecution({ status: ExecutionStatus.ERROR }), + createTestExecution({ status: ExecutionStatus.SUCCESS }), + ]; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockApiClient.listExecutions.mockResolvedValue({ + data: testExecutions, + nextCursor: null, + }); + + const result = await handleGetWorkflowDetails({ id: 'test-workflow-id' }); + + expect(result).toEqual({ + success: true, + data: { + workflow: testWorkflow, + executionStats: { + totalExecutions: 3, + successCount: 2, + errorCount: 1, + lastExecutionTime: '2024-01-01T00:00:00Z', + }, + hasWebhookTrigger: false, + webhookPath: null, + }, + }); + }); + + it('should handle workflow with webhook trigger', async () => { + const testWorkflow = createTestWorkflow({ + nodes: [ + { + id: 'webhook1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 1, + position: [100, 100], + parameters: { path: 'test-webhook' }, + }, + ], + }); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockApiClient.listExecutions.mockResolvedValue({ data: [], nextCursor: null }); + vi.mocked(n8nValidation.hasWebhookTrigger).mockReturnValue(true); + vi.mocked(n8nValidation.getWebhookUrl).mockReturnValue('/webhook/test-webhook'); + + const result = await handleGetWorkflowDetails({ id: 'test-workflow-id' }); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('hasWebhookTrigger', true); + expect(result.data).toHaveProperty('webhookPath', '/webhook/test-webhook'); + }); + }); + + describe('handleGetWorkflowStructure', () => { + it('should return simplified workflow structure', async () => { + const testWorkflow = createTestWorkflow({ + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: { complex: 'data' }, + disabled: false, + }, + { + id: 'node2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [300, 100], + parameters: { url: 'https://api.test.com' }, + disabled: true, + }, + ], + connections: { + node1: { + main: [[{ node: 'node2', type: 'main', index: 0 }]], + }, + }, + }); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + + const result = await handleGetWorkflowStructure({ id: 'test-workflow-id' }); + + expect(result).toEqual({ + success: true, + data: { + id: 'test-workflow-id', + name: 'Test Workflow', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [100, 100], + disabled: false, + }, + { + id: 'node2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + position: [300, 100], + disabled: true, + }, + ], + connections: testWorkflow.connections, + nodeCount: 2, + connectionCount: 1, + }, + }); + }); + }); + + describe('handleGetWorkflowMinimal', () => { + it('should return minimal workflow info', async () => { + const testWorkflow = createTestWorkflow({ + tags: ['automation', 'test'], + }); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + + const result = await handleGetWorkflowMinimal({ id: 'test-workflow-id' }); + + expect(result).toEqual({ + success: true, + data: { + id: 'test-workflow-id', + name: 'Test Workflow', + active: true, + tags: ['automation', 'test'], + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + }); + }); + }); + + describe('handleUpdateWorkflow', () => { + it('should update workflow successfully', async () => { + const existingWorkflow = createTestWorkflow(); + const updatedWorkflow = { ...existingWorkflow, name: 'Updated Workflow' }; + + mockApiClient.getWorkflow.mockResolvedValue(existingWorkflow); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + + const result = await handleUpdateWorkflow({ + id: 'test-workflow-id', + name: 'Updated Workflow', + }); + + expect(result).toEqual({ + success: true, + data: updatedWorkflow, + message: 'Workflow "Updated Workflow" updated successfully', + }); + expect(mockApiClient.updateWorkflow).toHaveBeenCalledWith('test-workflow-id', { + name: 'Updated Workflow', + }); + }); + + it('should validate structure when updating nodes/connections', async () => { + const existingWorkflow = createTestWorkflow(); + const newNodes = [ + { + id: 'node1', + name: 'New Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + ]; + + mockApiClient.getWorkflow.mockResolvedValue(existingWorkflow); + mockApiClient.updateWorkflow.mockResolvedValue({ + ...existingWorkflow, + nodes: newNodes, + }); + + const result = await handleUpdateWorkflow({ + id: 'test-workflow-id', + nodes: newNodes, + connections: {}, + }); + + expect(result.success).toBe(true); + expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith({ + nodes: newNodes, + connections: {}, + }); + }); + + it('should handle partial updates with fetching current workflow', async () => { + const existingWorkflow = createTestWorkflow(); + const newNodes = [{ id: 'new-node', name: 'New Node' }]; + + mockApiClient.getWorkflow.mockResolvedValue(existingWorkflow); + mockApiClient.updateWorkflow.mockResolvedValue({ + ...existingWorkflow, + nodes: newNodes, + }); + + const result = await handleUpdateWorkflow({ + id: 'test-workflow-id', + nodes: newNodes, + }); + + expect(result.success).toBe(true); + expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('test-workflow-id'); + expect(n8nValidation.validateWorkflowStructure).toHaveBeenCalledWith({ + ...existingWorkflow, + nodes: newNodes, + }); + }); + + it('should handle validation failures', async () => { + vi.mocked(n8nValidation.validateWorkflowStructure).mockReturnValue([ + 'Invalid node configuration', + ]); + + const result = await handleUpdateWorkflow({ + id: 'test-workflow-id', + nodes: [], + connections: {}, + }); + + expect(result).toEqual({ + success: false, + error: 'Workflow validation failed', + details: { errors: ['Invalid node configuration'] }, + }); + }); + }); + + describe('handleDeleteWorkflow', () => { + it('should delete workflow successfully', async () => { + mockApiClient.deleteWorkflow.mockResolvedValue(undefined); + + const result = await handleDeleteWorkflow({ id: 'test-workflow-id' }); + + expect(result).toEqual({ + success: true, + message: 'Workflow test-workflow-id deleted successfully', + }); + expect(mockApiClient.deleteWorkflow).toHaveBeenCalledWith('test-workflow-id'); + }); + + it('should handle not found error', async () => { + const notFoundError = new N8nNotFoundError('Workflow not found'); + mockApiClient.deleteWorkflow.mockRejectedValue(notFoundError); + + const result = await handleDeleteWorkflow({ id: 'non-existent' }); + + expect(result).toEqual({ + success: false, + error: 'Workflow not found', + code: 'NOT_FOUND', + }); + }); + }); + + describe('handleListWorkflows', () => { + it('should list workflows with minimal data', async () => { + const workflows = [ + createTestWorkflow({ id: 'wf1', name: 'Workflow 1', nodes: [{}, {}] }), + createTestWorkflow({ id: 'wf2', name: 'Workflow 2', active: false, nodes: [{}, {}, {}] }), + ]; + + mockApiClient.listWorkflows.mockResolvedValue({ + data: workflows, + nextCursor: 'next-page-cursor', + }); + + const result = await handleListWorkflows({ + limit: 50, + active: true, + }); + + expect(result).toEqual({ + success: true, + data: { + workflows: [ + { + id: 'wf1', + name: 'Workflow 1', + active: true, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + tags: [], + nodeCount: 2, + }, + { + id: 'wf2', + name: 'Workflow 2', + active: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + tags: [], + nodeCount: 3, + }, + ], + returned: 2, + nextCursor: 'next-page-cursor', + hasMore: true, + _note: 'More workflows available. Use cursor to get next page.', + }, + }); + }); + + it('should handle empty workflow list', async () => { + mockApiClient.listWorkflows.mockResolvedValue({ + data: [], + nextCursor: null, + }); + + const result = await handleListWorkflows({}); + + expect(result.success).toBe(true); + expect(result.data.workflows).toHaveLength(0); + expect(result.data.hasMore).toBe(false); + expect(result.data._note).toBeUndefined(); + }); + + it('should use default values for optional parameters', async () => { + mockApiClient.listWorkflows.mockResolvedValue({ + data: [], + nextCursor: null, + }); + + await handleListWorkflows({}); + + expect(mockApiClient.listWorkflows).toHaveBeenCalledWith({ + limit: 100, + cursor: undefined, + active: undefined, + tags: undefined, + projectId: undefined, + excludePinnedData: true, + }); + }); + }); + + describe('handleValidateWorkflow', () => { + it('should validate workflow from n8n instance', async () => { + const testWorkflow = createTestWorkflow(); + const mockNodeRepository = new NodeRepository(':memory:'); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockValidator.validateWorkflow.mockResolvedValue({ + valid: true, + errors: [], + warnings: [ + { + nodeName: 'node1', + message: 'Consider using newer version', + details: { currentVersion: 1, latestVersion: 2 }, + }, + ], + suggestions: ['Add error handling to workflow'], + statistics: { + totalNodes: 1, + enabledNodes: 1, + triggerNodes: 1, + validConnections: 0, + invalidConnections: 0, + expressionsValidated: 0, + }, + }); + + const result = await handleValidateWorkflow( + { id: 'test-workflow-id', options: { validateNodes: true } }, + mockNodeRepository + ); + + expect(result).toEqual({ + success: true, + data: { + valid: true, + workflowId: 'test-workflow-id', + workflowName: 'Test Workflow', + summary: { + totalNodes: 1, + enabledNodes: 1, + triggerNodes: 1, + validConnections: 0, + invalidConnections: 0, + expressionsValidated: 0, + errorCount: 0, + warningCount: 1, + }, + warnings: [ + { + node: 'node1', + message: 'Consider using newer version', + details: { currentVersion: 1, latestVersion: 2 }, + }, + ], + suggestions: ['Add error handling to workflow'], + }, + }); + }); + + it('should handle workflow fetch errors', async () => { + const notFoundError = new N8nNotFoundError('Workflow not found'); + mockApiClient.getWorkflow.mockRejectedValue(notFoundError); + + const result = await handleValidateWorkflow( + { id: 'non-existent' }, + new NodeRepository(':memory:') + ); + + expect(result).toEqual({ + success: false, + error: 'Workflow not found', + code: 'NOT_FOUND', + }); + }); + + it('should handle validation with errors', async () => { + const testWorkflow = createTestWorkflow(); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockValidator.validateWorkflow.mockResolvedValue({ + valid: false, + errors: [ + { + nodeName: 'node1', + message: 'Invalid node configuration', + details: { field: 'parameters.url' }, + }, + ], + warnings: [], + suggestions: [], + statistics: { + totalNodes: 1, + enabledNodes: 1, + triggerNodes: 0, + validConnections: 0, + invalidConnections: 0, + expressionsValidated: 0, + }, + }); + + const result = await handleValidateWorkflow( + { id: 'test-workflow-id' }, + new NodeRepository(':memory:') + ); + + expect(result.success).toBe(true); + expect(result.data.valid).toBe(false); + expect(result.data.errors).toHaveLength(1); + }); + }); + + describe('handleTriggerWebhookWorkflow', () => { + it('should trigger webhook successfully', async () => { + const webhookResponse = { executionId: 'exec-123', status: 'success' }; + mockApiClient.triggerWebhook.mockResolvedValue(webhookResponse); + + const result = await handleTriggerWebhookWorkflow({ + webhookUrl: 'https://n8n.test.com/webhook/test-webhook', + httpMethod: 'POST', + data: { test: 'data' }, + }); + + expect(result).toEqual({ + success: true, + data: webhookResponse, + message: 'Webhook triggered successfully', + }); + expect(mockApiClient.triggerWebhook).toHaveBeenCalledWith({ + webhookUrl: 'https://n8n.test.com/webhook/test-webhook', + httpMethod: 'POST', + data: { test: 'data' }, + headers: undefined, + waitForResponse: true, + }); + }); + + it('should use default values', async () => { + mockApiClient.triggerWebhook.mockResolvedValue({}); + + await handleTriggerWebhookWorkflow({ + webhookUrl: 'https://n8n.test.com/webhook/test', + }); + + expect(mockApiClient.triggerWebhook).toHaveBeenCalledWith({ + webhookUrl: 'https://n8n.test.com/webhook/test', + httpMethod: 'POST', + data: undefined, + headers: undefined, + waitForResponse: true, + }); + }); + + it('should handle invalid URL', async () => { + const result = await handleTriggerWebhookWorkflow({ + webhookUrl: 'not-a-valid-url', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + }); + }); + + describe('handleGetExecution', () => { + it('should get execution successfully', async () => { + const testExecution = createTestExecution(); + mockApiClient.getExecution.mockResolvedValue(testExecution); + + const result = await handleGetExecution({ + id: 'exec-123', + includeData: true, + }); + + expect(result).toEqual({ + success: true, + data: testExecution, + }); + expect(mockApiClient.getExecution).toHaveBeenCalledWith('exec-123', true); + }); + + it('should default includeData to false', async () => { + mockApiClient.getExecution.mockResolvedValue({}); + + await handleGetExecution({ id: 'exec-123' }); + + expect(mockApiClient.getExecution).toHaveBeenCalledWith('exec-123', false); + }); + }); + + describe('handleListExecutions', () => { + it('should list executions with filters', async () => { + const executions = [ + createTestExecution({ id: 'exec-1' }), + createTestExecution({ id: 'exec-2', status: ExecutionStatus.ERROR }), + ]; + + mockApiClient.listExecutions.mockResolvedValue({ + data: executions, + nextCursor: null, + }); + + const result = await handleListExecutions({ + workflowId: 'test-workflow-id', + status: 'success', + limit: 50, + }); + + expect(result).toEqual({ + success: true, + data: { + executions, + returned: 2, + nextCursor: null, + hasMore: false, + }, + }); + expect(mockApiClient.listExecutions).toHaveBeenCalledWith({ + limit: 50, + cursor: undefined, + workflowId: 'test-workflow-id', + projectId: undefined, + status: ExecutionStatus.SUCCESS, + includeData: false, + }); + }); + + it('should handle pagination', async () => { + mockApiClient.listExecutions.mockResolvedValue({ + data: [createTestExecution()], + nextCursor: 'next-page', + }); + + const result = await handleListExecutions({}); + + expect(result.data.hasMore).toBe(true); + expect(result.data._note).toBe('More executions available. Use cursor to get next page.'); + }); + }); + + describe('handleDeleteExecution', () => { + it('should delete execution successfully', async () => { + mockApiClient.deleteExecution.mockResolvedValue(undefined); + + const result = await handleDeleteExecution({ id: 'exec-123' }); + + expect(result).toEqual({ + success: true, + message: 'Execution exec-123 deleted successfully', + }); + expect(mockApiClient.deleteExecution).toHaveBeenCalledWith('exec-123'); + }); + }); + + describe('handleHealthCheck', () => { + it('should check health successfully', async () => { + const healthData = { + status: 'ok', + instanceId: 'n8n-instance-123', + n8nVersion: '1.0.0', + features: ['webhooks', 'api'], + }; + + mockApiClient.healthCheck.mockResolvedValue(healthData); + + const result = await handleHealthCheck(); + + expect(result.success).toBe(true); + expect(result.data).toMatchObject({ + status: 'ok', + instanceId: 'n8n-instance-123', + n8nVersion: '1.0.0', + features: ['webhooks', 'api'], + apiUrl: 'https://n8n.test.com', + }); + }); + + it('should handle API errors', async () => { + const apiError = new N8nServerError('Service unavailable'); + mockApiClient.healthCheck.mockRejectedValue(apiError); + + const result = await handleHealthCheck(); + + expect(result).toEqual({ + success: false, + error: 'Service unavailable', + code: 'SERVER_ERROR', + details: { + apiUrl: 'https://n8n.test.com', + hint: 'Check if n8n is running and API is enabled', + }, + }); + }); + }); + + describe('handleListAvailableTools', () => { + it('should list all available tools when API is configured', async () => { + const result = await handleListAvailableTools(); + + expect(result.success).toBe(true); + expect(result.data.apiConfigured).toBe(true); + expect(result.data.tools).toHaveLength(3); // 3 categories + expect(result.data.configuration).toEqual({ + apiUrl: 'https://n8n.test.com', + timeout: 30000, + maxRetries: 3, + }); + }); + + it('should indicate when API is not configured', async () => { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + + const result = await handleListAvailableTools(); + + expect(result.success).toBe(true); + expect(result.data.apiConfigured).toBe(false); + expect(result.data.configuration).toBeNull(); + }); + }); + + describe('handleDiagnostic', () => { + it('should provide diagnostic information', async () => { + const healthData = { + status: 'ok', + n8nVersion: '1.0.0', + }; + mockApiClient.healthCheck.mockResolvedValue(healthData); + + const result = await handlers.handleDiagnostic({ params: { arguments: {} } }); + + expect(result.success).toBe(true); + expect(result.data).toMatchObject({ + environment: { + N8N_API_URL: 'https://n8n.test.com', + N8N_API_KEY: '***configured***', + }, + apiConfiguration: { + configured: true, + status: { + configured: true, + connected: true, + version: '1.0.0', + }, + }, + toolsAvailability: { + documentationTools: { + count: 22, + enabled: true, + }, + managementTools: { + count: 16, + enabled: true, + }, + totalAvailable: 38, + }, + }); + }); + + it('should handle verbose mode', async () => { + mockApiClient.healthCheck.mockResolvedValue({ status: 'ok' }); + + const result = await handleDiagnostic({ + params: { arguments: { verbose: true } }, + }); + + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('debug'); + expect(result.data.debug).toHaveProperty('nodeVersion'); + expect(result.data.debug).toHaveProperty('platform'); + }); + + it('should show troubleshooting steps when API is not configured', async () => { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + + const result = await handlers.handleDiagnostic({ params: { arguments: {} } }); + + expect(result.success).toBe(true); + expect(result.data.apiConfiguration.configured).toBe(false); + expect(result.data.toolsAvailability.managementTools.enabled).toBe(false); + expect(result.data.troubleshooting.steps[0]).toContain('To enable management tools'); + }); + + it('should handle API connectivity errors', async () => { + const error = new Error('Connection refused'); + mockApiClient.healthCheck.mockRejectedValue(error); + + const result = await handlers.handleDiagnostic({ params: { arguments: {} } }); + + expect(result.success).toBe(true); + expect(result.data.apiConfiguration.status.connected).toBe(false); + expect(result.data.apiConfiguration.status.error).toBe('Connection refused'); + }); + }); + + describe('Error handling', () => { + it('should handle authentication errors', async () => { + const authError = new N8nAuthenticationError('Invalid API key'); + mockApiClient.getWorkflow.mockRejectedValue(authError); + + const result = await handleGetWorkflow({ id: 'test-id' }); + + expect(result).toEqual({ + success: false, + error: 'Invalid API key', + code: 'AUTHENTICATION_ERROR', + }); + }); + + it('should handle rate limit errors', async () => { + const rateLimitError = new N8nRateLimitError('Too many requests', 60); + mockApiClient.listWorkflows.mockRejectedValue(rateLimitError); + + const result = await handleListWorkflows({}); + + expect(result).toEqual({ + success: false, + error: 'Too many requests', + code: 'RATE_LIMIT_ERROR', + }); + }); + + it('should handle generic errors', async () => { + const genericError = new Error('Something went wrong'); + mockApiClient.createWorkflow.mockRejectedValue(genericError); + + const result = await handleCreateWorkflow({ + name: 'Test', + nodes: [], + connections: {}, + }); + + expect(result).toEqual({ + success: false, + error: 'Something went wrong', + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/mcp/handlers-workflow-diff.test.ts b/tests/unit/mcp/handlers-workflow-diff.test.ts new file mode 100644 index 0000000..fe977be --- /dev/null +++ b/tests/unit/mcp/handlers-workflow-diff.test.ts @@ -0,0 +1,590 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { handleUpdatePartialWorkflow } from '@/mcp/handlers-workflow-diff'; +import { WorkflowDiffEngine } from '@/services/workflow-diff-engine'; +import { N8nApiClient } from '@/services/n8n-api-client'; +import { + N8nApiError, + N8nAuthenticationError, + N8nNotFoundError, + N8nValidationError, + N8nRateLimitError, + N8nServerError, +} from '@/utils/n8n-errors'; +import { z } from 'zod'; + +// Mock dependencies +vi.mock('@/services/workflow-diff-engine'); +vi.mock('@/services/n8n-api-client'); +vi.mock('@/config/n8n-api'); +vi.mock('@/utils/logger'); +vi.mock('@/mcp/handlers-n8n-manager', () => ({ + getN8nApiClient: vi.fn(), +})); + +// Import mocked modules +import { getN8nApiClient } from '@/mcp/handlers-n8n-manager'; +import { logger } from '@/utils/logger'; + +describe('handlers-workflow-diff', () => { + let mockApiClient: any; + let mockDiffEngine: any; + + // Helper function to create test workflow + const createTestWorkflow = (overrides = {}) => ({ + id: 'test-workflow-id', + name: 'Test Workflow', + active: true, + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [100, 100], + parameters: {}, + }, + { + id: 'node2', + name: 'HTTP Request', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 3, + position: [300, 100], + parameters: { url: 'https://api.test.com' }, + }, + ], + connections: { + node1: { + main: [[{ node: 'node2', type: 'main', index: 0 }]], + }, + }, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + tags: [], + settings: {}, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Setup mock API client + mockApiClient = { + getWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + }; + + // Setup mock diff engine + mockDiffEngine = { + applyDiff: vi.fn(), + }; + + // Mock the API client getter + vi.mocked(getN8nApiClient).mockReturnValue(mockApiClient); + + // Mock WorkflowDiffEngine constructor + vi.mocked(WorkflowDiffEngine).mockImplementation(() => mockDiffEngine); + + // Set up default environment + process.env.DEBUG_MCP = 'false'; + }); + + describe('handleUpdatePartialWorkflow', () => { + it('should apply diff operations successfully', async () => { + const testWorkflow = createTestWorkflow(); + const updatedWorkflow = { + ...testWorkflow, + nodes: [ + ...testWorkflow.nodes, + { + id: 'node3', + name: 'New Node', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + ], + }; + + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'addNode', + node: { + id: 'node3', + name: 'New Node', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: updatedWorkflow, + operationsApplied: 1, + message: 'Successfully applied 1 operation', + errors: [], + }); + mockApiClient.updateWorkflow.mockResolvedValue(updatedWorkflow); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result).toEqual({ + success: true, + data: updatedWorkflow, + message: 'Workflow "Test Workflow" updated successfully. Applied 1 operations.', + details: { + operationsApplied: 1, + workflowId: 'test-workflow-id', + workflowName: 'Test Workflow', + }, + }); + + expect(mockApiClient.getWorkflow).toHaveBeenCalledWith('test-workflow-id'); + expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); + expect(mockApiClient.updateWorkflow).toHaveBeenCalledWith('test-workflow-id', updatedWorkflow); + }); + + it('should handle validation-only mode', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'updateNode', + nodeId: 'node2', + changes: { name: 'Updated HTTP Request' }, + }, + ], + validateOnly: true, + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: testWorkflow, + operationsApplied: 1, + message: 'Validation successful', + errors: [], + }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result).toEqual({ + success: true, + message: 'Validation successful', + data: { + valid: true, + operationsToApply: 1, + }, + }); + + expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); + }); + + it('should handle multiple operations', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'updateNode', + nodeId: 'node1', + changes: { name: 'Updated Start' }, + }, + { + type: 'addNode', + node: { + id: 'node3', + name: 'Set Node', + type: 'n8n-nodes-base.set', + typeVersion: 1, + position: [500, 100], + parameters: {}, + }, + }, + { + type: 'addConnection', + source: 'node2', + target: 'node3', + sourceOutput: 'main', + targetInput: 'main', + }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: { ...testWorkflow, nodes: [...testWorkflow.nodes, {}] }, + operationsApplied: 3, + message: 'Successfully applied 3 operations', + errors: [], + }); + mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result.success).toBe(true); + expect(result.message).toContain('Applied 3 operations'); + }); + + it('should handle diff application failures', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'updateNode', + nodeId: 'non-existent-node', + changes: { name: 'Updated' }, + }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: false, + workflow: null, + operationsApplied: 0, + message: 'Failed to apply operations', + errors: ['Node "non-existent-node" not found'], + }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result).toEqual({ + success: false, + error: 'Failed to apply diff operations', + details: { + errors: ['Node "non-existent-node" not found'], + operationsApplied: 0, + }, + }); + + expect(mockApiClient.updateWorkflow).not.toHaveBeenCalled(); + }); + + it('should handle API not configured error', async () => { + vi.mocked(getN8nApiClient).mockReturnValue(null); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', + }); + }); + + it('should handle workflow not found error', async () => { + const notFoundError = new N8nNotFoundError('Workflow not found'); + mockApiClient.getWorkflow.mockRejectedValue(notFoundError); + + const result = await handleUpdatePartialWorkflow({ + id: 'non-existent', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'Workflow not found', + code: 'NOT_FOUND', + }); + }); + + it('should handle API errors during update', async () => { + const testWorkflow = createTestWorkflow(); + const validationError = new N8nValidationError('Invalid workflow structure', { + field: 'connections', + message: 'Invalid connection configuration', + }); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: testWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + }); + mockApiClient.updateWorkflow.mockRejectedValue(validationError); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [{ type: 'updateNode', nodeId: 'node1', changes: {} }], + }); + + expect(result).toEqual({ + success: false, + error: 'Invalid workflow structure', + code: 'VALIDATION_ERROR', + details: { + field: 'connections', + message: 'Invalid connection configuration', + }, + }); + }); + + it('should handle input validation errors', async () => { + const invalidInput = { + id: 'test-id', + operations: [ + { + // Missing required 'type' field + nodeId: 'node1', + changes: {}, + }, + ], + }; + + const result = await handleUpdatePartialWorkflow(invalidInput); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + expect(result.details.errors).toBeInstanceOf(Array); + }); + + it('should handle complex operation types', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'moveNode', + nodeId: 'node2', + position: [400, 200], + }, + { + type: 'removeConnection', + source: 'node1', + target: 'node2', + sourceOutput: 'main', + targetInput: 'main', + }, + { + type: 'updateSettings', + settings: { + executionOrder: 'v1', + timezone: 'America/New_York', + }, + }, + { + type: 'addTag', + tag: 'automated', + }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: { ...testWorkflow, settings: { executionOrder: 'v1' } }, + operationsApplied: 4, + message: 'Successfully applied 4 operations', + errors: [], + }); + mockApiClient.updateWorkflow.mockResolvedValue({ ...testWorkflow }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result.success).toBe(true); + expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); + }); + + it('should handle debug logging when enabled', async () => { + process.env.DEBUG_MCP = 'true'; + const testWorkflow = createTestWorkflow(); + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: testWorkflow, + operationsApplied: 1, + message: 'Success', + errors: [], + }); + mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); + + await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [{ type: 'updateNode', nodeId: 'node1', changes: {} }], + }); + + expect(logger.debug).toHaveBeenCalledWith( + 'Workflow diff request received', + expect.objectContaining({ + argsType: 'object', + operationCount: 1, + }) + ); + }); + + it('should handle generic errors', async () => { + const genericError = new Error('Something went wrong'); + mockApiClient.getWorkflow.mockRejectedValue(genericError); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'Something went wrong', + }); + expect(logger.error).toHaveBeenCalledWith('Failed to update partial workflow', genericError); + }); + + it('should handle authentication errors', async () => { + const authError = new N8nAuthenticationError('Invalid API key'); + mockApiClient.getWorkflow.mockRejectedValue(authError); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'Invalid API key', + code: 'AUTHENTICATION_ERROR', + }); + }); + + it('should handle rate limit errors', async () => { + const rateLimitError = new N8nRateLimitError('Too many requests', 60); + mockApiClient.getWorkflow.mockRejectedValue(rateLimitError); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'Too many requests', + code: 'RATE_LIMIT_ERROR', + }); + }); + + it('should handle server errors', async () => { + const serverError = new N8nServerError('Internal server error'); + mockApiClient.getWorkflow.mockRejectedValue(serverError); + + const result = await handleUpdatePartialWorkflow({ + id: 'test-id', + operations: [], + }); + + expect(result).toEqual({ + success: false, + error: 'Internal server error', + code: 'SERVER_ERROR', + }); + }); + + it('should validate operation structure', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { + type: 'updateNode', + nodeId: 'node1', + nodeName: 'Start', // Both nodeId and nodeName provided + changes: { name: 'New Start' }, + description: 'Update start node name', + }, + { + type: 'addConnection', + source: 'node1', + target: 'node2', + sourceOutput: 'main', + targetInput: 'main', + sourceIndex: 0, + targetIndex: 0, + }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: testWorkflow, + operationsApplied: 2, + message: 'Success', + errors: [], + }); + mockApiClient.updateWorkflow.mockResolvedValue(testWorkflow); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result.success).toBe(true); + expect(mockDiffEngine.applyDiff).toHaveBeenCalledWith(testWorkflow, diffRequest); + }); + + it('should handle empty operations array', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: true, + workflow: testWorkflow, + operationsApplied: 0, + message: 'No operations to apply', + errors: [], + }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result.success).toBe(true); + expect(result.message).toContain('Applied 0 operations'); + }); + + it('should handle partial diff application', async () => { + const testWorkflow = createTestWorkflow(); + const diffRequest = { + id: 'test-workflow-id', + operations: [ + { type: 'updateNode', nodeId: 'node1', changes: { name: 'Updated' } }, + { type: 'updateNode', nodeId: 'invalid-node', changes: { name: 'Fail' } }, + { type: 'addTag', tag: 'test' }, + ], + }; + + mockApiClient.getWorkflow.mockResolvedValue(testWorkflow); + mockDiffEngine.applyDiff.mockResolvedValue({ + success: false, + workflow: null, + operationsApplied: 1, + message: 'Partially applied operations', + errors: ['Operation 2 failed: Node "invalid-node" not found'], + }); + + const result = await handleUpdatePartialWorkflow(diffRequest); + + expect(result).toEqual({ + success: false, + error: 'Failed to apply diff operations', + details: { + errors: ['Operation 2 failed: Node "invalid-node" not found'], + operationsApplied: 1, + }, + }); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/enhanced-config-validator.test.ts b/tests/unit/services/enhanced-config-validator.test.ts index 162ae2a..a01c77c 100644 --- a/tests/unit/services/enhanced-config-validator.test.ts +++ b/tests/unit/services/enhanced-config-validator.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { EnhancedConfigValidator, ValidationMode, ValidationProfile } from '@/services/enhanced-config-validator'; +import { ValidationError } from '@/services/config-validator'; import { NodeSpecificValidators } from '@/services/node-specific-validators'; import { nodeFactory } from '@tests/fixtures/factories/node.factory'; @@ -197,7 +198,7 @@ describe('EnhancedConfigValidator', () => { { type: 'invalid_type', property: 'channel', message: 'Different type error' } ]; - const deduplicated = EnhancedConfigValidator['deduplicateErrors'](errors); + const deduplicated = EnhancedConfigValidator['deduplicateErrors'](errors as ValidationError[]); expect(deduplicated).toHaveLength(2); // Should keep the longer message @@ -210,7 +211,7 @@ describe('EnhancedConfigValidator', () => { { type: 'missing_required', property: 'url', message: 'URL is required', fix: 'Add a valid URL like https://api.example.com' } ]; - const deduplicated = EnhancedConfigValidator['deduplicateErrors'](errors); + const deduplicated = EnhancedConfigValidator['deduplicateErrors'](errors as ValidationError[]); expect(deduplicated).toHaveLength(1); expect(deduplicated[0].fix).toBeDefined(); @@ -575,7 +576,7 @@ describe('EnhancedConfigValidator', () => { const mockValidateMongoDB = vi.mocked(NodeSpecificValidators.validateMongoDB); const config = { collection: 'users', operation: 'insert' }; - const properties = []; + const properties: any[] = []; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.mongoDb', @@ -593,7 +594,7 @@ describe('EnhancedConfigValidator', () => { const mockValidateMySQL = vi.mocked(NodeSpecificValidators.validateMySQL); const config = { table: 'users', operation: 'insert' }; - const properties = []; + const properties: any[] = []; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.mysql', @@ -609,7 +610,7 @@ describe('EnhancedConfigValidator', () => { const mockValidatePostgres = vi.mocked(NodeSpecificValidators.validatePostgres); const config = { table: 'users', operation: 'select' }; - const properties = []; + const properties: any[] = []; const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.postgres', @@ -666,7 +667,7 @@ describe('EnhancedConfigValidator', () => { // Mock isPropertyVisible to return false for hidden property const isVisibleSpy = vi.spyOn(EnhancedConfigValidator as any, 'isPropertyVisible'); - isVisibleSpy.mockImplementation((prop) => prop.name !== 'hidden'); + isVisibleSpy.mockImplementation((prop: any) => prop.name !== 'hidden'); const result = EnhancedConfigValidator.validateWithMode( 'nodes-base.test', diff --git a/tests/unit/services/n8n-api-client.test.ts b/tests/unit/services/n8n-api-client.test.ts index 99a8e1b..d112086 100644 --- a/tests/unit/services/n8n-api-client.test.ts +++ b/tests/unit/services/n8n-api-client.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import axios from 'axios'; import { N8nApiClient, N8nApiClientConfig } from '../../../src/services/n8n-api-client'; +import { ExecutionStatus } from '../../../src/types/n8n-api'; import { N8nApiError, N8nAuthenticationError, @@ -242,8 +243,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nValidationError); - expect(err.message).toBe('Invalid workflow'); - expect(err.statusCode).toBe(400); + expect((err as N8nValidationError).message).toBe('Invalid workflow'); + expect((err as N8nValidationError).statusCode).toBe(400); } }); }); @@ -275,8 +276,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect(err.message).toContain('not found'); - expect(err.statusCode).toBe(404); + expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).statusCode).toBe(404); } }); }); @@ -327,8 +328,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nValidationError); - expect(err.message).toBe('Invalid update'); - expect(err.statusCode).toBe(400); + expect((err as N8nValidationError).message).toBe('Invalid update'); + expect((err as N8nValidationError).statusCode).toBe(400); } }); }); @@ -358,8 +359,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect(err.message).toContain('not found'); - expect(err.statusCode).toBe(404); + expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).statusCode).toBe(404); } }); }); @@ -427,7 +428,7 @@ describe('N8nApiClient', () => { }); it('should list executions with filters', async () => { - const params = { workflowId: '123', status: 'success', limit: 50 }; + const params = { workflowId: '123', status: ExecutionStatus.SUCCESS, limit: 50 }; const response = { data: [], nextCursor: null }; mockAxiosInstance.get.mockResolvedValue({ data: response }); @@ -560,8 +561,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nAuthenticationError); - expect(err.message).toBe('Invalid API key'); - expect(err.statusCode).toBe(401); + expect((err as N8nAuthenticationError).message).toBe('Invalid API key'); + expect((err as N8nAuthenticationError).statusCode).toBe(401); } }); @@ -581,9 +582,9 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nRateLimitError); - expect(err.message).toContain('Rate limit exceeded'); - expect(err.statusCode).toBe(429); - expect(err.details?.retryAfter).toBe(60); + expect((err as N8nRateLimitError).message).toContain('Rate limit exceeded'); + expect((err as N8nRateLimitError).statusCode).toBe(429); + expect(((err as N8nRateLimitError).details as any)?.retryAfter).toBe(60); } }); @@ -602,8 +603,8 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nServerError); - expect(err.message).toBe('Internal server error'); - expect(err.statusCode).toBe(500); + expect((err as N8nServerError).message).toBe('Internal server error'); + expect((err as N8nServerError).statusCode).toBe(500); } }); @@ -827,12 +828,12 @@ describe('N8nApiClient', () => { beforeEach(() => { // Capture the interceptor functions - vi.mocked(mockAxiosInstance.interceptors.request.use).mockImplementation((onFulfilled) => { + vi.mocked(mockAxiosInstance.interceptors.request.use).mockImplementation((onFulfilled: any) => { requestInterceptor = onFulfilled; return 0; }); - vi.mocked(mockAxiosInstance.interceptors.response.use).mockImplementation((onFulfilled, onRejected) => { + vi.mocked(mockAxiosInstance.interceptors.response.use).mockImplementation((onFulfilled: any, onRejected: any) => { responseInterceptor = onFulfilled; responseErrorInterceptor = onRejected; return 0; @@ -882,7 +883,7 @@ describe('N8nApiClient', () => { }, }); - const result = await responseErrorInterceptor(error).catch(e => e); + const result = await responseErrorInterceptor(error).catch((e: any) => e); expect(result).toBeInstanceOf(N8nValidationError); expect(result.message).toBe('Bad request'); }); diff --git a/tests/unit/services/n8n-validation.test.ts b/tests/unit/services/n8n-validation.test.ts index 2592def..7805585 100644 --- a/tests/unit/services/n8n-validation.test.ts +++ b/tests/unit/services/n8n-validation.test.ts @@ -262,7 +262,7 @@ describe('n8n-validation', () => { tags: ['tag1'], }; - const cleaned = cleanWorkflowForCreate(workflow); + const cleaned = cleanWorkflowForCreate(workflow as any); expect(cleaned).not.toHaveProperty('id'); expect(cleaned).not.toHaveProperty('createdAt'); @@ -281,7 +281,7 @@ describe('n8n-validation', () => { connections: {}, }; - const cleaned = cleanWorkflowForCreate(workflow); + const cleaned = cleanWorkflowForCreate(workflow as Workflow); expect(cleaned.settings).toEqual(defaultWorkflowSettings); }); @@ -298,7 +298,7 @@ describe('n8n-validation', () => { settings: customSettings, }; - const cleaned = cleanWorkflowForCreate(workflow); + const cleaned = cleanWorkflowForCreate(workflow as Workflow); expect(cleaned.settings).toEqual(customSettings); }); }); @@ -370,7 +370,7 @@ describe('n8n-validation', () => { .connect('Webhook', 'Send Slack') .build(); - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); }); @@ -380,7 +380,7 @@ describe('n8n-validation', () => { connections: {}, }; - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow name is required'); }); @@ -390,7 +390,7 @@ describe('n8n-validation', () => { connections: {}, }; - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow must have at least one node'); }); @@ -401,17 +401,17 @@ describe('n8n-validation', () => { connections: {}, }; - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow must have at least one node'); }); it('should detect missing connections', () => { const workflow = { name: 'Test', - nodes: [{ id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 1, position: [0, 0], parameters: {} }], + nodes: [{ id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 1, position: [0, 0] as [number, number], parameters: {} }], }; - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow connections are required'); }); @@ -429,7 +429,7 @@ describe('n8n-validation', () => { connections: {}, }; - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); }); @@ -1155,7 +1155,7 @@ describe('n8n-validation', () => { }) .build(); - const errors = validateWorkflowStructure(workflow); + const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); // Validate individual components diff --git a/tests/unit/services/workflow-diff-engine.test.ts b/tests/unit/services/workflow-diff-engine.test.ts index 43afc0d..f63fa2d 100644 --- a/tests/unit/services/workflow-diff-engine.test.ts +++ b/tests/unit/services/workflow-diff-engine.test.ts @@ -41,13 +41,13 @@ describe('WorkflowDiffEngine', () => { // Convert connections from ID-based to name-based (as n8n expects) const newConnections: any = {}; for (const [nodeId, outputs] of Object.entries(baseWorkflow.connections)) { - const node = baseWorkflow.nodes.find(n => n.id === nodeId); + const node = baseWorkflow.nodes.find((n: any) => n.id === nodeId); if (node) { newConnections[node.name] = {}; for (const [outputName, connections] of Object.entries(outputs)) { - newConnections[node.name][outputName] = (connections as any[]).map(conns => - conns.map(conn => { - const targetNode = baseWorkflow.nodes.find(n => n.id === conn.node); + newConnections[node.name][outputName] = (connections as any[]).map((conns: any) => + conns.map((conn: any) => { + const targetNode = baseWorkflow.nodes.find((n: any) => n.id === conn.node); return { ...conn, node: targetNode ? targetNode.name : conn.node @@ -62,7 +62,7 @@ describe('WorkflowDiffEngine', () => { describe('Operation Limits', () => { it('should reject more than 5 operations', async () => { - const operations = Array(6).fill(null).map((_, i) => ({ + const operations = Array(6).fill(null).map((_: any, i: number) => ({ type: 'updateName', name: `Name ${i}` } as UpdateNameOperation)); @@ -213,7 +213,7 @@ describe('WorkflowDiffEngine', () => { expect(result.success).toBe(true); expect(result.workflow!.nodes).toHaveLength(2); - expect(result.workflow!.nodes.find(n => n.id === 'http-1')).toBeUndefined(); + expect(result.workflow!.nodes.find((n: any) => n.id === 'http-1')).toBeUndefined(); }); it('should remove node by name', async () => { @@ -231,7 +231,7 @@ describe('WorkflowDiffEngine', () => { expect(result.success).toBe(true); expect(result.workflow!.nodes).toHaveLength(2); - expect(result.workflow!.nodes.find(n => n.name === 'HTTP Request')).toBeUndefined(); + expect(result.workflow!.nodes.find((n: any) => n.name === 'HTTP Request')).toBeUndefined(); }); it('should clean up connections when removing node', async () => { @@ -295,7 +295,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const updatedNode = result.workflow!.nodes.find(n => n.id === 'http-1'); + const updatedNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); expect(updatedNode!.parameters.method).toBe('POST'); expect(updatedNode!.parameters.url).toBe('https://new-api.example.com'); }); @@ -319,7 +319,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const updatedNode = result.workflow!.nodes.find(n => n.name === 'Slack'); + const updatedNode = result.workflow!.nodes.find((n: any) => n.name === 'Slack'); expect(updatedNode!.parameters.resource).toBe('channel'); expect(updatedNode!.parameters.operation).toBe('create'); expect((updatedNode!.credentials as any).slackApi.name).toBe('New Slack Account'); @@ -362,7 +362,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const movedNode = result.workflow!.nodes.find(n => n.id === 'http-1'); + const movedNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); expect(movedNode!.position).toEqual([1000, 500]); }); @@ -381,7 +381,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const movedNode = result.workflow!.nodes.find(n => n.name === 'Webhook'); + const movedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook'); expect(movedNode!.position).toEqual([100, 100]); }); }); @@ -401,7 +401,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const disabledNode = result.workflow!.nodes.find(n => n.id === 'http-1'); + const disabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); expect(disabledNode!.disabled).toBe(true); }); @@ -422,7 +422,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const enabledNode = result.workflow!.nodes.find(n => n.id === 'http-1'); + const enabledNode = result.workflow!.nodes.find((n: any) => n.id === 'http-1'); expect(enabledNode!.disabled).toBe(false); }); }); @@ -1052,7 +1052,7 @@ describe('WorkflowDiffEngine', () => { const result = await diffEngine.applyDiff(baseWorkflow, request); expect(result.success).toBe(true); - const updatedNode = result.workflow!.nodes.find(n => n.name === 'Webhook'); + const updatedNode = result.workflow!.nodes.find((n: any) => n.name === 'Webhook'); expect(updatedNode!.parameters.path).toBe('new-webhook-path'); }); }); diff --git a/tests/unit/services/workflow-validator-comprehensive.test.ts b/tests/unit/services/workflow-validator-comprehensive.test.ts index fd33518..bc0fb1d 100644 --- a/tests/unit/services/workflow-validator-comprehensive.test.ts +++ b/tests/unit/services/workflow-validator-comprehensive.test.ts @@ -4,7 +4,7 @@ import { NodeRepository } from '@/database/node-repository'; import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; import { ExpressionValidator } from '@/services/expression-validator'; import { createWorkflow } from '@tests/utils/builders/workflow.builder'; -import type { WorkflowNode, WorkflowJson } from '@/services/workflow-validator'; +import type { WorkflowNode, Workflow } from '@/types/n8n-api'; // Mock dependencies vi.mock('@/database/node-repository'); @@ -21,7 +21,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => { vi.clearAllMocks(); // Create mock instances - mockNodeRepository = new NodeRepository() as any; + mockNodeRepository = new NodeRepository({} as any) as any; mockEnhancedConfigValidator = EnhancedConfigValidator as any; // Set up default mock behaviors @@ -131,15 +131,19 @@ describe('WorkflowValidator - Comprehensive Tests', () => { vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ errors: [], warnings: [], - suggestions: [] - }); + suggestions: [], + mode: 'operation' as const, + valid: true, + visibleProperties: [], + hiddenProperties: [] + } as any); vi.mocked(ExpressionValidator.validateNodeExpressions).mockReturnValue({ valid: true, errors: [], warnings: [], usedVariables: new Set(), - referencedNodes: new Set() + usedNodes: new Set() }); // Create validator instance @@ -637,10 +641,14 @@ describe('WorkflowValidator - Comprehensive Tests', () => { it('should add node validation errors and warnings', async () => { vi.mocked(mockEnhancedConfigValidator.validateWithMode).mockReturnValue({ - errors: ['Missing required field: url'], - warnings: ['Consider using HTTPS'], - suggestions: [] - }); + errors: [{ type: 'missing_required', property: 'url', message: 'Missing required field: url' }], + warnings: [{ type: 'security', property: 'url', message: 'Consider using HTTPS' }], + suggestions: [], + mode: 'operation' as const, + valid: false, + visibleProperties: [], + hiddenProperties: [] + } as any); const workflow = { nodes: [ @@ -658,8 +666,8 @@ describe('WorkflowValidator - Comprehensive Tests', () => { const result = await validator.validateWorkflow(workflow); - expect(result.errors.some(e => e.message === 'Missing required field: url')).toBe(true); - expect(result.warnings.some(w => w.message === 'Consider using HTTPS')).toBe(true); + expect(result.errors.some(e => e.message.includes('Missing required field: url'))).toBe(true); + expect(result.warnings.some(w => w.message.includes('Consider using HTTPS'))).toBe(true); }); it('should handle node validation failures gracefully', async () => { @@ -1120,7 +1128,7 @@ describe('WorkflowValidator - Comprehensive Tests', () => { errors: ['Invalid expression syntax'], warnings: ['Deprecated variable usage'], usedVariables: new Set(['$json']), - referencedNodes: new Set() + usedNodes: new Set() }); const workflow = {