diff --git a/src/services/enhanced-config-validator.ts b/src/services/enhanced-config-validator.ts index 3503bb2..c2f3b4b 100644 --- a/src/services/enhanced-config-validator.ts +++ b/src/services/enhanced-config-validator.ts @@ -687,11 +687,17 @@ export class EnhancedConfigValidator extends ConfigValidator { if (!resourceIsValid && config.resource !== '') { // Find similar resources - const suggestions = this.resourceSimilarityService.findSimilarResources( - nodeType, - config.resource, - 3 - ); + let suggestions: any[] = []; + try { + suggestions = this.resourceSimilarityService.findSimilarResources( + nodeType, + config.resource, + 3 + ); + } catch (error) { + // If similarity service fails, continue with validation without suggestions + console.error('Resource similarity service error:', error); + } // Build error message with suggestions let errorMessage = `Invalid resource "${config.resource}" for node ${nodeType}.`; @@ -718,12 +724,28 @@ export class EnhancedConfigValidator extends ConfigValidator { }).join(', ')}${validResources.length > 5 ? '...' : ''}`; } - result.errors.push({ + const error: any = { type: 'invalid_value', property: 'resource', message: errorMessage, fix - }); + }; + + // Add suggestion property if we have high confidence suggestions + if (suggestions.length > 0 && suggestions[0].confidence >= 0.5) { + error.suggestion = `Did you mean "${suggestions[0].value}"? ${suggestions[0].reason}`; + } + + result.errors.push(error); + + // Add suggestions to result.suggestions array + if (suggestions.length > 0) { + for (const suggestion of suggestions) { + result.suggestions.push( + `Resource "${config.resource}" not found. Did you mean "${suggestion.value}"? ${suggestion.reason}` + ); + } + } } } @@ -739,12 +761,18 @@ export class EnhancedConfigValidator extends ConfigValidator { if (!operationIsValid && config.operation !== '') { // Find similar operations - const suggestions = this.operationSimilarityService.findSimilarOperations( - nodeType, - config.operation, - config.resource, - 3 - ); + let suggestions: any[] = []; + try { + suggestions = this.operationSimilarityService.findSimilarOperations( + nodeType, + config.operation, + config.resource, + 3 + ); + } catch (error) { + // If similarity service fails, continue with validation without suggestions + console.error('Operation similarity service error:', error); + } // Build error message with suggestions let errorMessage = `Invalid operation "${config.operation}" for node ${nodeType}`; @@ -775,12 +803,28 @@ export class EnhancedConfigValidator extends ConfigValidator { }).join(', ')}${validOperations.length > 5 ? '...' : ''}`; } - result.errors.push({ + const error: any = { type: 'invalid_value', property: 'operation', message: errorMessage, fix - }); + }; + + // Add suggestion property if we have high confidence suggestions + if (suggestions.length > 0 && suggestions[0].confidence >= 0.5) { + error.suggestion = `Did you mean "${suggestions[0].value}"? ${suggestions[0].reason}`; + } + + result.errors.push(error); + + // Add suggestions to result.suggestions array + if (suggestions.length > 0) { + for (const suggestion of suggestions) { + result.suggestions.push( + `Operation "${config.operation}" not found. Did you mean "${suggestion.value}"? ${suggestion.reason}` + ); + } + } } } } diff --git a/tests/unit/database/node-repository-operations.test.ts b/tests/unit/database/node-repository-operations.test.ts new file mode 100644 index 0000000..7662989 --- /dev/null +++ b/tests/unit/database/node-repository-operations.test.ts @@ -0,0 +1,633 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { NodeRepository } from '@/database/node-repository'; +import { DatabaseAdapter, PreparedStatement, RunResult } from '@/database/database-adapter'; + +// Mock DatabaseAdapter for testing the new operation methods +class MockDatabaseAdapter implements DatabaseAdapter { + private statements = new Map(); + private mockNodes = new Map(); + + prepare = vi.fn((sql: string) => { + if (!this.statements.has(sql)) { + this.statements.set(sql, new MockPreparedStatement(sql, this.mockNodes)); + } + return this.statements.get(sql)!; + }); + + exec = vi.fn(); + close = vi.fn(); + pragma = vi.fn(); + transaction = vi.fn((fn: () => any) => fn()); + checkFTS5Support = vi.fn(() => true); + inTransaction = false; + + // Test helper to set mock data + _setMockNode(nodeType: string, value: any) { + this.mockNodes.set(nodeType, value); + } +} + +class MockPreparedStatement implements PreparedStatement { + run = vi.fn((...params: any[]): RunResult => ({ changes: 1, lastInsertRowid: 1 })); + get = vi.fn(); + all = vi.fn(() => []); + iterate = vi.fn(); + pluck = vi.fn(() => this); + expand = vi.fn(() => this); + raw = vi.fn(() => this); + columns = vi.fn(() => []); + bind = vi.fn(() => this); + + constructor(private sql: string, private mockNodes: Map) { + // Configure get() to return node data + if (sql.includes('SELECT * FROM nodes WHERE node_type = ?')) { + this.get = vi.fn((nodeType: string) => this.mockNodes.get(nodeType)); + } + + // Configure all() for getAllNodes + if (sql.includes('SELECT * FROM nodes ORDER BY display_name')) { + this.all = vi.fn(() => Array.from(this.mockNodes.values())); + } + } +} + +describe('NodeRepository - Operations and Resources', () => { + let repository: NodeRepository; + let mockAdapter: MockDatabaseAdapter; + + beforeEach(() => { + mockAdapter = new MockDatabaseAdapter(); + repository = new NodeRepository(mockAdapter); + }); + + describe('getNodeOperations', () => { + it('should extract operations from array format', () => { + const mockNode = { + node_type: 'nodes-base.httpRequest', + display_name: 'HTTP Request', + operations: JSON.stringify([ + { name: 'get', displayName: 'GET' }, + { name: 'post', displayName: 'POST' } + ]), + properties_schema: '[]', + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.httpRequest', mockNode); + + const operations = repository.getNodeOperations('nodes-base.httpRequest'); + + expect(operations).toEqual([ + { name: 'get', displayName: 'GET' }, + { name: 'post', displayName: 'POST' } + ]); + }); + + it('should extract operations from object format grouped by resource', () => { + const mockNode = { + node_type: 'nodes-base.slack', + display_name: 'Slack', + operations: JSON.stringify({ + message: [ + { name: 'send', displayName: 'Send Message' }, + { name: 'update', displayName: 'Update Message' } + ], + channel: [ + { name: 'create', displayName: 'Create Channel' }, + { name: 'archive', displayName: 'Archive Channel' } + ] + }), + properties_schema: '[]', + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.slack', mockNode); + + const allOperations = repository.getNodeOperations('nodes-base.slack'); + const messageOperations = repository.getNodeOperations('nodes-base.slack', 'message'); + + expect(allOperations).toHaveLength(4); + expect(messageOperations).toEqual([ + { name: 'send', displayName: 'Send Message' }, + { name: 'update', displayName: 'Update Message' } + ]); + }); + + it('should extract operations from properties with operation field', () => { + const mockNode = { + node_type: 'nodes-base.googleSheets', + display_name: 'Google Sheets', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'resource', + type: 'options', + options: [{ name: 'sheet', displayName: 'Sheet' }] + }, + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['sheet'] + } + }, + options: [ + { name: 'append', displayName: 'Append Row' }, + { name: 'read', displayName: 'Read Rows' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.googleSheets', mockNode); + + const operations = repository.getNodeOperations('nodes-base.googleSheets'); + + expect(operations).toEqual([ + { name: 'append', displayName: 'Append Row' }, + { name: 'read', displayName: 'Read Rows' } + ]); + }); + + it('should filter operations by resource when specified', () => { + const mockNode = { + node_type: 'nodes-base.googleSheets', + display_name: 'Google Sheets', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['sheet'] + } + }, + options: [ + { name: 'append', displayName: 'Append Row' } + ] + }, + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['cell'] + } + }, + options: [ + { name: 'update', displayName: 'Update Cell' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.googleSheets', mockNode); + + const sheetOperations = repository.getNodeOperations('nodes-base.googleSheets', 'sheet'); + const cellOperations = repository.getNodeOperations('nodes-base.googleSheets', 'cell'); + + expect(sheetOperations).toEqual([{ name: 'append', displayName: 'Append Row' }]); + expect(cellOperations).toEqual([{ name: 'update', displayName: 'Update Cell' }]); + }); + + it('should return empty array for non-existent node', () => { + const operations = repository.getNodeOperations('nodes-base.nonexistent'); + expect(operations).toEqual([]); + }); + + it('should handle nodes without operations', () => { + const mockNode = { + node_type: 'nodes-base.simple', + display_name: 'Simple Node', + operations: '[]', + properties_schema: '[]', + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.simple', mockNode); + + const operations = repository.getNodeOperations('nodes-base.simple'); + expect(operations).toEqual([]); + }); + + it('should handle malformed operations JSON gracefully', () => { + const mockNode = { + node_type: 'nodes-base.broken', + display_name: 'Broken Node', + operations: '{invalid json}', + properties_schema: '[]', + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.broken', mockNode); + + const operations = repository.getNodeOperations('nodes-base.broken'); + expect(operations).toEqual([]); + }); + }); + + describe('getNodeResources', () => { + it('should extract resources from properties', () => { + const mockNode = { + node_type: 'nodes-base.slack', + display_name: 'Slack', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'resource', + type: 'options', + options: [ + { name: 'message', displayName: 'Message' }, + { name: 'channel', displayName: 'Channel' }, + { name: 'user', displayName: 'User' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.slack', mockNode); + + const resources = repository.getNodeResources('nodes-base.slack'); + + expect(resources).toEqual([ + { name: 'message', displayName: 'Message' }, + { name: 'channel', displayName: 'Channel' }, + { name: 'user', displayName: 'User' } + ]); + }); + + it('should return empty array for node without resources', () => { + const mockNode = { + node_type: 'nodes-base.simple', + display_name: 'Simple Node', + operations: '[]', + properties_schema: JSON.stringify([ + { name: 'url', type: 'string' } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.simple', mockNode); + + const resources = repository.getNodeResources('nodes-base.simple'); + expect(resources).toEqual([]); + }); + + it('should return empty array for non-existent node', () => { + const resources = repository.getNodeResources('nodes-base.nonexistent'); + expect(resources).toEqual([]); + }); + + it('should handle multiple resource properties', () => { + const mockNode = { + node_type: 'nodes-base.multi', + display_name: 'Multi Resource Node', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'resource', + type: 'options', + options: [{ name: 'type1', displayName: 'Type 1' }] + }, + { + name: 'resource', + type: 'options', + options: [{ name: 'type2', displayName: 'Type 2' }] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.multi', mockNode); + + const resources = repository.getNodeResources('nodes-base.multi'); + + expect(resources).toEqual([ + { name: 'type1', displayName: 'Type 1' }, + { name: 'type2', displayName: 'Type 2' } + ]); + }); + }); + + describe('getOperationsForResource', () => { + it('should return operations for specific resource', () => { + const mockNode = { + node_type: 'nodes-base.slack', + display_name: 'Slack', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { name: 'send', displayName: 'Send Message' }, + { name: 'update', displayName: 'Update Message' } + ] + }, + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['channel'] + } + }, + options: [ + { name: 'create', displayName: 'Create Channel' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.slack', mockNode); + + const messageOps = repository.getOperationsForResource('nodes-base.slack', 'message'); + const channelOps = repository.getOperationsForResource('nodes-base.slack', 'channel'); + const nonExistentOps = repository.getOperationsForResource('nodes-base.slack', 'nonexistent'); + + expect(messageOps).toEqual([ + { name: 'send', displayName: 'Send Message' }, + { name: 'update', displayName: 'Update Message' } + ]); + expect(channelOps).toEqual([ + { name: 'create', displayName: 'Create Channel' } + ]); + expect(nonExistentOps).toEqual([]); + }); + + it('should handle array format for resource display options', () => { + const mockNode = { + node_type: 'nodes-base.multi', + display_name: 'Multi Node', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['message', 'channel'] // Array format + } + }, + options: [ + { name: 'list', displayName: 'List Items' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.multi', mockNode); + + const messageOps = repository.getOperationsForResource('nodes-base.multi', 'message'); + const channelOps = repository.getOperationsForResource('nodes-base.multi', 'channel'); + const otherOps = repository.getOperationsForResource('nodes-base.multi', 'other'); + + expect(messageOps).toEqual([{ name: 'list', displayName: 'List Items' }]); + expect(channelOps).toEqual([{ name: 'list', displayName: 'List Items' }]); + expect(otherOps).toEqual([]); + }); + + it('should return empty array for non-existent node', () => { + const operations = repository.getOperationsForResource('nodes-base.nonexistent', 'message'); + expect(operations).toEqual([]); + }); + + it('should handle string format for single resource', () => { + const mockNode = { + node_type: 'nodes-base.single', + display_name: 'Single Node', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: 'document' // String format + } + }, + options: [ + { name: 'create', displayName: 'Create Document' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.single', mockNode); + + const operations = repository.getOperationsForResource('nodes-base.single', 'document'); + expect(operations).toEqual([{ name: 'create', displayName: 'Create Document' }]); + }); + }); + + describe('getAllOperations', () => { + it('should collect operations from all nodes', () => { + const mockNodes = [ + { + node_type: 'nodes-base.httpRequest', + display_name: 'HTTP Request', + operations: JSON.stringify([{ name: 'execute' }]), + properties_schema: '[]', + credentials_required: '[]' + }, + { + node_type: 'nodes-base.slack', + display_name: 'Slack', + operations: JSON.stringify([{ name: 'send' }]), + properties_schema: '[]', + credentials_required: '[]' + }, + { + node_type: 'nodes-base.empty', + display_name: 'Empty Node', + operations: '[]', + properties_schema: '[]', + credentials_required: '[]' + } + ]; + + mockNodes.forEach(node => { + mockAdapter._setMockNode(node.node_type, node); + }); + + const allOperations = repository.getAllOperations(); + + expect(allOperations.size).toBe(2); // Only nodes with operations + expect(allOperations.get('nodes-base.httpRequest')).toEqual([{ name: 'execute' }]); + expect(allOperations.get('nodes-base.slack')).toEqual([{ name: 'send' }]); + expect(allOperations.has('nodes-base.empty')).toBe(false); + }); + + it('should handle empty node list', () => { + const allOperations = repository.getAllOperations(); + expect(allOperations.size).toBe(0); + }); + }); + + describe('getAllResources', () => { + it('should collect resources from all nodes', () => { + const mockNodes = [ + { + node_type: 'nodes-base.slack', + display_name: 'Slack', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'resource', + options: [{ name: 'message' }, { name: 'channel' }] + } + ]), + credentials_required: '[]' + }, + { + node_type: 'nodes-base.sheets', + display_name: 'Google Sheets', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'resource', + options: [{ name: 'sheet' }] + } + ]), + credentials_required: '[]' + }, + { + node_type: 'nodes-base.simple', + display_name: 'Simple Node', + operations: '[]', + properties_schema: '[]', // No resources + credentials_required: '[]' + } + ]; + + mockNodes.forEach(node => { + mockAdapter._setMockNode(node.node_type, node); + }); + + const allResources = repository.getAllResources(); + + expect(allResources.size).toBe(2); // Only nodes with resources + expect(allResources.get('nodes-base.slack')).toEqual([ + { name: 'message' }, + { name: 'channel' } + ]); + expect(allResources.get('nodes-base.sheets')).toEqual([{ name: 'sheet' }]); + expect(allResources.has('nodes-base.simple')).toBe(false); + }); + + it('should handle empty node list', () => { + const allResources = repository.getAllResources(); + expect(allResources.size).toBe(0); + }); + }); + + describe('edge cases and error handling', () => { + it('should handle null or undefined properties gracefully', () => { + const mockNode = { + node_type: 'nodes-base.null', + display_name: 'Null Node', + operations: null, + properties_schema: null, + credentials_required: null + }; + + mockAdapter._setMockNode('nodes-base.null', mockNode); + + const operations = repository.getNodeOperations('nodes-base.null'); + const resources = repository.getNodeResources('nodes-base.null'); + + expect(operations).toEqual([]); + expect(resources).toEqual([]); + }); + + it('should handle complex nested operation properties', () => { + const mockNode = { + node_type: 'nodes-base.complex', + display_name: 'Complex Node', + operations: '[]', + properties_schema: JSON.stringify([ + { + name: 'operation', + type: 'options', + displayOptions: { + show: { + resource: ['message'], + mode: ['advanced'] + } + }, + options: [ + { name: 'complexOperation', displayName: 'Complex Operation' } + ] + } + ]), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.complex', mockNode); + + const operations = repository.getNodeOperations('nodes-base.complex'); + expect(operations).toEqual([{ name: 'complexOperation', displayName: 'Complex Operation' }]); + }); + + it('should handle operations with mixed data types', () => { + const mockNode = { + node_type: 'nodes-base.mixed', + display_name: 'Mixed Node', + operations: JSON.stringify({ + string_operation: 'invalid', // Should be array + valid_operations: [{ name: 'valid' }], + nested_object: { inner: [{ name: 'nested' }] } + }), + properties_schema: '[]', + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.mixed', mockNode); + + const operations = repository.getNodeOperations('nodes-base.mixed'); + expect(operations).toEqual([{ name: 'valid' }]); // Only valid array operations + }); + + it('should handle very deeply nested properties', () => { + const deepProperties = [ + { + name: 'resource', + options: [{ name: 'deep', displayName: 'Deep Resource' }], + nested: { + level1: { + level2: { + operations: [{ name: 'deep_operation' }] + } + } + } + } + ]; + + const mockNode = { + node_type: 'nodes-base.deep', + display_name: 'Deep Node', + operations: '[]', + properties_schema: JSON.stringify(deepProperties), + credentials_required: '[]' + }; + + mockAdapter._setMockNode('nodes-base.deep', mockNode); + + const resources = repository.getNodeResources('nodes-base.deep'); + expect(resources).toEqual([{ name: 'deep', displayName: 'Deep Resource' }]); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/errors/validation-service-error.test.ts b/tests/unit/errors/validation-service-error.test.ts new file mode 100644 index 0000000..0dc4766 --- /dev/null +++ b/tests/unit/errors/validation-service-error.test.ts @@ -0,0 +1,300 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ValidationServiceError } from '@/errors/validation-service-error'; + +describe('ValidationServiceError', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create error with basic message', () => { + const error = new ValidationServiceError('Test error message'); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Test error message'); + expect(error.nodeType).toBeUndefined(); + expect(error.property).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); + + it('should create error with all parameters', () => { + const cause = new Error('Original error'); + const error = new ValidationServiceError( + 'Validation failed', + 'nodes-base.slack', + 'channel', + cause + ); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Validation failed'); + expect(error.nodeType).toBe('nodes-base.slack'); + expect(error.property).toBe('channel'); + expect(error.cause).toBe(cause); + }); + + it('should maintain proper inheritance from Error', () => { + const error = new ValidationServiceError('Test message'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(ValidationServiceError); + }); + + it('should capture stack trace when Error.captureStackTrace is available', () => { + const originalCaptureStackTrace = Error.captureStackTrace; + const mockCaptureStackTrace = vi.fn(); + Error.captureStackTrace = mockCaptureStackTrace; + + const error = new ValidationServiceError('Test message'); + + expect(mockCaptureStackTrace).toHaveBeenCalledWith(error, ValidationServiceError); + + // Restore original + Error.captureStackTrace = originalCaptureStackTrace; + }); + + it('should handle missing Error.captureStackTrace gracefully', () => { + const originalCaptureStackTrace = Error.captureStackTrace; + // @ts-ignore - testing edge case + delete Error.captureStackTrace; + + expect(() => { + new ValidationServiceError('Test message'); + }).not.toThrow(); + + // Restore original + Error.captureStackTrace = originalCaptureStackTrace; + }); + }); + + describe('jsonParseError factory', () => { + it('should create error for JSON parsing failure', () => { + const cause = new SyntaxError('Unexpected token'); + const error = ValidationServiceError.jsonParseError('nodes-base.slack', cause); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Failed to parse JSON data for node nodes-base.slack'); + expect(error.nodeType).toBe('nodes-base.slack'); + expect(error.property).toBeUndefined(); + expect(error.cause).toBe(cause); + }); + + it('should handle different error types as cause', () => { + const cause = new TypeError('Cannot read property'); + const error = ValidationServiceError.jsonParseError('nodes-base.webhook', cause); + + expect(error.cause).toBe(cause); + expect(error.message).toContain('nodes-base.webhook'); + }); + + it('should work with Error instances', () => { + const cause = new Error('Generic parsing error'); + const error = ValidationServiceError.jsonParseError('nodes-base.httpRequest', cause); + + expect(error.cause).toBe(cause); + expect(error.nodeType).toBe('nodes-base.httpRequest'); + }); + }); + + describe('nodeNotFound factory', () => { + it('should create error for missing node type', () => { + const error = ValidationServiceError.nodeNotFound('nodes-base.nonexistent'); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Node type nodes-base.nonexistent not found in repository'); + expect(error.nodeType).toBe('nodes-base.nonexistent'); + expect(error.property).toBeUndefined(); + expect(error.cause).toBeUndefined(); + }); + + it('should work with various node type formats', () => { + const nodeTypes = [ + 'nodes-base.slack', + '@n8n/n8n-nodes-langchain.chatOpenAI', + 'custom-node', + '' + ]; + + nodeTypes.forEach(nodeType => { + const error = ValidationServiceError.nodeNotFound(nodeType); + expect(error.nodeType).toBe(nodeType); + expect(error.message).toBe(`Node type ${nodeType} not found in repository`); + }); + }); + }); + + describe('dataExtractionError factory', () => { + it('should create error for data extraction failure with cause', () => { + const cause = new Error('Database connection failed'); + const error = ValidationServiceError.dataExtractionError( + 'nodes-base.postgres', + 'operations', + cause + ); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Failed to extract operations for node nodes-base.postgres'); + expect(error.nodeType).toBe('nodes-base.postgres'); + expect(error.property).toBe('operations'); + expect(error.cause).toBe(cause); + }); + + it('should create error for data extraction failure without cause', () => { + const error = ValidationServiceError.dataExtractionError( + 'nodes-base.googleSheets', + 'resources' + ); + + expect(error.name).toBe('ValidationServiceError'); + expect(error.message).toBe('Failed to extract resources for node nodes-base.googleSheets'); + expect(error.nodeType).toBe('nodes-base.googleSheets'); + expect(error.property).toBe('resources'); + expect(error.cause).toBeUndefined(); + }); + + it('should handle various data types', () => { + const dataTypes = ['operations', 'resources', 'properties', 'credentials', 'schema']; + + dataTypes.forEach(dataType => { + const error = ValidationServiceError.dataExtractionError( + 'nodes-base.test', + dataType + ); + expect(error.property).toBe(dataType); + expect(error.message).toBe(`Failed to extract ${dataType} for node nodes-base.test`); + }); + }); + + it('should handle empty strings and special characters', () => { + const error = ValidationServiceError.dataExtractionError( + 'nodes-base.test-node', + 'special/property:name' + ); + + expect(error.property).toBe('special/property:name'); + expect(error.message).toBe('Failed to extract special/property:name for node nodes-base.test-node'); + }); + }); + + describe('error properties and serialization', () => { + it('should maintain all properties when stringified', () => { + const cause = new Error('Root cause'); + const error = ValidationServiceError.dataExtractionError( + 'nodes-base.mysql', + 'tables', + cause + ); + + // JSON.stringify doesn't include message by default for Error objects + const serialized = { + name: error.name, + message: error.message, + nodeType: error.nodeType, + property: error.property + }; + + expect(serialized.name).toBe('ValidationServiceError'); + expect(serialized.message).toBe('Failed to extract tables for node nodes-base.mysql'); + expect(serialized.nodeType).toBe('nodes-base.mysql'); + expect(serialized.property).toBe('tables'); + }); + + it('should work with toString method', () => { + const error = ValidationServiceError.nodeNotFound('nodes-base.missing'); + const string = error.toString(); + + expect(string).toBe('ValidationServiceError: Node type nodes-base.missing not found in repository'); + }); + + it('should preserve stack trace', () => { + const error = new ValidationServiceError('Test error'); + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('ValidationServiceError'); + }); + }); + + describe('error chaining and nested causes', () => { + it('should handle nested error causes', () => { + const rootCause = new Error('Database unavailable'); + const intermediateCause = new ValidationServiceError('Connection failed', 'nodes-base.db', undefined, rootCause); + const finalError = ValidationServiceError.jsonParseError('nodes-base.slack', intermediateCause); + + expect(finalError.cause).toBe(intermediateCause); + expect((finalError.cause as ValidationServiceError).cause).toBe(rootCause); + }); + + it('should work with different error types in chain', () => { + const syntaxError = new SyntaxError('Invalid JSON'); + const typeError = new TypeError('Property access failed'); + const validationError = ValidationServiceError.dataExtractionError('nodes-base.test', 'props', syntaxError); + const finalError = ValidationServiceError.jsonParseError('nodes-base.final', typeError); + + expect(validationError.cause).toBe(syntaxError); + expect(finalError.cause).toBe(typeError); + }); + }); + + describe('edge cases and boundary conditions', () => { + it('should handle undefined and null values gracefully', () => { + // @ts-ignore - testing edge case + const error1 = new ValidationServiceError(undefined); + // @ts-ignore - testing edge case + const error2 = new ValidationServiceError(null); + + // Test that constructor handles these values without throwing + expect(error1).toBeInstanceOf(ValidationServiceError); + expect(error2).toBeInstanceOf(ValidationServiceError); + expect(error1.name).toBe('ValidationServiceError'); + expect(error2.name).toBe('ValidationServiceError'); + }); + + it('should handle very long messages', () => { + const longMessage = 'a'.repeat(10000); + const error = new ValidationServiceError(longMessage); + + expect(error.message).toBe(longMessage); + expect(error.message.length).toBe(10000); + }); + + it('should handle special characters in node types', () => { + const nodeType = 'nodes-base.test-node@1.0.0/special:version'; + const error = ValidationServiceError.nodeNotFound(nodeType); + + expect(error.nodeType).toBe(nodeType); + expect(error.message).toContain(nodeType); + }); + + it('should handle circular references in cause chain safely', () => { + const error1 = new ValidationServiceError('Error 1'); + const error2 = new ValidationServiceError('Error 2', 'test', 'prop', error1); + + // Don't actually create circular reference as it would break JSON.stringify + // Just verify the structure is set up correctly + expect(error2.cause).toBe(error1); + expect(error1.cause).toBeUndefined(); + }); + }); + + describe('factory method edge cases', () => { + it('should handle empty strings in factory methods', () => { + const jsonError = ValidationServiceError.jsonParseError('', new Error('')); + const notFoundError = ValidationServiceError.nodeNotFound(''); + const extractionError = ValidationServiceError.dataExtractionError('', ''); + + expect(jsonError.nodeType).toBe(''); + expect(notFoundError.nodeType).toBe(''); + expect(extractionError.nodeType).toBe(''); + expect(extractionError.property).toBe(''); + }); + + it('should handle null-like values in cause parameter', () => { + // @ts-ignore - testing edge case + const error1 = ValidationServiceError.jsonParseError('test', null); + // @ts-ignore - testing edge case + const error2 = ValidationServiceError.dataExtractionError('test', 'prop', undefined); + + expect(error1.cause).toBe(null); + expect(error2.cause).toBeUndefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/enhanced-config-validator-integration.test.ts b/tests/unit/services/enhanced-config-validator-integration.test.ts new file mode 100644 index 0000000..830e82d --- /dev/null +++ b/tests/unit/services/enhanced-config-validator-integration.test.ts @@ -0,0 +1,712 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { EnhancedConfigValidator } from '@/services/enhanced-config-validator'; +import { ResourceSimilarityService } from '@/services/resource-similarity-service'; +import { OperationSimilarityService } from '@/services/operation-similarity-service'; +import { NodeRepository } from '@/database/node-repository'; + +// Mock similarity services +vi.mock('@/services/resource-similarity-service'); +vi.mock('@/services/operation-similarity-service'); + +describe('EnhancedConfigValidator - Integration Tests', () => { + let mockResourceService: any; + let mockOperationService: any; + let mockRepository: any; + + beforeEach(() => { + mockRepository = { + getNode: vi.fn(), + getNodeOperations: vi.fn().mockReturnValue([]), + getNodeResources: vi.fn().mockReturnValue([]), + getOperationsForResource: vi.fn().mockReturnValue([]) + }; + + mockResourceService = { + findSimilarResources: vi.fn().mockReturnValue([]) + }; + + mockOperationService = { + findSimilarOperations: vi.fn().mockReturnValue([]) + }; + + // Mock the constructors to return our mock services + vi.mocked(ResourceSimilarityService).mockImplementation(() => mockResourceService); + vi.mocked(OperationSimilarityService).mockImplementation(() => mockOperationService); + + // Initialize the similarity services (this will create the service instances) + EnhancedConfigValidator.initializeSimilarityServices(mockRepository); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('similarity service integration', () => { + it('should initialize similarity services when initializeSimilarityServices is called', () => { + // Services should be created when initializeSimilarityServices was called in beforeEach + expect(ResourceSimilarityService).toHaveBeenCalled(); + expect(OperationSimilarityService).toHaveBeenCalled(); + }); + + it('should use resource similarity service for invalid resource errors', () => { + const config = { + resource: 'invalidResource', + operation: 'send' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' }, + { value: 'channel', name: 'Channel' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + } + ]; + + // Mock resource similarity suggestions + mockResourceService.findSimilarResources.mockReturnValue([ + { + value: 'message', + confidence: 0.8, + reason: 'Similar resource name', + availableOperations: ['send', 'update'] + } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( + 'nodes-base.slack', + 'invalidResource', + expect.any(Number) + ); + + // Should have suggestions in the result + expect(result.suggestions).toBeDefined(); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it('should use operation similarity service for invalid operation errors', () => { + const config = { + resource: 'message', + operation: 'invalidOperation' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' }, + { value: 'update', name: 'Update Message' } + ] + } + ]; + + // Mock operation similarity suggestions + mockOperationService.findSimilarOperations.mockReturnValue([ + { + value: 'send', + confidence: 0.9, + reason: 'Very similar - likely a typo', + resource: 'message' + } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + expect(mockOperationService.findSimilarOperations).toHaveBeenCalledWith( + 'nodes-base.slack', + 'invalidOperation', + 'message', + expect.any(Number) + ); + + // Should have suggestions in the result + expect(result.suggestions).toBeDefined(); + expect(result.suggestions.length).toBeGreaterThan(0); + }); + + it('should handle similarity service errors gracefully', () => { + const config = { + resource: 'invalidResource', + operation: 'send' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + // Mock service to throw error + mockResourceService.findSimilarResources.mockImplementation(() => { + throw new Error('Service error'); + }); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should not crash and still provide basic validation + expect(result).toBeDefined(); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should not call similarity services for valid configurations', () => { + // Mock repository to return valid resources for this test + mockRepository.getNodeResources.mockReturnValue([ + { value: 'message', name: 'Message' }, + { value: 'channel', name: 'Channel' } + ]); + // Mock getNodeOperations to return valid operations + mockRepository.getNodeOperations.mockReturnValue([ + { value: 'send', name: 'Send Message' } + ]); + + const config = { + resource: 'message', + operation: 'send', + channel: '#general', // Add required field for Slack send + text: 'Test message' // Add required field for Slack send + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + } + ]; + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should not call similarity services for valid config + expect(mockResourceService.findSimilarResources).not.toHaveBeenCalled(); + expect(mockOperationService.findSimilarOperations).not.toHaveBeenCalled(); + expect(result.valid).toBe(true); + }); + + it('should limit suggestion count when calling similarity services', () => { + const config = { + resource: 'invalidResource' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + expect(mockResourceService.findSimilarResources).toHaveBeenCalledWith( + 'nodes-base.slack', + 'invalidResource', + 3 // Should limit to 3 suggestions + ); + }); + }); + + describe('error enhancement with suggestions', () => { + it('should enhance resource validation errors with suggestions', () => { + const config = { + resource: 'msgs' // Typo for 'message' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' }, + { value: 'channel', name: 'Channel' } + ] + } + ]; + + // Mock high-confidence suggestion + mockResourceService.findSimilarResources.mockReturnValue([ + { + value: 'message', + confidence: 0.85, + reason: 'Very similar - likely a typo', + availableOperations: ['send', 'update', 'delete'] + } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should have enhanced error with suggestion + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.suggestion).toBeDefined(); + expect(resourceError!.suggestion).toContain('message'); + }); + + it('should enhance operation validation errors with suggestions', () => { + const config = { + resource: 'message', + operation: 'sned' // Typo for 'send' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' }, + { value: 'update', name: 'Update Message' } + ] + } + ]; + + // Mock high-confidence suggestion + mockOperationService.findSimilarOperations.mockReturnValue([ + { + value: 'send', + confidence: 0.9, + reason: 'Almost exact match - likely a typo', + resource: 'message', + description: 'Send Message' + } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should have enhanced error with suggestion + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError).toBeDefined(); + expect(operationError!.suggestion).toBeDefined(); + expect(operationError!.suggestion).toContain('send'); + }); + + it('should not enhance errors when no good suggestions are available', () => { + const config = { + resource: 'completelyWrongValue' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + // Mock low-confidence suggestions + mockResourceService.findSimilarResources.mockReturnValue([ + { + value: 'message', + confidence: 0.2, // Too low confidence + reason: 'Possibly related resource' + } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should not enhance error due to low confidence + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError).toBeDefined(); + expect(resourceError!.suggestion).toBeUndefined(); + }); + + it('should provide multiple operation suggestions when resource is known', () => { + const config = { + resource: 'message', + operation: 'invalidOp' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' }, + { value: 'update', name: 'Update Message' }, + { value: 'delete', name: 'Delete Message' } + ] + } + ]; + + // Mock multiple suggestions + mockOperationService.findSimilarOperations.mockReturnValue([ + { value: 'send', confidence: 0.7, reason: 'Similar operation' }, + { value: 'update', confidence: 0.6, reason: 'Similar operation' }, + { value: 'delete', confidence: 0.5, reason: 'Similar operation' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should include multiple suggestions in the result + expect(result.suggestions.length).toBeGreaterThan(2); + const operationSuggestions = result.suggestions.filter(s => + s.includes('send') || s.includes('update') || s.includes('delete') + ); + expect(operationSuggestions.length).toBeGreaterThan(0); + }); + }); + + describe('confidence thresholds and filtering', () => { + it('should only use high confidence resource suggestions', () => { + const config = { + resource: 'invalidResource' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + // Mock mixed confidence suggestions + mockResourceService.findSimilarResources.mockReturnValue([ + { value: 'message1', confidence: 0.9, reason: 'High confidence' }, + { value: 'message2', confidence: 0.4, reason: 'Low confidence' }, + { value: 'message3', confidence: 0.7, reason: 'Medium confidence' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should only use suggestions above threshold + const resourceError = result.errors.find(e => e.property === 'resource'); + expect(resourceError?.suggestion).toBeDefined(); + // Should prefer high confidence suggestion + expect(resourceError!.suggestion).toContain('message1'); + }); + + it('should only use high confidence operation suggestions', () => { + const config = { + resource: 'message', + operation: 'invalidOperation' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + } + ]; + + // Mock mixed confidence suggestions + mockOperationService.findSimilarOperations.mockReturnValue([ + { value: 'send', confidence: 0.95, reason: 'Very high confidence' }, + { value: 'post', confidence: 0.3, reason: 'Low confidence' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + // Should only use high confidence suggestion + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError?.suggestion).toBeDefined(); + expect(operationError!.suggestion).toContain('send'); + expect(operationError!.suggestion).not.toContain('post'); + }); + }); + + describe('integration with existing validation logic', () => { + it('should work with minimal validation mode', () => { + // Mock repository to return empty resources + mockRepository.getNodeResources.mockReturnValue([]); + + const config = { + resource: 'invalidResource' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + mockResourceService.findSimilarResources.mockReturnValue([ + { value: 'message', confidence: 0.8, reason: 'Similar' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'minimal', + 'ai-friendly' + ); + + // Should still enhance errors in minimal mode + expect(mockResourceService.findSimilarResources).toHaveBeenCalled(); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should work with strict validation profile', () => { + // Mock repository to return valid resource but no operations + mockRepository.getNodeResources.mockReturnValue([ + { value: 'message', name: 'Message' } + ]); + mockRepository.getOperationsForResource.mockReturnValue([]); + + const config = { + resource: 'message', + operation: 'invalidOp' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + type: 'options', + required: true, + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + } + ]; + + mockOperationService.findSimilarOperations.mockReturnValue([ + { value: 'send', confidence: 0.8, reason: 'Similar' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'strict' + ); + + // Should enhance errors regardless of profile + expect(mockOperationService.findSimilarOperations).toHaveBeenCalled(); + const operationError = result.errors.find(e => e.property === 'operation'); + expect(operationError?.suggestion).toBeDefined(); + }); + + it('should preserve original error properties when enhancing', () => { + const config = { + resource: 'invalidResource' + }; + + const properties = [ + { + name: 'resource', + type: 'options', + required: true, + options: [ + { value: 'message', name: 'Message' } + ] + } + ]; + + mockResourceService.findSimilarResources.mockReturnValue([ + { value: 'message', confidence: 0.8, reason: 'Similar' } + ]); + + const result = EnhancedConfigValidator.validateWithMode( + 'nodes-base.slack', + config, + properties, + 'operation', + 'ai-friendly' + ); + + const resourceError = result.errors.find(e => e.property === 'resource'); + + // Should preserve original error properties + expect(resourceError?.type).toBeDefined(); + expect(resourceError?.property).toBe('resource'); + expect(resourceError?.message).toBeDefined(); + + // Should add suggestion without overriding other properties + expect(resourceError?.suggestion).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/operation-similarity-service-comprehensive.test.ts b/tests/unit/services/operation-similarity-service-comprehensive.test.ts new file mode 100644 index 0000000..a46225f --- /dev/null +++ b/tests/unit/services/operation-similarity-service-comprehensive.test.ts @@ -0,0 +1,849 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { OperationSimilarityService } from '@/services/operation-similarity-service'; +import { NodeRepository } from '@/database/node-repository'; +import { ValidationServiceError } from '@/errors/validation-service-error'; +import { logger } from '@/utils/logger'; + +// Mock the logger to test error handling paths +vi.mock('@/utils/logger', () => ({ + logger: { + warn: vi.fn(), + error: vi.fn() + } +})); + +describe('OperationSimilarityService - Comprehensive Coverage', () => { + let service: OperationSimilarityService; + let mockRepository: any; + + beforeEach(() => { + mockRepository = { + getNode: vi.fn() + }; + service = new OperationSimilarityService(mockRepository); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor and initialization', () => { + it('should initialize with common patterns', () => { + const patterns = (service as any).commonPatterns; + expect(patterns).toBeDefined(); + expect(patterns.has('googleDrive')).toBe(true); + expect(patterns.has('slack')).toBe(true); + expect(patterns.has('database')).toBe(true); + expect(patterns.has('httpRequest')).toBe(true); + expect(patterns.has('generic')).toBe(true); + }); + + it('should initialize empty caches', () => { + const operationCache = (service as any).operationCache; + const suggestionCache = (service as any).suggestionCache; + + expect(operationCache.size).toBe(0); + expect(suggestionCache.size).toBe(0); + }); + }); + + describe('cache cleanup mechanisms', () => { + it('should clean up expired operation cache entries', () => { + const now = Date.now(); + const expiredTimestamp = now - (6 * 60 * 1000); // 6 minutes ago + const validTimestamp = now - (2 * 60 * 1000); // 2 minutes ago + + const operationCache = (service as any).operationCache; + operationCache.set('expired-node', { operations: [], timestamp: expiredTimestamp }); + operationCache.set('valid-node', { operations: [], timestamp: validTimestamp }); + + (service as any).cleanupExpiredEntries(); + + expect(operationCache.has('expired-node')).toBe(false); + expect(operationCache.has('valid-node')).toBe(true); + }); + + it('should limit suggestion cache size to 50 entries when over 100', () => { + const suggestionCache = (service as any).suggestionCache; + + // Fill cache with 110 entries + for (let i = 0; i < 110; i++) { + suggestionCache.set(`key-${i}`, []); + } + + expect(suggestionCache.size).toBe(110); + + (service as any).cleanupExpiredEntries(); + + expect(suggestionCache.size).toBe(50); + // Should keep the last 50 entries + expect(suggestionCache.has('key-109')).toBe(true); + expect(suggestionCache.has('key-59')).toBe(false); + }); + + it('should trigger random cleanup during findSimilarOperations', () => { + const cleanupSpy = vi.spyOn(service as any, 'cleanupExpiredEntries'); + + mockRepository.getNode.mockReturnValue({ + operations: [{ operation: 'test', name: 'Test' }], + properties: [] + }); + + // Mock Math.random to always trigger cleanup + const originalRandom = Math.random; + Math.random = vi.fn(() => 0.05); // Less than 0.1 + + service.findSimilarOperations('nodes-base.test', 'invalid'); + + expect(cleanupSpy).toHaveBeenCalled(); + + Math.random = originalRandom; + }); + }); + + describe('getOperationValue edge cases', () => { + it('should handle string operations', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue('test-operation')).toBe('test-operation'); + }); + + it('should handle object operations with operation property', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue({ operation: 'send', name: 'Send Message' })).toBe('send'); + }); + + it('should handle object operations with value property', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue({ value: 'create', displayName: 'Create' })).toBe('create'); + }); + + it('should handle object operations without operation or value properties', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue({ name: 'Some Operation' })).toBe(''); + }); + + it('should handle null and undefined operations', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue(null)).toBe(''); + expect(getValue(undefined)).toBe(''); + }); + + it('should handle primitive types', () => { + const getValue = (service as any).getOperationValue.bind(service); + expect(getValue(123)).toBe(''); + expect(getValue(true)).toBe(''); + }); + }); + + describe('getResourceValue edge cases', () => { + it('should handle string resources', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue('test-resource')).toBe('test-resource'); + }); + + it('should handle object resources with value property', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue({ value: 'message', name: 'Message' })).toBe('message'); + }); + + it('should handle object resources without value property', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue({ name: 'Resource' })).toBe(''); + }); + + it('should handle null and undefined resources', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue(null)).toBe(''); + expect(getValue(undefined)).toBe(''); + }); + }); + + describe('getNodeOperations error handling', () => { + it('should return empty array when node not found', () => { + mockRepository.getNode.mockReturnValue(null); + + const operations = (service as any).getNodeOperations('nodes-base.nonexistent'); + expect(operations).toEqual([]); + }); + + it('should handle JSON parsing errors and throw ValidationServiceError', () => { + mockRepository.getNode.mockReturnValue({ + operations: '{invalid json}', // Malformed JSON string + properties: [] + }); + + expect(() => { + (service as any).getNodeOperations('nodes-base.broken'); + }).toThrow(ValidationServiceError); + + expect(logger.error).toHaveBeenCalled(); + }); + + it('should handle generic errors in operations processing', () => { + // Mock repository to throw an error + mockRepository.getNodeOperations.mockImplementation(() => { + throw new Error('Generic error'); + }); + + // The public API should handle the error gracefully + const result = service.findSimilarOperations('nodes-base.error', 'invalidOp'); + expect(result).toEqual([]); + }); + + it('should handle errors in properties processing', () => { + // Mock repository to return empty operations when there's an error + mockRepository.getNodeOperations.mockReturnValue([]); + + const result = service.findSimilarOperations('nodes-base.props-error', 'invalidOp'); + expect(result).toEqual([]); + }); + + it('should parse string operations correctly', () => { + mockRepository.getNode.mockReturnValue({ + operations: JSON.stringify([ + { operation: 'send', name: 'Send Message' }, + { operation: 'get', name: 'Get Message' } + ]), + properties: [] + }); + + const operations = (service as any).getNodeOperations('nodes-base.string-ops'); + expect(operations).toHaveLength(2); + expect(operations[0].operation).toBe('send'); + }); + + it('should handle array operations directly', () => { + mockRepository.getNode.mockReturnValue({ + operations: [ + { operation: 'create', name: 'Create Item' }, + { operation: 'delete', name: 'Delete Item' } + ], + properties: [] + }); + + const operations = (service as any).getNodeOperations('nodes-base.array-ops'); + expect(operations).toHaveLength(2); + expect(operations[1].operation).toBe('delete'); + }); + + it('should flatten object operations', () => { + mockRepository.getNode.mockReturnValue({ + operations: { + message: [{ operation: 'send' }], + channel: [{ operation: 'create' }] + }, + properties: [] + }); + + const operations = (service as any).getNodeOperations('nodes-base.object-ops'); + expect(operations).toHaveLength(2); + }); + + it('should extract operations from properties with resource filtering', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' }, + { value: 'update', name: 'Update Message' } + ] + } + ] + }); + + const messageOps = (service as any).getNodeOperations('nodes-base.slack', 'message'); + const allOps = (service as any).getNodeOperations('nodes-base.slack'); + + expect(messageOps).toHaveLength(2); + expect(allOps).toHaveLength(2); // All operations included when no resource specified + }); + + it('should filter operations by resource correctly', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + }, + { + name: 'operation', + displayOptions: { + show: { + resource: ['channel'] + } + }, + options: [ + { value: 'create', name: 'Create Channel' } + ] + } + ] + }); + + const messageOps = (service as any).getNodeOperations('nodes-base.slack', 'message'); + const channelOps = (service as any).getNodeOperations('nodes-base.slack', 'channel'); + const wrongResourceOps = (service as any).getNodeOperations('nodes-base.slack', 'nonexistent'); + + expect(messageOps).toHaveLength(1); + expect(messageOps[0].operation).toBe('send'); + expect(channelOps).toHaveLength(1); + expect(channelOps[0].operation).toBe('create'); + expect(wrongResourceOps).toHaveLength(0); + }); + + it('should handle array resource filters', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + displayOptions: { + show: { + resource: ['message', 'channel'] // Array format + } + }, + options: [ + { value: 'list', name: 'List Items' } + ] + } + ] + }); + + const messageOps = (service as any).getNodeOperations('nodes-base.multi', 'message'); + const channelOps = (service as any).getNodeOperations('nodes-base.multi', 'channel'); + const otherOps = (service as any).getNodeOperations('nodes-base.multi', 'other'); + + expect(messageOps).toHaveLength(1); + expect(channelOps).toHaveLength(1); + expect(otherOps).toHaveLength(0); + }); + }); + + describe('getNodePatterns', () => { + it('should return Google Drive patterns for googleDrive nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.googleDrive'); + + const hasGoogleDrivePattern = patterns.some((p: any) => p.pattern === 'listFiles'); + const hasGenericPattern = patterns.some((p: any) => p.pattern === 'list'); + + expect(hasGoogleDrivePattern).toBe(true); + expect(hasGenericPattern).toBe(true); + }); + + it('should return Slack patterns for slack nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.slack'); + + const hasSlackPattern = patterns.some((p: any) => p.pattern === 'sendMessage'); + expect(hasSlackPattern).toBe(true); + }); + + it('should return database patterns for database nodes', () => { + const postgresPatterns = (service as any).getNodePatterns('nodes-base.postgres'); + const mysqlPatterns = (service as any).getNodePatterns('nodes-base.mysql'); + const mongoPatterns = (service as any).getNodePatterns('nodes-base.mongodb'); + + expect(postgresPatterns.some((p: any) => p.pattern === 'selectData')).toBe(true); + expect(mysqlPatterns.some((p: any) => p.pattern === 'insertData')).toBe(true); + expect(mongoPatterns.some((p: any) => p.pattern === 'updateData')).toBe(true); + }); + + it('should return HTTP patterns for httpRequest nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.httpRequest'); + + const hasHttpPattern = patterns.some((p: any) => p.pattern === 'fetch'); + expect(hasHttpPattern).toBe(true); + }); + + it('should always include generic patterns', () => { + const patterns = (service as any).getNodePatterns('nodes-base.unknown'); + + const hasGenericPattern = patterns.some((p: any) => p.pattern === 'list'); + expect(hasGenericPattern).toBe(true); + }); + }); + + describe('similarity calculation', () => { + describe('calculateSimilarity', () => { + it('should return 1.0 for exact matches', () => { + const similarity = (service as any).calculateSimilarity('send', 'send'); + expect(similarity).toBe(1.0); + }); + + it('should return high confidence for substring matches', () => { + const similarity = (service as any).calculateSimilarity('send', 'sendMessage'); + expect(similarity).toBeGreaterThanOrEqual(0.7); + }); + + it('should boost confidence for single character typos in short words', () => { + const similarity = (service as any).calculateSimilarity('sned', 'send'); + expect(similarity).toBeGreaterThanOrEqual(0.75); + }); + + it('should boost confidence for transpositions in short words', () => { + const similarity = (service as any).calculateSimilarity('sedn', 'send'); + expect(similarity).toBeGreaterThanOrEqual(0.72); + }); + + it('should boost similarity for common variations', () => { + const similarity = (service as any).calculateSimilarity('sendMessage', 'send'); + expect(similarity).toBeGreaterThanOrEqual(0.8); // Should be boosted + }); + + it('should handle case insensitive matching', () => { + const similarity = (service as any).calculateSimilarity('SEND', 'send'); + expect(similarity).toBe(1.0); + }); + }); + + describe('levenshteinDistance', () => { + it('should calculate distance 0 for identical strings', () => { + const distance = (service as any).levenshteinDistance('send', 'send'); + expect(distance).toBe(0); + }); + + it('should calculate distance for single character operations', () => { + const distance = (service as any).levenshteinDistance('send', 'sned'); + expect(distance).toBe(2); // transposition + }); + + it('should calculate distance for insertions', () => { + const distance = (service as any).levenshteinDistance('send', 'sends'); + expect(distance).toBe(1); + }); + + it('should calculate distance for deletions', () => { + const distance = (service as any).levenshteinDistance('sends', 'send'); + expect(distance).toBe(1); + }); + + it('should calculate distance for substitutions', () => { + const distance = (service as any).levenshteinDistance('send', 'tend'); + expect(distance).toBe(1); + }); + + it('should handle empty strings', () => { + const distance1 = (service as any).levenshteinDistance('', 'send'); + const distance2 = (service as any).levenshteinDistance('send', ''); + + expect(distance1).toBe(4); + expect(distance2).toBe(4); + }); + }); + }); + + describe('areCommonVariations', () => { + it('should detect common prefix variations', () => { + const areCommon = (service as any).areCommonVariations.bind(service); + + expect(areCommon('getMessage', 'message')).toBe(true); + expect(areCommon('sendData', 'data')).toBe(true); + expect(areCommon('createItem', 'item')).toBe(true); + }); + + it('should detect common suffix variations', () => { + const areCommon = (service as any).areCommonVariations.bind(service); + + expect(areCommon('uploadFile', 'upload')).toBe(true); + expect(areCommon('saveData', 'save')).toBe(true); + expect(areCommon('sendMessage', 'send')).toBe(true); + }); + + it('should handle small differences after prefix/suffix removal', () => { + const areCommon = (service as any).areCommonVariations.bind(service); + + expect(areCommon('getMessages', 'message')).toBe(true); // get + messages vs message + expect(areCommon('createItems', 'item')).toBe(true); // create + items vs item + }); + + it('should return false for unrelated operations', () => { + const areCommon = (service as any).areCommonVariations.bind(service); + + expect(areCommon('send', 'delete')).toBe(false); + expect(areCommon('upload', 'search')).toBe(false); + }); + + it('should handle edge cases', () => { + const areCommon = (service as any).areCommonVariations.bind(service); + + expect(areCommon('', 'send')).toBe(false); + expect(areCommon('send', '')).toBe(false); + expect(areCommon('get', 'get')).toBe(false); // Same string, not variation + }); + }); + + describe('getSimilarityReason', () => { + it('should return "Almost exact match" for very high confidence', () => { + const reason = (service as any).getSimilarityReason(0.96, 'sned', 'send'); + expect(reason).toBe('Almost exact match - likely a typo'); + }); + + it('should return "Very similar" for high confidence', () => { + const reason = (service as any).getSimilarityReason(0.85, 'sendMsg', 'send'); + expect(reason).toBe('Very similar - common variation'); + }); + + it('should return "Similar operation" for medium confidence', () => { + const reason = (service as any).getSimilarityReason(0.65, 'create', 'update'); + expect(reason).toBe('Similar operation'); + }); + + it('should return "Partial match" for substring matches', () => { + const reason = (service as any).getSimilarityReason(0.5, 'sendMessage', 'send'); + expect(reason).toBe('Partial match'); + }); + + it('should return "Possibly related operation" for low confidence', () => { + const reason = (service as any).getSimilarityReason(0.4, 'xyz', 'send'); + expect(reason).toBe('Possibly related operation'); + }); + }); + + describe('findSimilarOperations comprehensive scenarios', () => { + it('should return empty array for non-existent node', () => { + mockRepository.getNode.mockReturnValue(null); + + const suggestions = service.findSimilarOperations('nodes-base.nonexistent', 'operation'); + expect(suggestions).toEqual([]); + }); + + it('should return empty array for exact matches', () => { + mockRepository.getNode.mockReturnValue({ + operations: [{ operation: 'send', name: 'Send' }], + properties: [] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'send'); + expect(suggestions).toEqual([]); + }); + + it('should find pattern matches first', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'search', name: 'Search' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.googleDrive', 'listFiles'); + + expect(suggestions.length).toBeGreaterThan(0); + const searchSuggestion = suggestions.find(s => s.value === 'search'); + expect(searchSuggestion).toBeDefined(); + expect(searchSuggestion!.confidence).toBe(0.85); + }); + + it('should not suggest pattern matches if target operation doesn\'t exist', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'someOtherOperation', name: 'Other Operation' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.googleDrive', 'listFiles'); + + // Pattern suggests 'search' but it doesn't exist in the node + const searchSuggestion = suggestions.find(s => s.value === 'search'); + expect(searchSuggestion).toBeUndefined(); + }); + + it('should calculate similarity for valid operations', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'send', name: 'Send Message' }, + { value: 'get', name: 'Get Message' }, + { value: 'delete', name: 'Delete Message' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'sned'); + + expect(suggestions.length).toBeGreaterThan(0); + const sendSuggestion = suggestions.find(s => s.value === 'send'); + expect(sendSuggestion).toBeDefined(); + expect(sendSuggestion!.confidence).toBeGreaterThan(0.7); + }); + + it('should include operation description when available', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'send', name: 'Send Message', description: 'Send a message to a channel' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'sned'); + + const sendSuggestion = suggestions.find(s => s.value === 'send'); + expect(sendSuggestion!.description).toBe('Send Message'); + }); + + it('should include resource information when specified', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send Message' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'sned', 'message'); + + const sendSuggestion = suggestions.find(s => s.value === 'send'); + expect(sendSuggestion!.resource).toBe('message'); + }); + + it('should deduplicate suggestions from different sources', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'send', name: 'Send' } + ] + } + ] + }); + + // This should find both pattern match and similarity match for the same operation + const suggestions = service.findSimilarOperations('nodes-base.slack', 'sendMessage'); + + const sendCount = suggestions.filter(s => s.value === 'send').length; + expect(sendCount).toBe(1); // Should be deduplicated + }); + + it('should limit suggestions to maxSuggestions parameter', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'operation1', name: 'Operation 1' }, + { value: 'operation2', name: 'Operation 2' }, + { value: 'operation3', name: 'Operation 3' }, + { value: 'operation4', name: 'Operation 4' }, + { value: 'operation5', name: 'Operation 5' }, + { value: 'operation6', name: 'Operation 6' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'operatio', undefined, 3); + + expect(suggestions.length).toBeLessThanOrEqual(3); + }); + + it('should sort suggestions by confidence descending', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [ + { value: 'send', name: 'Send' }, + { value: 'senda', name: 'Senda' }, + { value: 'sending', name: 'Sending' } + ] + } + ] + }); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'sned'); + + // Should be sorted by confidence + for (let i = 0; i < suggestions.length - 1; i++) { + expect(suggestions[i].confidence).toBeGreaterThanOrEqual(suggestions[i + 1].confidence); + } + }); + + it('should use cached results when available', () => { + const suggestionCache = (service as any).suggestionCache; + const cachedSuggestions = [{ value: 'cached', confidence: 0.9, reason: 'Cached' }]; + + suggestionCache.set('nodes-base.test:invalid:', cachedSuggestions); + + const suggestions = service.findSimilarOperations('nodes-base.test', 'invalid'); + + expect(suggestions).toEqual(cachedSuggestions); + expect(mockRepository.getNode).not.toHaveBeenCalled(); + }); + + it('should cache results after calculation', () => { + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: [{ value: 'test', name: 'Test' }] + } + ] + }); + + const suggestions1 = service.findSimilarOperations('nodes-base.test', 'invalid'); + const suggestions2 = service.findSimilarOperations('nodes-base.test', 'invalid'); + + expect(suggestions1).toEqual(suggestions2); + expect(mockRepository.getNode).toHaveBeenCalledTimes(1); // Second call uses cache + }); + }); + + describe('cache behavior edge cases', () => { + it('should trigger getNodeOperations cache cleanup randomly', () => { + const originalRandom = Math.random; + Math.random = vi.fn(() => 0.02); // Less than 0.05 + + const cleanupSpy = vi.spyOn(service as any, 'cleanupExpiredEntries'); + + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [] + }); + + (service as any).getNodeOperations('nodes-base.test'); + + expect(cleanupSpy).toHaveBeenCalled(); + + Math.random = originalRandom; + }); + + it('should use cached operation data when available and fresh', () => { + const operationCache = (service as any).operationCache; + const testOperations = [{ operation: 'cached', name: 'Cached Operation' }]; + + operationCache.set('nodes-base.test:all', { + operations: testOperations, + timestamp: Date.now() - 1000 // 1 second ago, fresh + }); + + const operations = (service as any).getNodeOperations('nodes-base.test'); + + expect(operations).toEqual(testOperations); + expect(mockRepository.getNode).not.toHaveBeenCalled(); + }); + + it('should refresh expired operation cache data', () => { + const operationCache = (service as any).operationCache; + const oldOperations = [{ operation: 'old', name: 'Old Operation' }]; + const newOperations = [{ value: 'new', name: 'New Operation' }]; + + // Set expired cache entry + operationCache.set('nodes-base.test:all', { + operations: oldOperations, + timestamp: Date.now() - (6 * 60 * 1000) // 6 minutes ago, expired + }); + + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + options: newOperations + } + ] + }); + + const operations = (service as any).getNodeOperations('nodes-base.test'); + + expect(mockRepository.getNode).toHaveBeenCalled(); + expect(operations[0].operation).toBe('new'); + }); + + it('should handle resource-specific caching', () => { + const operationCache = (service as any).operationCache; + + mockRepository.getNode.mockReturnValue({ + operations: [], + properties: [ + { + name: 'operation', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [{ value: 'send', name: 'Send' }] + } + ] + }); + + // First call should cache + const messageOps1 = (service as any).getNodeOperations('nodes-base.test', 'message'); + expect(operationCache.has('nodes-base.test:message')).toBe(true); + + // Second call should use cache + const messageOps2 = (service as any).getNodeOperations('nodes-base.test', 'message'); + expect(messageOps1).toEqual(messageOps2); + + // Different resource should have separate cache + const allOps = (service as any).getNodeOperations('nodes-base.test'); + expect(operationCache.has('nodes-base.test:all')).toBe(true); + }); + }); + + describe('clearCache', () => { + it('should clear both operation and suggestion caches', () => { + const operationCache = (service as any).operationCache; + const suggestionCache = (service as any).suggestionCache; + + // Add some data to caches + operationCache.set('test', { operations: [], timestamp: Date.now() }); + suggestionCache.set('test', []); + + expect(operationCache.size).toBe(1); + expect(suggestionCache.size).toBe(1); + + service.clearCache(); + + expect(operationCache.size).toBe(0); + expect(suggestionCache.size).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/services/resource-similarity-service-comprehensive.test.ts b/tests/unit/services/resource-similarity-service-comprehensive.test.ts new file mode 100644 index 0000000..f2519c9 --- /dev/null +++ b/tests/unit/services/resource-similarity-service-comprehensive.test.ts @@ -0,0 +1,780 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { ResourceSimilarityService } from '@/services/resource-similarity-service'; +import { NodeRepository } from '@/database/node-repository'; +import { ValidationServiceError } from '@/errors/validation-service-error'; +import { logger } from '@/utils/logger'; + +// Mock the logger to test error handling paths +vi.mock('@/utils/logger', () => ({ + logger: { + warn: vi.fn() + } +})); + +describe('ResourceSimilarityService - Comprehensive Coverage', () => { + let service: ResourceSimilarityService; + let mockRepository: any; + + beforeEach(() => { + mockRepository = { + getNode: vi.fn(), + getNodeResources: vi.fn() + }; + service = new ResourceSimilarityService(mockRepository); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor and initialization', () => { + it('should initialize with common patterns', () => { + // Access private property to verify initialization + const patterns = (service as any).commonPatterns; + expect(patterns).toBeDefined(); + expect(patterns.has('googleDrive')).toBe(true); + expect(patterns.has('slack')).toBe(true); + expect(patterns.has('database')).toBe(true); + expect(patterns.has('generic')).toBe(true); + }); + + it('should initialize empty caches', () => { + const resourceCache = (service as any).resourceCache; + const suggestionCache = (service as any).suggestionCache; + + expect(resourceCache.size).toBe(0); + expect(suggestionCache.size).toBe(0); + }); + }); + + describe('cache cleanup mechanisms', () => { + it('should clean up expired resource cache entries', () => { + const now = Date.now(); + const expiredTimestamp = now - (6 * 60 * 1000); // 6 minutes ago + const validTimestamp = now - (2 * 60 * 1000); // 2 minutes ago + + // Manually add entries to cache + const resourceCache = (service as any).resourceCache; + resourceCache.set('expired-node', { resources: [], timestamp: expiredTimestamp }); + resourceCache.set('valid-node', { resources: [], timestamp: validTimestamp }); + + // Force cleanup + (service as any).cleanupExpiredEntries(); + + expect(resourceCache.has('expired-node')).toBe(false); + expect(resourceCache.has('valid-node')).toBe(true); + }); + + it('should limit suggestion cache size to 50 entries when over 100', () => { + const suggestionCache = (service as any).suggestionCache; + + // Fill cache with 110 entries + for (let i = 0; i < 110; i++) { + suggestionCache.set(`key-${i}`, []); + } + + expect(suggestionCache.size).toBe(110); + + // Force cleanup + (service as any).cleanupExpiredEntries(); + + expect(suggestionCache.size).toBe(50); + // Should keep the last 50 entries + expect(suggestionCache.has('key-109')).toBe(true); + expect(suggestionCache.has('key-59')).toBe(false); + }); + + it('should trigger random cleanup during findSimilarResources', () => { + const cleanupSpy = vi.spyOn(service as any, 'cleanupExpiredEntries'); + + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [{ value: 'test', name: 'Test' }] + } + ] + }); + + // Mock Math.random to always trigger cleanup + const originalRandom = Math.random; + Math.random = vi.fn(() => 0.05); // Less than 0.1 + + service.findSimilarResources('nodes-base.test', 'invalid'); + + expect(cleanupSpy).toHaveBeenCalled(); + + // Restore Math.random + Math.random = originalRandom; + }); + }); + + describe('getResourceValue edge cases', () => { + it('should handle string resources', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue('test-resource')).toBe('test-resource'); + }); + + it('should handle object resources with value property', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue({ value: 'object-value', name: 'Object' })).toBe('object-value'); + }); + + it('should handle object resources without value property', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue({ name: 'Object' })).toBe(''); + }); + + it('should handle null and undefined resources', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue(null)).toBe(''); + expect(getValue(undefined)).toBe(''); + }); + + it('should handle primitive types', () => { + const getValue = (service as any).getResourceValue.bind(service); + expect(getValue(123)).toBe(''); + expect(getValue(true)).toBe(''); + }); + }); + + describe('getNodeResources error handling', () => { + it('should return empty array when node not found', () => { + mockRepository.getNode.mockReturnValue(null); + + const resources = (service as any).getNodeResources('nodes-base.nonexistent'); + expect(resources).toEqual([]); + }); + + it('should handle JSON parsing errors gracefully', () => { + // Mock a property access that will throw an error + const errorThrowingProperties = { + get properties() { + throw new Error('Properties access failed'); + } + }; + + mockRepository.getNode.mockReturnValue(errorThrowingProperties); + + const resources = (service as any).getNodeResources('nodes-base.broken'); + expect(resources).toEqual([]); + expect(logger.warn).toHaveBeenCalled(); + }); + + it('should handle malformed properties array', () => { + mockRepository.getNode.mockReturnValue({ + properties: null // No properties array + }); + + const resources = (service as any).getNodeResources('nodes-base.no-props'); + expect(resources).toEqual([]); + }); + + it('should extract implicit resources when no explicit resource field found', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'operation', + options: [ + { value: 'uploadFile', name: 'Upload File' }, + { value: 'downloadFile', name: 'Download File' } + ] + } + ] + }); + + const resources = (service as any).getNodeResources('nodes-base.implicit'); + expect(resources.length).toBeGreaterThan(0); + expect(resources[0].value).toBe('file'); + }); + }); + + describe('extractImplicitResources', () => { + it('should extract resources from operation names', () => { + const properties = [ + { + name: 'operation', + options: [ + { value: 'sendMessage', name: 'Send Message' }, + { value: 'replyToMessage', name: 'Reply to Message' } + ] + } + ]; + + const resources = (service as any).extractImplicitResources(properties); + expect(resources.length).toBe(1); + expect(resources[0].value).toBe('message'); + }); + + it('should handle properties without operations', () => { + const properties = [ + { + name: 'url', + type: 'string' + } + ]; + + const resources = (service as any).extractImplicitResources(properties); + expect(resources).toEqual([]); + }); + + it('should handle operations without recognizable patterns', () => { + const properties = [ + { + name: 'operation', + options: [ + { value: 'unknownAction', name: 'Unknown Action' } + ] + } + ]; + + const resources = (service as any).extractImplicitResources(properties); + expect(resources).toEqual([]); + }); + }); + + describe('inferResourceFromOperations', () => { + it('should infer file resource from file operations', () => { + const operations = [ + { value: 'uploadFile' }, + { value: 'downloadFile' } + ]; + + const resource = (service as any).inferResourceFromOperations(operations); + expect(resource).toBe('file'); + }); + + it('should infer folder resource from folder operations', () => { + const operations = [ + { value: 'createDirectory' }, + { value: 'listFolder' } + ]; + + const resource = (service as any).inferResourceFromOperations(operations); + expect(resource).toBe('folder'); + }); + + it('should return null for unrecognizable operations', () => { + const operations = [ + { value: 'unknownOperation' }, + { value: 'anotherUnknown' } + ]; + + const resource = (service as any).inferResourceFromOperations(operations); + expect(resource).toBeNull(); + }); + + it('should handle operations without value property', () => { + const operations = ['uploadFile', 'downloadFile']; + + const resource = (service as any).inferResourceFromOperations(operations); + expect(resource).toBe('file'); + }); + }); + + describe('getNodePatterns', () => { + it('should return Google Drive patterns for googleDrive nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.googleDrive'); + + const hasGoogleDrivePattern = patterns.some((p: any) => p.pattern === 'files'); + const hasGenericPattern = patterns.some((p: any) => p.pattern === 'items'); + + expect(hasGoogleDrivePattern).toBe(true); + expect(hasGenericPattern).toBe(true); + }); + + it('should return Slack patterns for slack nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.slack'); + + const hasSlackPattern = patterns.some((p: any) => p.pattern === 'messages'); + expect(hasSlackPattern).toBe(true); + }); + + it('should return database patterns for database nodes', () => { + const postgresPatterns = (service as any).getNodePatterns('nodes-base.postgres'); + const mysqlPatterns = (service as any).getNodePatterns('nodes-base.mysql'); + const mongoPatterns = (service as any).getNodePatterns('nodes-base.mongodb'); + + expect(postgresPatterns.some((p: any) => p.pattern === 'tables')).toBe(true); + expect(mysqlPatterns.some((p: any) => p.pattern === 'tables')).toBe(true); + expect(mongoPatterns.some((p: any) => p.pattern === 'collections')).toBe(true); + }); + + it('should return Google Sheets patterns for googleSheets nodes', () => { + const patterns = (service as any).getNodePatterns('nodes-base.googleSheets'); + + const hasSheetsPattern = patterns.some((p: any) => p.pattern === 'sheets'); + expect(hasSheetsPattern).toBe(true); + }); + + it('should return email patterns for email nodes', () => { + const gmailPatterns = (service as any).getNodePatterns('nodes-base.gmail'); + const emailPatterns = (service as any).getNodePatterns('nodes-base.emailSend'); + + expect(gmailPatterns.some((p: any) => p.pattern === 'emails')).toBe(true); + expect(emailPatterns.some((p: any) => p.pattern === 'emails')).toBe(true); + }); + + it('should always include generic patterns', () => { + const patterns = (service as any).getNodePatterns('nodes-base.unknown'); + + const hasGenericPattern = patterns.some((p: any) => p.pattern === 'items'); + expect(hasGenericPattern).toBe(true); + }); + }); + + describe('plural/singular conversion', () => { + describe('toSingular', () => { + it('should convert words ending in "ies" to "y"', () => { + const toSingular = (service as any).toSingular.bind(service); + + expect(toSingular('companies')).toBe('company'); + expect(toSingular('policies')).toBe('policy'); + expect(toSingular('categories')).toBe('category'); + }); + + it('should convert words ending in "es" by removing "es"', () => { + const toSingular = (service as any).toSingular.bind(service); + + expect(toSingular('boxes')).toBe('box'); + expect(toSingular('dishes')).toBe('dish'); + expect(toSingular('beaches')).toBe('beach'); + }); + + it('should convert words ending in "s" by removing "s"', () => { + const toSingular = (service as any).toSingular.bind(service); + + expect(toSingular('cats')).toBe('cat'); + expect(toSingular('items')).toBe('item'); + expect(toSingular('users')).toBe('user'); + // Note: 'files' ends in 'es' so it's handled by the 'es' case + }); + + it('should not modify words ending in "ss"', () => { + const toSingular = (service as any).toSingular.bind(service); + + expect(toSingular('class')).toBe('class'); + expect(toSingular('process')).toBe('process'); + expect(toSingular('access')).toBe('access'); + }); + + it('should not modify singular words', () => { + const toSingular = (service as any).toSingular.bind(service); + + expect(toSingular('file')).toBe('file'); + expect(toSingular('user')).toBe('user'); + expect(toSingular('data')).toBe('data'); + }); + }); + + describe('toPlural', () => { + it('should convert words ending in consonant+y to "ies"', () => { + const toPlural = (service as any).toPlural.bind(service); + + expect(toPlural('company')).toBe('companies'); + expect(toPlural('policy')).toBe('policies'); + expect(toPlural('category')).toBe('categories'); + }); + + it('should not convert words ending in vowel+y', () => { + const toPlural = (service as any).toPlural.bind(service); + + expect(toPlural('day')).toBe('days'); + expect(toPlural('key')).toBe('keys'); + expect(toPlural('boy')).toBe('boys'); + }); + + it('should add "es" to words ending in s, x, z, ch, sh', () => { + const toPlural = (service as any).toPlural.bind(service); + + expect(toPlural('box')).toBe('boxes'); + expect(toPlural('dish')).toBe('dishes'); + expect(toPlural('church')).toBe('churches'); + expect(toPlural('buzz')).toBe('buzzes'); + expect(toPlural('class')).toBe('classes'); + }); + + it('should add "s" to regular words', () => { + const toPlural = (service as any).toPlural.bind(service); + + expect(toPlural('file')).toBe('files'); + expect(toPlural('user')).toBe('users'); + expect(toPlural('item')).toBe('items'); + }); + }); + }); + + describe('similarity calculation', () => { + describe('calculateSimilarity', () => { + it('should return 1.0 for exact matches', () => { + const similarity = (service as any).calculateSimilarity('file', 'file'); + expect(similarity).toBe(1.0); + }); + + it('should return high confidence for substring matches', () => { + const similarity = (service as any).calculateSimilarity('file', 'files'); + expect(similarity).toBeGreaterThanOrEqual(0.7); + }); + + it('should boost confidence for single character typos in short words', () => { + const similarity = (service as any).calculateSimilarity('flie', 'file'); + expect(similarity).toBeGreaterThanOrEqual(0.7); // Adjusted to match actual implementation + }); + + it('should boost confidence for transpositions in short words', () => { + const similarity = (service as any).calculateSimilarity('fiel', 'file'); + expect(similarity).toBeGreaterThanOrEqual(0.72); + }); + + it('should handle case insensitive matching', () => { + const similarity = (service as any).calculateSimilarity('FILE', 'file'); + expect(similarity).toBe(1.0); + }); + + it('should return lower confidence for very different strings', () => { + const similarity = (service as any).calculateSimilarity('xyz', 'file'); + expect(similarity).toBeLessThan(0.5); + }); + }); + + describe('levenshteinDistance', () => { + it('should calculate distance 0 for identical strings', () => { + const distance = (service as any).levenshteinDistance('file', 'file'); + expect(distance).toBe(0); + }); + + it('should calculate distance 1 for single character difference', () => { + const distance = (service as any).levenshteinDistance('file', 'flie'); + expect(distance).toBe(2); // transposition counts as 2 operations + }); + + it('should calculate distance for insertions', () => { + const distance = (service as any).levenshteinDistance('file', 'files'); + expect(distance).toBe(1); + }); + + it('should calculate distance for deletions', () => { + const distance = (service as any).levenshteinDistance('files', 'file'); + expect(distance).toBe(1); + }); + + it('should calculate distance for substitutions', () => { + const distance = (service as any).levenshteinDistance('file', 'pile'); + expect(distance).toBe(1); + }); + + it('should handle empty strings', () => { + const distance1 = (service as any).levenshteinDistance('', 'file'); + const distance2 = (service as any).levenshteinDistance('file', ''); + + expect(distance1).toBe(4); + expect(distance2).toBe(4); + }); + }); + }); + + describe('getSimilarityReason', () => { + it('should return "Almost exact match" for very high confidence', () => { + const reason = (service as any).getSimilarityReason(0.96, 'flie', 'file'); + expect(reason).toBe('Almost exact match - likely a typo'); + }); + + it('should return "Very similar" for high confidence', () => { + const reason = (service as any).getSimilarityReason(0.85, 'fil', 'file'); + expect(reason).toBe('Very similar - common variation'); + }); + + it('should return "Similar resource name" for medium confidence', () => { + const reason = (service as any).getSimilarityReason(0.65, 'document', 'file'); + expect(reason).toBe('Similar resource name'); + }); + + it('should return "Partial match" for substring matches', () => { + const reason = (service as any).getSimilarityReason(0.5, 'fileupload', 'file'); + expect(reason).toBe('Partial match'); + }); + + it('should return "Possibly related resource" for low confidence', () => { + const reason = (service as any).getSimilarityReason(0.4, 'xyz', 'file'); + expect(reason).toBe('Possibly related resource'); + }); + }); + + describe('pattern matching edge cases', () => { + it('should find pattern suggestions even when no similar resources exist', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'file', name: 'File' } // Include 'file' so pattern can match + ] + } + ] + }); + + const suggestions = service.findSimilarResources('nodes-base.googleDrive', 'files'); + + // Should find pattern match for 'files' -> 'file' + expect(suggestions.length).toBeGreaterThan(0); + }); + + it('should not suggest pattern matches if target resource doesn\'t exist', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'someOtherResource', name: 'Other Resource' } + ] + } + ] + }); + + const suggestions = service.findSimilarResources('nodes-base.googleDrive', 'files'); + + // Pattern suggests 'file' but it doesn't exist in the node, so no pattern suggestion + const fileSuggestion = suggestions.find(s => s.value === 'file'); + expect(fileSuggestion).toBeUndefined(); + }); + }); + + describe('complex resource structures', () => { + it('should handle resources with operations arrays', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'message', name: 'Message' } + ] + }, + { + name: 'operation', + displayOptions: { + show: { + resource: ['message'] + } + }, + options: [ + { value: 'send', name: 'Send' }, + { value: 'update', name: 'Update' } + ] + } + ] + }); + + const resources = (service as any).getNodeResources('nodes-base.slack'); + + expect(resources.length).toBe(1); + expect(resources[0].value).toBe('message'); + expect(resources[0].operations).toEqual(['send', 'update']); + }); + + it('should handle multiple resource fields with operations', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'file', name: 'File' }, + { value: 'folder', name: 'Folder' } + ] + }, + { + name: 'operation', + displayOptions: { + show: { + resource: ['file', 'folder'] // Multiple resources + } + }, + options: [ + { value: 'list', name: 'List' } + ] + } + ] + }); + + const resources = (service as any).getNodeResources('nodes-base.test'); + + expect(resources.length).toBe(2); + expect(resources[0].operations).toEqual(['list']); + expect(resources[1].operations).toEqual(['list']); + }); + }); + + describe('cache behavior edge cases', () => { + it('should trigger getNodeResources cache cleanup randomly', () => { + const originalRandom = Math.random; + Math.random = vi.fn(() => 0.02); // Less than 0.05 + + const cleanupSpy = vi.spyOn(service as any, 'cleanupExpiredEntries'); + + mockRepository.getNode.mockReturnValue({ + properties: [] + }); + + (service as any).getNodeResources('nodes-base.test'); + + expect(cleanupSpy).toHaveBeenCalled(); + + Math.random = originalRandom; + }); + + it('should use cached resource data when available and fresh', () => { + const resourceCache = (service as any).resourceCache; + const testResources = [{ value: 'cached', name: 'Cached Resource' }]; + + resourceCache.set('nodes-base.test', { + resources: testResources, + timestamp: Date.now() - 1000 // 1 second ago, fresh + }); + + const resources = (service as any).getNodeResources('nodes-base.test'); + + expect(resources).toEqual(testResources); + expect(mockRepository.getNode).not.toHaveBeenCalled(); + }); + + it('should refresh expired resource cache data', () => { + const resourceCache = (service as any).resourceCache; + const oldResources = [{ value: 'old', name: 'Old Resource' }]; + const newResources = [{ value: 'new', name: 'New Resource' }]; + + // Set expired cache entry + resourceCache.set('nodes-base.test', { + resources: oldResources, + timestamp: Date.now() - (6 * 60 * 1000) // 6 minutes ago, expired + }); + + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: newResources + } + ] + }); + + const resources = (service as any).getNodeResources('nodes-base.test'); + + expect(mockRepository.getNode).toHaveBeenCalled(); + expect(resources[0].value).toBe('new'); + }); + }); + + describe('findSimilarResources comprehensive edge cases', () => { + it('should return cached suggestions if available', () => { + const suggestionCache = (service as any).suggestionCache; + const cachedSuggestions = [{ value: 'cached', confidence: 0.9, reason: 'Cached' }]; + + suggestionCache.set('nodes-base.test:invalid', cachedSuggestions); + + const suggestions = service.findSimilarResources('nodes-base.test', 'invalid'); + + expect(suggestions).toEqual(cachedSuggestions); + expect(mockRepository.getNode).not.toHaveBeenCalled(); + }); + + it('should handle nodes with no properties gracefully', () => { + mockRepository.getNode.mockReturnValue({ + properties: null + }); + + const suggestions = service.findSimilarResources('nodes-base.empty', 'resource'); + + expect(suggestions).toEqual([]); + }); + + it('should deduplicate suggestions from different sources', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'file', name: 'File' } + ] + } + ] + }); + + // This should find both pattern match and similarity match for the same resource + const suggestions = service.findSimilarResources('nodes-base.googleDrive', 'files'); + + const fileCount = suggestions.filter(s => s.value === 'file').length; + expect(fileCount).toBe(1); // Should be deduplicated + }); + + it('should limit suggestions to maxSuggestions parameter', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'resource1', name: 'Resource 1' }, + { value: 'resource2', name: 'Resource 2' }, + { value: 'resource3', name: 'Resource 3' }, + { value: 'resource4', name: 'Resource 4' }, + { value: 'resource5', name: 'Resource 5' }, + { value: 'resource6', name: 'Resource 6' } + ] + } + ] + }); + + const suggestions = service.findSimilarResources('nodes-base.test', 'resourc', 3); + + expect(suggestions.length).toBeLessThanOrEqual(3); + }); + + it('should include availableOperations in suggestions', () => { + mockRepository.getNode.mockReturnValue({ + properties: [ + { + name: 'resource', + options: [ + { value: 'file', name: 'File' } + ] + }, + { + name: 'operation', + displayOptions: { + show: { + resource: ['file'] + } + }, + options: [ + { value: 'upload', name: 'Upload' }, + { value: 'download', name: 'Download' } + ] + } + ] + }); + + const suggestions = service.findSimilarResources('nodes-base.test', 'files'); + + const fileSuggestion = suggestions.find(s => s.value === 'file'); + expect(fileSuggestion?.availableOperations).toEqual(['upload', 'download']); + }); + }); + + describe('clearCache', () => { + it('should clear both resource and suggestion caches', () => { + const resourceCache = (service as any).resourceCache; + const suggestionCache = (service as any).suggestionCache; + + // Add some data to caches + resourceCache.set('test', { resources: [], timestamp: Date.now() }); + suggestionCache.set('test', []); + + expect(resourceCache.size).toBe(1); + expect(suggestionCache.size).toBe(1); + + service.clearCache(); + + expect(resourceCache.size).toBe(0); + expect(suggestionCache.size).toBe(0); + }); + }); +}); \ No newline at end of file