mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 05:23:08 +00:00
feat: add operation and resource validation with intelligent suggestions
- Added OperationSimilarityService for validating operations with "Did you mean...?" suggestions - Added ResourceSimilarityService for validating resources with plural/singular detection - Implements Levenshtein distance algorithm for typo detection - Pattern matching for common operation/resource mistakes - 5-minute cache with automatic cleanup to prevent memory leaks - Confidence scoring (30% minimum threshold) for suggestion quality - Resource-aware operation filtering for contextual suggestions - Safe JSON parsing with ValidationServiceError for proper error handling - Type guards for safe property access - Performance optimizations with early termination - Comprehensive test coverage (37 new tests) - Integration tested with n8n-mcp-tester agent Example use cases: - "listFiles" → suggests "search" for Google Drive - "files" → suggests singular "file" - "flie" → suggests "file" (typo correction) - "downlod" → suggests "download" 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
288
tests/unit/services/resource-similarity-service.test.ts
Normal file
288
tests/unit/services/resource-similarity-service.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Tests for ResourceSimilarityService
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ResourceSimilarityService } from '../../../src/services/resource-similarity-service';
|
||||
import { NodeRepository } from '../../../src/database/node-repository';
|
||||
import { createTestDatabase } from '../../utils/database-utils';
|
||||
|
||||
describe('ResourceSimilarityService', () => {
|
||||
let service: ResourceSimilarityService;
|
||||
let repository: NodeRepository;
|
||||
let testDb: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDb = await createTestDatabase();
|
||||
repository = testDb.nodeRepository;
|
||||
service = new ResourceSimilarityService(repository);
|
||||
|
||||
// Add test node with resources
|
||||
const testNode = {
|
||||
nodeType: 'nodes-base.googleDrive',
|
||||
packageName: 'n8n-nodes-base',
|
||||
displayName: 'Google Drive',
|
||||
description: 'Access Google Drive',
|
||||
category: 'transform',
|
||||
style: 'declarative',
|
||||
isAITool: false,
|
||||
isTrigger: false,
|
||||
isWebhook: false,
|
||||
isVersioned: true,
|
||||
version: 1,
|
||||
properties: [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'file', name: 'File' },
|
||||
{ value: 'folder', name: 'Folder' },
|
||||
{ value: 'drive', name: 'Shared Drive' },
|
||||
{ value: 'fileFolder', name: 'File & Folder' }
|
||||
]
|
||||
}
|
||||
],
|
||||
operations: [],
|
||||
credentials: []
|
||||
};
|
||||
|
||||
repository.saveNode(testNode);
|
||||
|
||||
// Add Slack node for testing different patterns
|
||||
const slackNode = {
|
||||
nodeType: 'nodes-base.slack',
|
||||
packageName: 'n8n-nodes-base',
|
||||
displayName: 'Slack',
|
||||
description: 'Send messages to Slack',
|
||||
category: 'communication',
|
||||
style: 'declarative',
|
||||
isAITool: false,
|
||||
isTrigger: false,
|
||||
isWebhook: false,
|
||||
isVersioned: true,
|
||||
version: 2,
|
||||
properties: [
|
||||
{
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ value: 'channel', name: 'Channel' },
|
||||
{ value: 'message', name: 'Message' },
|
||||
{ value: 'user', name: 'User' },
|
||||
{ value: 'file', name: 'File' },
|
||||
{ value: 'star', name: 'Star' }
|
||||
]
|
||||
}
|
||||
],
|
||||
operations: [],
|
||||
credentials: []
|
||||
};
|
||||
|
||||
repository.saveNode(slackNode);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (testDb) {
|
||||
await testDb.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
describe('findSimilarResources', () => {
|
||||
it('should find exact match', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'file',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(0); // No suggestions for valid resource
|
||||
});
|
||||
|
||||
it('should suggest singular form for plural input', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'files',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('file');
|
||||
expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9);
|
||||
expect(suggestions[0].reason).toContain('singular');
|
||||
});
|
||||
|
||||
it('should suggest singular form for folders', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'folders',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('folder');
|
||||
expect(suggestions[0].confidence).toBeGreaterThanOrEqual(0.9);
|
||||
});
|
||||
|
||||
it('should handle typos with Levenshtein distance', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'flie',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('file');
|
||||
expect(suggestions[0].confidence).toBeGreaterThan(0.7);
|
||||
});
|
||||
|
||||
it('should handle combined resources', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'fileAndFolder',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
// Should suggest 'fileFolder' (the actual combined resource)
|
||||
const fileFolderSuggestion = suggestions.find(s => s.value === 'fileFolder');
|
||||
expect(fileFolderSuggestion).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return empty array for node not found', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.nonexistent',
|
||||
'resource',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plural/singular detection', () => {
|
||||
it('should handle regular plurals (s)', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.slack',
|
||||
'channels',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('channel');
|
||||
});
|
||||
|
||||
it('should handle plural ending in es', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.slack',
|
||||
'messages',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('message');
|
||||
});
|
||||
|
||||
it('should handle plural ending in ies', () => {
|
||||
// Test with a hypothetical 'entities' -> 'entity' conversion
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'entities',
|
||||
5
|
||||
);
|
||||
|
||||
// Should not crash and provide some suggestions
|
||||
expect(suggestions).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('node-specific patterns', () => {
|
||||
it('should apply Google Drive specific patterns', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'sharedDrives',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
const driveSuggestion = suggestions.find(s => s.value === 'drive');
|
||||
expect(driveSuggestion).toBeDefined();
|
||||
});
|
||||
|
||||
it('should apply Slack specific patterns', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.slack',
|
||||
'users',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].value).toBe('user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('similarity calculation', () => {
|
||||
it('should rank exact matches highest', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'file',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(0); // Exact match, no suggestions
|
||||
});
|
||||
|
||||
it('should rank substring matches high', () => {
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'fil',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
const fileSuggestion = suggestions.find(s => s.value === 'file');
|
||||
expect(fileSuggestion).toBeDefined();
|
||||
expect(fileSuggestion!.confidence).toBeGreaterThanOrEqual(0.7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('caching', () => {
|
||||
it('should cache results for repeated queries', () => {
|
||||
// First call
|
||||
const suggestions1 = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'files',
|
||||
5
|
||||
);
|
||||
|
||||
// Second call with same params
|
||||
const suggestions2 = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'files',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions1).toEqual(suggestions2);
|
||||
});
|
||||
|
||||
it('should clear cache when requested', () => {
|
||||
// Add to cache
|
||||
service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'test',
|
||||
5
|
||||
);
|
||||
|
||||
// Clear cache
|
||||
service.clearCache();
|
||||
|
||||
// This would fetch fresh data (behavior is the same, just uncached)
|
||||
const suggestions = service.findSimilarResources(
|
||||
'nodes-base.googleDrive',
|
||||
'test',
|
||||
5
|
||||
);
|
||||
|
||||
expect(suggestions).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user