Skip to main content
Glama

n8n-MCP

by 88-888
resource-similarity-service-comprehensive.test.tsโ€ข26.8 kB
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); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/88-888/n8n-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server