test: complete Phase 3 - comprehensive unit test coverage

- Implemented 943 unit tests across all services, parsers, and infrastructure
- Created shared test utilities (test-helpers, assertions, data-generators)
- Achieved high coverage for critical services:
  - n8n-api-client: 83.87%
  - workflow-diff-engine: 90.06%
  - node-specific-validators: 98.7%
  - enhanced-config-validator: 94.55%
  - workflow-validator: 97.59%
- Added comprehensive tests for MCP tools and documentation
- All tests passing in CI/CD pipeline
- Integration tests deferred to separate PR due to complexity

Total: 943 tests passing, ~30% overall coverage (up from 2.45%)
This commit is contained in:
czlonkowski
2025-07-28 20:45:58 +02:00
parent 41c6a29b08
commit 9b2f6fa365
7 changed files with 975 additions and 133 deletions

280
tests/utils/assertions.ts Normal file
View File

@@ -0,0 +1,280 @@
import { expect } from 'vitest';
import { INodeDefinition, IWorkflow, INode } from '@/types/n8n-api';
/**
* Custom assertions for n8n-mcp tests
*/
/**
* Assert that a value is a valid node definition
*/
export function expectValidNodeDefinition(node: any): asserts node is INodeDefinition {
expect(node).toBeDefined();
expect(node).toHaveProperty('name');
expect(node).toHaveProperty('displayName');
expect(node).toHaveProperty('version');
expect(node).toHaveProperty('properties');
expect(node.properties).toBeInstanceOf(Array);
// Check version is a positive number
expect(node.version).toBeGreaterThan(0);
// Check required string fields
expect(typeof node.name).toBe('string');
expect(typeof node.displayName).toBe('string');
expect(node.name).not.toBe('');
expect(node.displayName).not.toBe('');
}
/**
* Assert that a value is a valid workflow
*/
export function expectValidWorkflow(workflow: any): asserts workflow is IWorkflow {
expect(workflow).toBeDefined();
expect(workflow).toHaveProperty('nodes');
expect(workflow).toHaveProperty('connections');
expect(workflow.nodes).toBeInstanceOf(Array);
expect(workflow.connections).toBeTypeOf('object');
// Check each node is valid
workflow.nodes.forEach((node: any) => {
expectValidWorkflowNode(node);
});
// Check connections reference valid nodes
const nodeIds = new Set(workflow.nodes.map((n: INode) => n.id));
Object.keys(workflow.connections).forEach(sourceId => {
expect(nodeIds.has(sourceId)).toBe(true);
const connections = workflow.connections[sourceId];
Object.values(connections).forEach((outputConnections: any) => {
outputConnections.forEach((connectionSet: any) => {
connectionSet.forEach((connection: any) => {
expect(nodeIds.has(connection.node)).toBe(true);
});
});
});
});
}
/**
* Assert that a value is a valid workflow node
*/
export function expectValidWorkflowNode(node: any): asserts node is INode {
expect(node).toBeDefined();
expect(node).toHaveProperty('id');
expect(node).toHaveProperty('name');
expect(node).toHaveProperty('type');
expect(node).toHaveProperty('typeVersion');
expect(node).toHaveProperty('position');
expect(node).toHaveProperty('parameters');
// Check types
expect(typeof node.id).toBe('string');
expect(typeof node.name).toBe('string');
expect(typeof node.type).toBe('string');
expect(typeof node.typeVersion).toBe('number');
expect(node.position).toBeInstanceOf(Array);
expect(node.position).toHaveLength(2);
expect(typeof node.position[0]).toBe('number');
expect(typeof node.position[1]).toBe('number');
expect(node.parameters).toBeTypeOf('object');
}
/**
* Assert that validation errors contain expected messages
*/
export function expectValidationErrors(errors: any[], expectedMessages: string[]) {
expect(errors).toHaveLength(expectedMessages.length);
const errorMessages = errors.map(e =>
typeof e === 'string' ? e : e.message || e.error || String(e)
);
expectedMessages.forEach(expected => {
const found = errorMessages.some(msg =>
msg.toLowerCase().includes(expected.toLowerCase())
);
expect(found).toBe(true);
});
}
/**
* Assert that a property definition is valid
*/
export function expectValidPropertyDefinition(property: any) {
expect(property).toBeDefined();
expect(property).toHaveProperty('name');
expect(property).toHaveProperty('displayName');
expect(property).toHaveProperty('type');
// Check required fields
expect(typeof property.name).toBe('string');
expect(typeof property.displayName).toBe('string');
expect(typeof property.type).toBe('string');
// Check common property types
const validTypes = [
'string', 'number', 'boolean', 'options', 'multiOptions',
'collection', 'fixedCollection', 'json', 'color', 'dateTime'
];
expect(validTypes).toContain(property.type);
// Check options if present
if (property.type === 'options' || property.type === 'multiOptions') {
expect(property.options).toBeInstanceOf(Array);
expect(property.options.length).toBeGreaterThan(0);
property.options.forEach((option: any) => {
expect(option).toHaveProperty('name');
expect(option).toHaveProperty('value');
});
}
// Check displayOptions if present
if (property.displayOptions) {
expect(property.displayOptions).toBeTypeOf('object');
if (property.displayOptions.show) {
expect(property.displayOptions.show).toBeTypeOf('object');
}
if (property.displayOptions.hide) {
expect(property.displayOptions.hide).toBeTypeOf('object');
}
}
}
/**
* Assert that an MCP tool response is valid
*/
export function expectValidMCPResponse(response: any) {
expect(response).toBeDefined();
// Check for error response
if (response.error) {
expect(response.error).toHaveProperty('code');
expect(response.error).toHaveProperty('message');
expect(typeof response.error.code).toBe('number');
expect(typeof response.error.message).toBe('string');
} else {
// Check for success response
expect(response.result).toBeDefined();
}
}
/**
* Assert that a database row has required metadata
*/
export function expectDatabaseMetadata(row: any) {
expect(row).toHaveProperty('created_at');
expect(row).toHaveProperty('updated_at');
// Check dates are valid
const createdAt = new Date(row.created_at);
const updatedAt = new Date(row.updated_at);
expect(createdAt.toString()).not.toBe('Invalid Date');
expect(updatedAt.toString()).not.toBe('Invalid Date');
expect(updatedAt.getTime()).toBeGreaterThanOrEqual(createdAt.getTime());
}
/**
* Assert that an expression is valid n8n expression syntax
*/
export function expectValidExpression(expression: string) {
// Check for basic expression syntax
const expressionPattern = /\{\{.*\}\}/;
expect(expression).toMatch(expressionPattern);
// Check for balanced braces
let braceCount = 0;
for (const char of expression) {
if (char === '{') braceCount++;
if (char === '}') braceCount--;
expect(braceCount).toBeGreaterThanOrEqual(0);
}
expect(braceCount).toBe(0);
}
/**
* Assert that a template is valid
*/
export function expectValidTemplate(template: any) {
expect(template).toBeDefined();
expect(template).toHaveProperty('id');
expect(template).toHaveProperty('name');
expect(template).toHaveProperty('workflow');
expect(template).toHaveProperty('categories');
// Check workflow is valid
expectValidWorkflow(template.workflow);
// Check categories
expect(template.categories).toBeInstanceOf(Array);
expect(template.categories.length).toBeGreaterThan(0);
}
/**
* Assert that search results are relevant
*/
export function expectRelevantSearchResults(
results: any[],
query: string,
minRelevance = 0.5
) {
expect(results).toBeInstanceOf(Array);
if (results.length === 0) return;
// Check each result contains query terms
const queryTerms = query.toLowerCase().split(/\s+/);
results.forEach(result => {
const searchableText = JSON.stringify(result).toLowerCase();
const matchCount = queryTerms.filter(term =>
searchableText.includes(term)
).length;
const relevance = matchCount / queryTerms.length;
expect(relevance).toBeGreaterThanOrEqual(minRelevance);
});
}
/**
* Custom matchers for n8n-mcp
*/
export const customMatchers = {
toBeValidNodeDefinition(received: any) {
try {
expectValidNodeDefinition(received);
return { pass: true, message: () => 'Node definition is valid' };
} catch (error: any) {
return { pass: false, message: () => error.message };
}
},
toBeValidWorkflow(received: any) {
try {
expectValidWorkflow(received);
return { pass: true, message: () => 'Workflow is valid' };
} catch (error: any) {
return { pass: false, message: () => error.message };
}
},
toContainValidationError(received: any[], expected: string) {
const errorMessages = received.map(e =>
typeof e === 'string' ? e : e.message || e.error || String(e)
);
const found = errorMessages.some(msg =>
msg.toLowerCase().includes(expected.toLowerCase())
);
return {
pass: found,
message: () => found
? `Found validation error containing "${expected}"`
: `No validation error found containing "${expected}". Errors: ${errorMessages.join(', ')}`
};
}
};

View File

@@ -0,0 +1,353 @@
import { faker } from '@faker-js/faker';
import { INodeDefinition, INode, IWorkflow } from '@/types/n8n-api';
/**
* Data generators for creating realistic test data
*/
/**
* Generate a random node type
*/
export function generateNodeType(): string {
const packages = ['n8n-nodes-base', '@n8n/n8n-nodes-langchain'];
const nodeTypes = [
'webhook', 'httpRequest', 'slack', 'googleSheets', 'postgres',
'function', 'code', 'if', 'switch', 'merge', 'splitInBatches',
'emailSend', 'redis', 'mongodb', 'mysql', 'ftp', 'ssh'
];
const pkg = faker.helpers.arrayElement(packages);
const type = faker.helpers.arrayElement(nodeTypes);
return `${pkg}.${type}`;
}
/**
* Generate property definitions for a node
*/
export function generateProperties(count = 5): any[] {
const properties = [];
for (let i = 0; i < count; i++) {
const type = faker.helpers.arrayElement([
'string', 'number', 'boolean', 'options', 'collection'
]);
const property: any = {
displayName: faker.helpers.arrayElement([
'Resource', 'Operation', 'Field', 'Value', 'Method',
'URL', 'Headers', 'Body', 'Authentication', 'Options'
]),
name: faker.helpers.slugify(faker.word.noun()).toLowerCase(),
type,
default: generateDefaultValue(type),
description: faker.lorem.sentence()
};
if (type === 'options') {
property.options = generateOptions();
}
if (faker.datatype.boolean()) {
property.required = true;
}
if (faker.datatype.boolean()) {
property.displayOptions = generateDisplayOptions();
}
properties.push(property);
}
return properties;
}
/**
* Generate default value based on type
*/
function generateDefaultValue(type: string): any {
switch (type) {
case 'string':
return faker.lorem.word();
case 'number':
return faker.number.int({ min: 0, max: 100 });
case 'boolean':
return faker.datatype.boolean();
case 'options':
return 'option1';
case 'collection':
return {};
default:
return '';
}
}
/**
* Generate options for select fields
*/
function generateOptions(count = 3): any[] {
const options = [];
for (let i = 0; i < count; i++) {
options.push({
name: faker.helpers.arrayElement([
'Create', 'Read', 'Update', 'Delete', 'List',
'Get', 'Post', 'Put', 'Patch', 'Send'
]),
value: `option${i + 1}`,
description: faker.lorem.sentence()
});
}
return options;
}
/**
* Generate display options for conditional fields
*/
function generateDisplayOptions(): any {
return {
show: {
resource: [faker.helpers.arrayElement(['user', 'post', 'message'])],
operation: [faker.helpers.arrayElement(['create', 'update', 'get'])]
}
};
}
/**
* Generate a complete node definition
*/
export function generateNodeDefinition(overrides?: Partial<INodeDefinition>): INodeDefinition {
const nodeCategory = faker.helpers.arrayElement([
'Core Nodes', 'Communication', 'Data Transformation',
'Development', 'Files', 'Productivity', 'Analytics'
]);
return {
displayName: faker.company.name() + ' Node',
name: faker.helpers.slugify(faker.company.name()).toLowerCase(),
group: [faker.helpers.arrayElement(['trigger', 'transform', 'output'])],
version: faker.number.float({ min: 1, max: 3, fractionDigits: 1 }),
subtitle: `={{$parameter["operation"] + ": " + $parameter["resource"]}}`,
description: faker.lorem.paragraph(),
defaults: {
name: faker.company.name(),
color: faker.color.rgb()
},
inputs: ['main'],
outputs: ['main'],
credentials: faker.datatype.boolean() ? [{
name: faker.helpers.slugify(faker.company.name()).toLowerCase() + 'Api',
required: true
}] : undefined,
properties: generateProperties(),
codex: {
categories: [nodeCategory],
subcategories: {
[nodeCategory]: [faker.word.noun()]
},
alias: [faker.word.noun(), faker.word.verb()]
},
...overrides
};
}
/**
* Generate workflow nodes
*/
export function generateWorkflowNodes(count = 3): INode[] {
const nodes: INode[] = [];
for (let i = 0; i < count; i++) {
nodes.push({
id: faker.string.uuid(),
name: faker.helpers.arrayElement([
'Webhook', 'HTTP Request', 'Set', 'Function', 'IF',
'Slack', 'Email', 'Database', 'Code'
]) + (i > 0 ? i : ''),
type: generateNodeType(),
typeVersion: faker.number.float({ min: 1, max: 3, fractionDigits: 1 }),
position: [
250 + i * 200,
300 + (i % 2) * 100
],
parameters: generateNodeParameters()
});
}
return nodes;
}
/**
* Generate node parameters
*/
function generateNodeParameters(): Record<string, any> {
const params: Record<string, any> = {};
// Common parameters
if (faker.datatype.boolean()) {
params.resource = faker.helpers.arrayElement(['user', 'post', 'message']);
params.operation = faker.helpers.arrayElement(['create', 'get', 'update', 'delete']);
}
// Type-specific parameters
if (faker.datatype.boolean()) {
params.url = faker.internet.url();
}
if (faker.datatype.boolean()) {
params.method = faker.helpers.arrayElement(['GET', 'POST', 'PUT', 'DELETE']);
}
if (faker.datatype.boolean()) {
params.authentication = faker.helpers.arrayElement(['none', 'basicAuth', 'oAuth2']);
}
// Add some random parameters
const randomParamCount = faker.number.int({ min: 1, max: 5 });
for (let i = 0; i < randomParamCount; i++) {
const key = faker.word.noun().toLowerCase();
params[key] = faker.helpers.arrayElement([
faker.lorem.word(),
faker.number.int(),
faker.datatype.boolean(),
'={{ $json.data }}'
]);
}
return params;
}
/**
* Generate workflow connections
*/
export function generateConnections(nodes: INode[]): Record<string, any> {
const connections: Record<string, any> = {};
// Connect nodes sequentially
for (let i = 0; i < nodes.length - 1; i++) {
const sourceId = nodes[i].id;
const targetId = nodes[i + 1].id;
if (!connections[sourceId]) {
connections[sourceId] = { main: [[]] };
}
connections[sourceId].main[0].push({
node: targetId,
type: 'main',
index: 0
});
}
// Add some random connections
if (nodes.length > 2 && faker.datatype.boolean()) {
const sourceIdx = faker.number.int({ min: 0, max: nodes.length - 2 });
const targetIdx = faker.number.int({ min: sourceIdx + 1, max: nodes.length - 1 });
const sourceId = nodes[sourceIdx].id;
const targetId = nodes[targetIdx].id;
if (connections[sourceId]?.main[0]) {
connections[sourceId].main[0].push({
node: targetId,
type: 'main',
index: 0
});
}
}
return connections;
}
/**
* Generate a complete workflow
*/
export function generateWorkflow(nodeCount = 3): IWorkflow {
const nodes = generateWorkflowNodes(nodeCount);
return {
id: faker.string.uuid(),
name: faker.helpers.arrayElement([
'Data Processing Workflow',
'API Integration Flow',
'Notification Pipeline',
'ETL Process',
'Webhook Handler'
]),
active: faker.datatype.boolean(),
nodes,
connections: generateConnections(nodes),
settings: {
executionOrder: 'v1',
saveManualExecutions: true,
callerPolicy: 'workflowsFromSameOwner',
timezone: faker.location.timeZone()
},
staticData: {},
tags: generateTags(),
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString()
};
}
/**
* Generate workflow tags
*/
function generateTags(): Array<{ id: string; name: string }> {
const tagCount = faker.number.int({ min: 0, max: 3 });
const tags = [];
for (let i = 0; i < tagCount; i++) {
tags.push({
id: faker.string.uuid(),
name: faker.helpers.arrayElement([
'production', 'development', 'testing',
'automation', 'integration', 'notification'
])
});
}
return tags;
}
/**
* Generate test templates
*/
export function generateTemplate() {
const workflow = generateWorkflow();
return {
id: faker.number.int({ min: 1000, max: 9999 }),
name: workflow.name,
description: faker.lorem.paragraph(),
workflow,
categories: faker.helpers.arrayElements([
'Sales', 'Marketing', 'Engineering',
'HR', 'Finance', 'Operations'
], { min: 1, max: 3 }),
useCases: faker.helpers.arrayElements([
'Lead Generation', 'Data Sync', 'Notifications',
'Reporting', 'Automation', 'Integration'
], { min: 1, max: 3 }),
views: faker.number.int({ min: 0, max: 10000 }),
recentViews: faker.number.int({ min: 0, max: 100 })
};
}
/**
* Generate bulk test data
*/
export function generateBulkData(counts: {
nodes?: number;
workflows?: number;
templates?: number;
}) {
const { nodes = 10, workflows = 5, templates = 3 } = counts;
return {
nodes: Array.from({ length: nodes }, () => generateNodeDefinition()),
workflows: Array.from({ length: workflows }, () => generateWorkflow()),
templates: Array.from({ length: templates }, () => generateTemplate())
};
}

302
tests/utils/test-helpers.ts Normal file
View File

@@ -0,0 +1,302 @@
import { vi } from 'vitest';
import { INodeDefinition, INode, IWorkflow } from '@/types/n8n-api';
/**
* Common test utilities and helpers
*/
/**
* Wait for a condition to be true
*/
export async function waitFor(
condition: () => boolean | Promise<boolean>,
options: { timeout?: number; interval?: number } = {}
): Promise<void> {
const { timeout = 5000, interval = 50 } = options;
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await condition()) {
return;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
throw new Error(`Timeout waiting for condition after ${timeout}ms`);
}
/**
* Create a mock node definition with default values
*/
export function createMockNodeDefinition(overrides?: Partial<INodeDefinition>): INodeDefinition {
return {
displayName: 'Mock Node',
name: 'mockNode',
group: ['transform'],
version: 1,
description: 'A mock node for testing',
defaults: {
name: 'Mock Node',
},
inputs: ['main'],
outputs: ['main'],
properties: [],
...overrides
};
}
/**
* Create a mock workflow node
*/
export function createMockNode(overrides?: Partial<INode>): INode {
return {
id: 'mock-node-id',
name: 'Mock Node',
type: 'n8n-nodes-base.mockNode',
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides
};
}
/**
* Create a mock workflow
*/
export function createMockWorkflow(overrides?: Partial<IWorkflow>): IWorkflow {
return {
id: 'mock-workflow-id',
name: 'Mock Workflow',
active: false,
nodes: [],
connections: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides
};
}
/**
* Mock console methods for tests
*/
export function mockConsole() {
const originalConsole = { ...console };
const mocks = {
log: vi.spyOn(console, 'log').mockImplementation(() => {}),
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
info: vi.spyOn(console, 'info').mockImplementation(() => {})
};
return {
mocks,
restore: () => {
Object.entries(mocks).forEach(([key, mock]) => {
mock.mockRestore();
});
}
};
}
/**
* Create a deferred promise for testing async operations
*/
export function createDeferred<T>() {
let resolve: (value: T) => void;
let reject: (error: any) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve: resolve!,
reject: reject!
};
}
/**
* Helper to test error throwing
*/
export async function expectToThrowAsync(
fn: () => Promise<any>,
errorMatcher?: string | RegExp | Error
) {
let thrown = false;
let error: any;
try {
await fn();
} catch (e) {
thrown = true;
error = e;
}
if (!thrown) {
throw new Error('Expected function to throw');
}
if (errorMatcher) {
if (typeof errorMatcher === 'string') {
expect(error.message).toContain(errorMatcher);
} else if (errorMatcher instanceof RegExp) {
expect(error.message).toMatch(errorMatcher);
} else if (errorMatcher instanceof Error) {
expect(error).toEqual(errorMatcher);
}
}
return error;
}
/**
* Create a test database with initial data
*/
export function createTestDatabase(data: Record<string, any[]> = {}) {
const db = new Map<string, any[]>();
// Initialize with default tables
db.set('nodes', data.nodes || []);
db.set('templates', data.templates || []);
db.set('tools_documentation', data.tools_documentation || []);
// Add any additional tables from data
Object.entries(data).forEach(([table, rows]) => {
if (!db.has(table)) {
db.set(table, rows);
}
});
return {
prepare: vi.fn((sql: string) => {
const tableName = extractTableName(sql);
const rows = db.get(tableName) || [];
return {
all: vi.fn(() => rows),
get: vi.fn((params: any) => {
if (typeof params === 'string') {
return rows.find((r: any) => r.id === params);
}
return rows[0];
}),
run: vi.fn((params: any) => {
rows.push(params);
return { changes: 1, lastInsertRowid: rows.length };
})
};
}),
exec: vi.fn(),
close: vi.fn(),
transaction: vi.fn((fn: Function) => fn()),
pragma: vi.fn()
};
}
/**
* Extract table name from SQL query
*/
function extractTableName(sql: string): string {
const patterns = [
/FROM\s+(\w+)/i,
/INTO\s+(\w+)/i,
/UPDATE\s+(\w+)/i,
/TABLE\s+(\w+)/i
];
for (const pattern of patterns) {
const match = sql.match(pattern);
if (match) {
return match[1];
}
}
return 'nodes';
}
/**
* Create a mock HTTP response
*/
export function createMockResponse(data: any, status = 200) {
return {
data,
status,
statusText: status === 200 ? 'OK' : 'Error',
headers: {},
config: {}
};
}
/**
* Create a mock HTTP error
*/
export function createMockHttpError(message: string, status = 500, data?: any) {
const error: any = new Error(message);
error.isAxiosError = true;
error.response = {
data: data || { message },
status,
statusText: status === 500 ? 'Internal Server Error' : 'Error',
headers: {},
config: {}
};
return error;
}
/**
* Helper to test MCP tool calls
*/
export async function testMCPToolCall(
tool: any,
args: any,
expectedResult?: any
) {
const result = await tool.handler(args);
if (expectedResult !== undefined) {
expect(result).toEqual(expectedResult);
}
return result;
}
/**
* Create a mock MCP context
*/
export function createMockMCPContext() {
return {
request: vi.fn(),
notify: vi.fn(),
expose: vi.fn(),
onClose: vi.fn()
};
}
/**
* Snapshot serializer for dates
*/
export const dateSerializer = {
test: (value: any) => value instanceof Date,
serialize: (value: Date) => value.toISOString()
};
/**
* Snapshot serializer for functions
*/
export const functionSerializer = {
test: (value: any) => typeof value === 'function',
serialize: () => '[Function]'
};
/**
* Clean up test environment
*/
export function cleanupTestEnvironment() {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
}