/** * API Smoke Test * * This test performs true e2e testing of all API endpoints by making direct HTTP requests * to the running API server. It tests for: * - Basic connectivity and response codes * - Presenter errors ("Presenter not presented") * - Response format validation * - Error handling * * This test is designed to run in the Docker e2e environment and can be executed with: * npm run test:e2e:website (which runs everything in Docker) */ import { test, expect, request } from '@playwright/test'; import * as fs from 'fs/promises'; import * as path from 'path'; interface EndpointTestResult { endpoint: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; status: number; success: boolean; error?: string; response?: unknown; hasPresenterError: boolean; responseTime: number; } interface TestReport { timestamp: string; summary: { total: number; success: number; failed: number; presenterErrors: number; avgResponseTime: number; }; results: EndpointTestResult[]; failures: EndpointTestResult[]; } const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101'; // Auth file paths const USER_AUTH_FILE = path.join(__dirname, '.auth/user-session.json'); const ADMIN_AUTH_FILE = path.join(__dirname, '.auth/admin-session.json'); test.describe('API Smoke Tests', () => { // Aggregate across the whole suite (used for final report). const allResults: EndpointTestResult[] = []; let testResults: EndpointTestResult[] = []; test.beforeAll(async () => { console.log(`[API SMOKE] Testing API at: ${API_BASE_URL}`); // Verify auth files exist const userAuthExists = await fs.access(USER_AUTH_FILE).then(() => true).catch(() => false); const adminAuthExists = await fs.access(ADMIN_AUTH_FILE).then(() => true).catch(() => false); if (!userAuthExists || !adminAuthExists) { throw new Error('Auth files not found. Run global setup first.'); } console.log('[API SMOKE] Auth files verified'); }); test.afterAll(async () => { await generateReport(); }); test('all public GET endpoints respond correctly', async ({ request }) => { testResults = []; const endpoints = [ // Race endpoints { method: 'GET' as const, path: '/races/all', name: 'Get all races' }, { method: 'GET' as const, path: '/races/total-races', name: 'Get total races count' }, { method: 'GET' as const, path: '/races/page-data', name: 'Get races page data' }, { method: 'GET' as const, path: '/races/all/page-data', name: 'Get all races page data' }, { method: 'GET' as const, path: '/races/reference/penalty-types', name: 'Get penalty types reference' }, // League endpoints { method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues' }, { method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues' }, // Team endpoints { method: 'GET' as const, path: '/teams/all', name: 'Get all teams' }, // Driver endpoints { method: 'GET' as const, path: '/drivers/leaderboard', name: 'Get driver leaderboard' }, { method: 'GET' as const, path: '/drivers/total-drivers', name: 'Get total drivers count' }, // Sponsor endpoints { method: 'GET' as const, path: '/sponsors/pricing', name: 'Get sponsorship pricing' }, // Features endpoint { method: 'GET' as const, path: '/features', name: 'Get feature flags' }, // Hello endpoint { method: 'GET' as const, path: '/hello', name: 'Hello World' }, // Media endpoints { method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' }, // Driver by ID { method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' }, ]; console.log(`\n[API SMOKE] Testing ${endpoints.length} public GET endpoints...`); for (const endpoint of endpoints) { await testEndpoint(request, endpoint); } // Check for failures const failures = testResults.filter(r => !r.success); if (failures.length > 0) { console.log('\n❌ FAILURES FOUND:'); failures.forEach(r => { console.log(` ${r.method} ${r.endpoint} - ${r.status} - ${r.error || r.response}`); }); } // Assert all endpoints succeeded expect(failures.length).toBe(0); }); test('POST endpoints handle requests gracefully', async ({ request }) => { testResults = []; const endpoints = [ // Auth endpoints (no auth required) { method: 'POST' as const, path: '/auth/signup', name: 'Signup', requiresAuth: false, body: { email: `test-smoke-${Date.now()}@example.com`, password: 'Password123', displayName: 'Smoke Test', username: 'smoketest' } }, { method: 'POST' as const, path: '/auth/login', name: 'Login', requiresAuth: false, body: { email: 'demo.driver@example.com', password: 'Demo1234!' } }, // Protected endpoints (require auth) { method: 'POST' as const, path: '/races/123/register', name: 'Register for race', requiresAuth: true, body: { driverId: 'test-driver' } }, { method: 'POST' as const, path: '/races/protests/file', name: 'File protest', requiresAuth: true, body: { raceId: '123', protestingDriverId: 'driver-1', accusedDriverId: 'driver-2', incident: { lap: 1, description: 'Test protest' } } }, { method: 'POST' as const, path: '/leagues/league-1/join', name: 'Join league', requiresAuth: true, body: {} }, { method: 'POST' as const, path: '/teams/123/join', name: 'Join team', requiresAuth: true, body: { teamId: '123' } }, ]; console.log(`\n[API SMOKE] Testing ${endpoints.length} POST endpoints...`); for (const endpoint of endpoints) { await testEndpoint(request, endpoint); } // Check for presenter errors const presenterErrors = testResults.filter(r => r.hasPresenterError); expect(presenterErrors.length).toBe(0); }); test('parameterized endpoints handle missing IDs gracefully', async ({ request }) => { testResults = []; const endpoints = [ { method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race', requiresAuth: false }, { method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results', requiresAuth: false }, { method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league', requiresAuth: false }, { method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team', requiresAuth: false }, { method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver', requiresAuth: false }, { method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar', requiresAuth: false }, ]; console.log(`\n[API SMOKE] Testing ${endpoints.length} parameterized endpoints with invalid IDs...`); for (const endpoint of endpoints) { await testEndpoint(request, endpoint); } // Check for failures const failures = testResults.filter(r => !r.success); expect(failures.length).toBe(0); }); test('authenticated endpoints respond correctly', async () => { testResults = []; // Load user auth cookies const userAuthData = await fs.readFile(USER_AUTH_FILE, 'utf-8'); const userCookies = JSON.parse(userAuthData).cookies; // Create new API request context with user auth const userContext = await request.newContext({ storageState: { cookies: userCookies, origins: [{ origin: API_BASE_URL, localStorage: [] }] } }); const endpoints = [ // Dashboard { method: 'GET' as const, path: '/dashboard/overview', name: 'Dashboard Overview' }, // Analytics { method: 'GET' as const, path: '/analytics/metrics', name: 'Analytics Metrics' }, // Notifications { method: 'GET' as const, path: '/notifications/unread', name: 'Unread Notifications' }, ]; console.log(`\n[API SMOKE] Testing ${endpoints.length} authenticated endpoints...`); for (const endpoint of endpoints) { await testEndpoint(userContext, endpoint); } // Check for presenter errors const presenterErrors = testResults.filter(r => r.hasPresenterError); expect(presenterErrors.length).toBe(0); // Clean up await userContext.dispose(); }); test('admin endpoints respond correctly', async () => { testResults = []; // Load admin auth cookies const adminAuthData = await fs.readFile(ADMIN_AUTH_FILE, 'utf-8'); const adminCookies = JSON.parse(adminAuthData).cookies; // Create new API request context with admin auth const adminContext = await request.newContext({ storageState: { cookies: adminCookies, origins: [{ origin: API_BASE_URL, localStorage: [] }] } }); const endpoints = [ // Payments (requires admin capability) { method: 'GET' as const, path: '/payments/wallets?leagueId=league-1', name: 'Wallets' }, ]; console.log(`\n[API SMOKE] Testing ${endpoints.length} admin endpoints...`); for (const endpoint of endpoints) { await testEndpoint(adminContext, endpoint); } // Check for presenter errors const presenterErrors = testResults.filter(r => r.hasPresenterError); expect(presenterErrors.length).toBe(0); // Clean up await adminContext.dispose(); }); async function testEndpoint( request: import('@playwright/test').APIRequestContext, endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown; requiresAuth?: boolean } ): Promise { const startTime = Date.now(); const fullUrl = `${API_BASE_URL}${endpoint.path}`; console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`); try { let response; const headers: Record = {}; // Playwright's request context handles cookies automatically // No need to set Authorization header for cookie-based auth switch (endpoint.method) { case 'GET': response = await request.get(fullUrl, { headers }); break; case 'POST': response = await request.post(fullUrl, { data: endpoint.body || {}, headers }); break; case 'PUT': response = await request.put(fullUrl, { data: endpoint.body || {}, headers }); break; case 'DELETE': response = await request.delete(fullUrl, { headers }); break; case 'PATCH': response = await request.patch(fullUrl, { data: endpoint.body || {}, headers }); break; } const responseTime = Date.now() - startTime; const status = response.status(); const body = await response.json().catch(() => null); const bodyText = await response.text().catch(() => ''); // Check for presenter errors const hasPresenterError = bodyText.includes('Presenter not presented') || bodyText.includes('presenter not presented') || (body && body.message && body.message.includes('Presenter not presented')) || (body && body.error && body.error.includes('Presenter not presented')); // Success is 200-299 status, or 404 for non-existent resources, and no presenter error const isNotFound = status === 404; const success = (status >= 200 && status < 300 || isNotFound) && !hasPresenterError; const result: EndpointTestResult = { endpoint: endpoint.path, method: endpoint.method, status, success, hasPresenterError, responseTime, response: body || bodyText.substring(0, 200), }; if (!success) { result.error = body?.message || bodyText.substring(0, 200); } testResults.push(result); allResults.push(result); if (hasPresenterError) { console.log(` ❌ PRESENTER ERROR: ${status} - ${body?.message || bodyText.substring(0, 100)}`); } else if (success) { console.log(` ✅ ${status} (${responseTime}ms)`); } else { console.log(` ⚠️ ${status} (${responseTime}ms) - ${body?.message || 'Error'}`); } } catch (error: unknown) { const responseTime = Date.now() - startTime; const errorString = error instanceof Error ? error.message : String(error); const result: EndpointTestResult = { endpoint: endpoint.path, method: endpoint.method, status: 0, success: false, hasPresenterError: false, responseTime, error: errorString, }; // Check if it's a presenter error if (errorString.includes('Presenter not presented')) { result.hasPresenterError = true; console.log(` ❌ PRESENTER ERROR (exception): ${errorString}`); } else { console.log(` ❌ EXCEPTION: ${errorString}`); } testResults.push(result); allResults.push(result); } } async function generateReport(): Promise { const summary = { total: allResults.length, success: allResults.filter(r => r.success).length, failed: allResults.filter(r => !r.success).length, presenterErrors: allResults.filter(r => r.hasPresenterError).length, avgResponseTime: allResults.reduce((sum, r) => sum + r.responseTime, 0) / allResults.length || 0, }; const report: TestReport = { timestamp: new Date().toISOString(), summary, results: allResults, failures: allResults.filter(r => !r.success), }; // Write JSON report const jsonPath = path.join(__dirname, '../../../api-smoke-report.json'); await fs.writeFile(jsonPath, JSON.stringify(report, null, 2)); // Write Markdown report const mdPath = path.join(__dirname, '../../../api-smoke-report.md'); let md = `# API Smoke Test Report\n\n`; md += `**Generated:** ${new Date().toISOString()}\n`; md += `**API Base URL:** ${API_BASE_URL}\n\n`; md += `## Summary\n\n`; md += `- **Total Endpoints:** ${summary.total}\n`; md += `- **✅ Success:** ${summary.success}\n`; md += `- **❌ Failed:** ${summary.failed}\n`; md += `- **⚠️ Presenter Errors:** ${summary.presenterErrors}\n`; md += `- **Avg Response Time:** ${summary.avgResponseTime.toFixed(2)}ms\n\n`; if (summary.presenterErrors > 0) { md += `## Presenter Errors\n\n`; const presenterFailures = allResults.filter(r => r.hasPresenterError); presenterFailures.forEach((r, i) => { md += `${i + 1}. **${r.method} ${r.endpoint}**\n`; md += ` - Status: ${r.status}\n`; md += ` - Error: ${r.error || 'No error message'}\n\n`; }); } if (summary.failed > 0 && summary.presenterErrors < summary.failed) { md += `## Other Failures\n\n`; const otherFailures = allResults.filter(r => !r.success && !r.hasPresenterError); otherFailures.forEach((r, i) => { md += `${i + 1}. **${r.method} ${r.endpoint}**\n`; md += ` - Status: ${r.status}\n`; md += ` - Error: ${r.error || 'No error message'}\n\n`; }); } await fs.writeFile(mdPath, md); console.log(`\n📊 Reports generated:`); console.log(` JSON: ${jsonPath}`); console.log(` Markdown: ${mdPath}`); console.log(`\nSummary: ${summary.success}/${summary.total} passed, ${summary.presenterErrors} presenter errors`); } });