Files
gridpilot.gg/tests/e2e/api/api-smoke.test.ts
2026-01-08 15:34:51 +01:00

332 lines
12 KiB
TypeScript

/**
* 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 } 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';
test.describe('API Smoke Tests', () => {
let testResults: EndpointTestResult[] = [];
test.beforeAll(async ({ request }) => {
console.log(`[API SMOKE] Testing API at: ${API_BASE_URL}`);
// Wait for API to be ready
const maxAttempts = 30;
let apiReady = false;
for (let i = 0; i < maxAttempts; i++) {
try {
const response = await request.get(`${API_BASE_URL}/health`);
if (response.ok()) {
apiReady = true;
console.log(`[API SMOKE] API is ready after ${i + 1} attempts`);
break;
}
} catch (error) {
// Continue trying
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
if (!apiReady) {
throw new Error('API failed to become ready');
}
});
test.afterAll(async () => {
await generateReport();
});
test('all public GET endpoints respond correctly', async ({ request }) => {
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', 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' },
// Dashboard endpoints (may require auth, but should handle gracefully)
{ method: 'GET' as const, path: '/dashboard/overview', name: 'Get dashboard overview' },
// Analytics endpoints
{ method: 'GET' as const, path: '/analytics/metrics', name: 'Get analytics metrics' },
// Sponsor endpoints
{ method: 'GET' as const, path: '/sponsors/pricing', name: 'Get sponsorship pricing' },
// Payments endpoints
{ method: 'GET' as const, path: '/payments/wallet', name: 'Get wallet (may require auth)' },
// Notifications endpoints
{ method: 'GET' as const, path: '/notifications/unread', name: 'Get unread notifications' },
// Features endpoint
{ method: 'GET' as const, path: '/features', name: 'Get feature flags' },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} public endpoints...`);
for (const endpoint of endpoints) {
await testEndpoint(request, endpoint);
}
// Check for presenter errors
const presenterErrors = testResults.filter(r => r.hasPresenterError);
if (presenterErrors.length > 0) {
console.log('\n❌ PRESENTER ERRORS FOUND:');
presenterErrors.forEach(r => {
console.log(` ${r.method} ${r.endpoint} - ${r.error}`);
});
}
// Assert no presenter errors
expect(presenterErrors.length).toBe(0);
});
test('POST endpoints handle requests gracefully', async ({ request }) => {
const endpoints = [
{ method: 'POST' as const, path: '/auth/login', name: 'Login', body: { email: 'test@example.com', password: 'test' } },
{ method: 'POST' as const, path: '/auth/signup', name: 'Signup', body: { email: 'test@example.com', password: 'test', name: 'Test User' } },
{ method: 'POST' as const, path: '/races/123/register', name: 'Register for race', body: { driverId: 'test-driver' } },
{ method: 'POST' as const, path: '/races/protests/file', name: 'File protest', body: { raceId: '123', driverId: '456', description: 'Test protest' } },
{ method: 'POST' as const, path: '/leagues/123/join', name: 'Join league', body: { driverId: 'test-driver' } },
{ method: 'POST' as const, path: '/teams/123/join', name: 'Join team', body: { driverId: 'test-driver' } },
];
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 }) => {
const endpoints = [
{ method: 'GET' as const, path: '/races/non-existent-id', name: 'Get non-existent race' },
{ method: 'GET' as const, path: '/races/non-existent-id/results', name: 'Get non-existent race results' },
{ method: 'GET' as const, path: '/leagues/non-existent-id', name: 'Get non-existent league' },
{ method: 'GET' as const, path: '/teams/non-existent-id', name: 'Get non-existent team' },
{ method: 'GET' as const, path: '/drivers/non-existent-id', name: 'Get non-existent driver' },
{ method: 'GET' as const, path: '/media/avatar/non-existent-id', name: 'Get non-existent avatar' },
];
console.log(`\n[API SMOKE] Testing ${endpoints.length} parameterized endpoints with invalid IDs...`);
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);
});
async function testEndpoint(
request: import('@playwright/test').APIRequestContext,
endpoint: { method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; path: string; name?: string; body?: unknown }
): Promise<void> {
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;
switch (endpoint.method) {
case 'GET':
response = await request.get(fullUrl);
break;
case 'POST':
response = await request.post(fullUrl, { data: endpoint.body || {} });
break;
case 'PUT':
response = await request.put(fullUrl, { data: endpoint.body || {} });
break;
case 'DELETE':
response = await request.delete(fullUrl);
break;
case 'PATCH':
response = await request.patch(fullUrl, { data: endpoint.body || {} });
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'));
const success = status < 400 && !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);
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);
}
}
async function generateReport(): Promise<void> {
const summary = {
total: testResults.length,
success: testResults.filter(r => r.success).length,
failed: testResults.filter(r => !r.success).length,
presenterErrors: testResults.filter(r => r.hasPresenterError).length,
avgResponseTime: testResults.reduce((sum, r) => sum + r.responseTime, 0) / testResults.length || 0,
};
const report: TestReport = {
timestamp: new Date().toISOString(),
summary,
results: testResults,
failures: testResults.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 = testResults.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 = testResults.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`);
}
});