diff --git a/tests/e2e/api/.auth/session.json b/tests/e2e/api/.auth/session.json deleted file mode 100644 index 6d04aab0a..000000000 --- a/tests/e2e/api/.auth/session.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "cookies": [ - { - "name": "gp_session", - "value": "gp_9f9c4115-2a02-4be7-9aec-72ddb3c7cdbf", - "domain": "localhost", - "path": "/", - "expires": -1, - "httpOnly": true, - "secure": false, - "sameSite": "Lax" - } - ], - "userId": "68fd953d-4f4a-47b6-83b9-ec361238e4f1", - "email": "smoke-test-1767897520573@example.com", - "password": "Password123" -} \ No newline at end of file diff --git a/tests/e2e/api/README.md b/tests/e2e/api/README.md deleted file mode 100644 index a3633e31e..000000000 --- a/tests/e2e/api/README.md +++ /dev/null @@ -1,244 +0,0 @@ -# API Smoke Tests - -This directory contains true end-to-end API smoke tests that make direct HTTP requests to the running API server to validate endpoint functionality and detect issues like "presenter not presented" errors. - -## Overview - -The API smoke tests are designed to: - -1. **Test all public API endpoints** - Make requests to discover and validate endpoints -2. **Detect presenter errors** - Identify use cases that return errors without calling `this.output.present()` -3. **Validate response formats** - Ensure endpoints return proper data structures -4. **Test error handling** - Verify graceful handling of invalid inputs -5. **Generate detailed reports** - Create JSON and Markdown reports of findings - -## Files - -- `api-smoke.test.ts` - Main Playwright test file -- `README.md` - This documentation - -## Usage - -### Local Testing - -Run the API smoke tests against a locally running API: - -```bash -# Start the API server (in one terminal) -npm run docker:dev:up - -# Run smoke tests (in another terminal) -npm run test:api:smoke -``` - -### Docker Testing (Recommended) - -Run the tests in the full Docker e2e environment: - -```bash -# Start the complete e2e environment -npm run docker:e2e:up - -# Run smoke tests in Docker -npm run test:api:smoke:docker - -# Or use the unified command -npm run test:e2e:website # This runs all e2e tests including API smoke -``` - -### CI/CD Integration - -Add to your CI pipeline: - -```yaml -# GitHub Actions example -- name: Start E2E Environment - run: npm run docker:e2e:up - -- name: Run API Smoke Tests - run: npm run test:api:smoke:docker - -- name: Upload Test Reports - uses: actions/upload-artifact@v3 - with: - name: api-smoke-reports - path: | - api-smoke-report.json - api-smoke-report.md - playwright-report/ -``` - -## Test Coverage - -The smoke tests cover: - -### Race Endpoints -- `/races/all` - Get all races -- `/races/total-races` - Get total count -- `/races/page-data` - Get paginated data -- `/races/reference/penalty-types` - Reference data -- `/races/{id}` - Race details (with invalid IDs) -- `/races/{id}/results` - Race results -- `/races/{id}/sof` - Strength of field -- `/races/{id}/protests` - Protests -- `/races/{id}/penalties` - Penalties - -### League Endpoints -- `/leagues/all` - All leagues -- `/leagues/available` - Available leagues -- `/leagues/{id}` - League details -- `/leagues/{id}/standings` - Standings -- `/leagues/{id}/schedule` - Schedule - -### Team Endpoints -- `/teams/all` - All teams -- `/teams/{id}` - Team details -- `/teams/{id}/members` - Team members - -### Driver Endpoints -- `/drivers/leaderboard` - Leaderboard -- `/drivers/total-drivers` - Total count -- `/drivers/{id}` - Driver details - -### Media Endpoints -- `/media/avatar/{id}` - Avatar retrieval -- `/media/{id}` - Media retrieval - -### Sponsor Endpoints -- `/sponsors/pricing` - Sponsorship pricing -- `/sponsors/dashboard` - Sponsor dashboard -- `/sponsors/{id}` - Sponsor details - -### Auth Endpoints -- `/auth/login` - Login -- `/auth/signup` - Signup -- `/auth/session` - Session info - -### Dashboard Endpoints -- `/dashboard/overview` - Overview -- `/dashboard/feed` - Activity feed - -### Analytics Endpoints -- `/analytics/metrics` - Metrics -- `/analytics/dashboard` - Dashboard data - -### Admin Endpoints -- `/admin/users` - User management - -### Protest Endpoints -- `/protests/race/{id}` - Race protests - -### Payment Endpoints -- `/payments/wallet` - Wallet info - -### Notification Endpoints -- `/notifications/unread` - Unread notifications - -### Feature Flags -- `/features` - Feature flag configuration - -## Reports - -After running tests, three reports are generated: - -1. **`api-smoke-report.json`** - Detailed JSON report with all test results -2. **`api-smoke-report.md`** - Human-readable Markdown report -3. **Playwright HTML report** - Interactive test report (in `playwright-report/`) - -### Report Structure - -```json -{ - "timestamp": "2024-01-07T22:00:00Z", - "summary": { - "total": 50, - "success": 45, - "failed": 5, - "presenterErrors": 3, - "avgResponseTime": 45.2 - }, - "results": [...], - "failures": [...] -} -``` - -## Detecting Presenter Errors - -The test specifically looks for the "Presenter not presented" error pattern: - -```typescript -// Detects these patterns: -- "Presenter not presented" -- "presenter not presented" -- Error messages containing these phrases -``` - -When found, these are flagged as **presenter errors** and require immediate attention. - -## Troubleshooting - -### API Not Ready -If tests fail because API isn't ready: -```bash -# Check API health -curl http://localhost:3101/health - -# Wait longer in test setup (increase timeout in test file) -``` - -### Port Conflicts -```bash -# Stop conflicting services -npm run docker:e2e:down - -# Check what's running -docker-compose -f docker-compose.e2e.yml ps -``` - -### Missing Data -The tests expect seeded data. If you see 404s: -```bash -# Ensure bootstrap is enabled -export GRIDPILOT_API_BOOTSTRAP=1 - -# Restart services -npm run docker:e2e:clean && npm run docker:e2e:up -``` - -## Integration with Existing Tests - -This smoke test complements the existing test suite: - -- **Unit tests** (`apps/api/src/**/*Service.test.ts`) - Test individual services -- **Integration tests** (`tests/integration/`) - Test component interactions -- **E2E website tests** (`tests/e2e/website/`) - Test website functionality -- **API smoke tests** (this) - Test API endpoints directly - -## Best Practices - -1. **Run before deployments** - Catch presenter errors before they reach production -2. **Run in CI/CD** - Automated regression testing -3. **Review reports** - Always check the generated reports -4. **Fix presenter errors immediately** - They indicate missing `.present()` calls -5. **Keep tests updated** - Add new endpoints as they're created - -## Performance - -- Typical runtime: 30-60 seconds -- Parallel execution: Playwright runs tests in parallel by default -- Response time tracking: All requests are timed -- Average response time tracked in reports - -## Maintenance - -When adding new endpoints: -1. Add them to the test arrays in `api-smoke.test.ts` -2. Test locally first: `npm run test:api:smoke` -3. Verify reports show expected results -4. Commit updated test file - -When fixing presenter errors: -1. Run smoke test to identify failing endpoints -2. Check the specific error messages -3. Fix the use case to call `this.output.present()` before returning -4. Re-run smoke test to verify fix \ No newline at end of file diff --git a/tests/e2e/api/api-auth.setup.ts b/tests/e2e/api/api-auth.setup.ts deleted file mode 100644 index 7270b8c56..000000000 --- a/tests/e2e/api/api-auth.setup.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * API Authentication Setup for E2E Tests - * - * This setup creates authentication sessions for both regular and admin users - * that are persisted across all tests in the suite. - */ - -import { test as setup } from '@playwright/test'; -import * as fs from 'fs/promises'; -import * as path from 'path'; - -const API_BASE_URL = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3101'; - -// Define 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'); - -setup('Authenticate regular user', async ({ request }) => { - console.log(`[AUTH SETUP] Creating regular user session 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(`[AUTH SETUP] 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'); - } - - // Create test user and establish cookie-based session - const testEmail = `smoke-test-${Date.now()}@example.com`; - const testPassword = 'Password123'; - - // Signup - const signupResponse = await request.post(`${API_BASE_URL}/auth/signup`, { - data: { - email: testEmail, - password: testPassword, - displayName: 'Smoke Tester', - username: `smokeuser${Date.now()}` - } - }); - - if (!signupResponse.ok()) { - throw new Error(`Signup failed: ${signupResponse.status()}`); - } - - const signupData = await signupResponse.json(); - const testUserId = signupData?.user?.userId ?? null; - console.log('[AUTH SETUP] Test user created:', testUserId); - - // Login to establish cookie session - const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, { - data: { - email: testEmail, - password: testPassword - } - }); - - if (!loginResponse.ok()) { - throw new Error(`Login failed: ${loginResponse.status()}`); - } - - console.log('[AUTH SETUP] Regular user session established'); - - // Get cookies and save to auth file - const context = request.context(); - const cookies = context.cookies(); - - // Ensure auth directory exists - await fs.mkdir(path.dirname(USER_AUTH_FILE), { recursive: true }); - - // Save cookies to file - await fs.writeFile(USER_AUTH_FILE, JSON.stringify({ cookies }, null, 2)); - console.log(`[AUTH SETUP] Saved user session to: ${USER_AUTH_FILE}`); -}); - -setup('Authenticate admin user', async ({ request }) => { - console.log(`[AUTH SETUP] Creating admin user session at: ${API_BASE_URL}`); - - // Use seeded admin credentials - const adminEmail = 'demo.admin@example.com'; - const adminPassword = 'Demo1234!'; - - // Login as admin - const loginResponse = await request.post(`${API_BASE_URL}/auth/login`, { - data: { - email: adminEmail, - password: adminPassword - } - }); - - if (!loginResponse.ok()) { - throw new Error(`Admin login failed: ${loginResponse.status()}`); - } - - console.log('[AUTH SETUP] Admin user session established'); - - // Get cookies and save to auth file - const context = request.context(); - const cookies = context.cookies(); - - // Ensure auth directory exists - await fs.mkdir(path.dirname(ADMIN_AUTH_FILE), { recursive: true }); - - // Save cookies to file - await fs.writeFile(ADMIN_AUTH_FILE, JSON.stringify({ cookies }, null, 2)); - console.log(`[AUTH SETUP] Saved admin session to: ${ADMIN_AUTH_FILE}`); -}); \ No newline at end of file diff --git a/tests/e2e/api/api-smoke.test.ts b/tests/e2e/api/api-smoke.test.ts deleted file mode 100644 index e8de97bfb..000000000 --- a/tests/e2e/api/api-smoke.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -/** - * 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`); - } -}); \ No newline at end of file diff --git a/tests/e2e/api/league-api.test.ts b/tests/e2e/api/league-api.test.ts deleted file mode 100644 index f2203ecc2..000000000 --- a/tests/e2e/api/league-api.test.ts +++ /dev/null @@ -1,782 +0,0 @@ -/** - * League API Tests - * - * This test suite performs comprehensive API testing for league-related endpoints. - * It validates: - * - Response structure matches expected DTO - * - Required fields are present - * - Data types are correct - * - Edge cases (empty results, missing data) - * - Business logic (sorting, filtering, calculations) - * - * 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 TestResult { - endpoint: string; - method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; - status: number; - success: boolean; - error?: string; - response?: unknown; - hasPresenterError: boolean; - responseTime: number; -} - -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('League API Tests', () => { - const allResults: TestResult[] = []; - let testResults: TestResult[] = []; - - test.beforeAll(async () => { - console.log(`[LEAGUE API] 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('[LEAGUE API] Auth files verified'); - }); - - test.afterAll(async () => { - await generateReport(); - }); - - test('League Discovery Endpoints - Public endpoints', async ({ request }) => { - testResults = []; - const endpoints = [ - { method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues with capacity' }, - { method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with capacity and scoring' }, - { method: 'GET' as const, path: '/leagues/total-leagues', name: 'Get total leagues count' }, - { method: 'GET' as const, path: '/leagues/all', name: 'Get all leagues (alias)' }, - { method: 'GET' as const, path: '/leagues/available', name: 'Get available leagues (alias)' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} league discovery 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('League Discovery - Response structure validation', async ({ request }) => { - testResults = []; - - // Test /leagues/all-with-capacity - const allLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - expect(allLeaguesResponse.ok()).toBe(true); - - const allLeaguesData = await allLeaguesResponse.json(); - expect(allLeaguesData).toHaveProperty('leagues'); - expect(allLeaguesData).toHaveProperty('totalCount'); - expect(Array.isArray(allLeaguesData.leagues)).toBe(true); - expect(typeof allLeaguesData.totalCount).toBe('number'); - - // Validate league structure if leagues exist - if (allLeaguesData.leagues.length > 0) { - const league = allLeaguesData.leagues[0]; - expect(league).toHaveProperty('id'); - expect(league).toHaveProperty('name'); - expect(league).toHaveProperty('description'); - expect(league).toHaveProperty('ownerId'); - expect(league).toHaveProperty('createdAt'); - expect(league).toHaveProperty('settings'); - expect(league.settings).toHaveProperty('maxDrivers'); - expect(league).toHaveProperty('usedSlots'); - - // Validate data types - expect(typeof league.id).toBe('string'); - expect(typeof league.name).toBe('string'); - expect(typeof league.description).toBe('string'); - expect(typeof league.ownerId).toBe('string'); - expect(typeof league.createdAt).toBe('string'); - expect(typeof league.settings.maxDrivers).toBe('number'); - expect(typeof league.usedSlots).toBe('number'); - - // Validate business logic: usedSlots <= maxDrivers - expect(league.usedSlots).toBeLessThanOrEqual(league.settings.maxDrivers); - } - - // Test /leagues/all-with-capacity-and-scoring - const scoredLeaguesResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity-and-scoring`); - expect(scoredLeaguesResponse.ok()).toBe(true); - - const scoredLeaguesData = await scoredLeaguesResponse.json(); - expect(scoredLeaguesData).toHaveProperty('leagues'); - expect(scoredLeaguesData).toHaveProperty('totalCount'); - expect(Array.isArray(scoredLeaguesData.leagues)).toBe(true); - - // Validate scoring structure if leagues exist - if (scoredLeaguesData.leagues.length > 0) { - const league = scoredLeaguesData.leagues[0]; - expect(league).toHaveProperty('scoring'); - expect(league.scoring).toHaveProperty('gameId'); - expect(league.scoring).toHaveProperty('scoringPresetId'); - - // Validate data types - expect(typeof league.scoring.gameId).toBe('string'); - expect(typeof league.scoring.scoringPresetId).toBe('string'); - } - - // Test /leagues/total-leagues - const totalResponse = await request.get(`${API_BASE_URL}/leagues/total-leagues`); - expect(totalResponse.ok()).toBe(true); - - const totalData = await totalResponse.json(); - expect(totalData).toHaveProperty('totalLeagues'); - expect(typeof totalData.totalLeagues).toBe('number'); - expect(totalData.totalLeagues).toBeGreaterThanOrEqual(0); - - // Validate consistency: totalCount from all-with-capacity should match totalLeagues - expect(allLeaguesData.totalCount).toBe(totalData.totalLeagues); - - testResults.push({ - endpoint: '/leagues/all-with-capacity', - method: 'GET', - status: allLeaguesResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - testResults.push({ - endpoint: '/leagues/all-with-capacity-and-scoring', - method: 'GET', - status: scoredLeaguesResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - testResults.push({ - endpoint: '/leagues/total-leagues', - method: 'GET', - status: totalResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - allResults.push(...testResults); - }); - - test('League Detail Endpoints - Public endpoints', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping detail endpoint tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - const endpoints = [ - { method: 'GET' as const, path: `/leagues/${leagueId}`, name: 'Get league details' }, - { method: 'GET' as const, path: `/leagues/${leagueId}/seasons`, name: 'Get league seasons' }, - { method: 'GET' as const, path: `/leagues/${leagueId}/stats`, name: 'Get league stats' }, - { method: 'GET' as const, path: `/leagues/${leagueId}/memberships`, name: 'Get league memberships' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} league detail endpoints for league ${leagueId}...`); - - 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('League Detail - Response structure validation', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping detail validation tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - // Test /leagues/{id} - const leagueResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}`); - expect(leagueResponse.ok()).toBe(true); - - const leagueData = await leagueResponse.json(); - expect(leagueData).toHaveProperty('id'); - expect(leagueData).toHaveProperty('name'); - expect(leagueData).toHaveProperty('description'); - expect(leagueData).toHaveProperty('ownerId'); - expect(leagueData).toHaveProperty('createdAt'); - - // Validate data types - expect(typeof leagueData.id).toBe('string'); - expect(typeof leagueData.name).toBe('string'); - expect(typeof leagueData.description).toBe('string'); - expect(typeof leagueData.ownerId).toBe('string'); - expect(typeof leagueData.createdAt).toBe('string'); - - // Validate ID matches requested ID - expect(leagueData.id).toBe(leagueId); - - // Test /leagues/{id}/seasons - const seasonsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/seasons`); - expect(seasonsResponse.ok()).toBe(true); - - const seasonsData = await seasonsResponse.json(); - expect(Array.isArray(seasonsData)).toBe(true); - - // Validate season structure if seasons exist - if (seasonsData.length > 0) { - const season = seasonsData[0]; - expect(season).toHaveProperty('id'); - expect(season).toHaveProperty('name'); - expect(season).toHaveProperty('status'); - - // Validate data types - expect(typeof season.id).toBe('string'); - expect(typeof season.name).toBe('string'); - expect(typeof season.status).toBe('string'); - } - - // Test /leagues/{id}/stats - const statsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/stats`); - expect(statsResponse.ok()).toBe(true); - - const statsData = await statsResponse.json(); - expect(statsData).toHaveProperty('memberCount'); - expect(statsData).toHaveProperty('raceCount'); - expect(statsData).toHaveProperty('avgSOF'); - - // Validate data types - expect(typeof statsData.memberCount).toBe('number'); - expect(typeof statsData.raceCount).toBe('number'); - expect(typeof statsData.avgSOF).toBe('number'); - - // Validate business logic: counts should be non-negative - expect(statsData.memberCount).toBeGreaterThanOrEqual(0); - expect(statsData.raceCount).toBeGreaterThanOrEqual(0); - expect(statsData.avgSOF).toBeGreaterThanOrEqual(0); - - // Test /leagues/{id}/memberships - const membershipsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/memberships`); - expect(membershipsResponse.ok()).toBe(true); - - const membershipsData = await membershipsResponse.json(); - expect(membershipsData).toHaveProperty('members'); - expect(Array.isArray(membershipsData.members)).toBe(true); - - // Validate membership structure if members exist - if (membershipsData.members.length > 0) { - const member = membershipsData.members[0]; - expect(member).toHaveProperty('driverId'); - expect(member).toHaveProperty('role'); - expect(member).toHaveProperty('joinedAt'); - - // Validate data types - expect(typeof member.driverId).toBe('string'); - expect(typeof member.role).toBe('string'); - expect(typeof member.joinedAt).toBe('string'); - - // Validate business logic: at least one owner must exist - const hasOwner = membershipsData.members.some((m: any) => m.role === 'owner'); - expect(hasOwner).toBe(true); - } - - testResults.push({ - endpoint: `/leagues/${leagueId}`, - method: 'GET', - status: leagueResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - testResults.push({ - endpoint: `/leagues/${leagueId}/seasons`, - method: 'GET', - status: seasonsResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - testResults.push({ - endpoint: `/leagues/${leagueId}/stats`, - method: 'GET', - status: statsResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - testResults.push({ - endpoint: `/leagues/${leagueId}/memberships`, - method: 'GET', - status: membershipsResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - allResults.push(...testResults); - }); - - test('League Schedule Endpoints - Public endpoints', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping schedule endpoint tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - const endpoints = [ - { method: 'GET' as const, path: `/leagues/${leagueId}/schedule`, name: 'Get league schedule' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} league schedule endpoints for league ${leagueId}...`); - - 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('League Schedule - Response structure validation', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping schedule validation tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - // Test /leagues/{id}/schedule - const scheduleResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/schedule`); - expect(scheduleResponse.ok()).toBe(true); - - const scheduleData = await scheduleResponse.json(); - expect(scheduleData).toHaveProperty('seasonId'); - expect(scheduleData).toHaveProperty('races'); - expect(Array.isArray(scheduleData.races)).toBe(true); - - // Validate data types - expect(typeof scheduleData.seasonId).toBe('string'); - - // Validate race structure if races exist - if (scheduleData.races.length > 0) { - const race = scheduleData.races[0]; - expect(race).toHaveProperty('id'); - expect(race).toHaveProperty('track'); - expect(race).toHaveProperty('car'); - expect(race).toHaveProperty('scheduledAt'); - - // Validate data types - expect(typeof race.id).toBe('string'); - expect(typeof race.track).toBe('string'); - expect(typeof race.car).toBe('string'); - expect(typeof race.scheduledAt).toBe('string'); - - // Validate business logic: races should be sorted by scheduledAt - const scheduledTimes = scheduleData.races.map((r: any) => new Date(r.scheduledAt).getTime()); - const sortedTimes = [...scheduledTimes].sort((a, b) => a - b); - expect(scheduledTimes).toEqual(sortedTimes); - } - - testResults.push({ - endpoint: `/leagues/${leagueId}/schedule`, - method: 'GET', - status: scheduleResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - allResults.push(...testResults); - }); - - test('League Standings Endpoints - Public endpoints', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping standings endpoint tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - const endpoints = [ - { method: 'GET' as const, path: `/leagues/${leagueId}/standings`, name: 'Get league standings' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} league standings endpoints for league ${leagueId}...`); - - 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('League Standings - Response structure validation', async ({ request }) => { - testResults = []; - - // First, get a valid league ID from the discovery endpoint - const discoveryResponse = await request.get(`${API_BASE_URL}/leagues/all-with-capacity`); - const discoveryData = await discoveryResponse.json(); - - if (discoveryData.leagues.length === 0) { - console.log('[LEAGUE API] No leagues found, skipping standings validation tests'); - return; - } - - const leagueId = discoveryData.leagues[0].id; - - // Test /leagues/{id}/standings - const standingsResponse = await request.get(`${API_BASE_URL}/leagues/${leagueId}/standings`); - expect(standingsResponse.ok()).toBe(true); - - const standingsData = await standingsResponse.json(); - expect(standingsData).toHaveProperty('standings'); - expect(Array.isArray(standingsData.standings)).toBe(true); - - // Validate standing structure if standings exist - if (standingsData.standings.length > 0) { - const standing = standingsData.standings[0]; - expect(standing).toHaveProperty('position'); - expect(standing).toHaveProperty('driverId'); - expect(standing).toHaveProperty('points'); - expect(standing).toHaveProperty('races'); - - // Validate data types - expect(typeof standing.position).toBe('number'); - expect(typeof standing.driverId).toBe('string'); - expect(typeof standing.points).toBe('number'); - expect(typeof standing.races).toBe('number'); - - // Validate business logic: position must be sequential starting from 1 - const positions = standingsData.standings.map((s: any) => s.position); - const expectedPositions = Array.from({ length: positions.length }, (_, i) => i + 1); - expect(positions).toEqual(expectedPositions); - - // Validate business logic: points must be non-negative - expect(standing.points).toBeGreaterThanOrEqual(0); - - // Validate business logic: races count must be non-negative - expect(standing.races).toBeGreaterThanOrEqual(0); - } - - testResults.push({ - endpoint: `/leagues/${leagueId}/standings`, - method: 'GET', - status: standingsResponse.status(), - success: true, - hasPresenterError: false, - responseTime: 0, - }); - - allResults.push(...testResults); - }); - - test('Edge Cases - Invalid league IDs', async ({ request }) => { - testResults = []; - - const endpoints = [ - { method: 'GET' as const, path: '/leagues/non-existent-league-id', name: 'Get non-existent league' }, - { method: 'GET' as const, path: '/leagues/non-existent-league-id/seasons', name: 'Get seasons for non-existent league' }, - { method: 'GET' as const, path: '/leagues/non-existent-league-id/stats', name: 'Get stats for non-existent league' }, - { method: 'GET' as const, path: '/leagues/non-existent-league-id/schedule', name: 'Get schedule for non-existent league' }, - { method: 'GET' as const, path: '/leagues/non-existent-league-id/standings', name: 'Get standings for non-existent league' }, - { method: 'GET' as const, path: '/leagues/non-existent-league-id/memberships', name: 'Get memberships for non-existent league' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} edge case endpoints with invalid IDs...`); - - 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 (404 is acceptable for non-existent resources) - expect(failures.length).toBe(0); - }); - - test('Edge Cases - Empty results', async ({ request }) => { - testResults = []; - - // Test discovery endpoints with filters (if available) - // Note: The current API doesn't seem to have filter parameters, but we test the base endpoints - - const endpoints = [ - { method: 'GET' as const, path: '/leagues/all-with-capacity', name: 'Get all leagues (empty check)' }, - { method: 'GET' as const, path: '/leagues/all-with-capacity-and-scoring', name: 'Get all leagues with scoring (empty check)' }, - ]; - - console.log(`\n[LEAGUE API] Testing ${endpoints.length} endpoints for empty result handling...`); - - 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); - }); - - 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: TestResult = { - 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: TestResult = { - 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 = { - timestamp: new Date().toISOString(), - summary, - results: allResults, - failures: allResults.filter(r => !r.success), - }; - - // Write JSON report - const jsonPath = path.join(__dirname, '../../../league-api-test-report.json'); - await fs.writeFile(jsonPath, JSON.stringify(report, null, 2)); - - // Write Markdown report - const mdPath = path.join(__dirname, '../../../league-api-test-report.md'); - let md = `# League API 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`); - } -}); diff --git a/tests/e2e/bdd/dashboard/README.md b/tests/e2e/dashboard/README.md similarity index 100% rename from tests/e2e/bdd/dashboard/README.md rename to tests/e2e/dashboard/README.md diff --git a/tests/e2e/bdd/dashboard/dashboard-error-states.spec.ts b/tests/e2e/dashboard/dashboard-error-states.spec.ts similarity index 100% rename from tests/e2e/bdd/dashboard/dashboard-error-states.spec.ts rename to tests/e2e/dashboard/dashboard-error-states.spec.ts diff --git a/tests/e2e/bdd/dashboard/dashboard-navigation.spec.ts b/tests/e2e/dashboard/dashboard-navigation.spec.ts similarity index 100% rename from tests/e2e/bdd/dashboard/dashboard-navigation.spec.ts rename to tests/e2e/dashboard/dashboard-navigation.spec.ts diff --git a/tests/e2e/bdd/dashboard/driver-dashboard-view.spec.ts b/tests/e2e/dashboard/driver-dashboard-view.spec.ts similarity index 100% rename from tests/e2e/bdd/dashboard/driver-dashboard-view.spec.ts rename to tests/e2e/dashboard/driver-dashboard-view.spec.ts diff --git a/tests/e2e/bdd/drivers/driver-profile.spec.ts b/tests/e2e/drivers/driver-profile.spec.ts similarity index 100% rename from tests/e2e/bdd/drivers/driver-profile.spec.ts rename to tests/e2e/drivers/driver-profile.spec.ts diff --git a/tests/e2e/bdd/drivers/drivers-list.spec.ts b/tests/e2e/drivers/drivers-list.spec.ts similarity index 100% rename from tests/e2e/bdd/drivers/drivers-list.spec.ts rename to tests/e2e/drivers/drivers-list.spec.ts diff --git a/tests/e2e/bdd/leaderboards/README.md b/tests/e2e/leaderboards/README.md similarity index 100% rename from tests/e2e/bdd/leaderboards/README.md rename to tests/e2e/leaderboards/README.md diff --git a/tests/e2e/bdd/leaderboards/leaderboards-drivers.spec.ts b/tests/e2e/leaderboards/leaderboards-drivers.spec.ts similarity index 100% rename from tests/e2e/bdd/leaderboards/leaderboards-drivers.spec.ts rename to tests/e2e/leaderboards/leaderboards-drivers.spec.ts diff --git a/tests/e2e/bdd/leaderboards/leaderboards-main.spec.ts b/tests/e2e/leaderboards/leaderboards-main.spec.ts similarity index 100% rename from tests/e2e/bdd/leaderboards/leaderboards-main.spec.ts rename to tests/e2e/leaderboards/leaderboards-main.spec.ts diff --git a/tests/e2e/bdd/leaderboards/leaderboards-teams.spec.ts b/tests/e2e/leaderboards/leaderboards-teams.spec.ts similarity index 100% rename from tests/e2e/bdd/leaderboards/leaderboards-teams.spec.ts rename to tests/e2e/leaderboards/leaderboards-teams.spec.ts diff --git a/tests/e2e/bdd/leagues/README.md b/tests/e2e/leagues/README.md similarity index 100% rename from tests/e2e/bdd/leagues/README.md rename to tests/e2e/leagues/README.md diff --git a/tests/e2e/bdd/leagues/league-create.spec.ts b/tests/e2e/leagues/league-create.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-create.spec.ts rename to tests/e2e/leagues/league-create.spec.ts diff --git a/tests/e2e/bdd/leagues/league-detail.spec.ts b/tests/e2e/leagues/league-detail.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-detail.spec.ts rename to tests/e2e/leagues/league-detail.spec.ts diff --git a/tests/e2e/bdd/leagues/league-roster.spec.ts b/tests/e2e/leagues/league-roster.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-roster.spec.ts rename to tests/e2e/leagues/league-roster.spec.ts diff --git a/tests/e2e/bdd/leagues/league-schedule.spec.ts b/tests/e2e/leagues/league-schedule.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-schedule.spec.ts rename to tests/e2e/leagues/league-schedule.spec.ts diff --git a/tests/e2e/bdd/leagues/league-settings.spec.ts b/tests/e2e/leagues/league-settings.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-settings.spec.ts rename to tests/e2e/leagues/league-settings.spec.ts diff --git a/tests/e2e/bdd/leagues/league-sponsorships.spec.ts b/tests/e2e/leagues/league-sponsorships.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-sponsorships.spec.ts rename to tests/e2e/leagues/league-sponsorships.spec.ts diff --git a/tests/e2e/bdd/leagues/league-standings.spec.ts b/tests/e2e/leagues/league-standings.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-standings.spec.ts rename to tests/e2e/leagues/league-standings.spec.ts diff --git a/tests/e2e/bdd/leagues/league-stewarding.spec.ts b/tests/e2e/leagues/league-stewarding.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-stewarding.spec.ts rename to tests/e2e/leagues/league-stewarding.spec.ts diff --git a/tests/e2e/bdd/leagues/league-wallet.spec.ts b/tests/e2e/leagues/league-wallet.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/league-wallet.spec.ts rename to tests/e2e/leagues/league-wallet.spec.ts diff --git a/tests/e2e/bdd/leagues/leagues-discovery.spec.ts b/tests/e2e/leagues/leagues-discovery.spec.ts similarity index 100% rename from tests/e2e/bdd/leagues/leagues-discovery.spec.ts rename to tests/e2e/leagues/leagues-discovery.spec.ts diff --git a/tests/e2e/bdd/media/avatar.spec.ts b/tests/e2e/media/avatar.spec.ts similarity index 100% rename from tests/e2e/bdd/media/avatar.spec.ts rename to tests/e2e/media/avatar.spec.ts diff --git a/tests/e2e/bdd/media/categories.spec.ts b/tests/e2e/media/categories.spec.ts similarity index 100% rename from tests/e2e/bdd/media/categories.spec.ts rename to tests/e2e/media/categories.spec.ts diff --git a/tests/e2e/bdd/media/leagues.spec.ts b/tests/e2e/media/leagues.spec.ts similarity index 100% rename from tests/e2e/bdd/media/leagues.spec.ts rename to tests/e2e/media/leagues.spec.ts diff --git a/tests/e2e/bdd/media/sponsors.spec.ts b/tests/e2e/media/sponsors.spec.ts similarity index 100% rename from tests/e2e/bdd/media/sponsors.spec.ts rename to tests/e2e/media/sponsors.spec.ts diff --git a/tests/e2e/bdd/media/teams.spec.ts b/tests/e2e/media/teams.spec.ts similarity index 100% rename from tests/e2e/bdd/media/teams.spec.ts rename to tests/e2e/media/teams.spec.ts diff --git a/tests/e2e/bdd/media/tracks.spec.ts b/tests/e2e/media/tracks.spec.ts similarity index 100% rename from tests/e2e/bdd/media/tracks.spec.ts rename to tests/e2e/media/tracks.spec.ts diff --git a/tests/e2e/bdd/onboarding/README.md b/tests/e2e/onboarding/README.md similarity index 100% rename from tests/e2e/bdd/onboarding/README.md rename to tests/e2e/onboarding/README.md diff --git a/tests/e2e/bdd/onboarding/onboarding-avatar.spec.ts b/tests/e2e/onboarding/onboarding-avatar.spec.ts similarity index 100% rename from tests/e2e/bdd/onboarding/onboarding-avatar.spec.ts rename to tests/e2e/onboarding/onboarding-avatar.spec.ts diff --git a/tests/e2e/bdd/onboarding/onboarding-personal-info.spec.ts b/tests/e2e/onboarding/onboarding-personal-info.spec.ts similarity index 100% rename from tests/e2e/bdd/onboarding/onboarding-personal-info.spec.ts rename to tests/e2e/onboarding/onboarding-personal-info.spec.ts diff --git a/tests/e2e/bdd/onboarding/onboarding-validation.spec.ts b/tests/e2e/onboarding/onboarding-validation.spec.ts similarity index 100% rename from tests/e2e/bdd/onboarding/onboarding-validation.spec.ts rename to tests/e2e/onboarding/onboarding-validation.spec.ts diff --git a/tests/e2e/bdd/onboarding/onboarding-wizard.spec.ts b/tests/e2e/onboarding/onboarding-wizard.spec.ts similarity index 100% rename from tests/e2e/bdd/onboarding/onboarding-wizard.spec.ts rename to tests/e2e/onboarding/onboarding-wizard.spec.ts diff --git a/tests/e2e/bdd/profile/README.md b/tests/e2e/profile/README.md similarity index 100% rename from tests/e2e/bdd/profile/README.md rename to tests/e2e/profile/README.md diff --git a/tests/e2e/bdd/profile/profile-leagues.spec.ts b/tests/e2e/profile/profile-leagues.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-leagues.spec.ts rename to tests/e2e/profile/profile-leagues.spec.ts diff --git a/tests/e2e/bdd/profile/profile-liveries.spec.ts b/tests/e2e/profile/profile-liveries.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-liveries.spec.ts rename to tests/e2e/profile/profile-liveries.spec.ts diff --git a/tests/e2e/bdd/profile/profile-livery-upload.spec.ts b/tests/e2e/profile/profile-livery-upload.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-livery-upload.spec.ts rename to tests/e2e/profile/profile-livery-upload.spec.ts diff --git a/tests/e2e/bdd/profile/profile-main.spec.ts b/tests/e2e/profile/profile-main.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-main.spec.ts rename to tests/e2e/profile/profile-main.spec.ts diff --git a/tests/e2e/bdd/profile/profile-settings.spec.ts b/tests/e2e/profile/profile-settings.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-settings.spec.ts rename to tests/e2e/profile/profile-settings.spec.ts diff --git a/tests/e2e/bdd/profile/profile-sponsorship-requests.spec.ts b/tests/e2e/profile/profile-sponsorship-requests.spec.ts similarity index 100% rename from tests/e2e/bdd/profile/profile-sponsorship-requests.spec.ts rename to tests/e2e/profile/profile-sponsorship-requests.spec.ts diff --git a/tests/e2e/bdd/races/README.md b/tests/e2e/races/README.md similarity index 100% rename from tests/e2e/bdd/races/README.md rename to tests/e2e/races/README.md diff --git a/tests/e2e/bdd/races/race-detail.spec.ts b/tests/e2e/races/race-detail.spec.ts similarity index 100% rename from tests/e2e/bdd/races/race-detail.spec.ts rename to tests/e2e/races/race-detail.spec.ts diff --git a/tests/e2e/bdd/races/race-results.spec.ts b/tests/e2e/races/race-results.spec.ts similarity index 100% rename from tests/e2e/bdd/races/race-results.spec.ts rename to tests/e2e/races/race-results.spec.ts diff --git a/tests/e2e/bdd/races/race-stewarding.spec.ts b/tests/e2e/races/race-stewarding.spec.ts similarity index 100% rename from tests/e2e/bdd/races/race-stewarding.spec.ts rename to tests/e2e/races/race-stewarding.spec.ts diff --git a/tests/e2e/bdd/races/races-all.spec.ts b/tests/e2e/races/races-all.spec.ts similarity index 100% rename from tests/e2e/bdd/races/races-all.spec.ts rename to tests/e2e/races/races-all.spec.ts diff --git a/tests/e2e/bdd/races/races-main.spec.ts b/tests/e2e/races/races-main.spec.ts similarity index 100% rename from tests/e2e/bdd/races/races-main.spec.ts rename to tests/e2e/races/races-main.spec.ts diff --git a/tests/e2e/bdd/sponsor/README.md b/tests/e2e/sponsor/README.md similarity index 100% rename from tests/e2e/bdd/sponsor/README.md rename to tests/e2e/sponsor/README.md diff --git a/tests/e2e/bdd/sponsor/sponsor-billing.spec.ts b/tests/e2e/sponsor/sponsor-billing.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-billing.spec.ts rename to tests/e2e/sponsor/sponsor-billing.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-campaigns.spec.ts b/tests/e2e/sponsor/sponsor-campaigns.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-campaigns.spec.ts rename to tests/e2e/sponsor/sponsor-campaigns.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-dashboard.spec.ts b/tests/e2e/sponsor/sponsor-dashboard.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-dashboard.spec.ts rename to tests/e2e/sponsor/sponsor-dashboard.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-league-detail.spec.ts b/tests/e2e/sponsor/sponsor-league-detail.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-league-detail.spec.ts rename to tests/e2e/sponsor/sponsor-league-detail.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-leagues.spec.ts b/tests/e2e/sponsor/sponsor-leagues.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-leagues.spec.ts rename to tests/e2e/sponsor/sponsor-leagues.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-settings.spec.ts b/tests/e2e/sponsor/sponsor-settings.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-settings.spec.ts rename to tests/e2e/sponsor/sponsor-settings.spec.ts diff --git a/tests/e2e/bdd/sponsor/sponsor-signup.spec.ts b/tests/e2e/sponsor/sponsor-signup.spec.ts similarity index 100% rename from tests/e2e/bdd/sponsor/sponsor-signup.spec.ts rename to tests/e2e/sponsor/sponsor-signup.spec.ts diff --git a/tests/e2e/bdd/teams/create-team.spec.ts b/tests/e2e/teams/create-team.spec.ts similarity index 100% rename from tests/e2e/bdd/teams/create-team.spec.ts rename to tests/e2e/teams/create-team.spec.ts diff --git a/tests/e2e/bdd/teams/team-detail.spec.ts b/tests/e2e/teams/team-detail.spec.ts similarity index 100% rename from tests/e2e/bdd/teams/team-detail.spec.ts rename to tests/e2e/teams/team-detail.spec.ts diff --git a/tests/e2e/bdd/teams/team-leaderboard.spec.ts b/tests/e2e/teams/team-leaderboard.spec.ts similarity index 100% rename from tests/e2e/bdd/teams/team-leaderboard.spec.ts rename to tests/e2e/teams/team-leaderboard.spec.ts diff --git a/tests/e2e/bdd/teams/teams.spec.ts b/tests/e2e/teams/teams.spec.ts similarity index 100% rename from tests/e2e/bdd/teams/teams.spec.ts rename to tests/e2e/teams/teams.spec.ts diff --git a/tests/e2e/website/league-pages.e2e.test.ts b/tests/e2e/website/league-pages.e2e.test.ts deleted file mode 100644 index b762b7564..000000000 --- a/tests/e2e/website/league-pages.e2e.test.ts +++ /dev/null @@ -1,628 +0,0 @@ -import { test, expect, Browser, APIRequestContext } from '@playwright/test'; -import { WebsiteAuthManager, AuthContext } from '../../shared/website/WebsiteAuthManager'; -import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture'; -import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager'; - -/** - * E2E Tests for League Pages with Data Validation - * - * Tests cover: - * 1. /leagues (Discovery Page) - League cards, filters, quick actions - * 2. /leagues/[id] (Overview Page) - Stats, next race, season progress - * 3. /leagues/[id]/schedule (Schedule Page) - Race list, registration, admin controls - * 4. /leagues/[id]/standings (Standings Page) - Trend indicators, stats, team toggle - * 5. /leagues/[id]/roster (Roster Page) - Driver cards, admin actions - */ - -test.describe('League Pages - E2E with Data Validation', () => { - const routeManager = new WebsiteRouteManager(); - const leagueId = routeManager.resolvePathTemplate('/leagues/[id]', { id: WebsiteRouteManager.IDs.LEAGUE }); - - const CONSOLE_ALLOWLIST = [ - /Download the React DevTools/i, - /Next.js-specific warning/i, - /Failed to load resource: the server responded with a status of 404/i, - /Failed to load resource: the server responded with a status of 403/i, - /Failed to load resource: the server responded with a status of 401/i, - /Failed to load resource: the server responded with a status of 500/i, - /net::ERR_NAME_NOT_RESOLVED/i, - /net::ERR_CONNECTION_CLOSED/i, - /net::ERR_ACCESS_DENIED/i, - /Minified React error #418/i, - /Event/i, - /An error occurred in the Server Components render/i, - /Route Error Boundary/i, - ]; - - test.beforeEach(async ({ page }) => { - const allowedHosts = [ - new URL(process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000').host, - new URL(process.env.API_BASE_URL || 'http://api:3000').host, - ]; - - await page.route('**/*', (route) => { - const url = new URL(route.request().url()); - if (allowedHosts.includes(url.host) || url.protocol === 'data:') { - route.continue(); - } else { - route.abort('accessdenied'); - } - }); - }); - - test.describe('1. /leagues (Discovery Page)', () => { - test('Unauthenticated user can view league discovery page', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/leagues'); - - // Verify featured leagues section displays - await expect(page.getByTestId('featured-leagues-section')).toBeVisible(); - - // Verify league cards are present - const leagueCards = page.getByTestId('league-card'); - await expect(leagueCards.first()).toBeVisible(); - - // Verify league cards show correct metadata - const firstCard = leagueCards.first(); - await expect(firstCard.getByTestId('league-card-title')).toBeVisible(); - await expect(firstCard.getByTestId('league-card-next-race')).toBeVisible(); - await expect(firstCard.getByTestId('league-card-active-drivers')).toBeVisible(); - - // Verify category filters are present - await expect(page.getByTestId('category-filters')).toBeVisible(); - - // Verify Quick Join/Follow buttons are present - await expect(page.getByTestId('quick-join-button')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Authenticated user can view league discovery page', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/leagues'); - - // Verify featured leagues section displays - await expect(page.getByTestId('featured-leagues-section')).toBeVisible(); - - // Verify league cards are present - const leagueCards = page.getByTestId('league-card'); - await expect(leagueCards.first()).toBeVisible(); - - // Verify league cards show correct metadata - const firstCard = leagueCards.first(); - await expect(firstCard.getByTestId('league-card-title')).toBeVisible(); - await expect(firstCard.getByTestId('league-card-next-race')).toBeVisible(); - await expect(firstCard.getByTestId('league-card-active-drivers')).toBeVisible(); - - // Verify category filters are present - await expect(page.getByTestId('category-filters')).toBeVisible(); - - // Verify Quick Join/Follow buttons are present - await expect(page.getByTestId('quick-join-button')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Category filters work correctly', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 }); - - // Verify category filters are present - await expect(page.getByTestId('category-filters')).toBeVisible(); - - // Click on a category filter - const filterButton = page.getByTestId('category-filter-all'); - await filterButton.click(); - - // Wait for filter to apply - await page.waitForTimeout(1000); - - // Verify league cards are still visible after filtering - const leagueCards = page.getByTestId('league-card'); - await expect(leagueCards.first()).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); - - test.describe('2. /leagues/[id] (Overview Page)', () => { - test('Unauthenticated user can view league overview', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/leagues/'); - - // Verify league name is displayed - await expect(page.getByTestId('league-detail-title')).toBeVisible(); - - // Verify stats section displays - await expect(page.getByTestId('league-stats-section')).toBeVisible(); - - // Verify Next Race countdown displays correctly - await expect(page.getByTestId('next-race-countdown')).toBeVisible(); - - // Verify Season progress bar shows correct percentage - await expect(page.getByTestId('season-progress-bar')).toBeVisible(); - - // Verify Activity feed shows recent activity - await expect(page.getByTestId('activity-feed')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Authenticated user can view league overview', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/leagues/'); - - // Verify league name is displayed - await expect(page.getByTestId('league-detail-title')).toBeVisible(); - - // Verify stats section displays - await expect(page.getByTestId('league-stats-section')).toBeVisible(); - - // Verify Next Race countdown displays correctly - await expect(page.getByTestId('next-race-countdown')).toBeVisible(); - - // Verify Season progress bar shows correct percentage - await expect(page.getByTestId('season-progress-bar')).toBeVisible(); - - // Verify Activity feed shows recent activity - await expect(page.getByTestId('activity-feed')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Admin user can view admin widgets', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/leagues/'); - - // Verify admin widgets are visible for authorized users - await expect(page.getByTestId('admin-widgets')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Stats match API values', async ({ page, request }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Fetch API data - const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}`); - const apiData = await apiResponse.json(); - - // Navigate to league overview - await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 }); - - // Verify stats match API values - const membersStat = page.getByTestId('stat-members'); - const racesStat = page.getByTestId('stat-races'); - const avgSofStat = page.getByTestId('stat-avg-sof'); - - await expect(membersStat).toBeVisible(); - await expect(racesStat).toBeVisible(); - await expect(avgSofStat).toBeVisible(); - - // Verify the stats contain expected values from API - const membersText = await membersStat.textContent(); - const racesText = await racesStat.textContent(); - const avgSofText = await avgSofStat.textContent(); - - // Basic validation - stats should not be empty - expect(membersText).toBeTruthy(); - expect(racesText).toBeTruthy(); - expect(avgSofText).toBeTruthy(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); - - test.describe('3. /leagues/[id]/schedule (Schedule Page)', () => { - const schedulePath = routeManager.resolvePathTemplate('/leagues/[id]/schedule', { id: WebsiteRouteManager.IDs.LEAGUE }); - - test('Unauthenticated user can view schedule page', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/schedule'); - - // Verify races are grouped by month - await expect(page.getByTestId('schedule-month-group')).toBeVisible(); - - // Verify race list is present - const raceItems = page.getByTestId('race-item'); - await expect(raceItems.first()).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Authenticated user can view schedule page', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/schedule'); - - // Verify races are grouped by month - await expect(page.getByTestId('schedule-month-group')).toBeVisible(); - - // Verify race list is present - const raceItems = page.getByTestId('race-item'); - await expect(raceItems.first()).toBeVisible(); - - // Verify Register/Withdraw buttons are present - await expect(page.getByTestId('register-button')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Admin user can view admin controls', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/schedule'); - - // Verify admin controls are visible for authorized users - await expect(page.getByTestId('admin-controls')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Race detail modal shows correct data', async ({ page, request }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Fetch API data - const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/schedule`); - const apiData = await apiResponse.json(); - - // Navigate to schedule page - await page.goto(schedulePath, { waitUntil: 'commit', timeout: 15000 }); - - // Click on a race item to open modal - const raceItem = page.getByTestId('race-item').first(); - await raceItem.click(); - - // Verify modal is visible - await expect(page.getByTestId('race-detail-modal')).toBeVisible(); - - // Verify modal contains race data - const modalContent = page.getByTestId('race-detail-modal'); - await expect(modalContent.getByTestId('race-track')).toBeVisible(); - await expect(modalContent.getByTestId('race-car')).toBeVisible(); - await expect(modalContent.getByTestId('race-date')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); - - test.describe('4. /leagues/[id]/standings (Standings Page)', () => { - const standingsPath = routeManager.resolvePathTemplate('/leagues/[id]/standings', { id: WebsiteRouteManager.IDs.LEAGUE }); - - test('Unauthenticated user can view standings page', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/standings'); - - // Verify standings table is present - await expect(page.getByTestId('standings-table')).toBeVisible(); - - // Verify trend indicators display correctly - await expect(page.getByTestId('trend-indicator')).toBeVisible(); - - // Verify championship stats show correct data - await expect(page.getByTestId('championship-stats')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Authenticated user can view standings page', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/standings'); - - // Verify standings table is present - await expect(page.getByTestId('standings-table')).toBeVisible(); - - // Verify trend indicators display correctly - await expect(page.getByTestId('trend-indicator')).toBeVisible(); - - // Verify championship stats show correct data - await expect(page.getByTestId('championship-stats')).toBeVisible(); - - // Verify team standings toggle is present - await expect(page.getByTestId('team-standings-toggle')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Team standings toggle works correctly', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify team standings toggle is present - await expect(page.getByTestId('team-standings-toggle')).toBeVisible(); - - // Click on team standings toggle - const toggle = page.getByTestId('team-standings-toggle'); - await toggle.click(); - - // Wait for toggle to apply - await page.waitForTimeout(1000); - - // Verify team standings are visible - await expect(page.getByTestId('team-standings-table')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Drop weeks are marked correctly', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify drop weeks are marked - const dropWeeks = page.getByTestId('drop-week-marker'); - await expect(dropWeeks.first()).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Standings data matches API values', async ({ page, request }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Fetch API data - const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/standings`); - const apiData = await apiResponse.json(); - - // Navigate to standings page - await page.goto(standingsPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify standings table is present - await expect(page.getByTestId('standings-table')).toBeVisible(); - - // Verify table rows match API data - const tableRows = page.getByTestId('standings-row'); - const rowCount = await tableRows.count(); - - // Basic validation - should have at least one row - expect(rowCount).toBeGreaterThan(0); - - // Verify first row contains expected data - const firstRow = tableRows.first(); - await expect(firstRow.getByTestId('standing-position')).toBeVisible(); - await expect(firstRow.getByTestId('standing-driver')).toBeVisible(); - await expect(firstRow.getByTestId('standing-points')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); - - test.describe('5. /leagues/[id]/roster (Roster Page)', () => { - const rosterPath = routeManager.resolvePathTemplate('/leagues/[id]/roster', { id: WebsiteRouteManager.IDs.LEAGUE }); - - test('Unauthenticated user can view roster page', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/roster'); - - // Verify driver cards are present - const driverCards = page.getByTestId('driver-card'); - await expect(driverCards.first()).toBeVisible(); - - // Verify driver cards show correct stats - const firstCard = driverCards.first(); - await expect(firstCard.getByTestId('driver-card-name')).toBeVisible(); - await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Authenticated user can view roster page', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/roster'); - - // Verify driver cards are present - const driverCards = page.getByTestId('driver-card'); - await expect(driverCards.first()).toBeVisible(); - - // Verify driver cards show correct stats - const firstCard = driverCards.first(); - await expect(firstCard.getByTestId('driver-card-name')).toBeVisible(); - await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Admin user can view admin actions', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'admin'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify page loads successfully - expect(page.url()).toContain('/roster'); - - // Verify admin actions are visible for authorized users - await expect(page.getByTestId('admin-actions')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Roster data matches API values', async ({ page, request }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Fetch API data - const apiResponse = await request.get(`${process.env.API_BASE_URL || 'http://api:3000'}/leagues/${WebsiteRouteManager.IDs.LEAGUE}/memberships`); - const apiData = await apiResponse.json(); - - // Navigate to roster page - await page.goto(rosterPath, { waitUntil: 'commit', timeout: 15000 }); - - // Verify driver cards are present - const driverCards = page.getByTestId('driver-card'); - const cardCount = await driverCards.count(); - - // Basic validation - should have at least one driver - expect(cardCount).toBeGreaterThan(0); - - // Verify first card contains expected data - const firstCard = driverCards.first(); - await expect(firstCard.getByTestId('driver-card-name')).toBeVisible(); - await expect(firstCard.getByTestId('driver-card-stats')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); - - test.describe('6. Navigation Between League Pages', () => { - test('User can navigate from discovery to league overview', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Navigate to leagues discovery page - await page.goto('/leagues', { waitUntil: 'commit', timeout: 15000 }); - - // Click on a league card - const leagueCard = page.getByTestId('league-card').first(); - await leagueCard.click(); - - // Verify navigation to league overview - await page.waitForURL(/\/leagues\/[^/]+$/, { timeout: 15000 }); - expect(page.url()).toMatch(/\/leagues\/[^/]+$/); - - // Verify league overview content is visible - await expect(page.getByTestId('league-detail-title')).toBeVisible(); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('User can navigate between league sub-pages', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - // Navigate to league overview - await page.goto(leagueId, { waitUntil: 'commit', timeout: 15000 }); - - // Click on Schedule tab - const scheduleTab = page.getByTestId('schedule-tab'); - await scheduleTab.click(); - - // Verify navigation to schedule page - await page.waitForURL(/\/leagues\/[^/]+\/schedule$/, { timeout: 15000 }); - expect(page.url()).toMatch(/\/leagues\/[^/]+\/schedule$/); - - // Click on Standings tab - const standingsTab = page.getByTestId('standings-tab'); - await standingsTab.click(); - - // Verify navigation to standings page - await page.waitForURL(/\/leagues\/[^/]+\/standings$/, { timeout: 15000 }); - expect(page.url()).toMatch(/\/leagues\/[^/]+\/standings$/); - - // Click on Roster tab - const rosterTab = page.getByTestId('roster-tab'); - await rosterTab.click(); - - // Verify navigation to roster page - await page.waitForURL(/\/leagues\/[^/]+\/roster$/, { timeout: 15000 }); - expect(page.url()).toMatch(/\/leagues\/[^/]+\/roster$/); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - }); -}); diff --git a/tests/e2e/website/route-coverage.e2e.test.ts b/tests/e2e/website/route-coverage.e2e.test.ts deleted file mode 100644 index f0fa5d2f1..000000000 --- a/tests/e2e/website/route-coverage.e2e.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { test, expect, Browser, APIRequestContext } from '@playwright/test'; -import { getWebsiteRouteContracts, RouteContract, ScenarioRole } from '../../shared/website/RouteContractSpec'; -import { WebsiteAuthManager, AuthContext } from '../../shared/website/WebsiteAuthManager'; -import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture'; -import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager'; - -/** - * Optimized Route Coverage E2E - */ - -test.describe('Website Route Coverage & Failure Modes', () => { - const routeManager = new WebsiteRouteManager(); - const contracts = getWebsiteRouteContracts(); - - const CONSOLE_ALLOWLIST = [ - /Download the React DevTools/i, - /Next.js-specific warning/i, - /Failed to load resource: the server responded with a status of 404/i, - /Failed to load resource: the server responded with a status of 403/i, - /Failed to load resource: the server responded with a status of 401/i, - /Failed to load resource: the server responded with a status of 500/i, - /net::ERR_NAME_NOT_RESOLVED/i, - /net::ERR_CONNECTION_CLOSED/i, - /net::ERR_ACCESS_DENIED/i, - /Minified React error #418/i, - /Event/i, - /An error occurred in the Server Components render/i, - /Route Error Boundary/i, - ]; - - test.beforeEach(async ({ page }) => { - const allowedHosts = [ - new URL(process.env.PLAYWRIGHT_BASE_URL || 'http://website:3000').host, - new URL(process.env.API_BASE_URL || 'http://api:3000').host, - ]; - - await page.route('**/*', (route) => { - const url = new URL(route.request().url()); - if (allowedHosts.includes(url.host) || url.protocol === 'data:') { - route.continue(); - } else { - route.abort('accessdenied'); - } - }); - }); - - test('Unauthenticated Access (All Routes)', async ({ page }) => { - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - for (const contract of contracts) { - const response = await page.goto(contract.path, { timeout: 15000, waitUntil: 'commit' }).catch(() => null); - - if (contract.scenarios.unauth?.expectedStatus === 'redirect') { - const currentPath = new URL(page.url()).pathname; - if (currentPath !== 'blank') { - expect(currentPath.replace(/\/$/, '')).toBe(contract.scenarios.unauth?.expectedRedirectTo?.replace(/\/$/, '')); - } - } else if (contract.scenarios.unauth?.expectedStatus === 'ok') { - if (response?.status()) { - // 500 is allowed for the dedicated /500 error page itself - if (contract.path === '/500') { - expect(response.status()).toBe(500); - } else { - expect(response.status(), `Failed to load ${contract.path} as unauth`).toBeLessThan(500); - } - } - } - } - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - }); - - test('Public Navigation Presence (Unauthenticated)', async ({ page }) => { - await page.goto('/'); - - // Top nav should be visible - await expect(page.getByTestId('public-top-nav')).toBeVisible(); - - // Login/Signup actions should be visible - await expect(page.getByTestId('public-nav-login')).toBeVisible(); - await expect(page.getByTestId('public-nav-signup')).toBeVisible(); - - // Navigation links should be present in the top nav - const topNav = page.getByTestId('public-top-nav'); - await expect(topNav.locator('a[href="/leagues"]')).toBeVisible(); - await expect(topNav.locator('a[href="/races"]')).toBeVisible(); - }); - - test('Role-Based Access (Auth, Admin & Sponsor)', async ({ browser, request }) => { - const roles: ScenarioRole[] = ['auth', 'admin', 'sponsor']; - - for (const role of roles) { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, role as any); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - for (const contract of contracts) { - const scenario = contract.scenarios[role]; - if (!scenario) continue; - - const response = await page.goto(contract.path, { timeout: 15000, waitUntil: 'commit' }).catch(() => null); - - if (scenario.expectedStatus === 'redirect') { - const currentPath = new URL(page.url()).pathname; - if (currentPath !== 'blank') { - expect(currentPath.replace(/\/$/, '')).toBe(scenario.expectedRedirectTo?.replace(/\/$/, '')); - } - } else if (scenario.expectedStatus === 'ok') { - // If it's 500, it might be a known issue we're tracking via console errors - // but we don't want to fail the whole loop here if we want to see all errors - if (response?.status() && response.status() >= 500) { - console.error(`[Role Access] ${role} got ${response.status()} on ${contract.path}`); - } - } - } - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - await context.close(); - } - }); - - test('Client-side Navigation Smoke', async ({ browser, request }) => { - const { context, page } = await WebsiteAuthManager.createAuthContext(browser, request, 'auth'); - const capture = new ConsoleErrorCapture(page); - capture.setAllowlist(CONSOLE_ALLOWLIST); - - try { - // Start at dashboard - await page.goto('/dashboard', { waitUntil: 'commit', timeout: 15000 }); - expect(page.url()).toContain('/dashboard'); - - // Click on Leagues in sidebar - const leaguesLink = page.locator('a[href="/leagues"]').first(); - await leaguesLink.click(); - - // Assert URL change - await page.waitForURL(/\/leagues/, { timeout: 15000 }); - expect(page.url()).toContain('/leagues'); - - expect(capture.getUnexpectedErrors(), capture.format()).toHaveLength(0); - } finally { - await context.close(); - } - }); - - test('Failure Modes', async ({ page, browser, request }) => { - // 1. Invalid IDs - const edgeCases = routeManager.getParamEdgeCases(); - for (const edge of edgeCases) { - const path = routeManager.resolvePathTemplate(edge.pathTemplate, edge.params); - const response = await page.goto(path).catch(() => null); - if (response?.status()) expect(response.status()).toBe(404); - } - - // 2. Session Drift - const driftRoutes = routeManager.getAuthDriftRoutes(); - const { context: dContext, page: dPage } = await WebsiteAuthManager.createAuthContext(browser, request, 'sponsor'); - await dContext.clearCookies(); - await dPage.goto(routeManager.resolvePathTemplate(driftRoutes[0].pathTemplate)).catch(() => null); - try { - await dPage.waitForURL(url => url.pathname === '/auth/login', { timeout: 5000 }); - expect(dPage.url()).toContain('/auth/login'); - } catch (e) { - // ignore if it didn't redirect fast enough in this environment - } - await dContext.close(); - - // 3. API 5xx - const target = routeManager.getFaultInjectionRoutes()[0]; - await page.route('**/api/**', async (route) => { - await route.fulfill({ status: 500, body: JSON.stringify({ message: 'Error' }) }); - }); - await page.goto(routeManager.resolvePathTemplate(target.pathTemplate, target.params)).catch(() => null); - const content = await page.content(); - // Relaxed check for error indicators - const hasError = ['error', '500', 'failed', 'wrong'].some(i => content.toLowerCase().includes(i)); - if (!hasError) console.warn(`[API 5xx] Page did not show obvious error indicator for ${target.pathTemplate}`); - }); -}); diff --git a/tests/integration/harness/ApiServerHarness.ts b/tests/integration/harness/ApiServerHarness.ts deleted file mode 100644 index 55b1ff611..000000000 --- a/tests/integration/harness/ApiServerHarness.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { spawn, ChildProcess } from 'child_process'; -import { join } from 'path'; - -export interface ApiServerHarnessOptions { - port?: number; - env?: Record; -} - -export class ApiServerHarness { - private process: ChildProcess | null = null; - private logs: string[] = []; - private port: number; - - constructor(options: ApiServerHarnessOptions = {}) { - this.port = options.port || 3001; - } - - async start(): Promise { - return new Promise((resolve, reject) => { - const cwd = join(process.cwd(), 'apps/api'); - - this.process = spawn('npm', ['run', 'start:dev'], { - cwd, - env: { - ...process.env, - PORT: this.port.toString(), - GRIDPILOT_API_PERSISTENCE: 'inmemory', - ENABLE_BOOTSTRAP: 'true', - }, - shell: true, - detached: true, - }); - - let resolved = false; - - const checkReadiness = async () => { - if (resolved) return; - try { - const res = await fetch(`http://localhost:${this.port}/health`); - if (res.ok) { - resolved = true; - resolve(); - } - } catch (e) { - // Not ready yet - } - }; - - this.process.stdout?.on('data', (data) => { - const str = data.toString(); - this.logs.push(str); - if (str.includes('Nest application successfully started') || str.includes('started')) { - checkReadiness(); - } - }); - - this.process.stderr?.on('data', (data) => { - const str = data.toString(); - this.logs.push(str); - }); - - this.process.on('error', (err) => { - if (!resolved) { - resolved = true; - reject(err); - } - }); - - this.process.on('exit', (code) => { - if (!resolved && code !== 0 && code !== null) { - resolved = true; - reject(new Error(`API server exited with code ${code}`)); - } - }); - - // Timeout after 60 seconds - setTimeout(() => { - if (!resolved) { - resolved = true; - reject(new Error(`API server failed to start within 60s. Logs:\n${this.getLogTail(20)}`)); - } - }, 60000); - }); - } - - async stop(): Promise { - if (this.process && this.process.pid) { - try { - process.kill(-this.process.pid); - } catch (e) { - this.process.kill(); - } - this.process = null; - } - } - - getLogTail(lines: number = 60): string { - return this.logs.slice(-lines).join(''); - } -} diff --git a/tests/integration/harness/HarnessTestContext.ts b/tests/integration/harness/HarnessTestContext.ts deleted file mode 100644 index 5095e30e6..000000000 --- a/tests/integration/harness/HarnessTestContext.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; -import { IntegrationTestHarness, createTestHarness } from './index'; -import { ApiClient } from './api-client'; -import { DatabaseManager } from './database-manager'; -import { DataFactory } from './data-factory'; - -/** - * Shared test context for harness-related integration tests. - * Provides a DRY setup for tests that verify the harness infrastructure itself. - */ -export class HarnessTestContext { - private harness: IntegrationTestHarness; - - constructor() { - this.harness = createTestHarness(); - } - - get api(): ApiClient { - return this.harness.getApi(); - } - - get db(): DatabaseManager { - return this.harness.getDatabase(); - } - - get factory(): DataFactory { - return this.harness.getFactory(); - } - - get testHarness(): IntegrationTestHarness { - return this.harness; - } - - /** - * Standard setup for harness tests - */ - async setup() { - await this.harness.beforeAll(); - } - - /** - * Standard teardown for harness tests - */ - async teardown() { - await this.harness.afterAll(); - } - - /** - * Standard per-test setup - */ - async reset() { - await this.harness.beforeEach(); - } -} - -/** - * Helper to create and register a HarnessTestContext with Vitest hooks - */ -export function setupHarnessTest() { - const context = new HarnessTestContext(); - - beforeAll(async () => { - await context.setup(); - }); - - afterAll(async () => { - await context.teardown(); - }); - - beforeEach(async () => { - await context.reset(); - }); - - return context; -} diff --git a/tests/integration/harness/WebsiteServerHarness.ts b/tests/integration/harness/WebsiteServerHarness.ts deleted file mode 100644 index d6c5a3ff6..000000000 --- a/tests/integration/harness/WebsiteServerHarness.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { spawn, ChildProcess } from 'child_process'; -import { join } from 'path'; - -export interface WebsiteServerHarnessOptions { - port?: number; - env?: Record; - cwd?: string; -} - -export class WebsiteServerHarness { - private process: ChildProcess | null = null; - private logs: string[] = []; - private port: number; - private options: WebsiteServerHarnessOptions; - - constructor(options: WebsiteServerHarnessOptions = {}) { - this.options = options; - this.port = options.port || 3000; - } - - async start(): Promise { - return new Promise((resolve, reject) => { - const cwd = join(process.cwd(), 'apps/website'); - - // Use 'npm run dev' or 'npm run start' depending on environment - // For integration tests, 'dev' is often easier if we don't want to build first, - // but 'start' is more realistic for SSR. - // Assuming 'npm run dev' for now as it's faster for local tests. - this.process = spawn('npm', ['run', 'dev', '--', '-p', this.port.toString()], { - cwd, - env: { - ...process.env, - ...this.options.env, - PORT: this.port.toString(), - }, - shell: true, - detached: true, // Start in a new process group - }); - - let resolved = false; - - const checkReadiness = async () => { - if (resolved) return; - try { - const res = await fetch(`http://localhost:${this.port}`, { method: 'HEAD' }); - if (res.ok || res.status === 307 || res.status === 200) { - resolved = true; - resolve(); - } - } catch (e) { - // Not ready yet - } - }; - - this.process.stdout?.on('data', (data) => { - const str = data.toString(); - this.logs.push(str); - if (str.includes('ready') || str.includes('started') || str.includes('Local:')) { - checkReadiness(); - } - }); - - this.process.stderr?.on('data', (data) => { - const str = data.toString(); - this.logs.push(str); - // Don't console.error here as it might be noisy, but keep in logs - }); - - this.process.on('error', (err) => { - if (!resolved) { - resolved = true; - reject(err); - } - }); - - this.process.on('exit', (code) => { - if (!resolved && code !== 0 && code !== null) { - resolved = true; - reject(new Error(`Website server exited with code ${code}`)); - } - }); - - // Timeout after 60 seconds (Next.js dev can be slow) - setTimeout(() => { - if (!resolved) { - resolved = true; - reject(new Error(`Website server failed to start within 60s. Logs:\n${this.getLogTail(20)}`)); - } - }, 60000); - }); - } - - async stop(): Promise { - if (this.process && this.process.pid) { - try { - // Kill the process group since we used detached: true - process.kill(-this.process.pid); - } catch (e) { - // Fallback to normal kill - this.process.kill(); - } - this.process = null; - } - } - - getLogs(): string[] { - return this.logs; - } - - getLogTail(lines: number = 60): string { - return this.logs.slice(-lines).join(''); - } - - hasErrorPatterns(): boolean { - const errorPatterns = [ - 'uncaughtException', - 'unhandledRejection', - // 'Error: ', // Too broad, catches expected API errors - ]; - - // Only fail on actual process-level errors or unexpected server crashes - return this.logs.some(log => errorPatterns.some(pattern => log.includes(pattern))); - } -} diff --git a/tests/integration/harness/api-client.ts b/tests/integration/harness/api-client.ts deleted file mode 100644 index 7a688f8fa..000000000 --- a/tests/integration/harness/api-client.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * API Client for Integration Tests - * Provides typed HTTP client for testing API endpoints - */ - -export interface ApiClientConfig { - baseUrl: string; - timeout?: number; -} - -export class ApiClient { - private baseUrl: string; - private timeout: number; - - constructor(config: ApiClientConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash - this.timeout = config.timeout || 30000; - } - - /** - * Make HTTP request to API - */ - private async request(method: string, path: string, body?: unknown, headers: Record = {}): Promise { - const url = `${this.baseUrl}${path}`; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.timeout); - - try { - const response = await fetch(url, { - method, - headers: { - 'Content-Type': 'application/json', - ...headers, - }, - body: body ? JSON.stringify(body) : undefined, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`API Error ${response.status}: ${errorText}`); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return (await response.json()) as T; - } - - return (await response.text()) as unknown as T; - } catch (error) { - clearTimeout(timeoutId); - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${this.timeout}ms`); - } - throw error; - } - } - - // GET requests - async get(path: string, headers?: Record): Promise { - return this.request('GET', path, undefined, headers); - } - - // POST requests - async post(path: string, body: unknown, headers?: Record): Promise { - return this.request('POST', path, body, headers); - } - - // PUT requests - async put(path: string, body: unknown, headers?: Record): Promise { - return this.request('PUT', path, body, headers); - } - - // PATCH requests - async patch(path: string, body: unknown, headers?: Record): Promise { - return this.request('PATCH', path, body, headers); - } - - // DELETE requests - async delete(path: string, headers?: Record): Promise { - return this.request('DELETE', path, undefined, headers); - } - - /** - * Health check - */ - async health(): Promise { - try { - const response = await fetch(`${this.baseUrl}/health`); - return response.ok; - } catch { - return false; - } - } - - /** - * Wait for API to be ready - */ - async waitForReady(timeout: number = 60000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - if (await this.health()) { - return; - } - await new Promise(resolve => setTimeout(resolve, 1000)); - } - - throw new Error(`API failed to become ready within ${timeout}ms`); - } -} \ No newline at end of file diff --git a/tests/integration/harness/data-factory.ts b/tests/integration/harness/data-factory.ts deleted file mode 100644 index 6b5363ec9..000000000 --- a/tests/integration/harness/data-factory.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * Data Factory for Integration Tests - * Uses TypeORM repositories to create test data - */ - -import { DataSource } from 'typeorm'; -import { LeagueOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/LeagueOrmEntity'; -import { SeasonOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/SeasonOrmEntity'; -import { DriverOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/DriverOrmEntity'; -import { RaceOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/RaceOrmEntity'; -import { ResultOrmEntity } from '../../../adapters/racing/persistence/typeorm/entities/ResultOrmEntity'; -import { LeagueOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper'; -import { SeasonOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper'; -import { RaceOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/RaceOrmMapper'; -import { ResultOrmMapper } from '../../../adapters/racing/persistence/typeorm/mappers/ResultOrmMapper'; -import { TypeOrmLeagueRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository'; -import { TypeOrmSeasonRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository'; -import { TypeOrmRaceRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository'; -import { TypeOrmResultRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmResultRepository'; -import { TypeOrmDriverRepository } from '../../../adapters/racing/persistence/typeorm/repositories/TypeOrmDriverRepository'; -import { League } from '../../../core/racing/domain/entities/League'; -import { Season } from '../../../core/racing/domain/entities/season/Season'; -import { Driver } from '../../../core/racing/domain/entities/Driver'; -import { Race } from '../../../core/racing/domain/entities/Race'; -import { Result } from '../../../core/racing/domain/entities/result/Result'; -import { v4 as uuidv4 } from 'uuid'; - -export class DataFactory { - private dataSource: DataSource; - private leagueRepo: TypeOrmLeagueRepository; - private seasonRepo: TypeOrmSeasonRepository; - private driverRepo: TypeOrmDriverRepository; - private raceRepo: TypeOrmRaceRepository; - private resultRepo: TypeOrmResultRepository; - - constructor(private dbUrl: string) { - this.dataSource = new DataSource({ - type: 'postgres', - url: dbUrl, - entities: [ - LeagueOrmEntity, - SeasonOrmEntity, - DriverOrmEntity, - RaceOrmEntity, - ResultOrmEntity, - ], - synchronize: false, // Don't sync, use existing schema - }); - } - - async initialize(): Promise { - if (!this.dataSource.isInitialized) { - await this.dataSource.initialize(); - } - - const leagueMapper = new LeagueOrmMapper(); - const seasonMapper = new SeasonOrmMapper(); - const raceMapper = new RaceOrmMapper(); - const resultMapper = new ResultOrmMapper(); - - this.leagueRepo = new TypeOrmLeagueRepository(this.dataSource, leagueMapper); - this.seasonRepo = new TypeOrmSeasonRepository(this.dataSource, seasonMapper); - this.driverRepo = new TypeOrmDriverRepository(this.dataSource, leagueMapper); // Reuse mapper - this.raceRepo = new TypeOrmRaceRepository(this.dataSource, raceMapper); - this.resultRepo = new TypeOrmResultRepository(this.dataSource, resultMapper); - } - - async cleanup(): Promise { - if (this.dataSource.isInitialized) { - await this.dataSource.destroy(); - } - } - - /** - * Create a test league - */ - async createLeague(overrides: Partial<{ - id: string; - name: string; - description: string; - ownerId: string; - }> = {}) { - const league = League.create({ - id: overrides.id || uuidv4(), - name: overrides.name || 'Test League', - description: overrides.description || 'Integration Test League', - ownerId: overrides.ownerId || uuidv4(), - settings: { - enableDriverChampionship: true, - enableTeamChampionship: false, - enableNationsChampionship: false, - enableTrophyChampionship: false, - visibility: 'unranked', - maxDrivers: 32, - }, - participantCount: 0, - }); - - await this.leagueRepo.create(league); - return league; - } - - /** - * Create a test season - */ - async createSeason(leagueId: string, overrides: Partial<{ - id: string; - name: string; - year: number; - status: string; - }> = {}) { - const season = Season.create({ - id: overrides.id || uuidv4(), - leagueId, - gameId: 'iracing', - name: overrides.name || 'Test Season', - year: overrides.year || 2024, - order: 1, - status: overrides.status || 'active', - startDate: new Date(), - endDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - schedulePublished: false, - }); - - await this.seasonRepo.create(season); - return season; - } - - /** - * Create a test driver - */ - async createDriver(overrides: Partial<{ - id: string; - name: string; - iracingId: string; - country: string; - }> = {}) { - const driver = Driver.create({ - id: overrides.id || uuidv4(), - iracingId: overrides.iracingId || `iracing-${uuidv4()}`, - name: overrides.name || 'Test Driver', - country: overrides.country || 'US', - }); - - // Need to insert directly since driver repo might not exist or be different - await this.dataSource.getRepository(DriverOrmEntity).save({ - id: driver.id.toString(), - iracingId: driver.iracingId, - name: driver.name.toString(), - country: driver.country, - joinedAt: new Date(), - bio: null, - category: null, - avatarRef: null, - }); - - return driver; - } - - /** - * Create a test race - */ - async createRace(overrides: Partial<{ - id: string; - leagueId: string; - scheduledAt: Date; - status: string; - track: string; - car: string; - }> = {}) { - const race = Race.create({ - id: overrides.id || uuidv4(), - leagueId: overrides.leagueId || uuidv4(), - scheduledAt: overrides.scheduledAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - track: overrides.track || 'Laguna Seca', - car: overrides.car || 'Formula Ford', - status: overrides.status || 'scheduled', - }); - - await this.raceRepo.create(race); - return race; - } - - /** - * Create a test result - */ - async createResult(raceId: string, driverId: string, overrides: Partial<{ - id: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; - }> = {}) { - const result = Result.create({ - id: overrides.id || uuidv4(), - raceId, - driverId, - position: overrides.position || 1, - fastestLap: overrides.fastestLap || 0, - incidents: overrides.incidents || 0, - startPosition: overrides.startPosition || 1, - }); - - await this.resultRepo.create(result); - return result; - } - - /** - * Create complete test scenario: league, season, drivers, races - */ - async createTestScenario() { - const league = await this.createLeague(); - const season = await this.createSeason(league.id.toString()); - const drivers = await Promise.all([ - this.createDriver({ name: 'Driver 1' }), - this.createDriver({ name: 'Driver 2' }), - this.createDriver({ name: 'Driver 3' }), - ]); - const races = await Promise.all([ - this.createRace({ - leagueId: league.id.toString(), - name: 'Race 1', - scheduledAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - }), - this.createRace({ - leagueId: league.id.toString(), - name: 'Race 2', - scheduledAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) - }), - ]); - - return { league, season, drivers, races }; - } - - /** - * Clean up specific entities - */ - async deleteEntities(entities: { id: string | number }[], entityType: string) { - const repository = this.dataSource.getRepository(entityType); - for (const entity of entities) { - await repository.delete(entity.id); - } - } -} \ No newline at end of file diff --git a/tests/integration/harness/database-manager.ts b/tests/integration/harness/database-manager.ts deleted file mode 100644 index b217930d0..000000000 --- a/tests/integration/harness/database-manager.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * Database Manager for Integration Tests - * Handles database connections, migrations, seeding, and cleanup - */ - -import { Pool, PoolClient, QueryResult } from 'pg'; -import { setTimeout } from 'timers/promises'; - -export interface DatabaseConfig { - host: string; - port: number; - database: string; - user: string; - password: string; -} - -export class DatabaseManager { - private pool: Pool; - private client: PoolClient | null = null; - - constructor(config: DatabaseConfig) { - this.pool = new Pool({ - host: config.host, - port: config.port, - database: config.database, - user: config.user, - password: config.password, - max: 1, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 10000, - }); - } - - /** - * Wait for database to be ready - */ - async waitForReady(timeout: number = 30000): Promise { - const startTime = Date.now(); - - while (Date.now() - startTime < timeout) { - try { - const client = await this.pool.connect(); - await client.query('SELECT 1'); - client.release(); - console.log('[DatabaseManager] ✓ Database is ready'); - return; - } catch (error) { - await setTimeout(1000); - } - } - - throw new Error('Database failed to become ready'); - } - - /** - * Get a client for transactions - */ - async getClient(): Promise { - if (!this.client) { - this.client = await this.pool.connect(); - } - return this.client; - } - - /** - * Execute query with automatic client management - */ - async query(text: string, params?: unknown[]): Promise { - const client = await this.getClient(); - return client.query(text, params); - } - - /** - * Begin transaction - */ - async begin(): Promise { - const client = await this.getClient(); - await client.query('BEGIN'); - } - - /** - * Commit transaction - */ - async commit(): Promise { - if (this.client) { - await this.client.query('COMMIT'); - } - } - - /** - * Rollback transaction - */ - async rollback(): Promise { - if (this.client) { - await this.client.query('ROLLBACK'); - } - } - - /** - * Truncate all tables (for cleanup between tests) - */ - async truncateAllTables(): Promise { - const client = await this.getClient(); - - // Get all table names - const result = await client.query(` - SELECT tablename - FROM pg_tables - WHERE schemaname = 'public' - AND tablename NOT LIKE 'pg_%' - AND tablename NOT LIKE 'sql_%' - `); - - if (result.rows.length === 0) return; - - // Disable triggers temporarily to allow truncation - await client.query('SET session_replication_role = replica'); - - const tableNames = result.rows.map(r => r.tablename).join(', '); - try { - await client.query(`TRUNCATE TABLE ${tableNames} CASCADE`); - console.log(`[DatabaseManager] ✓ Truncated tables: ${tableNames}`); - } finally { - await client.query('SET session_replication_role = DEFAULT'); - } - } - - /** - * Run database migrations - */ - async runMigrations(): Promise { - // This would typically run TypeORM migrations - // For now, we'll assume the API handles this on startup - console.log('[DatabaseManager] Migrations handled by API startup'); - } - - /** - * Seed minimal test data - */ - async seedMinimalData(): Promise { - // Insert minimal required data for tests - // This will be extended based on test requirements - - console.log('[DatabaseManager] ✓ Minimal test data seeded'); - } - - /** - * Check for constraint violations in recent operations - */ - async getRecentConstraintErrors(since: Date): Promise { - const client = await this.getClient(); - - const result = await client.query(` - SELECT - sqlstate, - message, - detail, - constraint_name - FROM pg_last_error_log() - WHERE sqlstate IN ('23505', '23503', '23514') - AND log_time > $1 - ORDER BY log_time DESC - `, [since]); - - return (result.rows as { message: string }[]).map(r => r.message); - } - - /** - * Get table constraints - */ - async getTableConstraints(tableName: string): Promise { - const client = await this.getClient(); - - const result = await client.query(` - SELECT - conname as constraint_name, - contype as constraint_type, - pg_get_constraintdef(oid) as definition - FROM pg_constraint - WHERE conrelid = $1::regclass - ORDER BY contype - `, [tableName]); - - return result.rows; - } - - /** - * Close connection pool - */ - async close(): Promise { - if (this.client) { - this.client.release(); - this.client = null; - } - await this.pool.end(); - } -} \ No newline at end of file diff --git a/tests/integration/harness/docker-manager.ts b/tests/integration/harness/docker-manager.ts deleted file mode 100644 index 013d880eb..000000000 --- a/tests/integration/harness/docker-manager.ts +++ /dev/null @@ -1,189 +0,0 @@ -/** - * Docker Manager for Integration Tests - * Manages Docker Compose services for integration testing - */ - -import { execSync, spawn } from 'child_process'; -import { setTimeout } from 'timers/promises'; - -export interface DockerServiceConfig { - name: string; - port: number; - healthCheck: string; - timeout?: number; -} - -export class DockerManager { - private static instance: DockerManager; - private services: Map = new Map(); - private composeProject = 'gridpilot-test'; - private composeFile = 'docker-compose.test.yml'; - - private constructor() {} - - static getInstance(): DockerManager { - if (!DockerManager.instance) { - DockerManager.instance = new DockerManager(); - } - return DockerManager.instance; - } - - /** - * Check if Docker services are already running - */ - isRunning(): boolean { - try { - const output = execSync( - `docker-compose -p ${this.composeProject} -f ${this.composeFile} ps -q 2>/dev/null || true`, - { encoding: 'utf8' } - ).trim(); - return output.length > 0; - } catch { - return false; - } - } - - /** - * Start Docker services with dependency checking - */ - async start(): Promise { - console.log('[DockerManager] Starting test environment...'); - - if (this.isRunning()) { - console.log('[DockerManager] Services already running, checking health...'); - await this.waitForServices(); - return; - } - - // Start services - execSync( - `COMPOSE_PARALLEL_LIMIT=1 docker-compose -p ${this.composeProject} -f ${this.composeFile} up -d ready api`, - { stdio: 'inherit' } - ); - - console.log('[DockerManager] Services starting, waiting for health...'); - await this.waitForServices(); - } - - /** - * Wait for all services to be healthy using polling - */ - async waitForServices(): Promise { - const services: DockerServiceConfig[] = [ - { - name: 'db', - port: 5433, - healthCheck: 'pg_isready -U gridpilot_test_user -d gridpilot_test', - timeout: 60000 - }, - { - name: 'api', - port: 3101, - healthCheck: 'curl -f http://localhost:3101/health', - timeout: 90000 - } - ]; - - for (const service of services) { - await this.waitForService(service); - } - } - - /** - * Wait for a single service to be healthy - */ - async waitForService(config: DockerServiceConfig): Promise { - const timeout = config.timeout || 30000; - const startTime = Date.now(); - - console.log(`[DockerManager] Waiting for ${config.name}...`); - - while (Date.now() - startTime < timeout) { - try { - // Try health check command - if (config.name === 'db') { - // For DB, check if it's ready to accept connections - try { - execSync( - `docker exec ${this.composeProject}-${config.name}-1 ${config.healthCheck} 2>/dev/null`, - { stdio: 'pipe' } - ); - console.log(`[DockerManager] ✓ ${config.name} is healthy`); - return; - } catch {} - } else { - // For API, check HTTP endpoint - const response = await fetch(`http://localhost:${config.port}/health`); - if (response.ok) { - console.log(`[DockerManager] ✓ ${config.name} is healthy`); - return; - } - } - } catch (error) { - // Service not ready yet, continue waiting - } - - await setTimeout(1000); - } - - throw new Error(`[DockerManager] ${config.name} failed to become healthy within ${timeout}ms`); - } - - /** - * Stop Docker services - */ - stop(): void { - console.log('[DockerManager] Stopping test environment...'); - try { - execSync( - `docker-compose -p ${this.composeProject} -f ${this.composeFile} down --remove-orphans`, - { stdio: 'inherit' } - ); - } catch (error) { - console.warn('[DockerManager] Warning: Failed to stop services cleanly:', error); - } - } - - /** - * Clean up volumes and containers - */ - clean(): void { - console.log('[DockerManager] Cleaning up test environment...'); - try { - execSync( - `docker-compose -p ${this.composeProject} -f ${this.composeFile} down -v --remove-orphans --volumes`, - { stdio: 'inherit' } - ); - } catch (error) { - console.warn('[DockerManager] Warning: Failed to clean up cleanly:', error); - } - } - - /** - * Execute a command in a service container - */ - execInService(service: string, command: string): string { - try { - return execSync( - `docker exec ${this.composeProject}-${service}-1 ${command}`, - { encoding: 'utf8' } - ); - } catch (error) { - throw new Error(`Failed to execute command in ${service}: ${error}`); - } - } - - /** - * Get service logs - */ - getLogs(service: string): string { - try { - return execSync( - `docker logs ${this.composeProject}-${service}-1 --tail 100`, - { encoding: 'utf8' } - ); - } catch (error) { - return `Failed to get logs: ${error}`; - } - } -} \ No newline at end of file diff --git a/tests/integration/harness/index.ts b/tests/integration/harness/index.ts deleted file mode 100644 index b5b641ba8..000000000 --- a/tests/integration/harness/index.ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Integration Test Harness - Main Entry Point - * Provides reusable setup, teardown, and utilities for integration tests - */ - -import { DockerManager } from './docker-manager'; -import { DatabaseManager } from './database-manager'; -import { ApiClient } from './api-client'; -import { DataFactory } from './data-factory'; - -export interface IntegrationTestConfig { - api: { - baseUrl: string; - port: number; - }; - database: { - host: string; - port: number; - database: string; - user: string; - password: string; - }; - timeouts?: { - setup?: number; - teardown?: number; - test?: number; - }; -} - -export class IntegrationTestHarness { - private docker: DockerManager; - private database: DatabaseManager; - private api: ApiClient; - private factory: DataFactory; - private config: IntegrationTestConfig; - - constructor(config: IntegrationTestConfig) { - this.config = { - timeouts: { - setup: 120000, - teardown: 30000, - test: 60000, - ...config.timeouts, - }, - ...config, - }; - - this.docker = DockerManager.getInstance(); - this.database = new DatabaseManager(config.database); - this.api = new ApiClient({ baseUrl: config.api.baseUrl, timeout: 60000 }); - - const { host, port, database, user, password } = config.database; - const dbUrl = `postgresql://${user}:${password}@${host}:${port}/${database}`; - this.factory = new DataFactory(dbUrl); - } - - /** - * Setup hook - starts Docker services and prepares database - * Called once before all tests in a suite - */ - async beforeAll(): Promise { - console.log('[Harness] Starting integration test setup...'); - - // Start Docker services - await this.docker.start(); - - // Wait for database to be ready - await this.database.waitForReady(this.config.timeouts?.setup); - - // Wait for API to be ready - await this.api.waitForReady(this.config.timeouts?.setup); - - console.log('[Harness] ✓ Setup complete - all services ready'); - } - - /** - * Teardown hook - stops Docker services and cleans up - * Called once after all tests in a suite - */ - async afterAll(): Promise { - console.log('[Harness] Starting integration test teardown...'); - - try { - await this.database.close(); - this.docker.stop(); - console.log('[Harness] ✓ Teardown complete'); - } catch (error) { - console.warn('[Harness] Teardown warning:', error); - } - } - - /** - * Setup hook - prepares database for each test - * Called before each test - */ - async beforeEach(): Promise { - // Truncate all tables to ensure clean state - await this.database.truncateAllTables(); - - // Optionally seed minimal required data - // await this.database.seedMinimalData(); - } - - /** - * Teardown hook - cleanup after each test - * Called after each test - */ - async afterEach(): Promise { - // Clean up any test-specific resources - // This can be extended by individual tests - } - - /** - * Get database manager - */ - getDatabase(): DatabaseManager { - return this.database; - } - - /** - * Get API client - */ - getApi(): ApiClient { - return this.api; - } - - /** - * Get Docker manager - */ - getDocker(): DockerManager { - return this.docker; - } - - /** - * Get data factory - */ - getFactory(): DataFactory { - return this.factory; - } - - /** - * Execute database transaction with automatic rollback - * Useful for tests that need to verify transaction behavior - */ - async withTransaction(callback: (db: DatabaseManager) => Promise): Promise { - await this.database.begin(); - try { - const result = await callback(this.database); - await this.database.rollback(); // Always rollback in tests - return result; - } catch (error) { - await this.database.rollback(); - throw error; - } - } - - /** - * Helper to verify constraint violations - */ - async expectConstraintViolation( - operation: () => Promise, - expectedConstraint?: string - ): Promise { - try { - await operation(); - throw new Error('Expected constraint violation but operation succeeded'); - } catch (error) { - // Check if it's a constraint violation - const message = error instanceof Error ? error.message : String(error); - const isConstraintError = - message.includes('constraint') || - message.includes('23505') || // Unique violation - message.includes('23503') || // Foreign key violation - message.includes('23514'); // Check violation - - if (!isConstraintError) { - throw new Error(`Expected constraint violation but got: ${message}`); - } - - if (expectedConstraint && !message.includes(expectedConstraint)) { - throw new Error(`Expected constraint '${expectedConstraint}' but got: ${message}`); - } - } - } -} - -// Default configuration for docker-compose.test.yml -export const DEFAULT_TEST_CONFIG: IntegrationTestConfig = { - api: { - baseUrl: 'http://localhost:3101', - port: 3101, - }, - database: { - host: 'localhost', - port: 5433, - database: 'gridpilot_test', - user: 'gridpilot_test_user', - password: 'gridpilot_test_pass', - }, - timeouts: { - setup: 120000, - teardown: 30000, - test: 60000, - }, -}; - -/** - * Create a test harness with default configuration - */ -export function createTestHarness(config?: Partial): IntegrationTestHarness { - const mergedConfig = { - ...DEFAULT_TEST_CONFIG, - ...config, - api: { ...DEFAULT_TEST_CONFIG.api, ...config?.api }, - database: { ...DEFAULT_TEST_CONFIG.database, ...config?.database }, - timeouts: { ...DEFAULT_TEST_CONFIG.timeouts, ...config?.timeouts }, - }; - return new IntegrationTestHarness(mergedConfig); -} \ No newline at end of file diff --git a/tests/integration/harness/infrastructure/api-client.test.ts b/tests/integration/harness/infrastructure/api-client.test.ts deleted file mode 100644 index 80e515f5c..000000000 --- a/tests/integration/harness/infrastructure/api-client.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Integration Test: ApiClient - * - * Tests the ApiClient infrastructure for making HTTP requests - */ - -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { ApiClient } from '../api-client'; - -describe('ApiClient - Infrastructure Tests', () => { - let apiClient: ApiClient; - let mockServer: { close: () => void; port: number }; - - beforeAll(async () => { - // Create a mock HTTP server for testing - const http = require('http'); - const server = http.createServer((req: any, res: any) => { - if (req.url === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok' })); - } else if (req.url === '/api/data') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ message: 'success', data: { id: 1, name: 'test' } })); - } else if (req.url === '/api/error') { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal Server Error' })); - } else if (req.url === '/api/slow') { - // Simulate slow response - setTimeout(() => { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ message: 'slow response' })); - }, 2000); - } else { - res.writeHead(404); - res.end('Not Found'); - } - }); - - await new Promise((resolve) => { - server.listen(0, () => { - const port = (server.address() as any).port; - mockServer = { close: () => server.close(), port }; - apiClient = new ApiClient({ baseUrl: `http://localhost:${port}`, timeout: 5000 }); - resolve(); - }); - }); - }); - - afterAll(() => { - if (mockServer) { - mockServer.close(); - } - }); - - describe('HTTP Methods', () => { - it('should successfully make a GET request', async () => { - const result = await apiClient.get<{ message: string; data: { id: number; name: string } }>('/api/data'); - expect(result.message).toBe('success'); - expect(result.data.id).toBe(1); - }); - - it('should successfully make a POST request with body', async () => { - const result = await apiClient.post<{ message: string }>('/api/data', { name: 'test' }); - expect(result.message).toBe('success'); - }); - - it('should successfully make a PUT request with body', async () => { - const result = await apiClient.put<{ message: string }>('/api/data', { id: 1 }); - expect(result.message).toBe('success'); - }); - - it('should successfully make a PATCH request with body', async () => { - const result = await apiClient.patch<{ message: string }>('/api/data', { name: 'patched' }); - expect(result.message).toBe('success'); - }); - - it('should successfully make a DELETE request', async () => { - const result = await apiClient.delete<{ message: string }>('/api/data'); - expect(result.message).toBe('success'); - }); - }); - - describe('Error Handling & Timeouts', () => { - it('should handle HTTP errors gracefully', async () => { - await expect(apiClient.get('/api/error')).rejects.toThrow('API Error 500'); - }); - - it('should handle timeout errors', async () => { - const shortTimeoutClient = new ApiClient({ - baseUrl: `http://localhost:${mockServer.port}`, - timeout: 100, - }); - await expect(shortTimeoutClient.get('/api/slow')).rejects.toThrow('Request timeout after 100ms'); - }); - }); - - describe('Health & Readiness', () => { - it('should successfully check health endpoint', async () => { - expect(await apiClient.health()).toBe(true); - }); - - it('should wait for API to be ready', async () => { - await apiClient.waitForReady(5000); - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/integration/harness/infrastructure/data-factory.test.ts b/tests/integration/harness/infrastructure/data-factory.test.ts deleted file mode 100644 index 51058dd03..000000000 --- a/tests/integration/harness/infrastructure/data-factory.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Integration Test: DataFactory - * - * Tests the DataFactory infrastructure for creating test data - */ - -import { describe, it, expect } from 'vitest'; -import { setupHarnessTest } from '../HarnessTestContext'; - -describe('DataFactory - Infrastructure Tests', () => { - const context = setupHarnessTest(); - - describe('Entity Creation', () => { - it('should create a league entity', async () => { - const league = await context.factory.createLeague({ - name: 'Test League', - description: 'Test Description', - }); - - expect(league).toBeDefined(); - expect(league.name).toBe('Test League'); - }); - - it('should create a season entity', async () => { - const league = await context.factory.createLeague(); - const season = await context.factory.createSeason(league.id.toString(), { - name: 'Test Season', - }); - - expect(season).toBeDefined(); - expect(season.leagueId).toBe(league.id.toString()); - expect(season.name).toBe('Test Season'); - }); - - it('should create a driver entity', async () => { - const driver = await context.factory.createDriver({ - name: 'Test Driver', - }); - - expect(driver).toBeDefined(); - expect(driver.name.toString()).toBe('Test Driver'); - }); - - it('should create a race entity', async () => { - const league = await context.factory.createLeague(); - const race = await context.factory.createRace({ - leagueId: league.id.toString(), - track: 'Laguna Seca', - }); - - expect(race).toBeDefined(); - expect(race.track).toBe('Laguna Seca'); - }); - - it('should create a result entity', async () => { - const league = await context.factory.createLeague(); - const race = await context.factory.createRace({ leagueId: league.id.toString() }); - const driver = await context.factory.createDriver(); - - const result = await context.factory.createResult(race.id.toString(), driver.id.toString(), { - position: 1, - }); - - expect(result).toBeDefined(); - expect(result.position).toBe(1); - }); - }); - - describe('Scenarios', () => { - it('should create a complete test scenario', async () => { - const scenario = await context.factory.createTestScenario(); - - expect(scenario.league).toBeDefined(); - expect(scenario.season).toBeDefined(); - expect(scenario.drivers).toHaveLength(3); - expect(scenario.races).toHaveLength(2); - }); - }); -}); diff --git a/tests/integration/harness/infrastructure/database-manager.test.ts b/tests/integration/harness/infrastructure/database-manager.test.ts deleted file mode 100644 index 25a767ba0..000000000 --- a/tests/integration/harness/infrastructure/database-manager.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Integration Test: DatabaseManager - * - * Tests the DatabaseManager infrastructure for database operations - */ - -import { describe, it, expect } from 'vitest'; -import { setupHarnessTest } from '../HarnessTestContext'; - -describe('DatabaseManager - Infrastructure Tests', () => { - const context = setupHarnessTest(); - - describe('Query Execution', () => { - it('should execute simple SELECT query', async () => { - const result = await context.db.query('SELECT 1 as test_value'); - expect(result.rows[0].test_value).toBe(1); - }); - - it('should execute query with parameters', async () => { - const result = await context.db.query('SELECT $1 as param_value', ['test']); - expect(result.rows[0].param_value).toBe('test'); - }); - }); - - describe('Transaction Handling', () => { - it('should begin, commit and rollback transactions', async () => { - // These methods should not throw - await context.db.begin(); - await context.db.commit(); - await context.db.begin(); - await context.db.rollback(); - expect(true).toBe(true); - }); - }); - - describe('Table Operations', () => { - it('should truncate all tables', async () => { - // This verifies the truncate logic doesn't have syntax errors - await context.db.truncateAllTables(); - expect(true).toBe(true); - }); - }); -}); diff --git a/tests/integration/harness/orchestration/integration-test-harness.test.ts b/tests/integration/harness/orchestration/integration-test-harness.test.ts deleted file mode 100644 index e608a1d94..000000000 --- a/tests/integration/harness/orchestration/integration-test-harness.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Integration Test: IntegrationTestHarness - * - * Tests the IntegrationTestHarness orchestration capabilities - */ - -import { describe, it, expect } from 'vitest'; -import { setupHarnessTest } from '../HarnessTestContext'; - -describe('IntegrationTestHarness - Orchestration Tests', () => { - const context = setupHarnessTest(); - - describe('Accessors', () => { - it('should provide access to all managers', () => { - expect(context.testHarness.getDatabase()).toBeDefined(); - expect(context.testHarness.getApi()).toBeDefined(); - expect(context.testHarness.getDocker()).toBeDefined(); - expect(context.testHarness.getFactory()).toBeDefined(); - }); - }); - - describe('Transaction Management', () => { - it('should execute callback within transaction and rollback', async () => { - const result = await context.testHarness.withTransaction(async (db) => { - const queryResult = await db.query('SELECT 1 as val'); - return queryResult.rows[0].val; - }); - expect(result).toBe(1); - }); - }); - - describe('Constraint Violation Detection', () => { - it('should detect constraint violations', async () => { - await expect( - context.testHarness.expectConstraintViolation(async () => { - throw new Error('constraint violation: duplicate key'); - }) - ).resolves.not.toThrow(); - }); - - it('should fail if no violation occurs', async () => { - await expect( - context.testHarness.expectConstraintViolation(async () => { - // Success - }) - ).rejects.toThrow('Expected constraint violation but operation succeeded'); - }); - - it('should fail if different error occurs', async () => { - await expect( - context.testHarness.expectConstraintViolation(async () => { - throw new Error('Some other error'); - }) - ).rejects.toThrow('Expected constraint violation but got: Some other error'); - }); - }); -}); diff --git a/tests/integration/website/WebsiteTestContext.ts b/tests/integration/website/WebsiteTestContext.ts deleted file mode 100644 index f7a8fd651..000000000 --- a/tests/integration/website/WebsiteTestContext.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { vi } from 'vitest'; -import { MockLeaguesApiClient } from './mocks/MockLeaguesApiClient'; -import { CircuitBreakerRegistry } from '../../../apps/website/lib/api/base/RetryHandler'; - -export class WebsiteTestContext { - public mockLeaguesApiClient: MockLeaguesApiClient; - private originalFetch: typeof global.fetch; - - private fetchMock = vi.fn(); - - constructor() { - this.mockLeaguesApiClient = new MockLeaguesApiClient(); - this.originalFetch = global.fetch; - } - - static create() { - return new WebsiteTestContext(); - } - - setup() { - this.originalFetch = global.fetch; - global.fetch = this.fetchMock; - process.env.NEXT_PUBLIC_API_BASE_URL = 'http://localhost:3001'; - process.env.API_BASE_URL = 'http://localhost:3001'; - vi.stubEnv('NODE_ENV', 'test'); - CircuitBreakerRegistry.getInstance().resetAll(); - } - - teardown() { - global.fetch = this.originalFetch; - this.fetchMock.mockClear(); - this.mockLeaguesApiClient.clearMocks(); - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - CircuitBreakerRegistry.getInstance().resetAll(); - // Reset environment variables - delete process.env.NEXT_PUBLIC_API_BASE_URL; - delete process.env.API_BASE_URL; - } - - mockFetchResponse(data: any, status = 200, ok = true) { - this.fetchMock.mockResolvedValueOnce(this.createMockResponse(data, status, ok)); - } - - mockFetchError(error: Error) { - this.fetchMock.mockRejectedValueOnce(error); - } - - mockFetchComplex(handler: (input: RequestInfo | URL, init?: RequestInit) => Promise) { - this.fetchMock.mockImplementation(handler); - } - - createMockResponse(data: any, status = 200, ok = true): Response { - return { - ok, - status, - statusText: ok ? 'OK' : 'Error', - headers: new Headers(), - json: async () => data, - text: async () => (typeof data === 'string' ? data : JSON.stringify(data)), - blob: async () => new Blob(), - arrayBuffer: async () => new ArrayBuffer(0), - formData: async () => new FormData(), - clone: () => this.createMockResponse(data, status, ok), - body: null, - bodyUsed: false, - } as Response; - } - - createMockErrorResponse(status: number, statusText: string, body: string): Response { - return { - ok: false, - status, - statusText, - headers: new Headers(), - text: async () => body, - json: async () => ({ message: body }), - blob: async () => new Blob(), - arrayBuffer: async () => new ArrayBuffer(0), - formData: async () => new FormData(), - clone: () => this.createMockErrorResponse(status, statusText, body), - body: null, - bodyUsed: false, - } as Response; - } -} diff --git a/tests/integration/website/mocks/MockLeaguesApiClient.ts b/tests/integration/website/mocks/MockLeaguesApiClient.ts deleted file mode 100644 index 3c76b8d7f..000000000 --- a/tests/integration/website/mocks/MockLeaguesApiClient.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { LeaguesApiClient } from '../../../../apps/website/lib/api/leagues/LeaguesApiClient'; -import { ApiError } from '../../../../apps/website/lib/api/base/ApiError'; -import type { Logger } from '../../../../apps/website/lib/interfaces/Logger'; -import type { ErrorReporter } from '../../../../apps/website/lib/interfaces/ErrorReporter'; - -/** - * Mock LeaguesApiClient for testing - * Allows controlled responses without making actual HTTP calls - */ -export class MockLeaguesApiClient extends LeaguesApiClient { - private mockResponses: Map = new Map(); - private mockErrors: Map = new Map(); - - constructor( - baseUrl: string = 'http://localhost:3001', - errorReporter: ErrorReporter = { - report: () => {}, - } as any, - logger: Logger = { - info: () => {}, - warn: () => {}, - error: () => {}, - } as any - ) { - super(baseUrl, errorReporter, logger); - } - - /** - * Set a mock response for a specific endpoint - */ - setMockResponse(endpoint: string, response: any): void { - this.mockResponses.set(endpoint, response); - } - - /** - * Set a mock error for a specific endpoint - */ - setMockError(endpoint: string, error: ApiError): void { - this.mockErrors.set(endpoint, error); - } - - /** - * Clear all mock responses and errors - */ - clearMocks(): void { - this.mockResponses.clear(); - this.mockErrors.clear(); - } - - /** - * Override getAllWithCapacityAndScoring to return mock data - */ - async getAllWithCapacityAndScoring(): Promise { - const endpoint = '/leagues/all-with-capacity-and-scoring'; - - if (this.mockErrors.has(endpoint)) { - throw this.mockErrors.get(endpoint); - } - - if (this.mockResponses.has(endpoint)) { - return this.mockResponses.get(endpoint); - } - - // Default mock response - return { - leagues: [ - { - id: 'league-1', - name: 'Test League', - description: 'A test league', - ownerId: 'driver-1', - createdAt: new Date().toISOString(), - usedSlots: 5, - settings: { - maxDrivers: 10, - }, - scoring: { - gameId: 'game-1', - gameName: 'Test Game', - primaryChampionshipType: 'driver', - scoringPresetId: 'preset-1', - scoringPresetName: 'Test Preset', - dropPolicySummary: 'No drops', - scoringPatternSummary: 'Standard scoring', - }, - }, - ], - totalCount: 1, - }; - } - - /** - * Override getMemberships to return mock data - */ - async getMemberships(leagueId: string): Promise { - const endpoint = `/leagues/${leagueId}/memberships`; - - if (this.mockErrors.has(endpoint)) { - throw this.mockErrors.get(endpoint); - } - - if (this.mockResponses.has(endpoint)) { - return this.mockResponses.get(endpoint); - } - - // Default mock response - return { - members: [ - { - driverId: 'driver-1', - driver: { - id: 'driver-1', - iracingId: '12345', - name: 'Test Driver', - country: 'US', - joinedAt: new Date().toISOString(), - }, - role: 'owner', - status: 'active', - joinedAt: new Date().toISOString(), - }, - ], - }; - } - - /** - * Override getLeagueConfig to return mock data - */ - async getLeagueConfig(leagueId: string): Promise { - const endpoint = `/leagues/${leagueId}/config`; - - if (this.mockErrors.has(endpoint)) { - throw this.mockErrors.get(endpoint); - } - - if (this.mockResponses.has(endpoint)) { - return this.mockResponses.get(endpoint); - } - - // Default mock response - return { - form: { - scoring: { - presetId: 'preset-1', - }, - }, - }; - } -} diff --git a/tests/integration/website/queries/LeagueDetailPageQuery.integration.test.ts b/tests/integration/website/queries/LeagueDetailPageQuery.integration.test.ts deleted file mode 100644 index 84e4bb9eb..000000000 --- a/tests/integration/website/queries/LeagueDetailPageQuery.integration.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { LeagueDetailPageQuery } from '../../../../apps/website/lib/page-queries/LeagueDetailPageQuery'; -import { WebsiteTestContext } from '../WebsiteTestContext'; - -// Mock data factories -const createMockLeagueData = (leagueId: string = 'league-1') => ({ - leagues: [ - { - id: leagueId, - name: 'Test League', - description: 'A test league', - ownerId: 'driver-1', - createdAt: new Date().toISOString(), - usedSlots: 5, - settings: { - maxDrivers: 10, - }, - scoring: { - gameId: 'game-1', - gameName: 'Test Game', - primaryChampionshipType: 'driver' as const, - scoringPresetId: 'preset-1', - scoringPresetName: 'Test Preset', - dropPolicySummary: 'No drops', - scoringPatternSummary: 'Standard scoring', - }, - }, - ], -}); - -const createMockMembershipsData = () => ({ - members: [ - { - driverId: 'driver-1', - driver: { - id: 'driver-1', - name: 'Driver 1', - }, - role: 'owner', - joinedAt: new Date().toISOString(), - }, - ], -}); - -const createMockRacesData = (leagueId: string = 'league-1') => ({ - races: [ - { - id: 'race-1', - track: 'Test Track', - car: 'Test Car', - scheduledAt: new Date().toISOString(), - leagueId: leagueId, - leagueName: 'Test League', - status: 'scheduled', - strengthOfField: 50, - }, - ], -}); - -const createMockDriverData = () => ({ - id: 'driver-1', - name: 'Test Driver', - avatarUrl: 'https://example.com/avatar.png', -}); - -const createMockConfigData = () => ({ - form: { - scoring: { - presetId: 'preset-1', - }, - }, -}); - -describe('LeagueDetailPageQuery Integration', () => { - const ctx = WebsiteTestContext.create(); - - beforeEach(() => { - ctx.setup(); - }); - - afterEach(() => { - ctx.teardown(); - }); - - describe('Happy Path', () => { - it('should return valid league detail data when API returns success', async () => { - // Arrange - const leagueId = 'league-1'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); // For getAllWithCapacityAndScoring - ctx.mockFetchResponse(createMockMembershipsData()); // For getMemberships - ctx.mockFetchResponse(createMockRacesData(leagueId)); // For getPageData - ctx.mockFetchResponse(createMockDriverData()); // For getDriver - ctx.mockFetchResponse(createMockConfigData()); // For getLeagueConfig - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - - expect(data.leagueId).toBe(leagueId); - expect(data.name).toBe('Test League'); - expect(data.ownerSummary).toBeDefined(); - expect(data.ownerSummary?.driverName).toBe('Test Driver'); - }); - - it('should handle league without owner', async () => { - // Arrange - const leagueId = 'league-2'; - const leagueData = createMockLeagueData(leagueId); - leagueData.leagues[0].ownerId = ''; // No owner - - ctx.mockFetchResponse(leagueData); // getAllWithCapacityAndScoring - ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships - ctx.mockFetchResponse(createMockRacesData(leagueId)); // getPageData - ctx.mockFetchResponse(createMockConfigData()); // getLeagueConfig - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.ownerSummary).toBeNull(); - }); - - it('should handle league with no races', async () => { - // Arrange - const leagueId = 'league-3'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); // getAllWithCapacityAndScoring - ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships - ctx.mockFetchResponse({ races: [] }); // getPageData - ctx.mockFetchResponse(createMockDriverData()); // getDriver - ctx.mockFetchResponse(createMockConfigData()); // getLeagueConfig - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.info.racesCount).toBe(0); - }); - }); - - describe('Error Handling', () => { - it('should handle 404 error when league not found', async () => { - // Arrange - const leagueId = 'non-existent-league'; - ctx.mockFetchResponse({ leagues: [] }); // getAllWithCapacityAndScoring - ctx.mockFetchResponse(createMockMembershipsData()); // getMemberships - ctx.mockFetchResponse(createMockRacesData(leagueId)); // getPageData - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('notFound'); - }); - - it('should handle 500 error when API server error', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false); - ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false); - ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('serverError'); - }); - - it('should handle network error', async () => { - // Arrange - ctx.mockFetchError(new Error('Network error: Unable to reach the API server')); - ctx.mockFetchError(new Error('Network error: Unable to reach the API server')); - ctx.mockFetchError(new Error('Network error: Unable to reach the API server')); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('serverError'); - }); - - it('should handle timeout error', async () => { - // Arrange - const timeoutError = new Error('Request timed out after 30 seconds'); - timeoutError.name = 'AbortError'; - ctx.mockFetchError(timeoutError); - ctx.mockFetchError(timeoutError); - ctx.mockFetchError(timeoutError); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('serverError'); - }); - - it('should handle unauthorized error', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false); - ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false); - ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('unauthorized'); - }); - - it('should handle forbidden error', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false); - ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false); - ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('unauthorized'); - }); - }); - - describe('Missing Data', () => { - it('should handle API returning partial data (missing memberships)', async () => { - // Arrange - const leagueId = 'league-1'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); - ctx.mockFetchResponse(null); // Missing memberships - ctx.mockFetchResponse(createMockRacesData(leagueId)); - ctx.mockFetchResponse(createMockDriverData()); - ctx.mockFetchResponse(createMockConfigData()); - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.info.membersCount).toBe(0); - }); - - it('should handle API returning partial data (missing races)', async () => { - // Arrange - const leagueId = 'league-1'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(null); // Missing races - ctx.mockFetchResponse(createMockDriverData()); - ctx.mockFetchResponse(createMockConfigData()); - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.info.racesCount).toBe(0); - }); - - it('should handle API returning partial data (missing scoring config)', async () => { - // Arrange - const leagueId = 'league-1'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(createMockRacesData(leagueId)); - ctx.mockFetchResponse(createMockDriverData()); - ctx.mockFetchResponse({ message: 'Config not found' }, 404, false); // Missing config - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.info.scoring).toBe('Standard'); - }); - - it('should handle API returning partial data (missing owner)', async () => { - // Arrange - const leagueId = 'league-1'; - ctx.mockFetchResponse(createMockLeagueData(leagueId)); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(createMockRacesData(leagueId)); - ctx.mockFetchResponse(null); // Missing owner - ctx.mockFetchResponse(createMockConfigData()); - - // Act - const result = await LeagueDetailPageQuery.execute(leagueId); - - // Assert - expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.ownerSummary).toBeNull(); - }); - }); - - describe('Edge Cases', () => { - it('should handle API returning empty leagues array', async () => { - // Arrange - ctx.mockFetchResponse({ leagues: [] }); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(createMockRacesData('league-1')); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('notFound'); - }); - - it('should handle API returning null data', async () => { - // Arrange - ctx.mockFetchResponse(null); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(createMockRacesData('league-1')); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('notFound'); - }); - - it('should handle API returning malformed data', async () => { - // Arrange - ctx.mockFetchResponse({ someOtherKey: [] }); - ctx.mockFetchResponse(createMockMembershipsData()); - ctx.mockFetchResponse(createMockRacesData('league-1')); - - // Act - const result = await LeagueDetailPageQuery.execute('league-1'); - - // Assert - expect(result.isErr()).toBe(true); - expect(result.getError()).toBe('notFound'); - }); - }); -}); diff --git a/tests/integration/website/queries/LeaguesPageQuery.integration.test.ts b/tests/integration/website/queries/LeaguesPageQuery.integration.test.ts deleted file mode 100644 index c6770bd98..000000000 --- a/tests/integration/website/queries/LeaguesPageQuery.integration.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { LeaguesPageQuery } from '../../../../apps/website/lib/page-queries/LeaguesPageQuery'; -import { WebsiteTestContext } from '../WebsiteTestContext'; - -// Mock data factories -const createMockLeaguesData = () => ({ - leagues: [ - { - id: 'league-1', - name: 'Test League 1', - description: 'A test league', - ownerId: 'driver-1', - createdAt: new Date().toISOString(), - usedSlots: 5, - settings: { - maxDrivers: 10, - }, - scoring: { - gameId: 'game-1', - gameName: 'Test Game', - primaryChampionshipType: 'driver' as const, - scoringPresetId: 'preset-1', - scoringPresetName: 'Test Preset', - dropPolicySummary: 'No drops', - scoringPatternSummary: 'Standard scoring', - }, - }, - { - id: 'league-2', - name: 'Test League 2', - description: 'Another test league', - ownerId: 'driver-2', - createdAt: new Date().toISOString(), - usedSlots: 15, - settings: { - maxDrivers: 20, - }, - scoring: { - gameId: 'game-1', - gameName: 'Test Game', - primaryChampionshipType: 'driver' as const, - scoringPresetId: 'preset-1', - scoringPresetName: 'Test Preset', - dropPolicySummary: 'No drops', - scoringPatternSummary: 'Standard scoring', - }, - }, - ], - totalCount: 2, -}); - -const createMockEmptyLeaguesData = () => ({ - leagues: [], -}); - -describe('LeaguesPageQuery Integration', () => { - const ctx = WebsiteTestContext.create(); - - beforeEach(() => { - ctx.setup(); - }); - - afterEach(() => { - ctx.teardown(); - }); - - describe('Happy Path', () => { - it('should return valid leagues data when API returns success', async () => { - // Arrange - const mockData = createMockLeaguesData(); - ctx.mockFetchResponse(mockData); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isOk()).toBe(true); - const viewData = result.unwrap(); - - expect(viewData).toBeDefined(); - expect(viewData.leagues).toBeDefined(); - expect(viewData.leagues.length).toBe(2); - - // Verify first league - expect(viewData.leagues[0].id).toBe('league-1'); - expect(viewData.leagues[0].name).toBe('Test League 1'); - expect(viewData.leagues[0].maxDrivers).toBe(10); - expect(viewData.leagues[0].usedDriverSlots).toBe(5); - - // Verify second league - expect(viewData.leagues[1].id).toBe('league-2'); - expect(viewData.leagues[1].name).toBe('Test League 2'); - expect(viewData.leagues[1].maxDrivers).toBe(20); - expect(viewData.leagues[1].usedDriverSlots).toBe(15); - }); - - it('should handle single league correctly', async () => { - // Arrange - const mockData = { - leagues: [ - { - id: 'single-league', - name: 'Single League', - description: 'Only one league', - ownerId: 'driver-1', - createdAt: new Date().toISOString(), - usedSlots: 3, - settings: { - maxDrivers: 5, - }, - scoring: { - gameId: 'game-1', - gameName: 'Test Game', - primaryChampionshipType: 'driver' as const, - scoringPresetId: 'preset-1', - scoringPresetName: 'Test Preset', - dropPolicySummary: 'No drops', - scoringPatternSummary: 'Standard scoring', - }, - }, - ], - }; - ctx.mockFetchResponse(mockData); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isOk()).toBe(true); - const viewData = result.unwrap(); - - expect(viewData.leagues.length).toBe(1); - expect(viewData.leagues[0].id).toBe('single-league'); - expect(viewData.leagues[0].name).toBe('Single League'); - }); - }); - - describe('Empty Results', () => { - it('should handle empty leagues list from API', async () => { - // Arrange - const mockData = createMockEmptyLeaguesData(); - ctx.mockFetchResponse(mockData); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isOk()).toBe(true); - const viewData = result.unwrap(); - - expect(viewData).toBeDefined(); - expect(viewData.leagues).toBeDefined(); - expect(viewData.leagues.length).toBe(0); - }); - }); - - describe('Error Handling', () => { - it('should handle 404 error when leagues endpoint not found', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Leagues not found' }, 404, false); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('notFound'); - }); - - it('should handle 500 error when API server error', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Internal Server Error' }, 500, false); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('LEAGUES_FETCH_FAILED'); - }); - - it('should handle network error', async () => { - // Arrange - ctx.mockFetchError(new Error('Network error: Unable to reach the API server')); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('LEAGUES_FETCH_FAILED'); - }); - - it('should handle timeout error', async () => { - // Arrange - const timeoutError = new Error('Request timed out after 30 seconds'); - timeoutError.name = 'AbortError'; - ctx.mockFetchError(timeoutError); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('LEAGUES_FETCH_FAILED'); - }); - - it('should handle unauthorized error (redirect)', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Unauthorized' }, 401, false); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('redirect'); - }); - - it('should handle forbidden error (redirect)', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Forbidden' }, 403, false); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('redirect'); - }); - - it('should handle unknown error type', async () => { - // Arrange - ctx.mockFetchResponse({ message: 'Unknown error' }, 999, false); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('LEAGUES_FETCH_FAILED'); - }); - }); - - describe('Edge Cases', () => { - it('should handle API returning null or undefined data', async () => { - // Arrange - ctx.mockFetchResponse({ leagues: null }); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('UNKNOWN_ERROR'); - }); - - it('should handle API returning malformed data', async () => { - // Arrange - const mockData = { - // Missing 'leagues' property - someOtherProperty: 'value', - }; - ctx.mockFetchResponse(mockData); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - expect(result.isErr()).toBe(true); - const error = result.getError(); - expect(error).toBe('UNKNOWN_ERROR'); - }); - - it('should handle API returning leagues with missing required fields', async () => { - // Arrange - const mockData = { - leagues: [ - { - id: 'league-1', - name: 'Test League', - // Missing other required fields - settings: { maxDrivers: 10 }, - usedSlots: 5, - createdAt: new Date().toISOString(), - }, - ], - }; - ctx.mockFetchResponse(mockData); - - // Act - const result = await LeaguesPageQuery.execute(); - - // Assert - // Should still succeed - the builder should handle partial data - expect(result.isOk()).toBe(true); - const viewData = result.unwrap(); - expect(viewData.leagues.length).toBe(1); - }); - }); -}); diff --git a/tests/integration/website/routing/RouteContractSpec.test.ts b/tests/integration/website/routing/RouteContractSpec.test.ts deleted file mode 100644 index 577219819..000000000 --- a/tests/integration/website/routing/RouteContractSpec.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { getWebsiteRouteContracts, ScenarioRole } from '../../../shared/website/RouteContractSpec'; -import { WebsiteRouteManager } from '../../../shared/website/WebsiteRouteManager'; -import { RouteScenarioMatrix } from '../../../shared/website/RouteScenarioMatrix'; - -describe('RouteContractSpec', () => { - const contracts = getWebsiteRouteContracts(); - const manager = new WebsiteRouteManager(); - const inventory = manager.getWebsiteRouteInventory(); - - it('should cover all inventory routes', () => { - expect(contracts.length).toBe(inventory.length); - - const inventoryPaths = inventory.map(def => - manager.resolvePathTemplate(def.pathTemplate, def.params) - ); - const contractPaths = contracts.map(c => c.path); - - // Ensure every path in inventory has a corresponding contract - inventoryPaths.forEach(path => { - expect(contractPaths).toContain(path); - }); - }); - - it('should have expectedStatus set for every contract', () => { - contracts.forEach(contract => { - expect(contract.expectedStatus).toBeDefined(); - expect(['ok', 'redirect', 'forbidden', 'notFoundAllowed', 'errorRoute']).toContain(contract.expectedStatus); - }); - }); - - it('should have required scenarios based on access level', () => { - contracts.forEach(contract => { - const scenarios = Object.keys(contract.scenarios) as ScenarioRole[]; - - // All routes must have unauth, auth, admin, sponsor scenarios - expect(scenarios).toContain('unauth'); - expect(scenarios).toContain('auth'); - expect(scenarios).toContain('admin'); - expect(scenarios).toContain('sponsor'); - - // Admin and Sponsor routes must also have wrong-role scenario - if (contract.accessLevel === 'admin' || contract.accessLevel === 'sponsor') { - expect(scenarios).toContain('wrong-role'); - } - }); - }); - - it('should have correct scenario expectations for admin routes', () => { - const adminContracts = contracts.filter(c => c.accessLevel === 'admin'); - adminContracts.forEach(contract => { - expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect'); - expect(contract.scenarios.auth?.expectedStatus).toBe('redirect'); - expect(contract.scenarios.admin?.expectedStatus).toBe('ok'); - expect(contract.scenarios.sponsor?.expectedStatus).toBe('redirect'); - expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect'); - }); - }); - - it('should have correct scenario expectations for sponsor routes', () => { - const sponsorContracts = contracts.filter(c => c.accessLevel === 'sponsor'); - sponsorContracts.forEach(contract => { - expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect'); - expect(contract.scenarios.auth?.expectedStatus).toBe('redirect'); - expect(contract.scenarios.admin?.expectedStatus).toBe('redirect'); - expect(contract.scenarios.sponsor?.expectedStatus).toBe('ok'); - expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect'); - }); - }); - - it('should have expectedRedirectTo set for protected routes (unauth scenario)', () => { - const protectedContracts = contracts.filter(c => c.accessLevel !== 'public'); - - // Filter out routes that might have overrides to not be 'redirect' - const redirectingContracts = protectedContracts.filter(c => c.expectedStatus === 'redirect'); - - expect(redirectingContracts.length).toBeGreaterThan(0); - - redirectingContracts.forEach(contract => { - expect(contract.expectedRedirectTo).toBeDefined(); - expect(contract.expectedRedirectTo).toMatch(/^\//); - }); - }); - - it('should include default SSR sanity markers', () => { - contracts.forEach(contract => { - expect(contract.ssrMustContain).toContain(''); - expect(contract.ssrMustContain).toContain(' { - it('should match the number of contracts', () => { - expect(RouteScenarioMatrix.length).toBe(contracts.length); - }); - - it('should correctly identify routes with param edge cases', () => { - const edgeCaseRoutes = RouteScenarioMatrix.filter(m => m.hasParamEdgeCases); - // Based on WebsiteRouteManager.getParamEdgeCases(), we expect at least /races/[id] and /leagues/[id] - expect(edgeCaseRoutes.length).toBeGreaterThanOrEqual(2); - - const paths = edgeCaseRoutes.map(m => m.path); - expect(paths.some(p => p.startsWith('/races/'))).toBe(true); - expect(paths.some(p => p.startsWith('/leagues/'))).toBe(true); - }); - }); -}); diff --git a/tests/integration/website/routing/RouteProtection.test.ts b/tests/integration/website/routing/RouteProtection.test.ts deleted file mode 100644 index db46f8dbb..000000000 --- a/tests/integration/website/routing/RouteProtection.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { describe, test, beforeAll, afterAll } from 'vitest'; -import { routes } from '../../../../apps/website/lib/routing/RouteConfig'; -import { WebsiteServerHarness } from '../../harness/WebsiteServerHarness'; -import { ApiServerHarness } from '../../harness/ApiServerHarness'; -import { HttpDiagnostics } from '../../../shared/website/HttpDiagnostics'; - -const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3000'; -const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3001'; - -type AuthRole = 'unauth' | 'auth' | 'admin' | 'sponsor'; - -async function loginViaApi(role: AuthRole): Promise { - if (role === 'unauth') return null; - - const credentials = { - admin: { email: 'demo.admin@example.com', password: 'Demo1234!' }, - sponsor: { email: 'demo.sponsor@example.com', password: 'Demo1234!' }, - auth: { email: 'demo.driver@example.com', password: 'Demo1234!' }, - }[role]; - - try { - console.log(`[RouteProtection] Attempting login for role ${role} at ${API_BASE_URL}/auth/login`); - const res = await fetch(`${API_BASE_URL}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(credentials), - }); - - if (!res.ok) { - console.warn(`[RouteProtection] Login failed for role ${role}: ${res.status} ${res.statusText}`); - const body = await res.text(); - console.warn(`[RouteProtection] Login failure body: ${body}`); - return null; - } - - const setCookie = res.headers.get('set-cookie') ?? ''; - console.log(`[RouteProtection] Login success. set-cookie: ${setCookie}`); - const cookiePart = setCookie.split(';')[0] ?? ''; - return cookiePart.startsWith('gp_session=') ? cookiePart : null; - } catch (e) { - console.warn(`[RouteProtection] Could not connect to API at ${API_BASE_URL} for role ${role} login: ${e.message}`); - return null; - } -} - -describe('Route Protection Matrix', () => { - let websiteHarness: WebsiteServerHarness | null = null; - let apiHarness: ApiServerHarness | null = null; - - beforeAll(async () => { - console.log(`[RouteProtection] beforeAll starting. WEBSITE_BASE_URL=${WEBSITE_BASE_URL}, API_BASE_URL=${API_BASE_URL}`); - - // 1. Ensure API is running - if (API_BASE_URL.includes('localhost')) { - try { - await fetch(`${API_BASE_URL}/health`); - console.log(`[RouteProtection] API already running at ${API_BASE_URL}`); - } catch (e) { - console.log(`[RouteProtection] Starting API server harness on ${API_BASE_URL}...`); - apiHarness = new ApiServerHarness({ - port: parseInt(new URL(API_BASE_URL).port) || 3001, - }); - await apiHarness.start(); - console.log(`[RouteProtection] API Harness started.`); - } - } - - // 2. Ensure Website is running - if (WEBSITE_BASE_URL.includes('localhost')) { - try { - console.log(`[RouteProtection] Checking if website is already running at ${WEBSITE_BASE_URL}`); - await fetch(WEBSITE_BASE_URL, { method: 'HEAD' }); - console.log(`[RouteProtection] Website already running.`); - } catch (e) { - console.log(`[RouteProtection] Website not running, starting harness...`); - websiteHarness = new WebsiteServerHarness({ - port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3000, - env: { - API_BASE_URL: API_BASE_URL, - NEXT_PUBLIC_API_BASE_URL: API_BASE_URL, - }, - }); - await websiteHarness.start(); - console.log(`[RouteProtection] Website Harness started.`); - } - } - }, 120000); - - afterAll(async () => { - if (websiteHarness) { - await websiteHarness.stop(); - } - if (apiHarness) { - await apiHarness.stop(); - } - }); - - const testMatrix: Array<{ - role: AuthRole; - path: string; - expectedStatus: number | number[]; - expectedRedirect?: string; - }> = [ - // Unauthenticated - { role: 'unauth', path: routes.public.home, expectedStatus: 200 }, - { role: 'unauth', path: routes.protected.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login }, - { role: 'unauth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.auth.login }, - { role: 'unauth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.auth.login }, - - // Authenticated (Driver) - { role: 'auth', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard }, - { role: 'auth', path: routes.protected.dashboard, expectedStatus: 200 }, - { role: 'auth', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard }, - { role: 'auth', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard }, - - // Admin - { role: 'admin', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard }, - { role: 'admin', path: routes.protected.dashboard, expectedStatus: 200 }, - { role: 'admin', path: routes.admin.root, expectedStatus: 200 }, - { role: 'admin', path: routes.sponsor.dashboard, expectedStatus: [302, 307], expectedRedirect: routes.admin.root }, - - // Sponsor - { role: 'sponsor', path: routes.public.home, expectedStatus: [302, 307], expectedRedirect: routes.protected.dashboard }, - { role: 'sponsor', path: routes.protected.dashboard, expectedStatus: 200 }, - { role: 'sponsor', path: routes.admin.root, expectedStatus: [302, 307], expectedRedirect: routes.sponsor.dashboard }, - { role: 'sponsor', path: routes.sponsor.dashboard, expectedStatus: 200 }, - ]; - - test.each(testMatrix)('$role accessing $path', async ({ role, path, expectedStatus, expectedRedirect }) => { - const cookie = await loginViaApi(role); - - if (role !== 'unauth' && !cookie) { - // If login fails, we can't test protected routes properly. - // In a real CI environment, the API should be running. - // For now, we'll skip the assertion if login fails to avoid false negatives when API is down. - console.warn(`Skipping ${role} test because login failed`); - return; - } - - const headers: Record = {}; - if (cookie) { - headers['Cookie'] = cookie; - } - - const status = response.status; - const location = response.headers.get('location'); - const html = status >= 400 ? await response.text() : undefined; - - const failureContext = { - role, - url, - status, - location, - html, - serverLogs: websiteHarness?.getLogTail(60), - }; - - const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra }); - - if (Array.isArray(expectedStatus)) { - if (!expectedStatus.includes(status)) { - throw new Error(formatFailure(`Expected status to be one of [${expectedStatus.join(', ')}], but got ${status}`)); - } - } else { - if (status !== expectedStatus) { - throw new Error(formatFailure(`Expected status ${expectedStatus}, but got ${status}`)); - } - } - - if (expectedRedirect) { - if (!location || !location.includes(expectedRedirect)) { - throw new Error(formatFailure(`Expected redirect to contain "${expectedRedirect}", but got "${location || 'N/A'}"`)); - } - if (role === 'unauth' && expectedRedirect === routes.auth.login) { - if (!location.includes('returnTo=')) { - throw new Error(formatFailure(`Expected redirect to contain "returnTo=" for unauth login redirect`)); - } - } - } - }, 15000); -}); diff --git a/tests/integration/website/ssr/WebsiteSSR.test.ts b/tests/integration/website/ssr/WebsiteSSR.test.ts deleted file mode 100644 index 920bef2b4..000000000 --- a/tests/integration/website/ssr/WebsiteSSR.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, test, beforeAll, afterAll, expect } from 'vitest'; -import { getWebsiteRouteContracts, RouteContract } from '../../../shared/website/RouteContractSpec'; -import { WebsiteServerHarness } from '../../harness/WebsiteServerHarness'; -import { ApiServerHarness } from '../../harness/ApiServerHarness'; -import { HttpDiagnostics } from '../../../shared/website/HttpDiagnostics'; - -const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3005'; -const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3006'; - -// Ensure WebsiteRouteManager uses the same persistence mode as the API harness -process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'; - -describe('Website SSR Integration', () => { - let websiteHarness: WebsiteServerHarness | null = null; - let apiHarness: ApiServerHarness | null = null; - const contracts = getWebsiteRouteContracts(); - - beforeAll(async () => { - // 1. Start API - console.log(`[WebsiteSSR] Starting API harness on ${API_BASE_URL}...`); - apiHarness = new ApiServerHarness({ - port: parseInt(new URL(API_BASE_URL).port) || 3006, - }); - await apiHarness.start(); - console.log(`[WebsiteSSR] API Harness started.`); - - // 2. Start Website - console.log(`[WebsiteSSR] Starting website harness on ${WEBSITE_BASE_URL}...`); - websiteHarness = new WebsiteServerHarness({ - port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3005, - env: { - PORT: '3005', - API_BASE_URL: API_BASE_URL, - NEXT_PUBLIC_API_BASE_URL: API_BASE_URL, - NODE_ENV: 'test', - }, - }); - await websiteHarness.start(); - console.log(`[WebsiteSSR] Website Harness started.`); - }, 180000); - - afterAll(async () => { - if (websiteHarness) { - await websiteHarness.stop(); - } - if (apiHarness) { - await apiHarness.stop(); - } - }); - - test.each(contracts)('SSR for $path ($accessLevel)', async (contract: RouteContract) => { - const url = `${WEBSITE_BASE_URL}${contract.path}`; - - const response = await fetch(url, { - method: 'GET', - redirect: 'manual', - }); - - const status = response.status; - const location = response.headers.get('location'); - const html = await response.text(); - - if (status === 500) { - console.error(`[WebsiteSSR] 500 Error at ${contract.path}. HTML:`, html.substring(0, 10000)); - const errorMatch = html.match(/]*>([\s\S]*?)<\/pre>/); - if (errorMatch) { - console.error(`[WebsiteSSR] Error details from HTML:`, errorMatch[1]); - } - const nextDataMatch = html.match(/