Skip to main content
Glama
WorkflowIntegrationTestSuite.ts18.6 kB
/** * @file Comprehensive integration test suite for validating complete workflows * from MCP client request through API calls to Tally.so and back. */ import request from 'supertest'; import axios from 'axios'; import { MCPServer } from '../server'; import { TallyApiClient } from '../services/TallyApiClient'; import { TallyApiService } from '../services/tally-api-service'; import { NlpService } from '../services/nlp-service'; import { FormCreationTool } from '../tools/form-creation-tool'; import { FormModificationTool } from '../tools/form-modification-tool'; import { FormSharingTool } from '../tools/form-sharing-tool'; import { TeamManager } from '../tools/team-manager'; import { SubmissionAnalysisTool } from '../tools/submission-tool'; import { TallyForm, FormConfig, QuestionType, SubmissionBehavior, FormTheme } from '../models'; // Mock all external dependencies jest.mock('axios'); jest.mock('../services/tally-api-service'); jest.mock('../services/nlp-service'); jest.mock('../tools/form-creation-tool'); jest.mock('../tools/form-modification-tool'); jest.mock('../tools/form-sharing-tool'); jest.mock('../tools/team-manager'); jest.mock('../tools/submission-tool'); interface WorkflowTestConfig { port?: number; host?: string; debug?: boolean; enableRealAPI?: boolean; tallyApiKey?: string; mockResponses?: MockResponseConfig; } interface MockResponseConfig { [key: string]: { status: number; data: any; delay?: number; }; } interface WorkflowExecutionLog { timestamp: string; workflow: string; stage: string; action: string; duration?: number; data?: any; error?: any; } interface DataTransformationValidation { input: any; expectedOutput: any; actualOutput: any; transformationStages: string[]; isValid: boolean; errors: string[]; } /** * Base class for comprehensive workflow integration testing * Provides setup, teardown, logging, and validation utilities */ export class WorkflowIntegrationTestSuite { protected server: MCPServer | null = null; protected baseUrl: string = ''; protected testPort: number = 0; protected config: WorkflowTestConfig; protected executionLogs: WorkflowExecutionLog[] = []; protected mockedAxios = axios as jest.Mocked<typeof axios>; protected startTime: number = 0; // Mock instances protected mockTallyApiService = TallyApiService as jest.MockedClass<typeof TallyApiService>; protected mockNlpService = NlpService as jest.MockedClass<typeof NlpService>; protected mockFormCreationTool = FormCreationTool as jest.MockedClass<typeof FormCreationTool>; protected mockFormModificationTool = FormModificationTool as jest.MockedClass<typeof FormModificationTool>; protected mockFormSharingTool = FormSharingTool as jest.MockedClass<typeof FormSharingTool>; protected mockTeamManager = TeamManager as jest.MockedClass<typeof TeamManager>; protected mockSubmissionTool = SubmissionAnalysisTool as jest.MockedClass<typeof SubmissionAnalysisTool>; constructor(config: WorkflowTestConfig = {}) { this.config = { host: '127.0.0.1', debug: true, enableRealAPI: false, ...config }; } /** * Initialize the test environment */ async setUp(): Promise<void> { this.startTime = Date.now(); this.log('setup', 'start', 'Initializing test environment'); try { // Generate unique port for this test instance this.testPort = this.config.port || (4000 + Math.floor(Math.random() * 1000)); this.baseUrl = `http://${this.config.host}:${this.testPort}`; // Setup mock configurations this.setupMockResponses(); // Create and initialize server const serverConfig = { port: this.testPort, host: this.config.host!, debug: this.config.debug!, }; this.server = new MCPServer(serverConfig); await this.server.initialize(); this.log('setup', 'complete', 'Test environment initialized successfully', { port: this.testPort, baseUrl: this.baseUrl }); } catch (error) { this.log('setup', 'error', 'Failed to initialize test environment', { error }); throw error; } } /** * Clean up test environment */ async tearDown(): Promise<void> { this.log('teardown', 'start', 'Cleaning up test environment'); try { if (this.server) { await this.server.shutdown(); this.server = null; } // Clear all mocks this.clearAllMocks(); const duration = Date.now() - this.startTime; this.log('teardown', 'complete', 'Test environment cleaned up successfully', { totalDuration: duration, totalLogs: this.executionLogs.length }); // Output execution logs for debugging if (this.config.debug) { this.outputExecutionLogs(); } } catch (error) { this.log('teardown', 'error', 'Failed to clean up test environment', { error }); throw error; } } /** * Setup mock responses for external dependencies */ protected setupMockResponses(): void { const defaultResponses = { 'tally-api-success': { status: 200, data: { success: true, id: 'mock-id' } }, 'mcp-success': { status: 200, data: { jsonrpc: '2.0', id: 'test-id', result: { success: true } } } }; const responses = { ...defaultResponses, ...this.config.mockResponses }; // Setup axios mocks this.mockedAxios.get.mockImplementation(async (url: string) => { await this.simulateNetworkDelay(); return responses['tally-api-success']; }); this.mockedAxios.post.mockImplementation(async (url: string, data?: any) => { await this.simulateNetworkDelay(); if (url.includes('/message')) { // Return unique ID for each MCP request to support concurrency tests const uniqueMcpResponse = { ...responses['mcp-success'], data: { ...responses['mcp-success'].data, id: `test-id-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` } }; return uniqueMcpResponse; } // Return unique ID for each API call to support concurrency tests const uniqueResponse = { ...responses['tally-api-success'], data: { ...responses['tally-api-success'].data, id: `mock-id-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` } }; return uniqueResponse; }); this.log('setup', 'mocks', 'Mock responses configured', { responses: Object.keys(responses) }); } /** * Clear all mock instances and history */ protected clearAllMocks(): void { this.mockedAxios.get.mockClear(); this.mockedAxios.post.mockClear(); this.mockTallyApiService.mockClear(); this.mockNlpService.mockClear(); this.mockFormCreationTool.mockClear(); this.mockFormModificationTool.mockClear(); this.mockFormSharingTool.mockClear(); this.mockTeamManager.mockClear(); this.mockSubmissionTool.mockClear(); jest.clearAllMocks(); } /** * Execute a complete workflow and validate data transformation */ async executeWorkflow( workflowName: string, mcpRequest: any, expectedTransformations: string[] = [] ): Promise<DataTransformationValidation> { const workflowStart = Date.now(); this.log(workflowName, 'start', 'Starting workflow execution', { request: mcpRequest }); try { // Execute MCP request const response = await request(this.baseUrl) .post('/message') .set('Content-Type', 'application/json') .send(mcpRequest); const duration = Date.now() - workflowStart; this.log(workflowName, 'complete', 'Workflow execution completed', { status: response.status, duration: duration || 0, // Ensure duration is never undefined responseBody: response.body }); // Validate data transformation return this.validateDataTransformation( workflowName, mcpRequest, response.body, expectedTransformations ); } catch (error) { const duration = Date.now() - workflowStart; this.log(workflowName, 'error', 'Workflow execution failed', { error, duration: duration || 0 }); throw error; } } /** * Validate data transformation accuracy */ protected validateDataTransformation( workflowName: string, input: any, output: any, expectedStages: string[] ): DataTransformationValidation { const validation: DataTransformationValidation = { input, expectedOutput: this.getExpectedOutput(workflowName, input), actualOutput: output, transformationStages: expectedStages, isValid: true, errors: [] }; // Validate JSON-RPC response structure if (!output.jsonrpc || output.jsonrpc !== '2.0') { validation.errors.push('Invalid JSON-RPC response format'); validation.isValid = false; } // Validate request/response ID matching if (input.id && output.id !== input.id) { validation.errors.push(`Request/response ID mismatch: ${input.id} !== ${output.id}`); validation.isValid = false; } // Validate that either result or error is present if (!output.result && !output.error) { validation.errors.push('Response missing both result and error fields'); validation.isValid = false; } this.log(workflowName, 'validation', 'Data transformation validation completed', { isValid: validation.isValid, errors: validation.errors }); return validation; } /** * Get expected output for a given workflow and input */ protected getExpectedOutput(workflowName: string, input: any): any { const baseExpected = { jsonrpc: '2.0', id: input.id }; switch (workflowName) { case 'form-creation': return { ...baseExpected, result: { success: true, formId: expect.any(String), url: expect.any(String) } }; case 'form-modification': return { ...baseExpected, result: { success: true, formId: input.params.arguments.formId, changes: expect.any(Object) } }; default: return { ...baseExpected, result: expect.any(Object) }; } } /** * Simulate network timeouts and failures */ async simulateFailureScenario( scenarioName: string, errorType: 'timeout' | 'server-error' | 'auth-failure' | 'malformed-request', workflowName: string, mcpRequest: any ): Promise<any> { // Note: Using scenarioName as workflow identifier for this simulation try { let result; switch (errorType) { case 'timeout': // Simulate timeout by making request with very short timeout result = await this.executeWorkflowWithTimeout(workflowName, mcpRequest, 1); break; case 'server-error': // Send malformed request that server will reject const malformedRequest = { ...mcpRequest, method: 'invalid/method' }; result = await this.executeWorkflow(workflowName, malformedRequest); break; case 'auth-failure': // Simulate auth failure by sending request with invalid tool name const invalidToolRequest = { ...mcpRequest, params: { ...mcpRequest.params, name: 'nonexistent_tool' } }; result = await this.executeWorkflow(workflowName, invalidToolRequest); break; case 'malformed-request': // Send truly malformed JSON request const malformedJson = { ...mcpRequest }; delete malformedJson.jsonrpc; result = await this.executeWorkflow(workflowName, malformedJson); break; default: result = await this.executeWorkflow(workflowName, mcpRequest); } // If we got here and expected an error, return error structure if (result && result.actualOutput && result.actualOutput.error) { this.log(scenarioName, 'error-captured', `${errorType} scenario executed with response error`, { error: result.actualOutput.error }); return { error: result.actualOutput.error }; } // For timeout case, if no error occurred, simulate one if (errorType === 'timeout') { this.log(scenarioName, 'error-captured', `${errorType} scenario executed with simulated timeout`, { error: 'Request timeout' }); return { error: new Error('Request timeout') }; } return result; } catch (error) { this.log(scenarioName, 'error-captured', `${errorType} scenario executed`, { error }); return { error }; } } /** * Execute workflow with timeout simulation */ private async executeWorkflowWithTimeout( workflowName: string, mcpRequest: any, timeoutMs: number = 100 ): Promise<any> { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error('Request timeout')); }, timeoutMs); this.executeWorkflow(workflowName, mcpRequest) .then((result) => { clearTimeout(timer); resolve(result); }) .catch((error) => { clearTimeout(timer); reject(error); }); }); } /** * Test concurrent workflow execution */ async testConcurrentExecution( workflows: Array<{ name: string; request: any }>, maxConcurrency: number = 5 ): Promise<Array<DataTransformationValidation | { error: any }>> { this.log('concurrency', 'start', `Testing concurrent execution of ${workflows.length} workflows`); const batches: Array<Array<{ name: string; request: any }>> = []; for (let i = 0; i < workflows.length; i += maxConcurrency) { batches.push(workflows.slice(i, i + maxConcurrency)); } const results: Array<DataTransformationValidation | { error: any }> = []; for (const batch of batches) { const batchPromises = batch.map(workflow => this.executeWorkflow(workflow.name, workflow.request).catch(error => ({ error })) ); const batchResults = await Promise.all(batchPromises); results.push(...batchResults); } this.log('concurrency', 'complete', 'Concurrent execution completed', { totalWorkflows: workflows.length, batches: batches.length, maxConcurrency }); return results; } /** * Generate test data factories */ createTestData() { return { formCreationRequest: (title: string = 'Test Form') => ({ jsonrpc: '2.0', id: `test-form-creation-${Date.now()}`, method: 'tools/call', params: { name: 'form_creation_tool', arguments: { naturalLanguagePrompt: `Create a form titled "${title}" with a single question: What is your name?`, } } }), formModificationRequest: (formId: string = 'form-123') => ({ jsonrpc: '2.0', id: `test-form-modification-${Date.now()}`, method: 'tools/call', params: { name: 'form_modification_tool', arguments: { formId, naturalLanguagePrompt: 'Change the title to "Updated Form Title"', } } }), submissionRequest: (formId: string = 'form-123') => ({ jsonrpc: '2.0', id: `test-submission-${Date.now()}`, method: 'tools/call', params: { name: 'submission_tool', arguments: { formId, action: 'get_submissions', } } }), teamManagementRequest: (teamId: string = 'team-456') => ({ jsonrpc: '2.0', id: `test-team-management-${Date.now()}`, method: 'tools/call', params: { name: 'team_manager', arguments: { teamId, action: 'invite_user', email: 'test@example.com', role: 'editor', } } }) }; } /** * Simulate network delays for realistic testing */ protected async simulateNetworkDelay(minMs: number = 10, maxMs: number = 100): Promise<void> { const delay = Math.random() * (maxMs - minMs) + minMs; await new Promise(resolve => setTimeout(resolve, delay)); } /** * Log workflow execution events */ protected log(workflow: string, stage: string, action: string, data?: any): void { const logEntry: WorkflowExecutionLog = { timestamp: new Date().toISOString(), workflow, stage, action, data: data ? { ...data } : undefined // Ensure data is properly copied }; this.executionLogs.push(logEntry); if (this.config.debug) { console.log(`[${logEntry.timestamp}] ${workflow}:${stage} - ${action}`, data || ''); } } /** * Output all execution logs for debugging */ protected outputExecutionLogs(): void { console.log('\n=== Workflow Execution Logs ==='); this.executionLogs.forEach(log => { console.log(`[${log.timestamp}] ${log.workflow}:${log.stage} - ${log.action}`); if (log.data) { console.log(' Data:', this.safeStringify(log.data)); } }); console.log('=== End Execution Logs ===\n'); } /** * Safely stringify objects that may contain circular references */ private safeStringify(obj: any, indent: number = 2): string { try { const seen = new WeakSet(); return JSON.stringify(obj, (key, val) => { if (val != null && typeof val === 'object') { if (seen.has(val)) { return '[Circular Reference]'; } seen.add(val); } return val; }, indent); } catch (error) { return `[Error stringifying object: ${error.message}]`; } } /** * Get execution statistics */ getExecutionStats() { const workflows = new Set(this.executionLogs.map(log => log.workflow)); const errors = this.executionLogs.filter(log => log.stage === 'error' || log.stage === 'error-captured'); const successful = this.executionLogs.filter(log => log.stage === 'complete'); return { totalLogs: this.executionLogs.length, uniqueWorkflows: workflows.size, errors: errors.length, successful: successful.length, totalDuration: Date.now() - this.startTime }; } }

Latest Blog Posts

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/learnwithcc/tally-mcp'

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