Skip to main content
Glama

n8n-MCP

by 88-888
cache-utils.test.ts14.2 kB
/** * Unit tests for cache utilities */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createCacheKey, getCacheConfig, createInstanceCache, CacheMutex, calculateBackoffDelay, withRetry, getCacheStatistics, cacheMetrics, DEFAULT_RETRY_CONFIG } from '../../../src/utils/cache-utils'; describe('cache-utils', () => { beforeEach(() => { // Reset environment variables delete process.env.INSTANCE_CACHE_MAX; delete process.env.INSTANCE_CACHE_TTL_MINUTES; // Reset cache metrics cacheMetrics.reset(); }); describe('createCacheKey', () => { it('should create consistent SHA-256 hash for same input', () => { const input = 'https://api.n8n.cloud:valid-key:instance1'; const hash1 = createCacheKey(input); const hash2 = createCacheKey(input); expect(hash1).toBe(hash2); expect(hash1).toHaveLength(64); // SHA-256 produces 64 hex chars expect(hash1).toMatch(/^[a-f0-9]+$/); // Only hex characters }); it('should produce different hashes for different inputs', () => { const hash1 = createCacheKey('input1'); const hash2 = createCacheKey('input2'); expect(hash1).not.toBe(hash2); }); it('should use memoization for repeated inputs', () => { const input = 'memoized-input'; // First call creates hash const hash1 = createCacheKey(input); // Second call should return memoized result const hash2 = createCacheKey(input); expect(hash1).toBe(hash2); }); it('should limit memoization cache size', () => { // Create more than MAX_MEMO_SIZE (1000) unique hashes const hashes = new Set<string>(); for (let i = 0; i < 1100; i++) { const hash = createCacheKey(`input-${i}`); hashes.add(hash); } // All hashes should be unique expect(hashes.size).toBe(1100); // Early entries should have been evicted from memo cache // but should still produce consistent results const earlyHash = createCacheKey('input-0'); expect(earlyHash).toBe(hashes.values().next().value); }); }); describe('getCacheConfig', () => { it('should return default configuration when no env vars set', () => { const config = getCacheConfig(); expect(config.max).toBe(100); expect(config.ttlMinutes).toBe(30); }); it('should use environment variables when set', () => { process.env.INSTANCE_CACHE_MAX = '500'; process.env.INSTANCE_CACHE_TTL_MINUTES = '60'; const config = getCacheConfig(); expect(config.max).toBe(500); expect(config.ttlMinutes).toBe(60); }); it('should enforce minimum bounds', () => { process.env.INSTANCE_CACHE_MAX = '0'; process.env.INSTANCE_CACHE_TTL_MINUTES = '0'; const config = getCacheConfig(); expect(config.max).toBe(1); // Min is 1 expect(config.ttlMinutes).toBe(1); // Min is 1 }); it('should enforce maximum bounds', () => { process.env.INSTANCE_CACHE_MAX = '20000'; process.env.INSTANCE_CACHE_TTL_MINUTES = '2000'; const config = getCacheConfig(); expect(config.max).toBe(10000); // Max is 10000 expect(config.ttlMinutes).toBe(1440); // Max is 1440 (24 hours) }); it('should handle invalid values gracefully', () => { process.env.INSTANCE_CACHE_MAX = 'invalid'; process.env.INSTANCE_CACHE_TTL_MINUTES = 'not-a-number'; const config = getCacheConfig(); expect(config.max).toBe(100); // Falls back to default expect(config.ttlMinutes).toBe(30); // Falls back to default }); }); describe('createInstanceCache', () => { it('should create LRU cache with correct configuration', () => { process.env.INSTANCE_CACHE_MAX = '50'; process.env.INSTANCE_CACHE_TTL_MINUTES = '15'; const cache = createInstanceCache<{ data: string }>(); // Add items to cache cache.set('key1', { data: 'value1' }); cache.set('key2', { data: 'value2' }); expect(cache.get('key1')).toEqual({ data: 'value1' }); expect(cache.get('key2')).toEqual({ data: 'value2' }); expect(cache.size).toBe(2); }); it('should call dispose callback on eviction', () => { const disposeFn = vi.fn(); const cache = createInstanceCache<{ data: string }>(disposeFn); // Set max to 2 for testing process.env.INSTANCE_CACHE_MAX = '2'; const smallCache = createInstanceCache<{ data: string }>(disposeFn); smallCache.set('key1', { data: 'value1' }); smallCache.set('key2', { data: 'value2' }); smallCache.set('key3', { data: 'value3' }); // Should evict key1 expect(disposeFn).toHaveBeenCalledWith({ data: 'value1' }, 'key1'); }); it('should update age on get', () => { const cache = createInstanceCache<{ data: string }>(); cache.set('key1', { data: 'value1' }); // Access should update age const value = cache.get('key1'); expect(value).toEqual({ data: 'value1' }); // Item should still be in cache expect(cache.has('key1')).toBe(true); }); }); describe('CacheMutex', () => { it('should prevent concurrent access to same key', async () => { const mutex = new CacheMutex(); const key = 'test-key'; const results: number[] = []; // First operation acquires lock const release1 = await mutex.acquire(key); // Second operation should wait const promise2 = mutex.acquire(key).then(release => { results.push(2); release(); }); // First operation completes results.push(1); release1(); // Wait for second operation await promise2; expect(results).toEqual([1, 2]); // Operations executed in order }); it('should allow concurrent access to different keys', async () => { const mutex = new CacheMutex(); const results: string[] = []; const [release1, release2] = await Promise.all([ mutex.acquire('key1'), mutex.acquire('key2') ]); results.push('both-acquired'); release1(); release2(); expect(results).toEqual(['both-acquired']); }); it('should check if key is locked', async () => { const mutex = new CacheMutex(); const key = 'test-key'; expect(mutex.isLocked(key)).toBe(false); const release = await mutex.acquire(key); expect(mutex.isLocked(key)).toBe(true); release(); expect(mutex.isLocked(key)).toBe(false); }); it('should clear all locks', async () => { const mutex = new CacheMutex(); const release1 = await mutex.acquire('key1'); const release2 = await mutex.acquire('key2'); expect(mutex.isLocked('key1')).toBe(true); expect(mutex.isLocked('key2')).toBe(true); mutex.clearAll(); expect(mutex.isLocked('key1')).toBe(false); expect(mutex.isLocked('key2')).toBe(false); // Should not throw when calling release after clear release1(); release2(); }); it('should handle timeout for stuck locks', async () => { const mutex = new CacheMutex(); const key = 'stuck-key'; // Acquire lock but don't release await mutex.acquire(key); // Wait for timeout (mock the timeout) vi.useFakeTimers(); // Try to acquire same lock const acquirePromise = mutex.acquire(key); // Fast-forward past timeout vi.advanceTimersByTime(6000); // Timeout is 5 seconds // Should be able to acquire after timeout const release = await acquirePromise; release(); vi.useRealTimers(); }); }); describe('calculateBackoffDelay', () => { it('should calculate exponential backoff correctly', () => { const config = { ...DEFAULT_RETRY_CONFIG, jitterFactor: 0 }; // No jitter for predictable tests expect(calculateBackoffDelay(0, config)).toBe(1000); // 1 * 1000 expect(calculateBackoffDelay(1, config)).toBe(2000); // 2 * 1000 expect(calculateBackoffDelay(2, config)).toBe(4000); // 4 * 1000 expect(calculateBackoffDelay(3, config)).toBe(8000); // 8 * 1000 }); it('should respect max delay', () => { const config = { ...DEFAULT_RETRY_CONFIG, maxDelayMs: 5000, jitterFactor: 0 }; expect(calculateBackoffDelay(10, config)).toBe(5000); // Capped at max }); it('should add jitter', () => { const config = { ...DEFAULT_RETRY_CONFIG, baseDelayMs: 1000, jitterFactor: 0.5 }; const delay = calculateBackoffDelay(0, config); // With 50% jitter, delay should be between 1000 and 1500 expect(delay).toBeGreaterThanOrEqual(1000); expect(delay).toBeLessThanOrEqual(1500); }); }); describe('withRetry', () => { it('should succeed on first attempt', async () => { const fn = vi.fn().mockResolvedValue('success'); const result = await withRetry(fn); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(1); }); it('should retry on failure and eventually succeed', async () => { // Create retryable errors (503 Service Unavailable) const retryableError1 = new Error('Service temporarily unavailable'); (retryableError1 as any).response = { status: 503 }; const retryableError2 = new Error('Another temporary failure'); (retryableError2 as any).response = { status: 503 }; const fn = vi.fn() .mockRejectedValueOnce(retryableError1) .mockRejectedValueOnce(retryableError2) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(3); }); it('should throw after max attempts', async () => { // Create retryable error (503 Service Unavailable) const retryableError = new Error('Persistent failure'); (retryableError as any).response = { status: 503 }; const fn = vi.fn().mockRejectedValue(retryableError); await expect(withRetry(fn, { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 })).rejects.toThrow('Persistent failure'); expect(fn).toHaveBeenCalledTimes(3); }); it('should not retry non-retryable errors', async () => { const error = new Error('Not retryable'); (error as any).response = { status: 400 }; // Client error const fn = vi.fn().mockRejectedValue(error); await expect(withRetry(fn)).rejects.toThrow('Not retryable'); expect(fn).toHaveBeenCalledTimes(1); // No retry }); it('should retry network errors', async () => { const networkError = new Error('Network error'); (networkError as any).code = 'ECONNREFUSED'; const fn = vi.fn() .mockRejectedValueOnce(networkError) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 2, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(2); }); it('should retry 429 Too Many Requests', async () => { const error = new Error('Rate limited'); (error as any).response = { status: 429 }; const fn = vi.fn() .mockRejectedValueOnce(error) .mockResolvedValue('success'); const result = await withRetry(fn, { maxAttempts: 2, baseDelayMs: 10, maxDelayMs: 100, jitterFactor: 0 }); expect(result).toBe('success'); expect(fn).toHaveBeenCalledTimes(2); }); }); describe('cacheMetrics', () => { it('should track cache operations', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.recordSet(); cacheMetrics.recordDelete(); cacheMetrics.recordEviction(); const metrics = cacheMetrics.getMetrics(); expect(metrics.hits).toBe(2); expect(metrics.misses).toBe(1); expect(metrics.sets).toBe(1); expect(metrics.deletes).toBe(1); expect(metrics.evictions).toBe(1); expect(metrics.avgHitRate).toBeCloseTo(0.667, 2); // 2/3 }); it('should update cache size', () => { cacheMetrics.updateSize(50, 100); const metrics = cacheMetrics.getMetrics(); expect(metrics.size).toBe(50); expect(metrics.maxSize).toBe(100); }); it('should reset metrics', () => { cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.reset(); const metrics = cacheMetrics.getMetrics(); expect(metrics.hits).toBe(0); expect(metrics.misses).toBe(0); expect(metrics.avgHitRate).toBe(0); }); it('should format metrics for logging', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.updateSize(25, 100); cacheMetrics.recordEviction(); const formatted = cacheMetrics.getFormattedMetrics(); expect(formatted).toContain('Hits=2'); expect(formatted).toContain('Misses=1'); expect(formatted).toContain('HitRate=66.67%'); expect(formatted).toContain('Size=25/100'); expect(formatted).toContain('Evictions=1'); }); }); describe('getCacheStatistics', () => { it('should return formatted statistics', () => { cacheMetrics.recordHit(); cacheMetrics.recordHit(); cacheMetrics.recordMiss(); cacheMetrics.updateSize(30, 100); const stats = getCacheStatistics(); expect(stats).toContain('Cache Statistics:'); expect(stats).toContain('Total Operations: 3'); expect(stats).toContain('Hit Rate: 66.67%'); expect(stats).toContain('Current Size: 30/100'); }); it('should calculate runtime', () => { const stats = getCacheStatistics(); expect(stats).toContain('Runtime:'); expect(stats).toMatch(/Runtime: \d+ minutes/); }); }); });

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