/** * Cloud Instances API Tester * * Comprehensive test suite for API endpoints with colorful console output. * Tests all endpoints with various parameter combinations and validates responses. * * Requirements: * API_KEY environment variable must be set * * Usage: * export API_KEY=your-api-key-here * npx tsx scripts/api-tester.ts * npx tsx scripts/api-tester.ts --endpoint /health * npx tsx scripts/api-tester.ts --verbose * * Or use npm scripts: * npm run test:api * npm run test:api:verbose */ // ============================================================ // Configuration // ============================================================ const API_URL = process.env.API_URL || 'https://cloud-instances-api.kappa-d8e.workers.dev'; const API_KEY = process.env.API_KEY; if (!API_KEY) { console.error('\nโŒ ERROR: API_KEY environment variable is required'); console.error('Please set API_KEY before running the tests:'); console.error(' export API_KEY=your-api-key-here'); console.error(' npm run test:api'); console.error('\nOr create a .env file (see .env.example for reference)'); process.exit(1); } if (API_KEY.length < 16) { console.error('\nโŒ ERROR: API_KEY must be at least 16 characters'); console.error('The provided API key is too short to be valid.'); console.error('Please check your API_KEY environment variable.'); process.exit(1); } // CLI flags const args = process.argv.slice(2); const VERBOSE = args.includes('--verbose'); const TARGET_ENDPOINT = args.find(arg => arg.startsWith('--endpoint='))?.split('=')[1]; // ============================================================ // Color Utilities // ============================================================ const colors = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', gray: '\x1b[90m', }; function color(text: string, colorCode: string): string { return `${colorCode}${text}${colors.reset}`; } function bold(text: string): string { return `${colors.bold}${text}${colors.reset}`; } // ============================================================ // Test Result Types // ============================================================ interface TestResult { name: string; endpoint: string; method: string; passed: boolean; duration: number; statusCode?: number; error?: string; details?: string; } interface TestReport { total: number; passed: number; failed: number; duration: number; results: TestResult[]; } // ============================================================ // API Test Helper // ============================================================ // Delay utility to avoid rate limiting (100 req/min = 600ms minimum between requests) async function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } // Helper to add delay between tests (delay BEFORE request to ensure rate limit compliance) async function testWithDelay( name: string, endpoint: string, options: Parameters[2] = {} ): Promise { await delay(800); // 800ms delay BEFORE request to avoid rate limiting (100 req/min) const result = await testRequest(name, endpoint, options); return result; } async function testRequest( name: string, endpoint: string, options: { method?: string; headers?: Record; body?: unknown; expectStatus?: number | number[]; validateResponse?: (data: unknown) => boolean | string; } = {} ): Promise { const { method = 'GET', headers = {}, body, expectStatus = 200, validateResponse, } = options; // Convert expectStatus to array for easier checking const expectedStatuses = Array.isArray(expectStatus) ? expectStatus : [expectStatus]; const startTime = Date.now(); const url = `${API_URL}${endpoint}`; try { const requestHeaders: Record = { 'Content-Type': 'application/json', ...headers, }; const response = await fetch(url, { method, headers: requestHeaders, body: body ? JSON.stringify(body) : undefined, }); const duration = Date.now() - startTime; const data = await response.json(); // Check status code (supports multiple expected statuses) if (!expectedStatuses.includes(response.status)) { return { name, endpoint, method, passed: false, duration, statusCode: response.status, error: `Expected status ${expectedStatuses.join(' or ')}, got ${response.status}`, details: JSON.stringify(data, null, 2), }; } // Validate response structure if (validateResponse) { const validationResult = validateResponse(data); if (validationResult !== true) { return { name, endpoint, method, passed: false, duration, statusCode: response.status, error: typeof validationResult === 'string' ? validationResult : 'Response validation failed', details: JSON.stringify(data, null, 2), }; } } return { name, endpoint, method, passed: true, duration, statusCode: response.status, details: VERBOSE ? JSON.stringify(data, null, 2) : undefined, }; } catch (error) { const duration = Date.now() - startTime; return { name, endpoint, method, passed: false, duration, error: error instanceof Error ? error.message : String(error), }; } } // ============================================================ // Response Validators // ============================================================ function validateHealthResponse(data: unknown): boolean | string { if (typeof data !== 'object' || data === null) { return 'Response is not an object'; } const response = data as Record; if (!response.status || typeof response.status !== 'string') { return 'Missing or invalid status field'; } // Accept both 'healthy' and 'degraded' status if (response.status !== 'healthy' && response.status !== 'degraded') { return `Invalid status value: ${response.status}`; } if (!response.timestamp || typeof response.timestamp !== 'string') { return 'Missing or invalid timestamp field'; } return true; } function validateInstancesResponse(data: unknown): boolean | string { if (typeof data !== 'object' || data === null) { return 'Response is not an object'; } const response = data as Record; if (!response.success) { return 'Response success field is false or missing'; } if (!response.data || typeof response.data !== 'object') { return 'Missing or invalid data field'; } const responseData = response.data as Record; if (!Array.isArray(responseData.instances)) { return 'Missing or invalid instances array'; } if (!responseData.pagination || typeof responseData.pagination !== 'object') { return 'Missing or invalid pagination field'; } return true; } function validateSyncResponse(data: unknown): boolean | string { if (typeof data !== 'object' || data === null) { return 'Response is not an object'; } const response = data as Record; if (typeof response.success !== 'boolean') { return 'Missing or invalid success field'; } if (response.success && (!response.data || typeof response.data !== 'object')) { return 'Missing or invalid data field for successful sync'; } return true; } // ============================================================ // Test Suites // ============================================================ async function testHealthEndpoint(): Promise { console.log(color('\n๐Ÿ“ Testing /health', colors.cyan)); const tests: TestResult[] = []; // Test without authentication (200 or 503 for degraded status) tests.push( await testWithDelay('GET /health (no auth)', '/health', { expectStatus: [200, 503], // 503 is valid when system is degraded validateResponse: validateHealthResponse, }) ); // Test with authentication (200 or 503 for degraded status) tests.push( await testWithDelay('GET /health (with auth)', '/health', { headers: { 'X-API-Key': API_KEY }, expectStatus: [200, 503], // 503 is valid when system is degraded validateResponse: validateHealthResponse, }) ); return tests; } async function testInstancesEndpoint(): Promise { console.log(color('\n๐Ÿ“ Testing /instances', colors.cyan)); const tests: TestResult[] = []; // Test basic query tests.push( await testWithDelay('GET /instances (basic)', '/instances', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test provider filter tests.push( await testWithDelay('GET /instances?provider=linode', '/instances?provider=linode', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test memory filter tests.push( await testWithDelay('GET /instances?min_memory_gb=4', '/instances?min_memory_gb=4', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test vCPU filter tests.push( await testWithDelay('GET /instances?min_vcpu=2&max_vcpu=8', '/instances?min_vcpu=2&max_vcpu=8', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test price filter tests.push( await testWithDelay('GET /instances?max_price=50', '/instances?max_price=50', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test GPU filter tests.push( await testWithDelay('GET /instances?has_gpu=true', '/instances?has_gpu=true', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test sorting tests.push( await testWithDelay('GET /instances?sort_by=price&order=asc', '/instances?sort_by=price&order=asc', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test pagination tests.push( await testWithDelay('GET /instances?limit=10&offset=0', '/instances?limit=10&offset=0', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test combined filters tests.push( await testWithDelay('GET /instances (combined)', '/instances?provider=linode&min_vcpu=2&max_price=100&sort_by=price&order=asc', { headers: { 'X-API-Key': API_KEY }, expectStatus: 200, validateResponse: validateInstancesResponse, }) ); // Test invalid provider (should fail) tests.push( await testWithDelay('GET /instances?provider=invalid (error)', '/instances?provider=invalid', { headers: { 'X-API-Key': API_KEY }, expectStatus: 400, }) ); // Test without auth (should fail) tests.push( await testWithDelay('GET /instances (no auth - error)', '/instances', { expectStatus: 401, }) ); return tests; } async function testSyncEndpoint(): Promise { console.log(color('\n๐Ÿ“ Testing /sync', colors.cyan)); const tests: TestResult[] = []; // Test Linode sync tests.push( await testWithDelay('POST /sync (linode)', '/sync', { method: 'POST', headers: { 'X-API-Key': API_KEY }, body: { providers: ['linode'] }, expectStatus: 200, validateResponse: validateSyncResponse, }) ); // Test without auth (should fail) tests.push( await testWithDelay('POST /sync (no auth - error)', '/sync', { method: 'POST', body: { providers: ['linode'] }, expectStatus: 401, }) ); // Test invalid provider (should fail) tests.push( await testWithDelay('POST /sync (invalid provider - error)', '/sync', { method: 'POST', headers: { 'X-API-Key': API_KEY }, body: { providers: ['invalid'] }, expectStatus: 400, }) ); return tests; } // ============================================================ // Test Runner // ============================================================ function printTestResult(result: TestResult): void { const icon = result.passed ? color('โœ…', colors.green) : color('โŒ', colors.red); const statusColor = result.passed ? colors.green : colors.red; const statusText = result.statusCode ? `${result.statusCode}` : 'ERROR'; let output = ` ${icon} ${result.name} - ${color(statusText, statusColor)}`; if (result.passed) { output += ` ${color(`(${result.duration}ms)`, colors.gray)}`; if (result.details && VERBOSE) { output += `\n${color(' Response:', colors.gray)}\n${result.details.split('\n').map(line => ` ${line}`).join('\n')}`; } } else { output += ` ${color(`(${result.duration}ms)`, colors.gray)}`; if (result.error) { output += `\n ${color('Error:', colors.red)} ${result.error}`; } if (result.details && VERBOSE) { output += `\n${color(' Response:', colors.gray)}\n${result.details.split('\n').map(line => ` ${line}`).join('\n')}`; } } console.log(output); } async function runTests(): Promise { const startTime = Date.now(); const allResults: TestResult[] = []; console.log(bold(color('\n๐Ÿงช Cloud Instances API Tester', colors.cyan))); console.log(color('================================', colors.cyan)); console.log(`${color('Target:', colors.white)} ${API_URL}`); const maskedKey = API_KEY.length > 4 ? `${API_KEY.substring(0, 4)}${'*'.repeat(8)}` : '****'; console.log(`${color('API Key:', colors.white)} ${maskedKey}`); if (VERBOSE) { console.log(color('Mode: VERBOSE', colors.yellow)); } if (TARGET_ENDPOINT) { console.log(color(`Filter: ${TARGET_ENDPOINT}`, colors.yellow)); } // Run test suites if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/health') { const healthResults = await testHealthEndpoint(); healthResults.forEach(printTestResult); allResults.push(...healthResults); } if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/instances') { const instancesResults = await testInstancesEndpoint(); instancesResults.forEach(printTestResult); allResults.push(...instancesResults); } if (!TARGET_ENDPOINT || TARGET_ENDPOINT === '/sync') { const syncResults = await testSyncEndpoint(); syncResults.forEach(printTestResult); allResults.push(...syncResults); } const duration = Date.now() - startTime; const passed = allResults.filter(r => r.passed).length; const failed = allResults.filter(r => !r.passed).length; return { total: allResults.length, passed, failed, duration, results: allResults, }; } function printReport(report: TestReport): void { console.log(color('\n================================', colors.cyan)); console.log(bold(color('๐Ÿ“Š Test Report', colors.cyan))); console.log(` ${color('Total:', colors.white)} ${report.total} tests`); console.log(` ${color('Passed:', colors.green)} ${report.passed} ${color('โœ…', colors.green)}`); console.log(` ${color('Failed:', colors.red)} ${report.failed} ${color('โŒ', colors.red)}`); console.log(` ${color('Duration:', colors.white)} ${(report.duration / 1000).toFixed(2)}s`); if (report.failed > 0) { console.log(color('\nโš ๏ธ Failed Tests:', colors.yellow)); report.results .filter(r => !r.passed) .forEach(r => { console.log(` ${color('โ€ข', colors.red)} ${r.name}`); if (r.error) { console.log(` ${color(r.error, colors.red)}`); } }); } console.log(''); } // ============================================================ // Main // ============================================================ async function main(): Promise { try { // Initial delay to ensure rate limit window is clear console.log(color('\nโณ Waiting for rate limit window...', colors.gray)); await delay(2000); const report = await runTests(); printReport(report); // Exit with error code if any tests failed process.exit(report.failed > 0 ? 1 : 0); } catch (error) { console.error(color('\nโŒ Fatal error:', colors.red), error); process.exit(1); } } main();