mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-06 13:33:11 +00:00
- Fix module resolution issues in LRU cache tests by using proper vi.mock() with importActual - Fix mock call count expectations by using valid API keys instead of empty strings - Add explicit types to test objects to resolve TypeScript linting errors - Change logger mock types to 'any' to avoid complex type issues - Add vi.clearAllMocks() for proper test isolation All tests now pass and TypeScript linting succeeds without errors. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
/**
|
|
* Comprehensive unit tests for LRU cache behavior in handlers-n8n-manager.ts
|
|
*
|
|
* This test file focuses specifically on cache behavior, TTL, eviction, and dispose callbacks
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi, Mock } from 'vitest';
|
|
import { LRUCache } from 'lru-cache';
|
|
import { createHash } from 'crypto';
|
|
import { getN8nApiClient } from '../../../src/mcp/handlers-n8n-manager';
|
|
import { InstanceContext, validateInstanceContext } from '../../../src/types/instance-context';
|
|
import { N8nApiClient } from '../../../src/services/n8n-api-client';
|
|
import { getN8nApiConfigFromContext } from '../../../src/config/n8n-api';
|
|
import { logger } from '../../../src/utils/logger';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../../../src/services/n8n-api-client');
|
|
vi.mock('../../../src/config/n8n-api');
|
|
vi.mock('../../../src/utils/logger');
|
|
vi.mock('../../../src/types/instance-context', async () => {
|
|
const actual = await vi.importActual('../../../src/types/instance-context');
|
|
return {
|
|
...actual,
|
|
validateInstanceContext: vi.fn()
|
|
};
|
|
});
|
|
|
|
describe('LRU Cache Behavior Tests', () => {
|
|
let mockN8nApiClient: Mock;
|
|
let mockGetN8nApiConfigFromContext: Mock;
|
|
let mockLogger: any; // Logger mock has complex type
|
|
let mockValidateInstanceContext: Mock;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
|
|
mockN8nApiClient = vi.mocked(N8nApiClient);
|
|
mockGetN8nApiConfigFromContext = vi.mocked(getN8nApiConfigFromContext);
|
|
mockLogger = vi.mocked(logger);
|
|
mockValidateInstanceContext = vi.mocked(validateInstanceContext);
|
|
|
|
// Default mock returns valid config
|
|
mockGetN8nApiConfigFromContext.mockReturnValue({
|
|
baseUrl: 'https://api.n8n.cloud',
|
|
apiKey: 'test-key',
|
|
timeout: 30000,
|
|
maxRetries: 3
|
|
});
|
|
|
|
// Default mock returns valid context validation
|
|
mockValidateInstanceContext.mockReturnValue({
|
|
valid: true,
|
|
errors: undefined
|
|
});
|
|
|
|
// Force re-import of the module to get fresh cache state
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Cache Key Generation and Collision', () => {
|
|
it('should generate different cache keys for different contexts', () => {
|
|
const context1: InstanceContext = {
|
|
n8nApiUrl: 'https://api1.n8n.cloud',
|
|
n8nApiKey: 'key1',
|
|
instanceId: 'instance1'
|
|
};
|
|
|
|
const context2: InstanceContext = {
|
|
n8nApiUrl: 'https://api2.n8n.cloud',
|
|
n8nApiKey: 'key2',
|
|
instanceId: 'instance2'
|
|
};
|
|
|
|
// Generate expected hashes manually
|
|
const hash1 = createHash('sha256')
|
|
.update(`${context1.n8nApiUrl}:${context1.n8nApiKey}:${context1.instanceId}`)
|
|
.digest('hex');
|
|
|
|
const hash2 = createHash('sha256')
|
|
.update(`${context2.n8nApiUrl}:${context2.n8nApiKey}:${context2.instanceId}`)
|
|
.digest('hex');
|
|
|
|
expect(hash1).not.toBe(hash2);
|
|
|
|
// Create clients to verify different cache entries
|
|
const client1 = getN8nApiClient(context1);
|
|
const client2 = getN8nApiClient(context2);
|
|
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should generate same cache key for identical contexts', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'same-key',
|
|
instanceId: 'same-instance'
|
|
};
|
|
|
|
const client1 = getN8nApiClient(context);
|
|
const client2 = getN8nApiClient(context);
|
|
|
|
// Should only create one client (cache hit)
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
expect(client1).toBe(client2);
|
|
});
|
|
|
|
it('should handle potential cache key collisions gracefully', () => {
|
|
// Create contexts that might produce similar hashes but are valid
|
|
const contexts = [
|
|
{
|
|
n8nApiUrl: 'https://a.com',
|
|
n8nApiKey: 'keyb',
|
|
instanceId: 'c'
|
|
},
|
|
{
|
|
n8nApiUrl: 'https://ab.com',
|
|
n8nApiKey: 'key',
|
|
instanceId: 'bc'
|
|
},
|
|
{
|
|
n8nApiUrl: 'https://abc.com',
|
|
n8nApiKey: 'differentkey', // Fixed: empty string causes config creation to fail
|
|
instanceId: 'key'
|
|
}
|
|
];
|
|
|
|
contexts.forEach((context, index) => {
|
|
const client = getN8nApiClient(context);
|
|
expect(client).toBeDefined();
|
|
});
|
|
|
|
// Each should create a separate client due to different hashes
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe('LRU Eviction Behavior', () => {
|
|
it('should evict oldest entries when cache is full', async () => {
|
|
const loggerDebugSpy = vi.spyOn(logger, 'debug');
|
|
|
|
// Create 101 different contexts to exceed max cache size of 100
|
|
const contexts: InstanceContext[] = [];
|
|
for (let i = 0; i < 101; i++) {
|
|
contexts.push({
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: `key-${i}`,
|
|
instanceId: `instance-${i}`
|
|
});
|
|
}
|
|
|
|
// Create clients for all contexts
|
|
contexts.forEach(context => {
|
|
getN8nApiClient(context);
|
|
});
|
|
|
|
// Should have called dispose callback for evicted entries
|
|
expect(loggerDebugSpy).toHaveBeenCalledWith(
|
|
'Evicting API client from cache',
|
|
expect.objectContaining({
|
|
cacheKey: expect.stringMatching(/^[a-f0-9]{8}\.\.\.$/i)
|
|
})
|
|
);
|
|
|
|
// Verify dispose was called at least once
|
|
expect(loggerDebugSpy).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should maintain LRU order during access', () => {
|
|
const contexts: InstanceContext[] = [];
|
|
for (let i = 0; i < 5; i++) {
|
|
contexts.push({
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: `key-${i}`,
|
|
instanceId: `instance-${i}`
|
|
});
|
|
}
|
|
|
|
// Create initial clients
|
|
contexts.forEach(context => {
|
|
getN8nApiClient(context);
|
|
});
|
|
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(5);
|
|
|
|
// Access first context again (should move to most recent)
|
|
getN8nApiClient(contexts[0]);
|
|
|
|
// Should not create new client (cache hit)
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it('should handle rapid successive access patterns', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'rapid-access-key',
|
|
instanceId: 'rapid-instance'
|
|
};
|
|
|
|
// Rapidly access same context multiple times
|
|
for (let i = 0; i < 10; i++) {
|
|
getN8nApiClient(context);
|
|
}
|
|
|
|
// Should only create one client despite multiple accesses
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('TTL (Time To Live) Behavior', () => {
|
|
it('should respect TTL settings', async () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'ttl-test-key',
|
|
instanceId: 'ttl-instance'
|
|
};
|
|
|
|
// Create initial client
|
|
const client1 = getN8nApiClient(context);
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
|
|
// Access again immediately (should hit cache)
|
|
const client2 = getN8nApiClient(context);
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
expect(client1).toBe(client2);
|
|
|
|
// Note: We can't easily test TTL expiration in unit tests
|
|
// as it requires actual time passage, but we can verify
|
|
// the updateAgeOnGet behavior
|
|
});
|
|
|
|
it('should update age on cache access (updateAgeOnGet)', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'age-update-key',
|
|
instanceId: 'age-instance'
|
|
};
|
|
|
|
// Create and access multiple times
|
|
getN8nApiClient(context);
|
|
getN8nApiClient(context);
|
|
getN8nApiClient(context);
|
|
|
|
// Should only create one client due to cache hits
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('Dispose Callback Security and Logging', () => {
|
|
it('should sanitize cache keys in dispose callback logs', () => {
|
|
const loggerDebugSpy = vi.spyOn(logger, 'debug');
|
|
|
|
// Create enough contexts to trigger eviction
|
|
const contexts: InstanceContext[] = [];
|
|
for (let i = 0; i < 102; i++) {
|
|
contexts.push({
|
|
n8nApiUrl: 'https://sensitive-api.n8n.cloud',
|
|
n8nApiKey: `super-secret-key-${i}`,
|
|
instanceId: `sensitive-instance-${i}`
|
|
});
|
|
}
|
|
|
|
// Create clients to trigger eviction
|
|
contexts.forEach(context => {
|
|
getN8nApiClient(context);
|
|
});
|
|
|
|
// Verify dispose callback logs don't contain sensitive data
|
|
const logCalls = loggerDebugSpy.mock.calls.filter(call =>
|
|
call[0] === 'Evicting API client from cache'
|
|
);
|
|
|
|
logCalls.forEach(call => {
|
|
const logData = call[1] as any;
|
|
|
|
// Should only log partial cache key (first 8 chars + ...)
|
|
expect(logData.cacheKey).toMatch(/^[a-f0-9]{8}\.\.\.$/i);
|
|
|
|
// Should not contain any sensitive information
|
|
const logString = JSON.stringify(call);
|
|
expect(logString).not.toContain('super-secret-key');
|
|
expect(logString).not.toContain('sensitive-api');
|
|
expect(logString).not.toContain('sensitive-instance');
|
|
});
|
|
});
|
|
|
|
it('should handle dispose callback with undefined client', () => {
|
|
const loggerDebugSpy = vi.spyOn(logger, 'debug');
|
|
|
|
// Create many contexts to trigger disposal
|
|
for (let i = 0; i < 105; i++) {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: `disposal-key-${i}`,
|
|
instanceId: `disposal-${i}`
|
|
};
|
|
getN8nApiClient(context);
|
|
}
|
|
|
|
// Should handle disposal gracefully
|
|
expect(() => {
|
|
// The dispose callback should have been called
|
|
expect(loggerDebugSpy).toHaveBeenCalled();
|
|
}).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe('Cache Memory Management', () => {
|
|
it('should maintain consistent cache size limits', () => {
|
|
// Create exactly 100 contexts (max cache size)
|
|
const contexts: InstanceContext[] = [];
|
|
for (let i = 0; i < 100; i++) {
|
|
contexts.push({
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: `memory-key-${i}`,
|
|
instanceId: `memory-${i}`
|
|
});
|
|
}
|
|
|
|
// Create all clients
|
|
contexts.forEach(context => {
|
|
getN8nApiClient(context);
|
|
});
|
|
|
|
// All should be cached
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(100);
|
|
|
|
// Access all again - should hit cache
|
|
contexts.forEach(context => {
|
|
getN8nApiClient(context);
|
|
});
|
|
|
|
// Should not create additional clients
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(100);
|
|
});
|
|
|
|
it('should handle edge case of single cache entry', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'single-key',
|
|
instanceId: 'single-instance'
|
|
};
|
|
|
|
// Create and access multiple times
|
|
for (let i = 0; i < 5; i++) {
|
|
getN8nApiClient(context);
|
|
}
|
|
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe('Cache Configuration Validation', () => {
|
|
it('should use reasonable cache limits', () => {
|
|
// These values should match the actual cache configuration
|
|
const MAX_CACHE_SIZE = 100;
|
|
const TTL_MINUTES = 30;
|
|
const TTL_MS = TTL_MINUTES * 60 * 1000;
|
|
|
|
// Verify limits are reasonable
|
|
expect(MAX_CACHE_SIZE).toBeGreaterThan(0);
|
|
expect(MAX_CACHE_SIZE).toBeLessThanOrEqual(1000);
|
|
expect(TTL_MS).toBeGreaterThan(0);
|
|
expect(TTL_MS).toBeLessThanOrEqual(60 * 60 * 1000); // Max 1 hour
|
|
});
|
|
});
|
|
|
|
describe('Cache Interaction with Validation', () => {
|
|
it('should not cache when context validation fails', () => {
|
|
// Reset mocks to ensure clean state for this test
|
|
vi.clearAllMocks();
|
|
mockValidateInstanceContext.mockClear();
|
|
|
|
const invalidContext: InstanceContext = {
|
|
n8nApiUrl: 'invalid-url',
|
|
n8nApiKey: 'test-key',
|
|
instanceId: 'invalid-instance'
|
|
};
|
|
|
|
// Mock validation failure
|
|
mockValidateInstanceContext.mockReturnValue({
|
|
valid: false,
|
|
errors: ['Invalid n8nApiUrl format']
|
|
});
|
|
|
|
const client = getN8nApiClient(invalidContext);
|
|
|
|
// Should not create client or cache anything
|
|
expect(client).toBeNull();
|
|
expect(mockN8nApiClient).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should handle cache when config creation fails', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'test-key',
|
|
instanceId: 'config-fail'
|
|
};
|
|
|
|
// Mock config creation failure
|
|
mockGetN8nApiConfigFromContext.mockReturnValue(null);
|
|
|
|
const client = getN8nApiClient(context);
|
|
|
|
expect(client).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Complex Cache Scenarios', () => {
|
|
it('should handle mixed valid and invalid contexts', () => {
|
|
// Reset mocks to ensure clean state for this test
|
|
vi.clearAllMocks();
|
|
mockValidateInstanceContext.mockClear();
|
|
|
|
// First, set up default valid behavior
|
|
mockValidateInstanceContext.mockReturnValue({
|
|
valid: true,
|
|
errors: undefined
|
|
});
|
|
|
|
const validContext: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'valid-key',
|
|
instanceId: 'valid'
|
|
};
|
|
|
|
const invalidContext: InstanceContext = {
|
|
n8nApiUrl: 'invalid-url',
|
|
n8nApiKey: 'key',
|
|
instanceId: 'invalid'
|
|
};
|
|
|
|
// Valid context should work
|
|
const validClient = getN8nApiClient(validContext);
|
|
expect(validClient).toBeDefined();
|
|
|
|
// Change mock for invalid context
|
|
mockValidateInstanceContext.mockReturnValueOnce({
|
|
valid: false,
|
|
errors: ['Invalid URL']
|
|
});
|
|
|
|
const invalidClient = getN8nApiClient(invalidContext);
|
|
expect(invalidClient).toBeNull();
|
|
|
|
// Reset mock back to valid for subsequent calls
|
|
mockValidateInstanceContext.mockReturnValue({
|
|
valid: true,
|
|
errors: undefined
|
|
});
|
|
|
|
// Valid context should still work (cache hit)
|
|
const validClient2 = getN8nApiClient(validContext);
|
|
expect(validClient2).toBe(validClient);
|
|
});
|
|
|
|
it('should handle concurrent access to same cache key', () => {
|
|
const context: InstanceContext = {
|
|
n8nApiUrl: 'https://api.n8n.cloud',
|
|
n8nApiKey: 'concurrent-key',
|
|
instanceId: 'concurrent'
|
|
};
|
|
|
|
// Simulate concurrent access
|
|
const promises = Array(10).fill(null).map(() =>
|
|
Promise.resolve(getN8nApiClient(context))
|
|
);
|
|
|
|
return Promise.all(promises).then(clients => {
|
|
// All should return the same cached client
|
|
const firstClient = clients[0];
|
|
clients.forEach(client => {
|
|
expect(client).toBe(firstClient);
|
|
});
|
|
|
|
// Should only create one client
|
|
expect(mockN8nApiClient).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
});
|
|
}); |