mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-28 21:23:07 +00:00
refactor: streamline test suite — 33 fewer files, 11.9x faster (#670)
* refactor: streamline test suite - cut 33 files, enable parallel execution (11.9x speedup) Remove duplicate, low-value, and fragmented test files while preserving all meaningful coverage. Enable parallel test execution and remove the entire benchmark infrastructure. Key changes: - Consolidate workflow-validator tests (13 files -> 3) - Consolidate config-validator tests (9 files -> 3) - Consolidate telemetry tests (11 files -> 6) - Merge AI validator tests (2 files -> 1) - Remove example/demo test files, mock-testing files, and already-skipped tests - Remove benchmark infrastructure (10 files, CI workflow, 4 npm scripts) - Enable parallel test execution (remove singleThread: true) - Remove retry:2 that was masking flaky tests - Slim CI publish-results job Results: 224 -> 191 test files, 4690 -> 4303 tests, 121K -> 106K lines Local runtime: 319s -> 27s (11.9x speedup) Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: absorb config-validator satellite tests into consolidated file The previous commit deleted 4 config-validator satellite files. This properly merges their unique tests into the consolidated config-validator.test.ts, recovering 89 tests that were dropped during the bulk deletion. Deduplicates 5 tests that existed in both the satellite files and the security test file. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: delete missed benchmark-pr.yml workflow, fix flaky session test - Remove benchmark-pr.yml that referenced deleted benchmark:ci script - Fix session-persistence round-trip test using timestamps closer to now to avoid edge cases exposed by removing retry:2 Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: rebuild FTS5 index after database rebuild to prevent stale rowid refs The FTS5 content-synced index could retain phantom rowid references from previous rebuild cycles, causing 'missing row N from content table' errors on MATCH queries. - Add explicit FTS5 rebuild command in rebuild script after all nodes saved - Add FTS5 rebuild in test beforeAll as defense-in-depth - Rebuild nodes.db with consistent FTS5 index Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use recent timestamps in all session persistence tests Session round-trip tests used timestamps 5-10 minutes in the past which could fail under CI load when combined with session timeout validation. Use timestamps 30 seconds in the past for all valid-session test data. Conceived by Romuald Członkowski - www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
07bd1d4cc2
commit
de2abaf89d
@@ -43,7 +43,14 @@ describe.skipIf(!dbExists)('Database Content Validation', () => {
|
||||
// Ignore NODE_DB_PATH env var which might be set to :memory: by vitest
|
||||
db = await createDatabaseAdapter(dbPath);
|
||||
repository = new NodeRepository(db);
|
||||
console.log('✅ Database found - running validation tests');
|
||||
|
||||
// Rebuild FTS5 index to ensure it is in sync with the nodes table.
|
||||
// The content-synced FTS5 index (content=nodes) can become stale if the
|
||||
// database was rebuilt without an explicit FTS5 rebuild command, leaving
|
||||
// phantom rowid references that cause "missing row" errors on MATCH queries.
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
|
||||
console.log('Database found - running validation tests');
|
||||
});
|
||||
|
||||
describe('[CRITICAL] Database Must Have Data', () => {
|
||||
|
||||
@@ -17,6 +17,12 @@ describe('Node FTS5 Search Integration Tests', () => {
|
||||
const testDbPath = './data/nodes.db';
|
||||
db = await createDatabaseAdapter(testDbPath);
|
||||
repository = new NodeRepository(db);
|
||||
|
||||
// Rebuild FTS5 index to ensure it is in sync with the nodes table.
|
||||
// The content-synced FTS5 index (content=nodes) can become stale if the
|
||||
// database was rebuilt without an explicit FTS5 rebuild command, leaving
|
||||
// phantom rowid references that cause "missing row" errors on MATCH queries.
|
||||
db.prepare("INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* Integration tests for rate limiting
|
||||
*
|
||||
* SECURITY: These tests verify rate limiting prevents brute force attacks
|
||||
* See: https://github.com/czlonkowski/n8n-mcp/issues/265 (HIGH-02)
|
||||
*
|
||||
* TODO: Re-enable when CI server startup issue is resolved
|
||||
* Server process fails to start on port 3001 in CI with ECONNREFUSED errors
|
||||
* Tests pass locally but consistently fail in GitHub Actions CI environment
|
||||
* Rate limiting functionality is verified and working in production
|
||||
*/
|
||||
describe.skip('Integration: Rate Limiting', () => {
|
||||
let serverProcess: ChildProcess;
|
||||
const port = 3001;
|
||||
const authToken = 'test-token-for-rate-limiting-test-32-chars';
|
||||
|
||||
beforeAll(async () => {
|
||||
// Start HTTP server with rate limiting
|
||||
serverProcess = spawn('node', ['dist/http-server-single-session.js'], {
|
||||
env: {
|
||||
...process.env,
|
||||
MCP_MODE: 'http',
|
||||
PORT: port.toString(),
|
||||
AUTH_TOKEN: authToken,
|
||||
NODE_ENV: 'test',
|
||||
AUTH_RATE_LIMIT_WINDOW: '900000', // 15 minutes
|
||||
AUTH_RATE_LIMIT_MAX: '20', // 20 attempts
|
||||
},
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Wait for server to start (longer wait for CI)
|
||||
await new Promise(resolve => setTimeout(resolve, 8000));
|
||||
}, 20000);
|
||||
|
||||
afterAll(() => {
|
||||
if (serverProcess) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
});
|
||||
|
||||
it('should block after max authentication attempts (sequential requests)', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
// IMPORTANT: Use sequential requests to ensure deterministic order
|
||||
// Parallel requests can cause race conditions with in-memory rate limiter
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: i },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true, // Don't throw on error status
|
||||
}
|
||||
);
|
||||
|
||||
if (i <= 20) {
|
||||
// First 20 attempts should be 401 (invalid authentication)
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.data.error.message).toContain('Unauthorized');
|
||||
} else {
|
||||
// Attempts 21+ should be 429 (rate limited)
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.data.error.message).toContain('Too many');
|
||||
}
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
it('should include rate limit headers', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: 1 },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
// Check for standard rate limit headers
|
||||
expect(response.headers['ratelimit-limit']).toBeDefined();
|
||||
expect(response.headers['ratelimit-remaining']).toBeDefined();
|
||||
expect(response.headers['ratelimit-reset']).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('should accept valid tokens within rate limit', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test', version: '1.0' },
|
||||
},
|
||||
id: 1,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.data.result).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('should return JSON-RPC formatted error on rate limit', async () => {
|
||||
const baseUrl = `http://localhost:${port}/mcp`;
|
||||
|
||||
// Exhaust rate limit
|
||||
for (let i = 0; i < 21; i++) {
|
||||
await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: i },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Get rate limited response
|
||||
const response = await axios.post(
|
||||
baseUrl,
|
||||
{ jsonrpc: '2.0', method: 'initialize', id: 999 },
|
||||
{
|
||||
headers: { Authorization: 'Bearer wrong-token' },
|
||||
validateStatus: () => true,
|
||||
}
|
||||
);
|
||||
|
||||
// Verify JSON-RPC error format
|
||||
expect(response.data).toHaveProperty('jsonrpc', '2.0');
|
||||
expect(response.data).toHaveProperty('error');
|
||||
expect(response.data.error).toHaveProperty('code', -32000);
|
||||
expect(response.data.error).toHaveProperty('message');
|
||||
expect(response.data).toHaveProperty('id', null);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -1,754 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||
import { telemetry } from '../../../src/telemetry/telemetry-manager';
|
||||
import { TelemetryConfigManager } from '../../../src/telemetry/config-manager';
|
||||
import { CallToolRequest, ListToolsRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../../src/utils/logger', () => ({
|
||||
Logger: vi.fn().mockImplementation(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry/telemetry-manager', () => ({
|
||||
telemetry: {
|
||||
trackSessionStart: vi.fn(),
|
||||
trackToolUsage: vi.fn(),
|
||||
trackToolSequence: vi.fn(),
|
||||
trackError: vi.fn(),
|
||||
trackSearchQuery: vi.fn(),
|
||||
trackValidationDetails: vi.fn(),
|
||||
trackWorkflowCreation: vi.fn(),
|
||||
trackPerformanceMetric: vi.fn(),
|
||||
getMetrics: vi.fn().mockReturnValue({
|
||||
status: 'enabled',
|
||||
initialized: true,
|
||||
tracking: { eventQueueSize: 0 },
|
||||
processing: { eventsTracked: 0 },
|
||||
errors: { totalErrors: 0 }
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/telemetry/config-manager');
|
||||
|
||||
// Mock database and other dependencies
|
||||
vi.mock('../../../src/database/node-repository');
|
||||
vi.mock('../../../src/services/enhanced-config-validator');
|
||||
vi.mock('../../../src/services/expression-validator');
|
||||
vi.mock('../../../src/services/workflow-validator');
|
||||
|
||||
// TODO: This test needs to be refactored. It's currently mocking everything
|
||||
// which defeats the purpose of an integration test. It should either:
|
||||
// 1. Be moved to unit tests if we want to test with mocks
|
||||
// 2. Be rewritten as a proper integration test without mocks
|
||||
// Skipping for now to unblock CI - the telemetry functionality is tested
|
||||
// properly in the unit tests at tests/unit/telemetry/
|
||||
describe.skip('MCP Telemetry Integration', () => {
|
||||
let mcpServer: N8NDocumentationMCPServer;
|
||||
let mockTelemetryConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock TelemetryConfigManager
|
||||
mockTelemetryConfig = {
|
||||
isEnabled: vi.fn().mockReturnValue(true),
|
||||
getUserId: vi.fn().mockReturnValue('test-user-123'),
|
||||
disable: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
getStatus: vi.fn().mockReturnValue('enabled')
|
||||
};
|
||||
vi.mocked(TelemetryConfigManager.getInstance).mockReturnValue(mockTelemetryConfig);
|
||||
|
||||
// Mock database repository
|
||||
const mockNodeRepository = {
|
||||
searchNodes: vi.fn().mockResolvedValue({ results: [], totalResults: 0 }),
|
||||
getNodeInfo: vi.fn().mockResolvedValue(null),
|
||||
getAllNodes: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn()
|
||||
};
|
||||
vi.doMock('../../../src/database/node-repository', () => ({
|
||||
NodeRepository: vi.fn().mockImplementation(() => mockNodeRepository)
|
||||
}));
|
||||
|
||||
// Create a mock server instance to avoid initialization issues
|
||||
const mockServer = {
|
||||
requestHandlers: new Map(),
|
||||
notificationHandlers: new Map(),
|
||||
setRequestHandler: vi.fn((method: string, handler: any) => {
|
||||
mockServer.requestHandlers.set(method, handler);
|
||||
}),
|
||||
setNotificationHandler: vi.fn((method: string, handler: any) => {
|
||||
mockServer.notificationHandlers.set(method, handler);
|
||||
})
|
||||
};
|
||||
|
||||
// Set up basic handlers
|
||||
mockServer.requestHandlers.set('initialize', async () => {
|
||||
telemetry.trackSessionStart();
|
||||
return { protocolVersion: '2024-11-05' };
|
||||
});
|
||||
|
||||
mockServer.requestHandlers.set('tools/call', async (params: any) => {
|
||||
// Use the actual tool name from the request
|
||||
const toolName = params?.name || 'unknown-tool';
|
||||
|
||||
try {
|
||||
// Call executeTool if it's been mocked
|
||||
if ((mcpServer as any).executeTool) {
|
||||
const result = await (mcpServer as any).executeTool(params);
|
||||
|
||||
// Track specific telemetry based on tool type
|
||||
if (toolName === 'search_nodes') {
|
||||
const query = params?.arguments?.query || '';
|
||||
const totalResults = result?.totalResults || 0;
|
||||
const mode = params?.arguments?.mode || 'OR';
|
||||
telemetry.trackSearchQuery(query, totalResults, mode);
|
||||
} else if (toolName === 'validate_workflow') {
|
||||
const workflow = params?.arguments?.workflow || {};
|
||||
const validationPassed = result?.isValid !== false;
|
||||
telemetry.trackWorkflowCreation(workflow, validationPassed);
|
||||
if (!validationPassed && result?.errors) {
|
||||
result.errors.forEach((error: any) => {
|
||||
telemetry.trackValidationDetails(error.nodeType || 'unknown', error.type || 'validation_error', error);
|
||||
});
|
||||
}
|
||||
} else if (toolName === 'validate_node_operation' || toolName === 'validate_node_minimal') {
|
||||
const nodeType = params?.arguments?.nodeType || 'unknown';
|
||||
const errorType = result?.errors?.[0]?.type || 'validation_error';
|
||||
telemetry.trackValidationDetails(nodeType, errorType, result);
|
||||
}
|
||||
|
||||
// Simulate a duration for tool execution
|
||||
const duration = params?.duration || Math.random() * 100;
|
||||
telemetry.trackToolUsage(toolName, true, duration);
|
||||
return { content: [{ type: 'text', text: JSON.stringify(result) }] };
|
||||
} else {
|
||||
// Default behavior if executeTool is not mocked
|
||||
telemetry.trackToolUsage(toolName, true);
|
||||
return { content: [{ type: 'text', text: 'Success' }] };
|
||||
}
|
||||
} catch (error: any) {
|
||||
telemetry.trackToolUsage(toolName, false);
|
||||
telemetry.trackError(
|
||||
error.constructor.name,
|
||||
error.message,
|
||||
toolName,
|
||||
error.message
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Mock the N8NDocumentationMCPServer to have the server property
|
||||
mcpServer = {
|
||||
server: mockServer,
|
||||
handleTool: vi.fn().mockResolvedValue({ content: [{ type: 'text', text: 'Success' }] }),
|
||||
executeTool: vi.fn().mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook' }],
|
||||
totalResults: 1
|
||||
}),
|
||||
close: vi.fn()
|
||||
} as any;
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Session tracking', () => {
|
||||
it('should track session start on MCP initialize', async () => {
|
||||
const initializeRequest = {
|
||||
method: 'initialize' as const,
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
clientInfo: {
|
||||
name: 'test-client',
|
||||
version: '1.0.0'
|
||||
},
|
||||
capabilities: {}
|
||||
}
|
||||
};
|
||||
|
||||
// Access the private server instance for testing
|
||||
const server = (mcpServer as any).server;
|
||||
const initializeHandler = server.requestHandlers.get('initialize');
|
||||
|
||||
if (initializeHandler) {
|
||||
await initializeHandler(initializeRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSessionStart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool usage tracking', () => {
|
||||
it('should track successful tool execution', async () => {
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the executeTool method to return a successful result
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook' }],
|
||||
totalResults: 1
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
|
||||
it('should track failed tool execution', async () => {
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_node',
|
||||
arguments: { nodeType: 'invalid-node' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock the executeTool method to throw an error
|
||||
const error = new Error('Node not found');
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockRejectedValue(error);
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
try {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
} catch (e) {
|
||||
// Expected to throw
|
||||
}
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith('get_node', false);
|
||||
expect(telemetry.trackError).toHaveBeenCalledWith(
|
||||
'Error',
|
||||
'Node not found',
|
||||
'get_node'
|
||||
);
|
||||
});
|
||||
|
||||
it('should track tool sequences', async () => {
|
||||
// Set up previous tool state
|
||||
(mcpServer as any).previousTool = 'search_nodes';
|
||||
(mcpServer as any).previousToolTimestamp = Date.now() - 5000;
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'get_node',
|
||||
arguments: { nodeType: 'nodes-base.webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
nodeType: 'nodes-base.webhook',
|
||||
displayName: 'Webhook'
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolSequence).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
'get_node',
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search query tracking', () => {
|
||||
it('should track search queries with results', async () => {
|
||||
const searchRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook', mode: 'OR' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock search results
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [
|
||||
{ nodeType: 'nodes-base.webhook', score: 0.95 },
|
||||
{ nodeType: 'nodes-base.httpRequest', score: 0.8 }
|
||||
],
|
||||
totalResults: 2
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(searchRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('webhook', 2, 'OR');
|
||||
});
|
||||
|
||||
it('should track zero-result searches', async () => {
|
||||
const zeroResultRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'nonexistent', mode: 'AND' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(zeroResultRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('nonexistent', 0, 'AND');
|
||||
});
|
||||
|
||||
it('should track fallback search queries', async () => {
|
||||
const fallbackRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'partial-match', mode: 'OR' }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock main search with no results, triggering fallback
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'nodes-base.webhook', score: 0.6 }],
|
||||
totalResults: 1,
|
||||
usedFallback: true
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(fallbackRequest.params);
|
||||
}
|
||||
|
||||
// Should track both main query and fallback
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 0, 'OR');
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledWith('partial-match', 1, 'OR_LIKE_FALLBACK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow validation tracking', () => {
|
||||
it('should track successful workflow creation', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'webhook', name: 'Webhook' },
|
||||
{ id: '2', type: 'httpRequest', name: 'HTTP Request' }
|
||||
],
|
||||
connections: {
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] }
|
||||
}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
summary: { totalIssues: 0, criticalIssues: 0 }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(workflow, true);
|
||||
});
|
||||
|
||||
it('should track validation details for failed workflows', async () => {
|
||||
const workflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'invalid-node', name: 'Invalid Node' }
|
||||
],
|
||||
connections: {}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow }
|
||||
}
|
||||
};
|
||||
|
||||
const validationResult = {
|
||||
isValid: false,
|
||||
errors: [
|
||||
{
|
||||
nodeId: '1',
|
||||
nodeType: 'invalid-node',
|
||||
category: 'node_validation',
|
||||
severity: 'error',
|
||||
message: 'Unknown node type',
|
||||
details: { type: 'unknown_node_type' }
|
||||
}
|
||||
],
|
||||
warnings: [],
|
||||
summary: { totalIssues: 1, criticalIssues: 1 }
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue(validationResult);
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackValidationDetails).toHaveBeenCalledWith(
|
||||
'invalid-node',
|
||||
'unknown_node_type',
|
||||
expect.objectContaining({
|
||||
category: 'node_validation',
|
||||
severity: 'error'
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Node configuration tracking', () => {
|
||||
it('should track node configuration validation', async () => {
|
||||
const validateNodeRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_node_operation',
|
||||
arguments: {
|
||||
nodeType: 'nodes-base.httpRequest',
|
||||
config: { url: 'https://api.example.com', method: 'GET' }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
nodeConfig: { url: 'https://api.example.com', method: 'GET' }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateNodeRequest.params);
|
||||
}
|
||||
|
||||
// Should track the validation attempt
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'validate_node_operation',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance metric tracking', () => {
|
||||
it('should track slow tool executions', async () => {
|
||||
const slowToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'http', limit: 1000 }
|
||||
}
|
||||
};
|
||||
|
||||
// Mock a slow operation
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockImplementation(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // 2 second delay
|
||||
return { results: [], totalCount: 0 };
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(slowToolRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'search_nodes',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
|
||||
// Verify duration is tracked (should be around 2000ms)
|
||||
const trackUsageCall = vi.mocked(telemetry.trackToolUsage).mock.calls[0];
|
||||
expect(trackUsageCall[2]).toBeGreaterThan(1500); // Allow some variance
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tool listing and capabilities', () => {
|
||||
it('should handle tool listing without telemetry interference', async () => {
|
||||
const listToolsRequest: ListToolsRequest = {
|
||||
method: 'tools/list',
|
||||
params: {}
|
||||
};
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const listToolsHandler = server.requestHandlers.get('tools/list');
|
||||
|
||||
if (listToolsHandler) {
|
||||
const result = await listToolsHandler(listToolsRequest.params);
|
||||
expect(result).toHaveProperty('tools');
|
||||
expect(Array.isArray(result.tools)).toBe(true);
|
||||
}
|
||||
|
||||
// Tool listing shouldn't generate telemetry events
|
||||
expect(telemetry.trackToolUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling and telemetry', () => {
|
||||
it('should track errors without breaking MCP protocol', async () => {
|
||||
const errorRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'nonexistent_tool',
|
||||
arguments: {}
|
||||
}
|
||||
};
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
try {
|
||||
await callToolHandler(errorRequest.params);
|
||||
} catch (error) {
|
||||
// Error should be handled by MCP server
|
||||
expect(error).toBeDefined();
|
||||
}
|
||||
}
|
||||
|
||||
// Should track error without throwing
|
||||
expect(telemetry.trackError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle telemetry errors gracefully', async () => {
|
||||
// Mock telemetry to throw an error
|
||||
vi.mocked(telemetry.trackToolUsage).mockImplementation(() => {
|
||||
throw new Error('Telemetry service unavailable');
|
||||
});
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
// Should not throw even if telemetry fails
|
||||
if (callToolHandler) {
|
||||
await expect(callToolHandler(callToolRequest.params)).resolves.toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Telemetry configuration integration', () => {
|
||||
it('should respect telemetry disabled state', async () => {
|
||||
mockTelemetryConfig.isEnabled.mockReturnValue(false);
|
||||
|
||||
const callToolRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [],
|
||||
totalResults: 0
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(callToolRequest.params);
|
||||
}
|
||||
|
||||
// Should still track if telemetry manager handles disabled state
|
||||
// The actual filtering happens in telemetry manager, not MCP server
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex workflow scenarios', () => {
|
||||
it('should track comprehensive workflow validation scenario', async () => {
|
||||
const complexWorkflow = {
|
||||
nodes: [
|
||||
{ id: '1', type: 'webhook', name: 'Webhook Trigger' },
|
||||
{ id: '2', type: 'httpRequest', name: 'API Call', parameters: { url: 'https://api.example.com' } },
|
||||
{ id: '3', type: 'set', name: 'Transform Data' },
|
||||
{ id: '4', type: 'if', name: 'Conditional Logic' },
|
||||
{ id: '5', type: 'slack', name: 'Send Notification' }
|
||||
],
|
||||
connections: {
|
||||
'1': { main: [[{ node: '2', type: 'main', index: 0 }]] },
|
||||
'2': { main: [[{ node: '3', type: 'main', index: 0 }]] },
|
||||
'3': { main: [[{ node: '4', type: 'main', index: 0 }]] },
|
||||
'4': { main: [[{ node: '5', type: 'main', index: 0 }]] }
|
||||
}
|
||||
};
|
||||
|
||||
const validateRequest: CallToolRequest = {
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'validate_workflow',
|
||||
arguments: { workflow: complexWorkflow }
|
||||
}
|
||||
};
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [
|
||||
{
|
||||
nodeId: '2',
|
||||
nodeType: 'httpRequest',
|
||||
category: 'configuration',
|
||||
severity: 'warning',
|
||||
message: 'Consider adding error handling'
|
||||
}
|
||||
],
|
||||
summary: { totalIssues: 1, criticalIssues: 0 }
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await callToolHandler(validateRequest.params);
|
||||
}
|
||||
|
||||
expect(telemetry.trackWorkflowCreation).toHaveBeenCalledWith(complexWorkflow, true);
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledWith(
|
||||
'validate_workflow',
|
||||
true,
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP server lifecycle and telemetry', () => {
|
||||
it('should handle server initialization with telemetry', async () => {
|
||||
// Set up minimal environment for server creation
|
||||
process.env.NODE_DB_PATH = ':memory:';
|
||||
|
||||
// Verify that server creation doesn't interfere with telemetry
|
||||
const newServer = {} as N8NDocumentationMCPServer; // Mock instance
|
||||
expect(newServer).toBeDefined();
|
||||
|
||||
// Telemetry should still be functional
|
||||
expect(telemetry.getMetrics).toBeDefined();
|
||||
expect(typeof telemetry.trackToolUsage).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle concurrent tool executions with telemetry', async () => {
|
||||
const requests = [
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'webhook' }
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'http' }
|
||||
}
|
||||
},
|
||||
{
|
||||
method: 'tools/call' as const,
|
||||
params: {
|
||||
name: 'search_nodes',
|
||||
arguments: { query: 'database' }
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
vi.spyOn(mcpServer as any, 'executeTool').mockResolvedValue({
|
||||
results: [{ nodeType: 'test-node' }],
|
||||
totalResults: 1
|
||||
});
|
||||
|
||||
const server = (mcpServer as any).server;
|
||||
const callToolHandler = server.requestHandlers.get('tools/call');
|
||||
|
||||
if (callToolHandler) {
|
||||
await Promise.all(
|
||||
requests.map(req => callToolHandler(req.params))
|
||||
);
|
||||
}
|
||||
|
||||
// All three calls should be tracked
|
||||
expect(telemetry.trackToolUsage).toHaveBeenCalledTimes(3);
|
||||
expect(telemetry.trackSearchQuery).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user