mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 06:22:04 +00:00
- Add comprehensive tests for ValidationServiceError (25 tests) - Add tests for NodeRepository operations methods (23 tests) - Add comprehensive tests for ResourceSimilarityService (66 tests) - Add comprehensive tests for OperationSimilarityService (58 tests) - Add integration tests for EnhancedConfigValidator (15 tests) - Fix EnhancedConfigValidator to handle errors gracefully - Add suggestions to both error objects and result.suggestions array - Improve overall test coverage from 69.76% towards 80%+ target 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
633 lines
20 KiB
TypeScript
633 lines
20 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { NodeRepository } from '@/database/node-repository';
|
|
import { DatabaseAdapter, PreparedStatement, RunResult } from '@/database/database-adapter';
|
|
|
|
// Mock DatabaseAdapter for testing the new operation methods
|
|
class MockDatabaseAdapter implements DatabaseAdapter {
|
|
private statements = new Map<string, MockPreparedStatement>();
|
|
private mockNodes = new Map<string, any>();
|
|
|
|
prepare = vi.fn((sql: string) => {
|
|
if (!this.statements.has(sql)) {
|
|
this.statements.set(sql, new MockPreparedStatement(sql, this.mockNodes));
|
|
}
|
|
return this.statements.get(sql)!;
|
|
});
|
|
|
|
exec = vi.fn();
|
|
close = vi.fn();
|
|
pragma = vi.fn();
|
|
transaction = vi.fn((fn: () => any) => fn());
|
|
checkFTS5Support = vi.fn(() => true);
|
|
inTransaction = false;
|
|
|
|
// Test helper to set mock data
|
|
_setMockNode(nodeType: string, value: any) {
|
|
this.mockNodes.set(nodeType, value);
|
|
}
|
|
}
|
|
|
|
class MockPreparedStatement implements PreparedStatement {
|
|
run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 }));
|
|
get = vi.fn();
|
|
all = vi.fn(() => []);
|
|
iterate = vi.fn();
|
|
pluck = vi.fn(() => this);
|
|
expand = vi.fn(() => this);
|
|
raw = vi.fn(() => this);
|
|
columns = vi.fn(() => []);
|
|
bind = vi.fn(() => this);
|
|
|
|
constructor(private sql: string, private mockNodes: Map<string, any>) {
|
|
// Configure get() to return node data
|
|
if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) {
|
|
this.get = vi.fn((nodeType: string) => this.mockNodes.get(nodeType));
|
|
}
|
|
|
|
// Configure all() for getAllNodes
|
|
if (sql.includes('SELECT * FROM nodes ORDER BY display_name')) {
|
|
this.all = vi.fn(() => Array.from(this.mockNodes.values()));
|
|
}
|
|
}
|
|
}
|
|
|
|
describe('NodeRepository - Operations and Resources', () => {
|
|
let repository: NodeRepository;
|
|
let mockAdapter: MockDatabaseAdapter;
|
|
|
|
beforeEach(() => {
|
|
mockAdapter = new MockDatabaseAdapter();
|
|
repository = new NodeRepository(mockAdapter);
|
|
});
|
|
|
|
describe('getNodeOperations', () => {
|
|
it('should extract operations from array format', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.httpRequest',
|
|
display_name: 'HTTP Request',
|
|
operations: JSON.stringify([
|
|
{ name: 'get', displayName: 'GET' },
|
|
{ name: 'post', displayName: 'POST' }
|
|
]),
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.httpRequest', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.httpRequest');
|
|
|
|
expect(operations).toEqual([
|
|
{ name: 'get', displayName: 'GET' },
|
|
{ name: 'post', displayName: 'POST' }
|
|
]);
|
|
});
|
|
|
|
it('should extract operations from object format grouped by resource', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.slack',
|
|
display_name: 'Slack',
|
|
operations: JSON.stringify({
|
|
message: [
|
|
{ name: 'send', displayName: 'Send Message' },
|
|
{ name: 'update', displayName: 'Update Message' }
|
|
],
|
|
channel: [
|
|
{ name: 'create', displayName: 'Create Channel' },
|
|
{ name: 'archive', displayName: 'Archive Channel' }
|
|
]
|
|
}),
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.slack', mockNode);
|
|
|
|
const allOperations = repository.getNodeOperations('nodes-base.slack');
|
|
const messageOperations = repository.getNodeOperations('nodes-base.slack', 'message');
|
|
|
|
expect(allOperations).toHaveLength(4);
|
|
expect(messageOperations).toEqual([
|
|
{ name: 'send', displayName: 'Send Message' },
|
|
{ name: 'update', displayName: 'Update Message' }
|
|
]);
|
|
});
|
|
|
|
it('should extract operations from properties with operation field', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.googleSheets',
|
|
display_name: 'Google Sheets',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'resource',
|
|
type: 'options',
|
|
options: [{ name: 'sheet', displayName: 'Sheet' }]
|
|
},
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['sheet']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'append', displayName: 'Append Row' },
|
|
{ name: 'read', displayName: 'Read Rows' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.googleSheets', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.googleSheets');
|
|
|
|
expect(operations).toEqual([
|
|
{ name: 'append', displayName: 'Append Row' },
|
|
{ name: 'read', displayName: 'Read Rows' }
|
|
]);
|
|
});
|
|
|
|
it('should filter operations by resource when specified', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.googleSheets',
|
|
display_name: 'Google Sheets',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['sheet']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'append', displayName: 'Append Row' }
|
|
]
|
|
},
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['cell']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'update', displayName: 'Update Cell' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.googleSheets', mockNode);
|
|
|
|
const sheetOperations = repository.getNodeOperations('nodes-base.googleSheets', 'sheet');
|
|
const cellOperations = repository.getNodeOperations('nodes-base.googleSheets', 'cell');
|
|
|
|
expect(sheetOperations).toEqual([{ name: 'append', displayName: 'Append Row' }]);
|
|
expect(cellOperations).toEqual([{ name: 'update', displayName: 'Update Cell' }]);
|
|
});
|
|
|
|
it('should return empty array for non-existent node', () => {
|
|
const operations = repository.getNodeOperations('nodes-base.nonexistent');
|
|
expect(operations).toEqual([]);
|
|
});
|
|
|
|
it('should handle nodes without operations', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.simple',
|
|
display_name: 'Simple Node',
|
|
operations: '[]',
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.simple', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.simple');
|
|
expect(operations).toEqual([]);
|
|
});
|
|
|
|
it('should handle malformed operations JSON gracefully', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.broken',
|
|
display_name: 'Broken Node',
|
|
operations: '{invalid json}',
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.broken', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.broken');
|
|
expect(operations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('getNodeResources', () => {
|
|
it('should extract resources from properties', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.slack',
|
|
display_name: 'Slack',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'resource',
|
|
type: 'options',
|
|
options: [
|
|
{ name: 'message', displayName: 'Message' },
|
|
{ name: 'channel', displayName: 'Channel' },
|
|
{ name: 'user', displayName: 'User' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.slack', mockNode);
|
|
|
|
const resources = repository.getNodeResources('nodes-base.slack');
|
|
|
|
expect(resources).toEqual([
|
|
{ name: 'message', displayName: 'Message' },
|
|
{ name: 'channel', displayName: 'Channel' },
|
|
{ name: 'user', displayName: 'User' }
|
|
]);
|
|
});
|
|
|
|
it('should return empty array for node without resources', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.simple',
|
|
display_name: 'Simple Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{ name: 'url', type: 'string' }
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.simple', mockNode);
|
|
|
|
const resources = repository.getNodeResources('nodes-base.simple');
|
|
expect(resources).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array for non-existent node', () => {
|
|
const resources = repository.getNodeResources('nodes-base.nonexistent');
|
|
expect(resources).toEqual([]);
|
|
});
|
|
|
|
it('should handle multiple resource properties', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.multi',
|
|
display_name: 'Multi Resource Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'resource',
|
|
type: 'options',
|
|
options: [{ name: 'type1', displayName: 'Type 1' }]
|
|
},
|
|
{
|
|
name: 'resource',
|
|
type: 'options',
|
|
options: [{ name: 'type2', displayName: 'Type 2' }]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.multi', mockNode);
|
|
|
|
const resources = repository.getNodeResources('nodes-base.multi');
|
|
|
|
expect(resources).toEqual([
|
|
{ name: 'type1', displayName: 'Type 1' },
|
|
{ name: 'type2', displayName: 'Type 2' }
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('getOperationsForResource', () => {
|
|
it('should return operations for specific resource', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.slack',
|
|
display_name: 'Slack',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['message']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'send', displayName: 'Send Message' },
|
|
{ name: 'update', displayName: 'Update Message' }
|
|
]
|
|
},
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['channel']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'create', displayName: 'Create Channel' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.slack', mockNode);
|
|
|
|
const messageOps = repository.getOperationsForResource('nodes-base.slack', 'message');
|
|
const channelOps = repository.getOperationsForResource('nodes-base.slack', 'channel');
|
|
const nonExistentOps = repository.getOperationsForResource('nodes-base.slack', 'nonexistent');
|
|
|
|
expect(messageOps).toEqual([
|
|
{ name: 'send', displayName: 'Send Message' },
|
|
{ name: 'update', displayName: 'Update Message' }
|
|
]);
|
|
expect(channelOps).toEqual([
|
|
{ name: 'create', displayName: 'Create Channel' }
|
|
]);
|
|
expect(nonExistentOps).toEqual([]);
|
|
});
|
|
|
|
it('should handle array format for resource display options', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.multi',
|
|
display_name: 'Multi Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['message', 'channel'] // Array format
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'list', displayName: 'List Items' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.multi', mockNode);
|
|
|
|
const messageOps = repository.getOperationsForResource('nodes-base.multi', 'message');
|
|
const channelOps = repository.getOperationsForResource('nodes-base.multi', 'channel');
|
|
const otherOps = repository.getOperationsForResource('nodes-base.multi', 'other');
|
|
|
|
expect(messageOps).toEqual([{ name: 'list', displayName: 'List Items' }]);
|
|
expect(channelOps).toEqual([{ name: 'list', displayName: 'List Items' }]);
|
|
expect(otherOps).toEqual([]);
|
|
});
|
|
|
|
it('should return empty array for non-existent node', () => {
|
|
const operations = repository.getOperationsForResource('nodes-base.nonexistent', 'message');
|
|
expect(operations).toEqual([]);
|
|
});
|
|
|
|
it('should handle string format for single resource', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.single',
|
|
display_name: 'Single Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: 'document' // String format
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'create', displayName: 'Create Document' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.single', mockNode);
|
|
|
|
const operations = repository.getOperationsForResource('nodes-base.single', 'document');
|
|
expect(operations).toEqual([{ name: 'create', displayName: 'Create Document' }]);
|
|
});
|
|
});
|
|
|
|
describe('getAllOperations', () => {
|
|
it('should collect operations from all nodes', () => {
|
|
const mockNodes = [
|
|
{
|
|
node_type: 'nodes-base.httpRequest',
|
|
display_name: 'HTTP Request',
|
|
operations: JSON.stringify([{ name: 'execute' }]),
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
},
|
|
{
|
|
node_type: 'nodes-base.slack',
|
|
display_name: 'Slack',
|
|
operations: JSON.stringify([{ name: 'send' }]),
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
},
|
|
{
|
|
node_type: 'nodes-base.empty',
|
|
display_name: 'Empty Node',
|
|
operations: '[]',
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
}
|
|
];
|
|
|
|
mockNodes.forEach(node => {
|
|
mockAdapter._setMockNode(node.node_type, node);
|
|
});
|
|
|
|
const allOperations = repository.getAllOperations();
|
|
|
|
expect(allOperations.size).toBe(2); // Only nodes with operations
|
|
expect(allOperations.get('nodes-base.httpRequest')).toEqual([{ name: 'execute' }]);
|
|
expect(allOperations.get('nodes-base.slack')).toEqual([{ name: 'send' }]);
|
|
expect(allOperations.has('nodes-base.empty')).toBe(false);
|
|
});
|
|
|
|
it('should handle empty node list', () => {
|
|
const allOperations = repository.getAllOperations();
|
|
expect(allOperations.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('getAllResources', () => {
|
|
it('should collect resources from all nodes', () => {
|
|
const mockNodes = [
|
|
{
|
|
node_type: 'nodes-base.slack',
|
|
display_name: 'Slack',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'resource',
|
|
options: [{ name: 'message' }, { name: 'channel' }]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
},
|
|
{
|
|
node_type: 'nodes-base.sheets',
|
|
display_name: 'Google Sheets',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'resource',
|
|
options: [{ name: 'sheet' }]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
},
|
|
{
|
|
node_type: 'nodes-base.simple',
|
|
display_name: 'Simple Node',
|
|
operations: '[]',
|
|
properties_schema: '[]', // No resources
|
|
credentials_required: '[]'
|
|
}
|
|
];
|
|
|
|
mockNodes.forEach(node => {
|
|
mockAdapter._setMockNode(node.node_type, node);
|
|
});
|
|
|
|
const allResources = repository.getAllResources();
|
|
|
|
expect(allResources.size).toBe(2); // Only nodes with resources
|
|
expect(allResources.get('nodes-base.slack')).toEqual([
|
|
{ name: 'message' },
|
|
{ name: 'channel' }
|
|
]);
|
|
expect(allResources.get('nodes-base.sheets')).toEqual([{ name: 'sheet' }]);
|
|
expect(allResources.has('nodes-base.simple')).toBe(false);
|
|
});
|
|
|
|
it('should handle empty node list', () => {
|
|
const allResources = repository.getAllResources();
|
|
expect(allResources.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('edge cases and error handling', () => {
|
|
it('should handle null or undefined properties gracefully', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.null',
|
|
display_name: 'Null Node',
|
|
operations: null,
|
|
properties_schema: null,
|
|
credentials_required: null
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.null', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.null');
|
|
const resources = repository.getNodeResources('nodes-base.null');
|
|
|
|
expect(operations).toEqual([]);
|
|
expect(resources).toEqual([]);
|
|
});
|
|
|
|
it('should handle complex nested operation properties', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.complex',
|
|
display_name: 'Complex Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify([
|
|
{
|
|
name: 'operation',
|
|
type: 'options',
|
|
displayOptions: {
|
|
show: {
|
|
resource: ['message'],
|
|
mode: ['advanced']
|
|
}
|
|
},
|
|
options: [
|
|
{ name: 'complexOperation', displayName: 'Complex Operation' }
|
|
]
|
|
}
|
|
]),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.complex', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.complex');
|
|
expect(operations).toEqual([{ name: 'complexOperation', displayName: 'Complex Operation' }]);
|
|
});
|
|
|
|
it('should handle operations with mixed data types', () => {
|
|
const mockNode = {
|
|
node_type: 'nodes-base.mixed',
|
|
display_name: 'Mixed Node',
|
|
operations: JSON.stringify({
|
|
string_operation: 'invalid', // Should be array
|
|
valid_operations: [{ name: 'valid' }],
|
|
nested_object: { inner: [{ name: 'nested' }] }
|
|
}),
|
|
properties_schema: '[]',
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.mixed', mockNode);
|
|
|
|
const operations = repository.getNodeOperations('nodes-base.mixed');
|
|
expect(operations).toEqual([{ name: 'valid' }]); // Only valid array operations
|
|
});
|
|
|
|
it('should handle very deeply nested properties', () => {
|
|
const deepProperties = [
|
|
{
|
|
name: 'resource',
|
|
options: [{ name: 'deep', displayName: 'Deep Resource' }],
|
|
nested: {
|
|
level1: {
|
|
level2: {
|
|
operations: [{ name: 'deep_operation' }]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
];
|
|
|
|
const mockNode = {
|
|
node_type: 'nodes-base.deep',
|
|
display_name: 'Deep Node',
|
|
operations: '[]',
|
|
properties_schema: JSON.stringify(deepProperties),
|
|
credentials_required: '[]'
|
|
};
|
|
|
|
mockAdapter._setMockNode('nodes-base.deep', mockNode);
|
|
|
|
const resources = repository.getNodeResources('nodes-base.deep');
|
|
expect(resources).toEqual([{ name: 'deep', displayName: 'Deep Resource' }]);
|
|
});
|
|
});
|
|
}); |