test: add unit tests for n8n manager and workflow diff handlers

This commit is contained in:
czlonkowski
2025-07-28 18:15:21 +02:00
parent a37054685f
commit 2b54710fda
8 changed files with 1758 additions and 64 deletions

View File

@@ -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',

View File

@@ -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');
});

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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 = {