diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 34d6b4b..ead3463 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.13.2] - 2025-01-24 + +### Added +- **Operation and Resource Validation with Intelligent Suggestions**: New similarity services for n8n node configuration validation + - `OperationSimilarityService`: Validates operations and suggests similar alternatives using Levenshtein distance and pattern matching + - `ResourceSimilarityService`: Validates resources with automatic plural/singular conversion and typo detection + - Provides "Did you mean...?" suggestions when invalid operations or resources are used + - Example: `operation: "listFiles"` suggests `"search"` for Google Drive nodes + - Example: `resource: "files"` suggests singular `"file"` with 95% confidence + - Confidence-based suggestions (minimum 30% threshold) with contextual fix messages + - Resource-aware operation filtering ensures suggestions are contextually appropriate + - 5-minute cache duration for performance optimization + - Integrated into `EnhancedConfigValidator` for seamless validation flow + +- **Custom Error Handling**: New `ValidationServiceError` class for better error management + - Proper error chaining with cause tracking + - Specialized factory methods for common error scenarios + - Type-safe error propagation throughout the validation pipeline + +### Enhanced +- **Code Quality and Security Improvements** (based on code review feedback): + - Safe JSON parsing with try-catch error boundaries + - Type guards for safe property access (`getOperationValue`, `getResourceValue`) + - Memory leak prevention with periodic cache cleanup + - Performance optimization with early termination for exact matches + - Replaced magic numbers with named constants for better maintainability + - Comprehensive JSDoc documentation for all public methods + - Improved confidence calculation for typos and transpositions + +### Fixed +- **Test Compatibility**: Updated test expectations to correctly handle exact match scenarios +- **Cache Management**: Fixed cache cleanup to prevent unbounded memory growth +- **Validation Deduplication**: Enhanced config validator now properly replaces base validator errors with detailed suggestions + +### Testing +- Added comprehensive test coverage for similarity services (37 new tests) +- All unit tests passing with proper edge case handling +- Integration confirmed via n8n-mcp-tester agent validation + ## [2.13.1] - 2025-01-24 ### Changed diff --git a/package.json b/package.json index 771ce37..88e81d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.13.1", + "version": "2.13.2", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "bin": { diff --git a/package.runtime.json b/package.runtime.json index e6175c1..fae6d9b 100644 --- a/package.runtime.json +++ b/package.runtime.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp-runtime", - "version": "2.13.0", + "version": "2.13.2", "description": "n8n MCP Server Runtime Dependencies Only", "private": true, "dependencies": { diff --git a/scripts/test-operation-validation.ts b/scripts/test-operation-validation.ts new file mode 100644 index 0000000..01832ef --- /dev/null +++ b/scripts/test-operation-validation.ts @@ -0,0 +1,178 @@ +/** + * Test script for operation and resource validation with Google Drive example + */ + +import { DatabaseAdapter } from '../src/database/database-adapter'; +import { NodeRepository } from '../src/database/node-repository'; +import { EnhancedConfigValidator } from '../src/services/enhanced-config-validator'; +import { WorkflowValidator } from '../src/services/workflow-validator'; +import { createDatabaseAdapter } from '../src/database/database-adapter'; +import { logger } from '../src/utils/logger'; +import chalk from 'chalk'; + +async function testOperationValidation() { + console.log(chalk.blue('Testing Operation and Resource Validation')); + console.log('='.repeat(60)); + + // Initialize database + const dbPath = process.env.NODE_DB_PATH || 'data/nodes.db'; + const db = await createDatabaseAdapter(dbPath); + const repository = new NodeRepository(db); + + // Initialize similarity services + EnhancedConfigValidator.initializeSimilarityServices(repository); + + // Test 1: Invalid operation "listFiles" + console.log(chalk.yellow('\nšŸ“ Test 1: Google Drive with invalid operation "listFiles"')); + const invalidConfig = { + resource: 'fileFolder', + operation: 'listFiles' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + if (!node) { + console.error(chalk.red('Google Drive node not found in database')); + process.exit(1); + } + + const result1 = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + invalidConfig, + node.properties, + 'operation', + 'ai-friendly' + ); + + console.log(`Valid: ${result1.valid ? chalk.green('āœ“') : chalk.red('āœ—')}`); + if (result1.errors.length > 0) { + console.log(chalk.red('Errors:')); + result1.errors.forEach(error => { + console.log(` - ${error.property}: ${error.message}`); + if (error.fix) { + console.log(chalk.cyan(` Fix: ${error.fix}`)); + } + }); + } + + // Test 2: Invalid resource "files" (should be singular) + console.log(chalk.yellow('\nšŸ“ Test 2: Google Drive with invalid resource "files"')); + const pluralResourceConfig = { + resource: 'files', + operation: 'download' + }; + + const result2 = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + pluralResourceConfig, + node.properties, + 'operation', + 'ai-friendly' + ); + + console.log(`Valid: ${result2.valid ? chalk.green('āœ“') : chalk.red('āœ—')}`); + if (result2.errors.length > 0) { + console.log(chalk.red('Errors:')); + result2.errors.forEach(error => { + console.log(` - ${error.property}: ${error.message}`); + if (error.fix) { + console.log(chalk.cyan(` Fix: ${error.fix}`)); + } + }); + } + + // Test 3: Valid configuration + console.log(chalk.yellow('\nšŸ“ Test 3: Google Drive with valid configuration')); + const validConfig = { + resource: 'file', + operation: 'download' + }; + + const result3 = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + validConfig, + node.properties, + 'operation', + 'ai-friendly' + ); + + console.log(`Valid: ${result3.valid ? chalk.green('āœ“') : chalk.red('āœ—')}`); + if (result3.errors.length > 0) { + console.log(chalk.red('Errors:')); + result3.errors.forEach(error => { + console.log(` - ${error.property}: ${error.message}`); + }); + } else { + console.log(chalk.green('No errors - configuration is valid!')); + } + + // Test 4: Test in workflow context + console.log(chalk.yellow('\nšŸ“ Test 4: Full workflow with invalid Google Drive node')); + const workflow = { + name: 'Test Workflow', + nodes: [ + { + id: '1', + name: 'Google Drive', + type: 'n8n-nodes-base.googleDrive', + position: [100, 100] as [number, number], + parameters: { + resource: 'fileFolder', + operation: 'listFiles' // Invalid operation + } + } + ], + connections: {} + }; + + const validator = new WorkflowValidator(repository, EnhancedConfigValidator); + const workflowResult = await validator.validateWorkflow(workflow, { + validateNodes: true, + profile: 'ai-friendly' + }); + + console.log(`Workflow Valid: ${workflowResult.valid ? chalk.green('āœ“') : chalk.red('āœ—')}`); + if (workflowResult.errors.length > 0) { + console.log(chalk.red('Errors:')); + workflowResult.errors.forEach(error => { + console.log(` - ${error.nodeName || 'Workflow'}: ${error.message}`); + if (error.details?.fix) { + console.log(chalk.cyan(` Fix: ${error.details.fix}`)); + } + }); + } + + // Test 5: Typo in operation + console.log(chalk.yellow('\nšŸ“ Test 5: Typo in operation "downlod"')); + const typoConfig = { + resource: 'file', + operation: 'downlod' // Typo + }; + + const result5 = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + typoConfig, + node.properties, + 'operation', + 'ai-friendly' + ); + + console.log(`Valid: ${result5.valid ? chalk.green('āœ“') : chalk.red('āœ—')}`); + if (result5.errors.length > 0) { + console.log(chalk.red('Errors:')); + result5.errors.forEach(error => { + console.log(` - ${error.property}: ${error.message}`); + if (error.fix) { + console.log(chalk.cyan(` Fix: ${error.fix}`)); + } + }); + } + + console.log(chalk.green('\nāœ… All tests completed!')); + db.close(); +} + +// Run tests +testOperationValidation().catch(error => { + console.error(chalk.red('Error running tests:'), error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/database/node-repository.ts b/src/database/node-repository.ts index 5078fa6..745b827 100644 --- a/src/database/node-repository.ts +++ b/src/database/node-repository.ts @@ -248,4 +248,133 @@ export class NodeRepository { outputNames: row.output_names ? this.safeJsonParse(row.output_names, null) : null }; } + + /** + * Get operations for a specific node, optionally filtered by resource + */ + getNodeOperations(nodeType: string, resource?: string): any[] { + const node = this.getNode(nodeType); + if (!node) return []; + + const operations: any[] = []; + + // Parse operations field + if (node.operations) { + if (Array.isArray(node.operations)) { + operations.push(...node.operations); + } else if (typeof node.operations === 'object') { + // Operations might be grouped by resource + if (resource && node.operations[resource]) { + return node.operations[resource]; + } else { + // Return all operations + Object.values(node.operations).forEach(ops => { + if (Array.isArray(ops)) { + operations.push(...ops); + } + }); + } + } + } + + // Also check properties for operation fields + if (node.properties && Array.isArray(node.properties)) { + for (const prop of node.properties) { + if (prop.name === 'operation' && prop.options) { + // If resource is specified, filter by displayOptions + if (resource && prop.displayOptions?.show?.resource) { + const allowedResources = Array.isArray(prop.displayOptions.show.resource) + ? prop.displayOptions.show.resource + : [prop.displayOptions.show.resource]; + if (!allowedResources.includes(resource)) { + continue; + } + } + + // Add operations from this property + operations.push(...prop.options); + } + } + } + + return operations; + } + + /** + * Get all resources defined for a node + */ + getNodeResources(nodeType: string): any[] { + const node = this.getNode(nodeType); + if (!node || !node.properties) return []; + + const resources: any[] = []; + + // Look for resource property + for (const prop of node.properties) { + if (prop.name === 'resource' && prop.options) { + resources.push(...prop.options); + } + } + + return resources; + } + + /** + * Get operations that are valid for a specific resource + */ + getOperationsForResource(nodeType: string, resource: string): any[] { + const node = this.getNode(nodeType); + if (!node || !node.properties) return []; + + const operations: any[] = []; + + // Find operation properties that are visible for this resource + for (const prop of node.properties) { + if (prop.name === 'operation' && prop.displayOptions?.show?.resource) { + const allowedResources = Array.isArray(prop.displayOptions.show.resource) + ? prop.displayOptions.show.resource + : [prop.displayOptions.show.resource]; + + if (allowedResources.includes(resource) && prop.options) { + operations.push(...prop.options); + } + } + } + + return operations; + } + + /** + * Get all operations across all nodes (for analysis) + */ + getAllOperations(): Map { + const allOperations = new Map(); + const nodes = this.getAllNodes(); + + for (const node of nodes) { + const operations = this.getNodeOperations(node.nodeType); + if (operations.length > 0) { + allOperations.set(node.nodeType, operations); + } + } + + return allOperations; + } + + /** + * Get all resources across all nodes (for analysis) + */ + getAllResources(): Map { + const allResources = new Map(); + const nodes = this.getAllNodes(); + + for (const node of nodes) { + const resources = this.getNodeResources(node.nodeType); + if (resources.length > 0) { + allResources.set(node.nodeType, resources); + } + } + + return allResources; + } } \ No newline at end of file diff --git a/src/errors/validation-service-error.ts b/src/errors/validation-service-error.ts new file mode 100644 index 0000000..8f28b1e --- /dev/null +++ b/src/errors/validation-service-error.ts @@ -0,0 +1,53 @@ +/** + * Custom error class for validation service failures + */ +export class ValidationServiceError extends Error { + constructor( + message: string, + public readonly nodeType?: string, + public readonly property?: string, + public readonly cause?: Error + ) { + super(message); + this.name = 'ValidationServiceError'; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ValidationServiceError); + } + } + + /** + * Create error for JSON parsing failure + */ + static jsonParseError(nodeType: string, cause: Error): ValidationServiceError { + return new ValidationServiceError( + `Failed to parse JSON data for node ${nodeType}`, + nodeType, + undefined, + cause + ); + } + + /** + * Create error for node not found + */ + static nodeNotFound(nodeType: string): ValidationServiceError { + return new ValidationServiceError( + `Node type ${nodeType} not found in repository`, + nodeType + ); + } + + /** + * Create error for critical data extraction failure + */ + static dataExtractionError(nodeType: string, dataType: string, cause?: Error): ValidationServiceError { + return new ValidationServiceError( + `Failed to extract ${dataType} for node ${nodeType}`, + nodeType, + dataType, + cause + ); + } +} \ No newline at end of file diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 79dc0d5..a81920d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -134,6 +134,10 @@ export class N8NDocumentationMCPServer { this.repository = new NodeRepository(this.db); this.templateService = new TemplateService(this.db); + + // Initialize similarity services for enhanced validation + EnhancedConfigValidator.initializeSimilarityServices(this.repository); + logger.info(`Initialized database from: ${dbPath}`); } catch (error) { logger.error('Failed to initialize database:', error); diff --git a/src/services/enhanced-config-validator.ts b/src/services/enhanced-config-validator.ts index fb372fe..3503bb2 100644 --- a/src/services/enhanced-config-validator.ts +++ b/src/services/enhanced-config-validator.ts @@ -8,6 +8,10 @@ import { ConfigValidator, ValidationResult, ValidationError, ValidationWarning } from './config-validator'; import { NodeSpecificValidators, NodeValidationContext } from './node-specific-validators'; import { FixedCollectionValidator } from '../utils/fixed-collection-validator'; +import { OperationSimilarityService } from './operation-similarity-service'; +import { ResourceSimilarityService } from './resource-similarity-service'; +import { NodeRepository } from '../database/node-repository'; +import { DatabaseAdapter } from '../database/database-adapter'; export type ValidationMode = 'full' | 'operation' | 'minimal'; export type ValidationProfile = 'strict' | 'runtime' | 'ai-friendly' | 'minimal'; @@ -35,6 +39,18 @@ export interface OperationContext { } export class EnhancedConfigValidator extends ConfigValidator { + private static operationSimilarityService: OperationSimilarityService | null = null; + private static resourceSimilarityService: ResourceSimilarityService | null = null; + private static nodeRepository: NodeRepository | null = null; + + /** + * Initialize similarity services (called once at startup) + */ + static initializeSimilarityServices(repository: NodeRepository): void { + this.nodeRepository = repository; + this.operationSimilarityService = new OperationSimilarityService(repository); + this.resourceSimilarityService = new ResourceSimilarityService(repository); + } /** * Validate with operation awareness */ @@ -213,7 +229,10 @@ export class EnhancedConfigValidator extends ConfigValidator { }); return; } - + + // Validate resource and operation using similarity services + this.validateResourceAndOperation(nodeType, config, result); + // First, validate fixedCollection properties for known problematic nodes this.validateFixedCollectionStructures(nodeType, config, result); @@ -642,4 +661,127 @@ export class EnhancedConfigValidator extends ConfigValidator { // Add any Filter-node-specific validation here in the future } + + /** + * Validate resource and operation values using similarity services + */ + private static validateResourceAndOperation( + nodeType: string, + config: Record, + result: EnhancedValidationResult + ): void { + // Skip if similarity services not initialized + if (!this.operationSimilarityService || !this.resourceSimilarityService || !this.nodeRepository) { + return; + } + + // Validate resource field if present + if (config.resource !== undefined) { + // Remove any existing resource error from base validator to replace with our enhanced version + result.errors = result.errors.filter(e => e.property !== 'resource'); + const validResources = this.nodeRepository.getNodeResources(nodeType); + const resourceIsValid = validResources.some(r => { + const resourceValue = typeof r === 'string' ? r : r.value; + return resourceValue === config.resource; + }); + + if (!resourceIsValid && config.resource !== '') { + // Find similar resources + const suggestions = this.resourceSimilarityService.findSimilarResources( + nodeType, + config.resource, + 3 + ); + + // Build error message with suggestions + let errorMessage = `Invalid resource "${config.resource}" for node ${nodeType}.`; + let fix = ''; + + if (suggestions.length > 0) { + const topSuggestion = suggestions[0]; + // Always use "Did you mean" for the top suggestion + errorMessage += ` Did you mean "${topSuggestion.value}"?`; + if (topSuggestion.confidence >= 0.8) { + fix = `Change resource to "${topSuggestion.value}". ${topSuggestion.reason}`; + } else { + // For lower confidence, still show valid resources in the fix + fix = `Valid resources: ${validResources.slice(0, 5).map(r => { + const val = typeof r === 'string' ? r : r.value; + return `"${val}"`; + }).join(', ')}${validResources.length > 5 ? '...' : ''}`; + } + } else { + // No similar resources found, list valid ones + fix = `Valid resources: ${validResources.slice(0, 5).map(r => { + const val = typeof r === 'string' ? r : r.value; + return `"${val}"`; + }).join(', ')}${validResources.length > 5 ? '...' : ''}`; + } + + result.errors.push({ + type: 'invalid_value', + property: 'resource', + message: errorMessage, + fix + }); + } + } + + // Validate operation field if present + if (config.operation !== undefined) { + // Remove any existing operation error from base validator to replace with our enhanced version + result.errors = result.errors.filter(e => e.property !== 'operation'); + const validOperations = this.nodeRepository.getNodeOperations(nodeType, config.resource); + const operationIsValid = validOperations.some(op => { + const opValue = op.operation || op.value || op; + return opValue === config.operation; + }); + + if (!operationIsValid && config.operation !== '') { + // Find similar operations + const suggestions = this.operationSimilarityService.findSimilarOperations( + nodeType, + config.operation, + config.resource, + 3 + ); + + // Build error message with suggestions + let errorMessage = `Invalid operation "${config.operation}" for node ${nodeType}`; + if (config.resource) { + errorMessage += ` with resource "${config.resource}"`; + } + errorMessage += '.'; + + let fix = ''; + + if (suggestions.length > 0) { + const topSuggestion = suggestions[0]; + if (topSuggestion.confidence >= 0.8) { + errorMessage += ` Did you mean "${topSuggestion.value}"?`; + fix = `Change operation to "${topSuggestion.value}". ${topSuggestion.reason}`; + } else { + errorMessage += ` Similar operations: ${suggestions.map(s => `"${s.value}"`).join(', ')}`; + fix = `Valid operations${config.resource ? ` for resource "${config.resource}"` : ''}: ${validOperations.slice(0, 5).map(op => { + const val = op.operation || op.value || op; + return `"${val}"`; + }).join(', ')}${validOperations.length > 5 ? '...' : ''}`; + } + } else { + // No similar operations found, list valid ones + fix = `Valid operations${config.resource ? ` for resource "${config.resource}"` : ''}: ${validOperations.slice(0, 5).map(op => { + const val = op.operation || op.value || op; + return `"${val}"`; + }).join(', ')}${validOperations.length > 5 ? '...' : ''}`; + } + + result.errors.push({ + type: 'invalid_value', + property: 'operation', + message: errorMessage, + fix + }); + } + } + } } diff --git a/src/services/operation-similarity-service.ts b/src/services/operation-similarity-service.ts new file mode 100644 index 0000000..8f73cd3 --- /dev/null +++ b/src/services/operation-similarity-service.ts @@ -0,0 +1,485 @@ +import { NodeRepository } from '../database/node-repository'; +import { logger } from '../utils/logger'; +import { ValidationServiceError } from '../errors/validation-service-error'; + +export interface OperationSuggestion { + value: string; + confidence: number; + reason: string; + resource?: string; + description?: string; +} + +interface OperationPattern { + pattern: string; + suggestion: string; + confidence: number; + reason: string; +} + +export class OperationSimilarityService { + private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes + private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest + private static readonly MAX_SUGGESTIONS = 5; + + // Confidence thresholds for better code clarity + private static readonly CONFIDENCE_THRESHOLDS = { + EXACT: 1.0, + VERY_HIGH: 0.95, + HIGH: 0.8, + MEDIUM: 0.6, + MIN_SUBSTRING: 0.7 + } as const; + + private repository: NodeRepository; + private operationCache: Map = new Map(); + private suggestionCache: Map = new Map(); + private commonPatterns: Map; + + constructor(repository: NodeRepository) { + this.repository = repository; + this.commonPatterns = this.initializeCommonPatterns(); + } + + /** + * Clean up expired cache entries to prevent memory leaks + * Should be called periodically or before cache operations + */ + private cleanupExpiredEntries(): void { + const now = Date.now(); + + // Clean operation cache + for (const [key, value] of this.operationCache.entries()) { + if (now - value.timestamp >= OperationSimilarityService.CACHE_DURATION_MS) { + this.operationCache.delete(key); + } + } + + // Clean suggestion cache - these don't have timestamps, so clear if cache is too large + if (this.suggestionCache.size > 100) { + // Keep only the most recent 50 entries + const entries = Array.from(this.suggestionCache.entries()); + this.suggestionCache.clear(); + entries.slice(-50).forEach(([key, value]) => { + this.suggestionCache.set(key, value); + }); + } + } + + /** + * Initialize common operation mistake patterns + */ + private initializeCommonPatterns(): Map { + const patterns = new Map(); + + // Google Drive patterns + patterns.set('googleDrive', [ + { pattern: 'listFiles', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }, + { pattern: 'uploadFile', suggestion: 'upload', confidence: 0.95, reason: 'Use "upload" instead of "uploadFile"' }, + { pattern: 'deleteFile', suggestion: 'deleteFile', confidence: 1.0, reason: 'Exact match' }, + { pattern: 'downloadFile', suggestion: 'download', confidence: 0.95, reason: 'Use "download" instead of "downloadFile"' }, + { pattern: 'getFile', suggestion: 'download', confidence: 0.8, reason: 'Use "download" to retrieve file content' }, + { pattern: 'listFolders', suggestion: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder"' }, + ]); + + // Slack patterns + patterns.set('slack', [ + { pattern: 'sendMessage', suggestion: 'send', confidence: 0.95, reason: 'Use "send" instead of "sendMessage"' }, + { pattern: 'getMessage', suggestion: 'get', confidence: 0.9, reason: 'Use "get" to retrieve messages' }, + { pattern: 'postMessage', suggestion: 'send', confidence: 0.9, reason: 'Use "send" to post messages' }, + { pattern: 'deleteMessage', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteMessage"' }, + { pattern: 'createChannel', suggestion: 'create', confidence: 0.9, reason: 'Use "create" with resource: "channel"' }, + ]); + + // Database patterns (postgres, mysql, mongodb) + patterns.set('database', [ + { pattern: 'selectData', suggestion: 'select', confidence: 0.95, reason: 'Use "select" instead of "selectData"' }, + { pattern: 'insertData', suggestion: 'insert', confidence: 0.95, reason: 'Use "insert" instead of "insertData"' }, + { pattern: 'updateData', suggestion: 'update', confidence: 0.95, reason: 'Use "update" instead of "updateData"' }, + { pattern: 'deleteData', suggestion: 'delete', confidence: 0.95, reason: 'Use "delete" instead of "deleteData"' }, + { pattern: 'query', suggestion: 'select', confidence: 0.7, reason: 'Use "select" for queries' }, + { pattern: 'fetch', suggestion: 'select', confidence: 0.7, reason: 'Use "select" to fetch data' }, + ]); + + // HTTP patterns + patterns.set('httpRequest', [ + { pattern: 'fetch', suggestion: 'GET', confidence: 0.8, reason: 'Use "GET" method for fetching data' }, + { pattern: 'send', suggestion: 'POST', confidence: 0.7, reason: 'Use "POST" method for sending data' }, + { pattern: 'create', suggestion: 'POST', confidence: 0.8, reason: 'Use "POST" method for creating resources' }, + { pattern: 'update', suggestion: 'PUT', confidence: 0.8, reason: 'Use "PUT" method for updating resources' }, + { pattern: 'delete', suggestion: 'DELETE', confidence: 0.9, reason: 'Use "DELETE" method' }, + ]); + + // Generic patterns + patterns.set('generic', [ + { pattern: 'list', suggestion: 'get', confidence: 0.6, reason: 'Consider using "get" or "search"' }, + { pattern: 'retrieve', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to retrieve data' }, + { pattern: 'fetch', suggestion: 'get', confidence: 0.8, reason: 'Use "get" to fetch data' }, + { pattern: 'remove', suggestion: 'delete', confidence: 0.85, reason: 'Use "delete" to remove items' }, + { pattern: 'add', suggestion: 'create', confidence: 0.7, reason: 'Use "create" to add new items' }, + ]); + + return patterns; + } + + /** + * Find similar operations for an invalid operation using Levenshtein distance + * and pattern matching algorithms + * + * @param nodeType - The n8n node type (e.g., 'nodes-base.slack') + * @param invalidOperation - The invalid operation provided by the user + * @param resource - Optional resource to filter operations + * @param maxSuggestions - Maximum number of suggestions to return (default: 5) + * @returns Array of operation suggestions sorted by confidence + * + * @example + * findSimilarOperations('nodes-base.googleDrive', 'listFiles', 'fileFolder') + * // Returns: [{ value: 'search', confidence: 0.85, reason: 'Use "search" with resource: "fileFolder" to list files' }] + */ + findSimilarOperations( + nodeType: string, + invalidOperation: string, + resource?: string, + maxSuggestions: number = OperationSimilarityService.MAX_SUGGESTIONS + ): OperationSuggestion[] { + // Clean up expired cache entries periodically + if (Math.random() < 0.1) { // 10% chance to cleanup on each call + this.cleanupExpiredEntries(); + } + // Check cache first + const cacheKey = `${nodeType}:${invalidOperation}:${resource || ''}`; + if (this.suggestionCache.has(cacheKey)) { + return this.suggestionCache.get(cacheKey)!; + } + + const suggestions: OperationSuggestion[] = []; + + // Get valid operations for the node + const nodeInfo = this.repository.getNode(nodeType); + if (!nodeInfo) { + return []; + } + + const validOperations = this.getNodeOperations(nodeType, resource); + + // Early termination for exact match - no suggestions needed + for (const op of validOperations) { + const opValue = this.getOperationValue(op); + if (opValue.toLowerCase() === invalidOperation.toLowerCase()) { + return []; // Valid operation, no suggestions needed + } + } + + // Check for exact pattern matches first + const nodePatterns = this.getNodePatterns(nodeType); + for (const pattern of nodePatterns) { + if (pattern.pattern.toLowerCase() === invalidOperation.toLowerCase()) { + // Type-safe operation value extraction + const exists = validOperations.some(op => { + const opValue = this.getOperationValue(op); + return opValue === pattern.suggestion; + }); + if (exists) { + suggestions.push({ + value: pattern.suggestion, + confidence: pattern.confidence, + reason: pattern.reason, + resource + }); + } + } + } + + // Calculate similarity for all valid operations + for (const op of validOperations) { + const opValue = this.getOperationValue(op); + + const similarity = this.calculateSimilarity(invalidOperation, opValue); + + if (similarity >= OperationSimilarityService.MIN_CONFIDENCE) { + // Don't add if already suggested by pattern + if (!suggestions.some(s => s.value === opValue)) { + suggestions.push({ + value: opValue, + confidence: similarity, + reason: this.getSimilarityReason(similarity, invalidOperation, opValue), + resource: typeof op === 'object' ? op.resource : undefined, + description: typeof op === 'object' ? (op.description || op.name) : undefined + }); + } + } + } + + // Sort by confidence and limit + suggestions.sort((a, b) => b.confidence - a.confidence); + const topSuggestions = suggestions.slice(0, maxSuggestions); + + // Cache the result + this.suggestionCache.set(cacheKey, topSuggestions); + + return topSuggestions; + } + + /** + * Type-safe extraction of operation value from various formats + * @param op - Operation object or string + * @returns The operation value as a string + */ + private getOperationValue(op: any): string { + if (typeof op === 'string') { + return op; + } + if (typeof op === 'object' && op !== null) { + return op.operation || op.value || ''; + } + return ''; + } + + /** + * Type-safe extraction of resource value + * @param resource - Resource object or string + * @returns The resource value as a string + */ + private getResourceValue(resource: any): string { + if (typeof resource === 'string') { + return resource; + } + if (typeof resource === 'object' && resource !== null) { + return resource.value || ''; + } + return ''; + } + + /** + * Get operations for a node, handling resource filtering + */ + private getNodeOperations(nodeType: string, resource?: string): any[] { + // Cleanup cache periodically + if (Math.random() < 0.05) { // 5% chance + this.cleanupExpiredEntries(); + } + + const cacheKey = `${nodeType}:${resource || 'all'}`; + const cached = this.operationCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < OperationSimilarityService.CACHE_DURATION_MS) { + return cached.operations; + } + + const nodeInfo = this.repository.getNode(nodeType); + if (!nodeInfo) return []; + + let operations: any[] = []; + + // Parse operations from the node with safe JSON parsing + try { + const opsData = nodeInfo.operations; + if (typeof opsData === 'string') { + // Safe JSON parsing + try { + operations = JSON.parse(opsData); + } catch (parseError) { + logger.error(`JSON parse error for operations in ${nodeType}:`, parseError); + throw ValidationServiceError.jsonParseError(nodeType, parseError as Error); + } + } else if (Array.isArray(opsData)) { + operations = opsData; + } else if (opsData && typeof opsData === 'object') { + operations = Object.values(opsData).flat(); + } + } catch (error) { + // Re-throw ValidationServiceError, log and continue for others + if (error instanceof ValidationServiceError) { + throw error; + } + logger.warn(`Failed to process operations for ${nodeType}:`, error); + } + + // Also check properties for operation fields + try { + const properties = nodeInfo.properties || []; + for (const prop of properties) { + if (prop.name === 'operation' && prop.options) { + // Filter by resource if specified + if (prop.displayOptions?.show?.resource) { + const allowedResources = Array.isArray(prop.displayOptions.show.resource) + ? prop.displayOptions.show.resource + : [prop.displayOptions.show.resource]; + // Only filter if a specific resource is requested + if (resource && !allowedResources.includes(resource)) { + continue; + } + // If no resource specified, include all operations + } + + operations.push(...prop.options.map((opt: any) => ({ + operation: opt.value, + name: opt.name, + description: opt.description, + resource + }))); + } + } + } catch (error) { + logger.warn(`Failed to extract operations from properties for ${nodeType}:`, error); + } + + // Cache and return + this.operationCache.set(cacheKey, { operations, timestamp: Date.now() }); + return operations; + } + + /** + * Get patterns for a specific node type + */ + private getNodePatterns(nodeType: string): OperationPattern[] { + const patterns: OperationPattern[] = []; + + // Add node-specific patterns + if (nodeType.includes('googleDrive')) { + patterns.push(...(this.commonPatterns.get('googleDrive') || [])); + } else if (nodeType.includes('slack')) { + patterns.push(...(this.commonPatterns.get('slack') || [])); + } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { + patterns.push(...(this.commonPatterns.get('database') || [])); + } else if (nodeType.includes('httpRequest')) { + patterns.push(...(this.commonPatterns.get('httpRequest') || [])); + } + + // Always add generic patterns + patterns.push(...(this.commonPatterns.get('generic') || [])); + + return patterns; + } + + /** + * Calculate similarity between two strings using Levenshtein distance + */ + private calculateSimilarity(str1: string, str2: string): number { + const s1 = str1.toLowerCase(); + const s2 = str2.toLowerCase(); + + // Exact match + if (s1 === s2) return 1.0; + + // One is substring of the other + if (s1.includes(s2) || s2.includes(s1)) { + const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); + return Math.max(OperationSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); + } + + // Calculate Levenshtein distance + const distance = this.levenshteinDistance(s1, s2); + const maxLength = Math.max(s1.length, s2.length); + + // Convert distance to similarity (0 to 1) + let similarity = 1 - (distance / maxLength); + + // Boost confidence for single character typos and transpositions in short words + if (distance === 1 && maxLength <= 5) { + similarity = Math.max(similarity, 0.75); + } else if (distance === 2 && maxLength <= 5) { + // Boost for transpositions + similarity = Math.max(similarity, 0.72); + } + + // Boost similarity for common patterns + if (this.areCommonVariations(s1, s2)) { + return Math.min(1.0, similarity + 0.2); + } + + return similarity; + } + + /** + * Calculate Levenshtein distance between two strings + */ + private levenshteinDistance(str1: string, str2: string): number { + const m = str1.length; + const n = str2.length; + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = Math.min( + dp[i - 1][j] + 1, // deletion + dp[i][j - 1] + 1, // insertion + dp[i - 1][j - 1] + 1 // substitution + ); + } + } + } + + return dp[m][n]; + } + + /** + * Check if two strings are common variations + */ + private areCommonVariations(str1: string, str2: string): boolean { + // Check for common prefixes/suffixes + const commonPrefixes = ['get', 'set', 'create', 'delete', 'update', 'send', 'fetch']; + const commonSuffixes = ['data', 'item', 'record', 'message', 'file', 'folder']; + + for (const prefix of commonPrefixes) { + if ((str1.startsWith(prefix) && !str2.startsWith(prefix)) || + (!str1.startsWith(prefix) && str2.startsWith(prefix))) { + const s1Clean = str1.replace(prefix, ''); + const s2Clean = str2.replace(prefix, ''); + if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { + return true; + } + } + } + + for (const suffix of commonSuffixes) { + if ((str1.endsWith(suffix) && !str2.endsWith(suffix)) || + (!str1.endsWith(suffix) && str2.endsWith(suffix))) { + const s1Clean = str1.replace(suffix, ''); + const s2Clean = str2.replace(suffix, ''); + if (s1Clean === s2Clean || this.levenshteinDistance(s1Clean, s2Clean) <= 2) { + return true; + } + } + } + + return false; + } + + /** + * Generate a human-readable reason for the similarity + * @param confidence - Similarity confidence score + * @param invalid - The invalid operation string + * @param valid - The valid operation string + * @returns Human-readable explanation of the similarity + */ + private getSimilarityReason(confidence: number, invalid: string, valid: string): string { + const { VERY_HIGH, HIGH, MEDIUM } = OperationSimilarityService.CONFIDENCE_THRESHOLDS; + + if (confidence >= VERY_HIGH) { + return 'Almost exact match - likely a typo'; + } else if (confidence >= HIGH) { + return 'Very similar - common variation'; + } else if (confidence >= MEDIUM) { + return 'Similar operation'; + } else if (invalid.includes(valid) || valid.includes(invalid)) { + return 'Partial match'; + } else { + return 'Possibly related operation'; + } + } + + /** + * Clear caches + */ + clearCache(): void { + this.operationCache.clear(); + this.suggestionCache.clear(); + } +} \ No newline at end of file diff --git a/src/services/resource-similarity-service.ts b/src/services/resource-similarity-service.ts new file mode 100644 index 0000000..13c9d7f --- /dev/null +++ b/src/services/resource-similarity-service.ts @@ -0,0 +1,522 @@ +import { NodeRepository } from '../database/node-repository'; +import { logger } from '../utils/logger'; +import { ValidationServiceError } from '../errors/validation-service-error'; + +export interface ResourceSuggestion { + value: string; + confidence: number; + reason: string; + availableOperations?: string[]; +} + +interface ResourcePattern { + pattern: string; + suggestion: string; + confidence: number; + reason: string; +} + +export class ResourceSimilarityService { + private static readonly CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes + private static readonly MIN_CONFIDENCE = 0.3; // 30% minimum confidence to suggest + private static readonly MAX_SUGGESTIONS = 5; + + // Confidence thresholds for better code clarity + private static readonly CONFIDENCE_THRESHOLDS = { + EXACT: 1.0, + VERY_HIGH: 0.95, + HIGH: 0.8, + MEDIUM: 0.6, + MIN_SUBSTRING: 0.7 + } as const; + + private repository: NodeRepository; + private resourceCache: Map = new Map(); + private suggestionCache: Map = new Map(); + private commonPatterns: Map; + + constructor(repository: NodeRepository) { + this.repository = repository; + this.commonPatterns = this.initializeCommonPatterns(); + } + + /** + * Clean up expired cache entries to prevent memory leaks + */ + private cleanupExpiredEntries(): void { + const now = Date.now(); + + // Clean resource cache + for (const [key, value] of this.resourceCache.entries()) { + if (now - value.timestamp >= ResourceSimilarityService.CACHE_DURATION_MS) { + this.resourceCache.delete(key); + } + } + + // Clean suggestion cache - these don't have timestamps, so clear if cache is too large + if (this.suggestionCache.size > 100) { + // Keep only the most recent 50 entries + const entries = Array.from(this.suggestionCache.entries()); + this.suggestionCache.clear(); + entries.slice(-50).forEach(([key, value]) => { + this.suggestionCache.set(key, value); + }); + } + } + + /** + * Initialize common resource mistake patterns + */ + private initializeCommonPatterns(): Map { + const patterns = new Map(); + + // Google Drive patterns + patterns.set('googleDrive', [ + { pattern: 'files', suggestion: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }, + { pattern: 'folders', suggestion: 'folder', confidence: 0.95, reason: 'Use singular "folder" not plural' }, + { pattern: 'permissions', suggestion: 'permission', confidence: 0.9, reason: 'Use singular form' }, + { pattern: 'fileAndFolder', suggestion: 'fileFolder', confidence: 0.9, reason: 'Use "fileFolder" for combined operations' }, + { pattern: 'driveFiles', suggestion: 'file', confidence: 0.8, reason: 'Use "file" for file operations' }, + { pattern: 'sharedDrives', suggestion: 'drive', confidence: 0.85, reason: 'Use "drive" for shared drive operations' }, + ]); + + // Slack patterns + patterns.set('slack', [ + { pattern: 'messages', suggestion: 'message', confidence: 0.95, reason: 'Use singular "message" not plural' }, + { pattern: 'channels', suggestion: 'channel', confidence: 0.95, reason: 'Use singular "channel" not plural' }, + { pattern: 'users', suggestion: 'user', confidence: 0.95, reason: 'Use singular "user" not plural' }, + { pattern: 'msg', suggestion: 'message', confidence: 0.85, reason: 'Use full "message" not abbreviation' }, + { pattern: 'dm', suggestion: 'message', confidence: 0.7, reason: 'Use "message" for direct messages' }, + { pattern: 'conversation', suggestion: 'channel', confidence: 0.7, reason: 'Use "channel" for conversations' }, + ]); + + // Database patterns (postgres, mysql, mongodb) + patterns.set('database', [ + { pattern: 'tables', suggestion: 'table', confidence: 0.95, reason: 'Use singular "table" not plural' }, + { pattern: 'queries', suggestion: 'query', confidence: 0.95, reason: 'Use singular "query" not plural' }, + { pattern: 'collections', suggestion: 'collection', confidence: 0.95, reason: 'Use singular "collection" not plural' }, + { pattern: 'documents', suggestion: 'document', confidence: 0.95, reason: 'Use singular "document" not plural' }, + { pattern: 'records', suggestion: 'record', confidence: 0.85, reason: 'Use "record" or "document"' }, + { pattern: 'rows', suggestion: 'row', confidence: 0.9, reason: 'Use singular "row"' }, + ]); + + // Google Sheets patterns + patterns.set('googleSheets', [ + { pattern: 'sheets', suggestion: 'sheet', confidence: 0.95, reason: 'Use singular "sheet" not plural' }, + { pattern: 'spreadsheets', suggestion: 'spreadsheet', confidence: 0.95, reason: 'Use singular "spreadsheet"' }, + { pattern: 'cells', suggestion: 'cell', confidence: 0.9, reason: 'Use singular "cell"' }, + { pattern: 'ranges', suggestion: 'range', confidence: 0.9, reason: 'Use singular "range"' }, + { pattern: 'worksheets', suggestion: 'sheet', confidence: 0.8, reason: 'Use "sheet" for worksheet operations' }, + ]); + + // Email patterns + patterns.set('email', [ + { pattern: 'emails', suggestion: 'email', confidence: 0.95, reason: 'Use singular "email" not plural' }, + { pattern: 'messages', suggestion: 'message', confidence: 0.9, reason: 'Use "message" for email operations' }, + { pattern: 'mails', suggestion: 'email', confidence: 0.9, reason: 'Use "email" not "mail"' }, + { pattern: 'attachments', suggestion: 'attachment', confidence: 0.95, reason: 'Use singular "attachment"' }, + ]); + + // Generic plural/singular patterns + patterns.set('generic', [ + { pattern: 'items', suggestion: 'item', confidence: 0.9, reason: 'Use singular form' }, + { pattern: 'objects', suggestion: 'object', confidence: 0.9, reason: 'Use singular form' }, + { pattern: 'entities', suggestion: 'entity', confidence: 0.9, reason: 'Use singular form' }, + { pattern: 'resources', suggestion: 'resource', confidence: 0.9, reason: 'Use singular form' }, + { pattern: 'elements', suggestion: 'element', confidence: 0.9, reason: 'Use singular form' }, + ]); + + return patterns; + } + + /** + * Find similar resources for an invalid resource using pattern matching + * and Levenshtein distance algorithms + * + * @param nodeType - The n8n node type (e.g., 'nodes-base.googleDrive') + * @param invalidResource - The invalid resource provided by the user + * @param maxSuggestions - Maximum number of suggestions to return (default: 5) + * @returns Array of resource suggestions sorted by confidence + * + * @example + * findSimilarResources('nodes-base.googleDrive', 'files', 3) + * // Returns: [{ value: 'file', confidence: 0.95, reason: 'Use singular "file" not plural' }] + */ + findSimilarResources( + nodeType: string, + invalidResource: string, + maxSuggestions: number = ResourceSimilarityService.MAX_SUGGESTIONS + ): ResourceSuggestion[] { + // Clean up expired cache entries periodically + if (Math.random() < 0.1) { // 10% chance to cleanup on each call + this.cleanupExpiredEntries(); + } + // Check cache first + const cacheKey = `${nodeType}:${invalidResource}`; + if (this.suggestionCache.has(cacheKey)) { + return this.suggestionCache.get(cacheKey)!; + } + + const suggestions: ResourceSuggestion[] = []; + + // Get valid resources for the node + const validResources = this.getNodeResources(nodeType); + + // Early termination for exact match - no suggestions needed + for (const resource of validResources) { + const resourceValue = this.getResourceValue(resource); + if (resourceValue.toLowerCase() === invalidResource.toLowerCase()) { + return []; // Valid resource, no suggestions needed + } + } + + // Check for exact pattern matches first + const nodePatterns = this.getNodePatterns(nodeType); + for (const pattern of nodePatterns) { + if (pattern.pattern.toLowerCase() === invalidResource.toLowerCase()) { + // Check if the suggested resource actually exists with type safety + const exists = validResources.some(r => { + const resourceValue = this.getResourceValue(r); + return resourceValue === pattern.suggestion; + }); + if (exists) { + suggestions.push({ + value: pattern.suggestion, + confidence: pattern.confidence, + reason: pattern.reason + }); + } + } + } + + // Handle automatic plural/singular conversion + const singularForm = this.toSingular(invalidResource); + const pluralForm = this.toPlural(invalidResource); + + for (const resource of validResources) { + const resourceValue = this.getResourceValue(resource); + + // Check for plural/singular match + if (resourceValue === singularForm || resourceValue === pluralForm) { + if (!suggestions.some(s => s.value === resourceValue)) { + suggestions.push({ + value: resourceValue, + confidence: 0.9, + reason: invalidResource.endsWith('s') ? + 'Use singular form for resources' : + 'Incorrect plural/singular form', + availableOperations: typeof resource === 'object' ? resource.operations : undefined + }); + } + } + + // Calculate similarity + const similarity = this.calculateSimilarity(invalidResource, resourceValue); + if (similarity >= ResourceSimilarityService.MIN_CONFIDENCE) { + if (!suggestions.some(s => s.value === resourceValue)) { + suggestions.push({ + value: resourceValue, + confidence: similarity, + reason: this.getSimilarityReason(similarity, invalidResource, resourceValue), + availableOperations: typeof resource === 'object' ? resource.operations : undefined + }); + } + } + } + + // Sort by confidence and limit + suggestions.sort((a, b) => b.confidence - a.confidence); + const topSuggestions = suggestions.slice(0, maxSuggestions); + + // Cache the result + this.suggestionCache.set(cacheKey, topSuggestions); + + return topSuggestions; + } + + /** + * Type-safe extraction of resource value from various formats + * @param resource - Resource object or string + * @returns The resource value as a string + */ + private getResourceValue(resource: any): string { + if (typeof resource === 'string') { + return resource; + } + if (typeof resource === 'object' && resource !== null) { + return resource.value || ''; + } + return ''; + } + + /** + * Get resources for a node with caching + */ + private getNodeResources(nodeType: string): any[] { + // Cleanup cache periodically + if (Math.random() < 0.05) { // 5% chance + this.cleanupExpiredEntries(); + } + + const cacheKey = nodeType; + const cached = this.resourceCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < ResourceSimilarityService.CACHE_DURATION_MS) { + return cached.resources; + } + + const nodeInfo = this.repository.getNode(nodeType); + if (!nodeInfo) return []; + + const resources: any[] = []; + const resourceMap: Map = new Map(); + + // Parse properties for resource fields + try { + const properties = nodeInfo.properties || []; + for (const prop of properties) { + if (prop.name === 'resource' && prop.options) { + for (const option of prop.options) { + resources.push({ + value: option.value, + name: option.name, + operations: [] + }); + resourceMap.set(option.value, []); + } + } + + // Find operations for each resource + if (prop.name === 'operation' && prop.displayOptions?.show?.resource) { + const resourceValues = Array.isArray(prop.displayOptions.show.resource) + ? prop.displayOptions.show.resource + : [prop.displayOptions.show.resource]; + + for (const resourceValue of resourceValues) { + if (resourceMap.has(resourceValue) && prop.options) { + const ops = prop.options.map((op: any) => op.value); + resourceMap.get(resourceValue)!.push(...ops); + } + } + } + } + + // Update resources with their operations + for (const resource of resources) { + if (resourceMap.has(resource.value)) { + resource.operations = resourceMap.get(resource.value); + } + } + + // If no explicit resources, check for common patterns + if (resources.length === 0) { + // Some nodes don't have explicit resource fields + const implicitResources = this.extractImplicitResources(properties); + resources.push(...implicitResources); + } + } catch (error) { + logger.warn(`Failed to extract resources for ${nodeType}:`, error); + } + + // Cache and return + this.resourceCache.set(cacheKey, { resources, timestamp: Date.now() }); + return resources; + } + + /** + * Extract implicit resources from node properties + */ + private extractImplicitResources(properties: any[]): any[] { + const resources: any[] = []; + + // Look for properties that suggest resources + for (const prop of properties) { + if (prop.name === 'operation' && prop.options) { + // If there's no explicit resource field, operations might imply resources + const resourceFromOps = this.inferResourceFromOperations(prop.options); + if (resourceFromOps) { + resources.push({ + value: resourceFromOps, + name: resourceFromOps.charAt(0).toUpperCase() + resourceFromOps.slice(1), + operations: prop.options.map((op: any) => op.value) + }); + } + } + } + + return resources; + } + + /** + * Infer resource type from operations + */ + private inferResourceFromOperations(operations: any[]): string | null { + // Common patterns in operation names that suggest resources + const patterns = [ + { keywords: ['file', 'upload', 'download'], resource: 'file' }, + { keywords: ['folder', 'directory'], resource: 'folder' }, + { keywords: ['message', 'send', 'reply'], resource: 'message' }, + { keywords: ['channel', 'broadcast'], resource: 'channel' }, + { keywords: ['user', 'member'], resource: 'user' }, + { keywords: ['table', 'row', 'column'], resource: 'table' }, + { keywords: ['document', 'doc'], resource: 'document' }, + ]; + + for (const pattern of patterns) { + for (const op of operations) { + const opName = (op.value || op).toLowerCase(); + if (pattern.keywords.some(keyword => opName.includes(keyword))) { + return pattern.resource; + } + } + } + + return null; + } + + /** + * Get patterns for a specific node type + */ + private getNodePatterns(nodeType: string): ResourcePattern[] { + const patterns: ResourcePattern[] = []; + + // Add node-specific patterns + if (nodeType.includes('googleDrive')) { + patterns.push(...(this.commonPatterns.get('googleDrive') || [])); + } else if (nodeType.includes('slack')) { + patterns.push(...(this.commonPatterns.get('slack') || [])); + } else if (nodeType.includes('postgres') || nodeType.includes('mysql') || nodeType.includes('mongodb')) { + patterns.push(...(this.commonPatterns.get('database') || [])); + } else if (nodeType.includes('googleSheets')) { + patterns.push(...(this.commonPatterns.get('googleSheets') || [])); + } else if (nodeType.includes('gmail') || nodeType.includes('email')) { + patterns.push(...(this.commonPatterns.get('email') || [])); + } + + // Always add generic patterns + patterns.push(...(this.commonPatterns.get('generic') || [])); + + return patterns; + } + + /** + * Convert to singular form (simple heuristic) + */ + private toSingular(word: string): string { + if (word.endsWith('ies')) { + return word.slice(0, -3) + 'y'; + } else if (word.endsWith('es')) { + return word.slice(0, -2); + } else if (word.endsWith('s') && !word.endsWith('ss')) { + return word.slice(0, -1); + } + return word; + } + + /** + * Convert to plural form (simple heuristic) + */ + private toPlural(word: string): string { + if (word.endsWith('y') && !['ay', 'ey', 'iy', 'oy', 'uy'].includes(word.slice(-2))) { + return word.slice(0, -1) + 'ies'; + } else if (word.endsWith('s') || word.endsWith('x') || word.endsWith('z') || + word.endsWith('ch') || word.endsWith('sh')) { + return word + 'es'; + } else { + return word + 's'; + } + } + + /** + * Calculate similarity between two strings using Levenshtein distance + */ + private calculateSimilarity(str1: string, str2: string): number { + const s1 = str1.toLowerCase(); + const s2 = str2.toLowerCase(); + + // Exact match + if (s1 === s2) return 1.0; + + // One is substring of the other + if (s1.includes(s2) || s2.includes(s1)) { + const ratio = Math.min(s1.length, s2.length) / Math.max(s1.length, s2.length); + return Math.max(ResourceSimilarityService.CONFIDENCE_THRESHOLDS.MIN_SUBSTRING, ratio); + } + + // Calculate Levenshtein distance + const distance = this.levenshteinDistance(s1, s2); + const maxLength = Math.max(s1.length, s2.length); + + // Convert distance to similarity + let similarity = 1 - (distance / maxLength); + + // Boost confidence for single character typos and transpositions in short words + if (distance === 1 && maxLength <= 5) { + similarity = Math.max(similarity, 0.75); + } else if (distance === 2 && maxLength <= 5) { + // Boost for transpositions (e.g., "flie" -> "file") + similarity = Math.max(similarity, 0.72); + } + + return similarity; + } + + /** + * Calculate Levenshtein distance between two strings + */ + private levenshteinDistance(str1: string, str2: string): number { + const m = str1.length; + const n = str2.length; + const dp: number[][] = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0)); + + for (let i = 0; i <= m; i++) dp[i][0] = i; + for (let j = 0; j <= n; j++) dp[0][j] = j; + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (str1[i - 1] === str2[j - 1]) { + dp[i][j] = dp[i - 1][j - 1]; + } else { + dp[i][j] = Math.min( + dp[i - 1][j] + 1, // deletion + dp[i][j - 1] + 1, // insertion + dp[i - 1][j - 1] + 1 // substitution + ); + } + } + } + + return dp[m][n]; + } + + /** + * Generate a human-readable reason for the similarity + * @param confidence - Similarity confidence score + * @param invalid - The invalid resource string + * @param valid - The valid resource string + * @returns Human-readable explanation of the similarity + */ + private getSimilarityReason(confidence: number, invalid: string, valid: string): string { + const { VERY_HIGH, HIGH, MEDIUM } = ResourceSimilarityService.CONFIDENCE_THRESHOLDS; + + if (confidence >= VERY_HIGH) { + return 'Almost exact match - likely a typo'; + } else if (confidence >= HIGH) { + return 'Very similar - common variation'; + } else if (confidence >= MEDIUM) { + return 'Similar resource name'; + } else if (invalid.includes(valid) || valid.includes(invalid)) { + return 'Partial match'; + } else { + return 'Possibly related resource'; + } + } + + /** + * Clear caches + */ + clearCache(): void { + this.resourceCache.clear(); + this.suggestionCache.clear(); + } +} \ No newline at end of file diff --git a/tests/unit/services/enhanced-config-validator-operations.test.ts b/tests/unit/services/enhanced-config-validator-operations.test.ts new file mode 100644 index 0000000..3edfa6b --- /dev/null +++ b/tests/unit/services/enhanced-config-validator-operations.test.ts @@ -0,0 +1,421 @@ +/** + * Tests for EnhancedConfigValidator operation and resource validation + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { EnhancedConfigValidator } from '../../../src/services/enhanced-config-validator'; +import { NodeRepository } from '../../../src/database/node-repository'; +import { createTestDatabase } from '../../utils/database-utils'; + +describe('EnhancedConfigValidator - Operation and Resource Validation', () => { + let repository: NodeRepository; + let testDb: any; + + beforeEach(async () => { + testDb = await createTestDatabase(); + repository = testDb.nodeRepository; + + // Initialize similarity services + EnhancedConfigValidator.initializeSimilarityServices(repository); + + // Add Google Drive test node + const googleDriveNode = { + 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', + required: true, + options: [ + { value: 'file', name: 'File' }, + { value: 'folder', name: 'Folder' }, + { value: 'fileFolder', name: 'File & Folder' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + 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', + required: true, + displayOptions: { + show: { + resource: ['folder'] + } + }, + options: [ + { value: 'create', name: 'Create' }, + { value: 'delete', name: 'Delete' }, + { value: 'share', name: 'Share' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['fileFolder'] + } + }, + options: [ + { value: 'search', name: 'Search' } + ] + } + ], + operations: [], + credentials: [] + }; + + repository.saveNode(googleDriveNode); + + // Add Slack test node + 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', + required: true, + options: [ + { value: 'channel', name: 'Channel' }, + { value: 'message', name: 'Message' }, + { value: 'user', name: 'User' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send' }, + { value: 'update', name: 'Update' }, + { value: 'delete', name: 'Delete' } + ] + } + ], + operations: [], + credentials: [] + }; + + repository.saveNode(slackNode); + }); + + afterEach(async () => { + // Clean up database + if (testDb) { + await testDb.cleanup(); + } + }); + + describe('Invalid Operations', () => { + it('should detect invalid operation "listFiles" for Google Drive', () => { + const config = { + resource: 'fileFolder', + operation: 'listFiles' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + // Should have an error for invalid operation + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.message).toContain('Invalid operation "listFiles"'); + expect(operationError!.message).toContain('Did you mean'); + expect(operationError!.fix).toContain('search'); // Should suggest 'search' for fileFolder resource + }); + + it('should provide suggestions for typos in operations', () => { + const config = { + resource: 'file', + operation: 'downlod' // Typo: missing 'a' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.message).toContain('Did you mean "download"'); + }); + + it('should list valid operations for the resource', () => { + const config = { + resource: 'folder', + operation: 'upload' // Invalid for folder resource + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.fix).toContain('Valid operations for resource "folder"'); + expect(operationError!.fix).toContain('create'); + expect(operationError!.fix).toContain('delete'); + expect(operationError!.fix).toContain('share'); + }); + }); + + describe('Invalid Resources', () => { + it('should detect plural resource "files" and suggest singular', () => { + const config = { + resource: 'files', // Should be 'file' + operation: 'list' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.message).toContain('Invalid resource "files"'); + expect(resourceError!.message).toContain('Did you mean "file"'); + expect(resourceError!.fix).toContain('Use singular'); + }); + + it('should suggest similar resources for typos', () => { + const config = { + resource: 'flie', // Typo + operation: 'download' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.message).toContain('Did you mean "file"'); + }); + + it('should list valid resources when no match found', () => { + const config = { + resource: 'document', // Not a valid resource + operation: 'create' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.fix).toContain('Valid resources:'); + expect(resourceError!.fix).toContain('file'); + expect(resourceError!.fix).toContain('folder'); + }); + }); + + describe('Combined Resource and Operation Validation', () => { + it('should validate both resource and operation together', () => { + const config = { + resource: 'files', // Invalid: should be singular + operation: 'listFiles' // Invalid: should be 'list' or 'search' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThanOrEqual(2); + + // Should have error for resource + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.message).toContain('files'); + + // Should have error for operation + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.message).toContain('listFiles'); + }); + }); + + describe('Slack Node Validation', () => { + it('should suggest "send" instead of "sendMessage"', () => { + const config = { + resource: 'message', + operation: 'sendMessage' // Common mistake + }; + + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.message).toContain('Did you mean "send"'); + }); + + it('should suggest singular "channel" instead of "channels"', () => { + const config = { + resource: 'channels', // Should be singular + operation: 'create' + }; + + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + expect(result.valid).toBe(false); + + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.message).toContain('Did you mean "channel"'); + }); + }); + + describe('Valid Configurations', () => { + it('should accept valid Google Drive configuration', () => { + const config = { + resource: 'file', + operation: 'download' + }; + + const node = repository.getNode('nodes-base.googleDrive'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.googleDrive', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + // Should not have errors for resource or operation + const resourceError = result.errors.find(e => e.property === 'resource'); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(resourceError).toBeUndefined(); + expect(operationError).toBeUndefined(); + }); + + it('should accept valid Slack configuration', () => { + const config = { + resource: 'message', + operation: 'send' + }; + + const node = repository.getNode('nodes-base.slack'); + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + node.properties, + 'operation', + 'ai-friendly' + ); + + // Should not have errors for resource or operation + const resourceError = result.errors.find(e => e.property === 'resource'); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(resourceError).toBeUndefined(); + expect(operationError).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/operation-similarity-service.test.ts b/tests/unit/services/operation-similarity-service.test.ts new file mode 100644 index 0000000..3250005 --- /dev/null +++ b/tests/unit/services/operation-similarity-service.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/resource-similarity-service.test.ts b/tests/unit/services/resource-similarity-service.test.ts new file mode 100644 index 0000000..293e172 --- /dev/null +++ b/tests/unit/services/resource-similarity-service.test.ts @@ -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(); + }); + }); +}); \ No newline at end of file