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:
czlonkowski
2025-09-24 23:57:25 +02:00
parent c23442249a
commit f6160d43a0
13 changed files with 2498 additions and 3 deletions

View File

@@ -0,0 +1,234 @@
/**
* Tests for OperationSimilarityService
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { OperationSimilarityService } from '../../../src/services/operation-similarity-service';
import { NodeRepository } from '../../../src/database/node-repository';
import { createTestDatabase } from '../../utils/database-utils';
describe('OperationSimilarityService', () => {
let service: OperationSimilarityService;
let repository: NodeRepository;
let testDb: any;
beforeEach(async () => {
testDb = await createTestDatabase();
repository = testDb.nodeRepository;
service = new OperationSimilarityService(repository);
// Add test node with operations
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' },
]
},
{
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['file']
}
},
options: [
{ value: 'copy', name: 'Copy' },
{ value: 'delete', name: 'Delete' },
{ value: 'download', name: 'Download' },
{ value: 'list', name: 'List' },
{ value: 'share', name: 'Share' },
{ value: 'update', name: 'Update' },
{ value: 'upload', name: 'Upload' }
]
},
{
name: 'operation',
type: 'options',
displayOptions: {
show: {
resource: ['folder']
}
},
options: [
{ value: 'create', name: 'Create' },
{ value: 'delete', name: 'Delete' },
{ value: 'share', name: 'Share' }
]
}
],
operations: [],
credentials: []
};
repository.saveNode(testNode);
});
afterEach(async () => {
if (testDb) {
await testDb.cleanup();
}
});
describe('findSimilarOperations', () => {
it('should find exact match', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'download',
'file'
);
expect(suggestions).toHaveLength(0); // No suggestions for valid operation
});
it('should suggest similar operations for typos', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'downlod',
'file'
);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].value).toBe('download');
expect(suggestions[0].confidence).toBeGreaterThan(0.8);
});
it('should handle common mistakes with patterns', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'uploadFile',
'file'
);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].value).toBe('upload');
expect(suggestions[0].reason).toContain('instead of');
});
it('should filter operations by resource', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'upload',
'folder'
);
// Upload is not valid for folder resource
expect(suggestions).toBeDefined();
expect(suggestions.find(s => s.value === 'upload')).toBeUndefined();
});
it('should return empty array for node not found', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.nonexistent',
'operation',
undefined
);
expect(suggestions).toEqual([]);
});
it('should handle operations without resource filtering', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'updat', // Missing 'e' at the end
undefined
);
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions[0].value).toBe('update');
});
});
describe('similarity calculation', () => {
it('should rank exact matches highest', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'delete',
'file'
);
expect(suggestions).toHaveLength(0); // Exact match, no suggestions needed
});
it('should rank substring matches high', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'del',
'file'
);
expect(suggestions.length).toBeGreaterThan(0);
const deleteSuggestion = suggestions.find(s => s.value === 'delete');
expect(deleteSuggestion).toBeDefined();
expect(deleteSuggestion!.confidence).toBeGreaterThanOrEqual(0.7);
});
it('should detect common variations', () => {
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'getData',
'file'
);
expect(suggestions.length).toBeGreaterThan(0);
// Should suggest 'download' or similar
});
});
describe('caching', () => {
it('should cache results for repeated queries', () => {
// First call
const suggestions1 = service.findSimilarOperations(
'nodes-base.googleDrive',
'downlod',
'file'
);
// Second call with same params
const suggestions2 = service.findSimilarOperations(
'nodes-base.googleDrive',
'downlod',
'file'
);
expect(suggestions1).toEqual(suggestions2);
});
it('should clear cache when requested', () => {
// Add to cache
service.findSimilarOperations(
'nodes-base.googleDrive',
'test',
'file'
);
// Clear cache
service.clearCache();
// This would fetch fresh data (behavior is the same, just uncached)
const suggestions = service.findSimilarOperations(
'nodes-base.googleDrive',
'test',
'file'
);
expect(suggestions).toBeDefined();
});
});
});