Skip to main content
Glama

n8n-MCP

by 88-888
n8n-validation.test.ts43.5 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { workflowNodeSchema, workflowConnectionSchema, workflowSettingsSchema, defaultWorkflowSettings, validateWorkflowNode, validateWorkflowConnections, validateWorkflowSettings, cleanWorkflowForCreate, cleanWorkflowForUpdate, validateWorkflowStructure, hasWebhookTrigger, getWebhookUrl, getWorkflowStructureExample, getWorkflowFixSuggestions, } from '../../../src/services/n8n-validation'; import { WorkflowBuilder } from '../../utils/builders/workflow.builder'; import { z } from 'zod'; import { WorkflowNode, WorkflowConnection, Workflow } from '../../../src/types/n8n-api'; describe('n8n-validation', () => { describe('Zod Schemas', () => { describe('workflowNodeSchema', () => { it('should validate a complete valid node', () => { const validNode = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [100, 200], parameters: { key: 'value' }, credentials: { api: 'cred-id' }, disabled: false, notes: 'Test notes', notesInFlow: true, continueOnFail: true, retryOnFail: true, maxTries: 3, waitBetweenTries: 1000, alwaysOutputData: true, executeOnce: false, }; const result = workflowNodeSchema.parse(validNode); expect(result).toEqual(validNode); }); it('should validate a minimal valid node', () => { const minimalNode = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [100, 200], parameters: {}, }; const result = workflowNodeSchema.parse(minimalNode); expect(result).toEqual(minimalNode); }); it('should reject node with missing required fields', () => { const invalidNode = { name: 'Test Node', type: 'n8n-nodes-base.set', }; expect(() => workflowNodeSchema.parse(invalidNode)).toThrow(); }); it('should reject node with invalid position format', () => { const invalidNode = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [100], // Should be tuple of 2 numbers parameters: {}, }; expect(() => workflowNodeSchema.parse(invalidNode)).toThrow(); }); it('should reject node with invalid type values', () => { const invalidNode = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: '3', // Should be number position: [100, 200], parameters: {}, }; expect(() => workflowNodeSchema.parse(invalidNode)).toThrow(); }); }); describe('workflowConnectionSchema', () => { it('should validate valid connections', () => { const validConnections = { 'node-1': { main: [[{ node: 'node-2', type: 'main', index: 0 }]], }, 'node-2': { main: [ [ { node: 'node-3', type: 'main', index: 0 }, { node: 'node-4', type: 'main', index: 0 }, ], ], }, }; const result = workflowConnectionSchema.parse(validConnections); expect(result).toEqual(validConnections); }); it('should validate empty connections', () => { const emptyConnections = {}; const result = workflowConnectionSchema.parse(emptyConnections); expect(result).toEqual(emptyConnections); }); it('should reject invalid connection structure', () => { const invalidConnections = { 'node-1': { main: [{ node: 'node-2', type: 'main', index: 0 }], // Should be array of arrays }, }; expect(() => workflowConnectionSchema.parse(invalidConnections)).toThrow(); }); it('should reject connections missing required fields', () => { const invalidConnections = { 'node-1': { main: [[{ node: 'node-2' }]], // Missing type and index }, }; expect(() => workflowConnectionSchema.parse(invalidConnections)).toThrow(); }); }); describe('workflowSettingsSchema', () => { it('should validate complete settings', () => { const completeSettings = { executionOrder: 'v1' as const, timezone: 'America/New_York', saveDataErrorExecution: 'all' as const, saveDataSuccessExecution: 'all' as const, saveManualExecutions: true, saveExecutionProgress: true, executionTimeout: 300, errorWorkflow: 'error-handler-workflow', }; const result = workflowSettingsSchema.parse(completeSettings); expect(result).toEqual(completeSettings); }); it('should apply defaults for missing fields', () => { const minimalSettings = {}; const result = workflowSettingsSchema.parse(minimalSettings); expect(result).toEqual({ executionOrder: 'v1', saveDataErrorExecution: 'all', saveDataSuccessExecution: 'all', saveManualExecutions: true, saveExecutionProgress: true, }); }); it('should reject invalid enum values', () => { const invalidSettings = { executionOrder: 'v2', // Invalid enum value }; expect(() => workflowSettingsSchema.parse(invalidSettings)).toThrow(); }); }); }); describe('Validation Functions', () => { describe('validateWorkflowNode', () => { it('should validate and return a valid node', () => { const node = { id: 'test-1', name: 'Test', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: {}, }; const result = validateWorkflowNode(node); expect(result).toEqual(node); }); it('should throw for invalid node', () => { const invalidNode = { name: 'Test' }; expect(() => validateWorkflowNode(invalidNode)).toThrow(); }); }); describe('validateWorkflowConnections', () => { it('should validate and return valid connections', () => { const connections = { 'Node1': { main: [[{ node: 'Node2', type: 'main', index: 0 }]], }, }; const result = validateWorkflowConnections(connections); expect(result).toEqual(connections); }); it('should throw for invalid connections', () => { const invalidConnections = { 'Node1': { main: 'invalid', // Should be array }, }; expect(() => validateWorkflowConnections(invalidConnections)).toThrow(); }); }); describe('validateWorkflowSettings', () => { it('should validate and return valid settings', () => { const settings = { executionOrder: 'v1' as const, timezone: 'UTC', }; const result = validateWorkflowSettings(settings); expect(result).toMatchObject(settings); }); it('should apply defaults and validate', () => { const result = validateWorkflowSettings({}); expect(result).toMatchObject(defaultWorkflowSettings); }); }); }); describe('Workflow Cleaning Functions', () => { describe('cleanWorkflowForCreate', () => { it('should remove read-only fields', () => { const workflow = { id: 'should-be-removed', name: 'Test Workflow', nodes: [], connections: {}, createdAt: '2023-01-01', updatedAt: '2023-01-01', versionId: 'v123', meta: { test: 'data' }, active: true, tags: ['tag1'], }; const cleaned = cleanWorkflowForCreate(workflow as any); expect(cleaned).not.toHaveProperty('id'); expect(cleaned).not.toHaveProperty('createdAt'); expect(cleaned).not.toHaveProperty('updatedAt'); expect(cleaned).not.toHaveProperty('versionId'); expect(cleaned).not.toHaveProperty('meta'); expect(cleaned).not.toHaveProperty('active'); expect(cleaned).not.toHaveProperty('tags'); expect(cleaned.name).toBe('Test Workflow'); }); it('should add default settings if not present', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, }; const cleaned = cleanWorkflowForCreate(workflow as Workflow); expect(cleaned.settings).toEqual(defaultWorkflowSettings); }); it('should preserve existing settings', () => { const customSettings = { executionOrder: 'v0' as const, timezone: 'America/New_York', }; const workflow = { name: 'Test Workflow', nodes: [], connections: {}, settings: customSettings, }; const cleaned = cleanWorkflowForCreate(workflow as Workflow); expect(cleaned.settings).toEqual(customSettings); }); }); describe('cleanWorkflowForUpdate', () => { it('should remove all read-only and computed fields', () => { const workflow = { id: 'keep-id', name: 'Updated Workflow', nodes: [], connections: {}, createdAt: '2023-01-01', updatedAt: '2023-01-01', versionId: 'v123', meta: { test: 'data' }, staticData: { some: 'data' }, pinData: { pin: 'data' }, tags: ['tag1'], isArchived: false, usedCredentials: ['cred1'], sharedWithProjects: ['proj1'], triggerCount: 5, shared: true, active: true, settings: { executionOrder: 'v1' }, } as any; const cleaned = cleanWorkflowForUpdate(workflow); // Should remove all these fields expect(cleaned).not.toHaveProperty('id'); expect(cleaned).not.toHaveProperty('createdAt'); expect(cleaned).not.toHaveProperty('updatedAt'); expect(cleaned).not.toHaveProperty('versionId'); expect(cleaned).not.toHaveProperty('meta'); expect(cleaned).not.toHaveProperty('staticData'); expect(cleaned).not.toHaveProperty('pinData'); expect(cleaned).not.toHaveProperty('tags'); expect(cleaned).not.toHaveProperty('isArchived'); expect(cleaned).not.toHaveProperty('usedCredentials'); expect(cleaned).not.toHaveProperty('sharedWithProjects'); expect(cleaned).not.toHaveProperty('triggerCount'); expect(cleaned).not.toHaveProperty('shared'); expect(cleaned).not.toHaveProperty('active'); // Should keep name and filter settings to safe properties expect(cleaned.name).toBe('Updated Workflow'); expect(cleaned.settings).toEqual({ executionOrder: 'v1' }); }); it('should add empty settings object for cloud API compatibility', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, } as any; const cleaned = cleanWorkflowForUpdate(workflow); expect(cleaned.settings).toEqual({}); }); it('should filter settings to safe properties to prevent API errors (Issue #248 - final fix)', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, settings: { executionOrder: 'v1' as const, saveDataSuccessExecution: 'none' as const, callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out (not in OpenAPI spec) timeSavedPerExecution: 5, // Filtered out (UI-only property) }, } as any; const cleaned = cleanWorkflowForUpdate(workflow); // Unsafe properties filtered out, safe properties kept expect(cleaned.settings).toEqual({ executionOrder: 'v1', saveDataSuccessExecution: 'none' }); expect(cleaned.settings).not.toHaveProperty('callerPolicy'); expect(cleaned.settings).not.toHaveProperty('timeSavedPerExecution'); }); it('should filter out callerPolicy (Issue #248 - API limitation)', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, settings: { executionOrder: 'v1' as const, callerPolicy: 'workflowsFromSameOwner' as const, // Filtered out errorWorkflow: 'N2O2nZy3aUiBRGFN', }, } as any; const cleaned = cleanWorkflowForUpdate(workflow); // callerPolicy filtered out (causes API errors), safe properties kept expect(cleaned.settings).toEqual({ executionOrder: 'v1', errorWorkflow: 'N2O2nZy3aUiBRGFN' }); expect(cleaned.settings).not.toHaveProperty('callerPolicy'); }); it('should filter all settings properties correctly (Issue #248 - API design)', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, settings: { executionOrder: 'v0' as const, timezone: 'UTC', saveDataErrorExecution: 'all' as const, saveDataSuccessExecution: 'none' as const, saveManualExecutions: false, saveExecutionProgress: false, executionTimeout: 300, errorWorkflow: 'error-workflow-id', callerPolicy: 'workflowsFromAList' as const, // Filtered out (not in OpenAPI spec) }, } as any; const cleaned = cleanWorkflowForUpdate(workflow); // Safe properties kept, unsafe properties filtered out // See: https://community.n8n.io/t/api-workflow-update-endpoint-doesnt-support-setting-callerpolicy/161916 expect(cleaned.settings).toEqual({ executionOrder: 'v0', timezone: 'UTC', saveDataErrorExecution: 'all', saveDataSuccessExecution: 'none', saveManualExecutions: false, saveExecutionProgress: false, executionTimeout: 300, errorWorkflow: 'error-workflow-id' }); expect(cleaned.settings).not.toHaveProperty('callerPolicy'); }); it('should handle workflows without settings gracefully', () => { const workflow = { name: 'Test Workflow', nodes: [], connections: {}, } as any; const cleaned = cleanWorkflowForUpdate(workflow); expect(cleaned.settings).toEqual({}); }); }); }); describe('validateWorkflowStructure', () => { it('should return no errors for valid workflow', () => { const workflow = new WorkflowBuilder('Valid Workflow') .addWebhookNode({ id: 'webhook-1', name: 'Webhook' }) .addSlackNode({ id: 'slack-1', name: 'Send Slack' }) .connect('Webhook', 'Send Slack') .build(); const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); }); it('should detect missing workflow name', () => { const workflow = { nodes: [], connections: {}, }; const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow name is required'); }); it('should detect missing nodes', () => { const workflow = { name: 'Test', connections: {}, }; const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow must have at least one node'); }); it('should detect empty nodes array', () => { const workflow = { name: 'Test', nodes: [], connections: {}, }; 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] as [number, number], parameters: {} }], }; const errors = validateWorkflowStructure(workflow as any); expect(errors).toContain('Workflow connections are required'); }); it('should allow single webhook node workflow', () => { const workflow = { name: 'Webhook Only', nodes: [{ id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: {}, }], connections: {}, }; const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); }); it('should reject single non-webhook node workflow', () => { const workflow = { name: 'Invalid Single Node', nodes: [{ id: 'set-1', name: 'Set', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }], connections: {}, }; const errors = validateWorkflowStructure(workflow); expect(errors.some(e => e.includes('Single non-webhook node workflow is invalid'))).toBe(true); }); it('should detect empty connections in multi-node workflow', () => { const workflow = { name: 'Disconnected Nodes', nodes: [ { id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }, { id: 'node-2', name: 'Node 2', type: 'n8n-nodes-base.set', typeVersion: 3, position: [550, 300] as [number, number], parameters: {}, }, ], connections: {}, }; const errors = validateWorkflowStructure(workflow); expect(errors.some(e => e.includes('Multi-node workflow has no connections between nodes'))).toBe(true); }); it('should validate node type format - missing package prefix', () => { const workflow = { name: 'Invalid Node Type', nodes: [{ id: 'node-1', name: 'Node 1', type: 'webhook', // Missing package prefix typeVersion: 2, position: [250, 300] as [number, number], parameters: {}, }], connections: {}, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Invalid node type "webhook" at index 0. Node types must include package prefix (e.g., "n8n-nodes-base.webhook").'); }); it('should validate node type format - wrong prefix format', () => { const workflow = { name: 'Invalid Node Type', nodes: [{ id: 'node-1', name: 'Node 1', type: 'nodes-base.webhook', // Wrong prefix typeVersion: 2, position: [250, 300] as [number, number], parameters: {}, }], connections: {}, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Invalid node type "nodes-base.webhook" at index 0. Use "n8n-nodes-base.webhook" instead.'); }); it('should detect invalid node structure', () => { const workflow = { name: 'Invalid Node', nodes: [{ name: 'Missing Required Fields', // Missing id, type, typeVersion, position, parameters } as any], connections: {}, }; const errors = validateWorkflowStructure(workflow); // The validation will fail because the node is missing required fields expect(errors.some(e => e.includes('Invalid node at index 0'))).toBe(true); }); it('should detect non-existent connection source by name', () => { const workflow = { name: 'Bad Connection', nodes: [{ id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }], connections: { 'Non-existent Node': { main: [[{ node: 'Node 1', type: 'main', index: 0 }]], }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Connection references non-existent node: Non-existent Node'); }); it('should detect non-existent connection target by name', () => { const workflow = { name: 'Bad Connection Target', nodes: [{ id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }], connections: { 'Node 1': { main: [[{ node: 'Non-existent Node', type: 'main', index: 0 }]], }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Connection references non-existent target node: Non-existent Node (from Node 1[0][0])'); }); it('should detect when node ID is used instead of name in connection source', () => { const workflow = { name: 'ID Instead of Name', nodes: [ { id: 'node-1', name: 'First Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }, { id: 'node-2', name: 'Second Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [550, 300] as [number, number], parameters: {}, }, ], connections: { 'node-1': { // Using ID instead of name main: [[{ node: 'Second Node', type: 'main', index: 0 }]], }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain("Connection uses node ID 'node-1' but must use node name 'First Node'. Change connections.node-1 to connections['First Node']"); }); it('should detect when node ID is used instead of name in connection target', () => { const workflow = { name: 'ID Instead of Name in Target', nodes: [ { id: 'node-1', name: 'First Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }, { id: 'node-2', name: 'Second Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [550, 300] as [number, number], parameters: {}, }, ], connections: { 'First Node': { main: [[{ node: 'node-2', type: 'main', index: 0 }]], // Using ID instead of name }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain("Connection target uses node ID 'node-2' but must use node name 'Second Node' (from First Node[0][0])"); }); it('should handle complex multi-output connections', () => { const workflow = { name: 'Complex Connections', nodes: [ { id: 'if-1', name: 'IF Node', type: 'n8n-nodes-base.if', typeVersion: 2, position: [250, 300] as [number, number], parameters: {}, }, { id: 'true-1', name: 'True Branch', type: 'n8n-nodes-base.set', typeVersion: 3, position: [450, 200] as [number, number], parameters: {}, }, { id: 'false-1', name: 'False Branch', type: 'n8n-nodes-base.set', typeVersion: 3, position: [450, 400] as [number, number], parameters: {}, }, ], connections: { 'IF Node': { main: [ [{ node: 'True Branch', type: 'main', index: 0 }], [{ node: 'False Branch', type: 'main', index: 0 }], ], }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toEqual([]); }); it('should validate invalid connections structure', () => { const workflow = { name: 'Invalid Connections', nodes: [ { id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }, { id: 'node-2', name: 'Node 2', type: 'n8n-nodes-base.set', typeVersion: 3, position: [550, 300] as [number, number], parameters: {}, } ], connections: { 'Node 1': 'invalid', // Should be an object } as any, }; const errors = validateWorkflowStructure(workflow); expect(errors.some(e => e.includes('Invalid connections'))).toBe(true); }); }); describe('hasWebhookTrigger', () => { it('should return true for workflow with webhook node', () => { const workflow = new WorkflowBuilder() .addWebhookNode() .build() as Workflow; expect(hasWebhookTrigger(workflow)).toBe(true); }); it('should return true for workflow with webhookTrigger node', () => { const workflow = { name: 'Test', nodes: [{ id: 'webhook-1', name: 'Webhook Trigger', type: 'n8n-nodes-base.webhookTrigger', typeVersion: 1, position: [250, 300] as [number, number], parameters: {}, }], connections: {}, } as Workflow; expect(hasWebhookTrigger(workflow)).toBe(true); }); it('should return false for workflow without webhook nodes', () => { const workflow = new WorkflowBuilder() .addSlackNode() .addHttpRequestNode() .build() as Workflow; expect(hasWebhookTrigger(workflow)).toBe(false); }); it('should return true even if webhook is not the first node', () => { const workflow = new WorkflowBuilder() .addSlackNode() .addWebhookNode() .addHttpRequestNode() .build() as Workflow; expect(hasWebhookTrigger(workflow)).toBe(true); }); }); describe('getWebhookUrl', () => { it('should return webhook path from webhook node', () => { const workflow = { name: 'Test', nodes: [{ id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: { path: 'my-custom-webhook', }, }], connections: {}, } as Workflow; expect(getWebhookUrl(workflow)).toBe('my-custom-webhook'); }); it('should return webhook path from webhookTrigger node', () => { const workflow = { name: 'Test', nodes: [{ id: 'webhook-1', name: 'Webhook Trigger', type: 'n8n-nodes-base.webhookTrigger', typeVersion: 1, position: [250, 300] as [number, number], parameters: { path: 'trigger-webhook-path', }, }], connections: {}, } as Workflow; expect(getWebhookUrl(workflow)).toBe('trigger-webhook-path'); }); it('should return null if no webhook node exists', () => { const workflow = new WorkflowBuilder() .addSlackNode() .build() as Workflow; expect(getWebhookUrl(workflow)).toBe(null); }); it('should return null if webhook node has no parameters', () => { const workflow = { name: 'Test', nodes: [{ id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: undefined as any, }], connections: {}, } as Workflow; expect(getWebhookUrl(workflow)).toBe(null); }); it('should return null if webhook node has no path parameter', () => { const workflow = { name: 'Test', nodes: [{ id: 'webhook-1', name: 'Webhook', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: { method: 'POST', // No path parameter }, }], connections: {}, } as Workflow; expect(getWebhookUrl(workflow)).toBe(null); }); it('should return first webhook path when multiple webhooks exist', () => { const workflow = { name: 'Test', nodes: [ { id: 'webhook-1', name: 'Webhook 1', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [250, 300] as [number, number], parameters: { path: 'first-webhook', }, }, { id: 'webhook-2', name: 'Webhook 2', type: 'n8n-nodes-base.webhook', typeVersion: 2, position: [550, 300] as [number, number], parameters: { path: 'second-webhook', }, }, ], connections: {}, } as Workflow; expect(getWebhookUrl(workflow)).toBe('first-webhook'); }); }); describe('getWorkflowStructureExample', () => { it('should return a string containing example workflow structure', () => { const example = getWorkflowStructureExample(); expect(example).toContain('Minimal Workflow Example'); expect(example).toContain('Manual Trigger'); expect(example).toContain('Set Data'); expect(example).toContain('connections'); expect(example).toContain('IMPORTANT: In connections, use the node NAME'); }); it('should contain valid JSON structure in example', () => { const example = getWorkflowStructureExample(); // Extract the JSON part between the first { and last } const match = example.match(/\{[\s\S]*\}/); expect(match).toBeTruthy(); if (match) { // Should not throw when parsing expect(() => JSON.parse(match[0])).not.toThrow(); } }); }); describe('getWorkflowFixSuggestions', () => { it('should suggest fixes for empty connections', () => { const errors = ['Multi-node workflow has empty connections']; const suggestions = getWorkflowFixSuggestions(errors); expect(suggestions).toContain('Add connections between your nodes. Each node (except endpoints) should connect to another node.'); expect(suggestions).toContain('Connection format: connections: { "Source Node Name": { "main": [[{ "node": "Target Node Name", "type": "main", "index": 0 }]] } }'); }); it('should suggest fixes for single-node workflows', () => { const errors = ['Single-node workflows are only valid for webhooks']; const suggestions = getWorkflowFixSuggestions(errors); expect(suggestions).toContain('Add at least one more node to process data. Common patterns: Trigger → Process → Output'); expect(suggestions).toContain('Examples: Manual Trigger → Set, Webhook → HTTP Request, Schedule Trigger → Database Query'); }); it('should suggest fixes for node ID usage instead of names', () => { const errors = ["Connection uses node ID 'set-1' but must use node name 'Set Data' instead of node name"]; const suggestions = getWorkflowFixSuggestions(errors); expect(suggestions.some(s => s.includes('Replace node IDs with node names'))).toBe(true); expect(suggestions.some(s => s.includes('connections: { "set-1": {...} }'))).toBe(true); }); it('should return empty array for no errors', () => { const suggestions = getWorkflowFixSuggestions([]); expect(suggestions).toEqual([]); }); it('should handle multiple error types', () => { const errors = [ 'Multi-node workflow has empty connections', 'Single-node workflows are only valid for webhooks', "Connection uses node ID instead of node name", ]; const suggestions = getWorkflowFixSuggestions(errors); expect(suggestions.length).toBeGreaterThan(3); expect(suggestions).toContain('Add connections between your nodes. Each node (except endpoints) should connect to another node.'); expect(suggestions).toContain('Add at least one more node to process data. Common patterns: Trigger → Process → Output'); expect(suggestions).toContain('Replace node IDs with node names in connections. The name is what appears in the node header.'); }); it('should not duplicate suggestions for similar errors', () => { const errors = [ "Connection uses node ID 'id1' instead of node name", "Connection uses node ID 'id2' instead of node name", ]; const suggestions = getWorkflowFixSuggestions(errors); // Should only have 2 suggestions for this error type const idSuggestions = suggestions.filter(s => s.includes('Replace node IDs')); expect(idSuggestions.length).toBe(1); }); }); describe('Edge Cases and Error Conditions', () => { it('should handle workflow with null values gracefully', () => { const workflow = { name: 'Test', nodes: null as any, connections: null as any, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Workflow must have at least one node'); expect(errors).toContain('Workflow connections are required'); }); it('should handle undefined parameters in cleaning functions', () => { const workflow = { name: undefined as any, nodes: undefined as any, connections: undefined as any, }; expect(() => cleanWorkflowForCreate(workflow)).not.toThrow(); expect(() => cleanWorkflowForUpdate(workflow as any)).not.toThrow(); }); it('should handle circular references in workflow structure', () => { const node1: any = { id: 'node-1', name: 'Node 1', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300], parameters: {}, }; // Create circular reference node1.parameters.circular = node1; const workflow = { name: 'Circular Ref', nodes: [node1], connections: {}, }; // Should handle circular references without crashing expect(() => validateWorkflowStructure(workflow)).not.toThrow(); }); it('should validate very large position values', () => { const node = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER] as [number, number], parameters: {}, }; expect(() => validateWorkflowNode(node)).not.toThrow(); }); it('should handle special characters in node names', () => { const workflow = { name: 'Special Chars', nodes: [ { id: 'node-1', name: 'Node with "quotes" & special <chars>', type: 'n8n-nodes-base.set', typeVersion: 3, position: [250, 300] as [number, number], parameters: {}, }, { id: 'node-2', name: 'Normal Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [550, 300] as [number, number], parameters: {}, }, ], connections: { 'Node with "quotes" & special <chars>': { main: [[{ node: 'Normal Node', type: 'main', index: 0 }]], }, }, }; const errors = validateWorkflowStructure(workflow); expect(errors).toEqual([]); }); it('should handle empty string values', () => { const workflow = { name: '', nodes: [{ id: '', name: '', type: '', typeVersion: 1, position: [0, 0] as [number, number], parameters: {}, }], connections: {}, }; const errors = validateWorkflowStructure(workflow); expect(errors).toContain('Workflow name is required'); // Empty string for type will be caught as invalid expect(errors.some(e => e.includes('Invalid node at index 0') || e.includes('Node types must include package prefix'))).toBe(true); }); it('should handle negative position values', () => { const node = { id: 'node-1', name: 'Test Node', type: 'n8n-nodes-base.set', typeVersion: 3, position: [-100, -200] as [number, number], parameters: {}, }; // Negative positions are valid expect(() => validateWorkflowNode(node)).not.toThrow(); }); it('should validate settings with additional unknown properties', () => { const settings = { executionOrder: 'v1' as const, timezone: 'UTC', unknownProperty: 'should be allowed', anotherUnknown: { nested: 'object' }, }; // Zod by default strips unknown properties const result = validateWorkflowSettings(settings); expect(result).toHaveProperty('executionOrder', 'v1'); expect(result).toHaveProperty('timezone', 'UTC'); expect(result).not.toHaveProperty('unknownProperty'); expect(result).not.toHaveProperty('anotherUnknown'); }); }); describe('Integration Tests', () => { it('should validate a complete real-world workflow', () => { const workflow = new WorkflowBuilder('Production Workflow') .addWebhookNode({ id: 'webhook-1', name: 'Order Webhook', parameters: { path: 'new-order', method: 'POST', }, }) .addIfNode({ id: 'if-1', name: 'Check Order Value', parameters: { conditions: { options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' }, conditions: [{ id: '1', leftValue: '={{ $json.orderValue }}', rightValue: '100', operator: { type: 'number', operation: 'gte' }, }], combinator: 'and', }, }, }) .addSlackNode({ id: 'slack-1', name: 'Notify High Value', parameters: { channel: '#high-value-orders', text: 'High value order received: ${{ $json.orderId }}', }, }) .addHttpRequestNode({ id: 'http-1', name: 'Update Inventory', parameters: { method: 'POST', url: 'https://api.inventory.com/update', sendBody: true, bodyParametersJson: '={{ $json }}', }, }) .connect('Order Webhook', 'Check Order Value') .connect('Check Order Value', 'Notify High Value', 0) // True output .connect('Check Order Value', 'Update Inventory', 1) // False output .setSettings({ executionOrder: 'v1', timezone: 'America/New_York', saveDataErrorExecution: 'all', saveDataSuccessExecution: 'none', executionTimeout: 300, }) .build(); const errors = validateWorkflowStructure(workflow as any); expect(errors).toEqual([]); // Validate individual components workflow.nodes.forEach(node => { expect(() => validateWorkflowNode(node)).not.toThrow(); }); expect(() => validateWorkflowConnections(workflow.connections)).not.toThrow(); expect(() => validateWorkflowSettings(workflow.settings!)).not.toThrow(); }); it('should clean and validate workflow for API operations', () => { const originalWorkflow = { id: 'wf-123', name: 'API Test Workflow', nodes: [ { id: 'manual-1', name: 'Manual Trigger', type: 'n8n-nodes-base.manualTrigger', typeVersion: 1, position: [250, 300] as [number, number], parameters: {}, }, { id: 'set-1', name: 'Set Data', type: 'n8n-nodes-base.set', typeVersion: 3.4, position: [450, 300] as [number, number], parameters: { mode: 'manual', assignments: { assignments: [{ id: '1', name: 'testKey', value: 'testValue', type: 'string', }], }, }, } ], connections: { 'Manual Trigger': { main: [[{ node: 'Set Data', type: 'main', index: 0, }]], }, }, createdAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-02T00:00:00Z', versionId: 'v123', active: true, tags: ['test', 'api'], meta: { instanceId: 'instance-123' }, }; // Test create cleaning const forCreate = cleanWorkflowForCreate(originalWorkflow); expect(forCreate).not.toHaveProperty('id'); expect(forCreate).not.toHaveProperty('createdAt'); expect(forCreate).not.toHaveProperty('updatedAt'); expect(forCreate).not.toHaveProperty('versionId'); expect(forCreate).not.toHaveProperty('active'); expect(forCreate).not.toHaveProperty('tags'); expect(forCreate).not.toHaveProperty('meta'); expect(forCreate).toHaveProperty('settings'); expect(validateWorkflowStructure(forCreate)).toEqual([]); // Test update cleaning const forUpdate = cleanWorkflowForUpdate(originalWorkflow as any); expect(forUpdate).not.toHaveProperty('id'); expect(forUpdate).not.toHaveProperty('createdAt'); expect(forUpdate).not.toHaveProperty('updatedAt'); expect(forUpdate).not.toHaveProperty('versionId'); expect(forUpdate).not.toHaveProperty('active'); expect(forUpdate).not.toHaveProperty('tags'); expect(forUpdate).not.toHaveProperty('meta'); expect(forUpdate.settings).toEqual({}); // Settings replaced with empty object for API compatibility expect(validateWorkflowStructure(forUpdate)).toEqual([]); }); }); });

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/88-888/n8n-mcp'

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