integration tests cleanup

This commit is contained in:
2026-01-23 12:56:53 +01:00
parent 6df38a462a
commit a00ca4edfd
83 changed files with 0 additions and 5324 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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}`);
});

View File

@@ -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<void> {
const startTime = Date.now();
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
try {
let response;
const headers: Record<string, string> = {};
// 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<void> {
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`);
}
});

View File

@@ -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<void> {
const startTime = Date.now();
const fullUrl = `${API_BASE_URL}${endpoint.path}`;
console.log(`\n[TEST] ${endpoint.method} ${endpoint.path} (${endpoint.name || 'Unknown'})`);
try {
let response;
const headers: Record<string, string> = {};
// 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<void> {
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`);
}
});

View File

@@ -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);
});
});
});

View File

@@ -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}`);
});
});

View File

@@ -1,100 +0,0 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
export interface ApiServerHarnessOptions {
port?: number;
env?: Record<string, string>;
}
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<void> {
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<void> {
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('');
}
}

View File

@@ -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;
}

View File

@@ -1,124 +0,0 @@
import { spawn, ChildProcess } from 'child_process';
import { join } from 'path';
export interface WebsiteServerHarnessOptions {
port?: number;
env?: Record<string, string>;
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<void> {
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<void> {
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)));
}
}

View File

@@ -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<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
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<T>(path: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>('GET', path, undefined, headers);
}
// POST requests
async post<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
return this.request<T>('POST', path, body, headers);
}
// PUT requests
async put<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
return this.request<T>('PUT', path, body, headers);
}
// PATCH requests
async patch<T>(path: string, body: unknown, headers?: Record<string, string>): Promise<T> {
return this.request<T>('PATCH', path, body, headers);
}
// DELETE requests
async delete<T>(path: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>('DELETE', path, undefined, headers);
}
/**
* Health check
*/
async health(): Promise<boolean> {
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<void> {
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`);
}
}

View File

@@ -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<void> {
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<void> {
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);
}
}
}

View File

@@ -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<void> {
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<PoolClient> {
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<QueryResult> {
const client = await this.getClient();
return client.query(text, params);
}
/**
* Begin transaction
*/
async begin(): Promise<void> {
const client = await this.getClient();
await client.query('BEGIN');
}
/**
* Commit transaction
*/
async commit(): Promise<void> {
if (this.client) {
await this.client.query('COMMIT');
}
}
/**
* Rollback transaction
*/
async rollback(): Promise<void> {
if (this.client) {
await this.client.query('ROLLBACK');
}
}
/**
* Truncate all tables (for cleanup between tests)
*/
async truncateAllTables(): Promise<void> {
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<void> {
// 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<void> {
// 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<string[]> {
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<unknown[]> {
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<void> {
if (this.client) {
this.client.release();
this.client = null;
}
await this.pool.end();
}
}

View File

@@ -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<string, boolean> = 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<void> {
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<void> {
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<void> {
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}`;
}
}
}

View File

@@ -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<void> {
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<void> {
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<void> {
// 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<void> {
// 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<T>(callback: (db: DatabaseManager) => Promise<T>): Promise<T> {
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<unknown>,
expectedConstraint?: string
): Promise<void> {
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<IntegrationTestConfig>): 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);
}

View File

@@ -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<void>((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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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');
});
});
});

View File

@@ -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<Response>) {
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;
}
}

View File

@@ -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<string, any> = new Map();
private mockErrors: Map<string, ApiError> = 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<any> {
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<any> {
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<any> {
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',
},
},
};
}
}

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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('<!DOCTYPE html>');
expect(contract.ssrMustContain).toContain('<body');
expect(contract.ssrMustNotContain).toContain('__NEXT_ERROR__');
expect(contract.ssrMustNotContain).toContain('Application error: a client-side exception has occurred');
});
});
describe('RouteScenarioMatrix', () => {
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);
});
});
});

View File

@@ -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<string | null> {
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<string, string> = {};
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);
});

View File

@@ -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(/<pre[^>]*>([\s\S]*?)<\/pre>/);
if (errorMatch) {
console.error(`[WebsiteSSR] Error details from HTML:`, errorMatch[1]);
}
const nextDataMatch = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
if (nextDataMatch) {
console.error(`[WebsiteSSR] NEXT_DATA:`, nextDataMatch[1]);
}
// Look for Next.js 13+ flight data or error markers
const flightDataMatch = html.match(/self\.__next_f\.push\(\[1,"([^"]+)"\]\)/g);
if (flightDataMatch) {
console.error(`[WebsiteSSR] Flight Data found, checking for errors...`);
flightDataMatch.forEach(m => {
if (m.includes('Error') || m.includes('failed')) {
console.error(`[WebsiteSSR] Potential error in flight data:`, m);
}
});
}
// Check for specific error message in the body
if (html.includes('Error:')) {
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/);
if (bodyMatch) {
console.error(`[WebsiteSSR] Body content:`, bodyMatch[1].substring(0, 1000));
}
}
// Check for Next.js 14+ error markers
const nextErrorMatch = html.match(/<meta name="next-error" content="([^"]+)"\/>/);
if (nextErrorMatch) {
console.error(`[WebsiteSSR] Next.js Error Marker:`, nextErrorMatch[1]);
}
// Check for "digest" error markers
const digestMatch = html.match(/"digest":"([^"]+)"/);
if (digestMatch) {
console.error(`[WebsiteSSR] Error Digest:`, digestMatch[1]);
}
// Check for "notFound" in flight data
if (html.includes('notFound')) {
console.error(`[WebsiteSSR] "notFound" found in HTML source`);
}
// Check for "NEXT_NOT_FOUND"
if (html.includes('NEXT_NOT_FOUND')) {
console.error(`[WebsiteSSR] "NEXT_NOT_FOUND" found in HTML source`);
}
// Check for "Invariant: notFound() called in shell"
if (html.includes('Invariant: notFound() called in shell')) {
console.error(`[WebsiteSSR] "Invariant: notFound() called in shell" found in HTML source`);
}
// Check for "Error: notFound()"
if (html.includes('Error: notFound()')) {
console.error(`[WebsiteSSR] "Error: notFound()" found in HTML source`);
}
// Check for "DIGEST"
if (html.includes('DIGEST')) {
console.error(`[WebsiteSSR] "DIGEST" found in HTML source`);
}
// Check for "NEXT_REDIRECT"
if (html.includes('NEXT_REDIRECT')) {
console.error(`[WebsiteSSR] "NEXT_REDIRECT" found in HTML source`);
}
// Check for "Error: "
const genericErrorMatch = html.match(/Error: ([^<]+)/);
if (genericErrorMatch) {
console.error(`[WebsiteSSR] Generic Error Match:`, genericErrorMatch[1]);
}
}
const failureContext = {
url,
status,
location,
html: html.substring(0, 1000), // Limit HTML in logs
serverLogs: websiteHarness?.getLogTail(60),
};
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });
// 1. Assert Status
if (contract.expectedStatus === 'ok') {
if (status !== 200) {
throw new Error(formatFailure(`Expected status 200 OK, but got ${status}`));
}
} else if (contract.expectedStatus === 'redirect') {
if (status !== 302 && status !== 307) {
throw new Error(formatFailure(`Expected redirect status (302/307), but got ${status}`));
}
// 2. Assert Redirect Location
if (contract.expectedRedirectTo) {
if (!location) {
throw new Error(formatFailure(`Expected redirect to ${contract.expectedRedirectTo}, but got no Location header`));
}
const locationPathname = new URL(location, WEBSITE_BASE_URL).pathname;
if (locationPathname !== contract.expectedRedirectTo) {
throw new Error(formatFailure(`Expected redirect to pathname "${contract.expectedRedirectTo}", but got "${locationPathname}" (full: ${location})`));
}
}
} else if (contract.expectedStatus === 'notFoundAllowed') {
if (status !== 404 && status !== 200) {
throw new Error(formatFailure(`Expected 404 or 200 (notFoundAllowed), but got ${status}`));
}
} else if (contract.expectedStatus === 'errorRoute') {
// Error routes themselves should return 200 or their respective error codes (like 500)
if (status >= 600) {
throw new Error(formatFailure(`Error route returned unexpected status ${status}`));
}
}
// 3. Assert SSR HTML Markers (only if not a redirect)
if (status === 200 || status === 404) {
if (contract.ssrMustContain) {
for (const marker of contract.ssrMustContain) {
if (typeof marker === 'string') {
if (!html.includes(marker)) {
throw new Error(formatFailure(`SSR HTML missing expected marker: "${marker}"`));
}
} else if (marker instanceof RegExp) {
if (!marker.test(html)) {
throw new Error(formatFailure(`SSR HTML missing expected regex marker: ${marker}`));
}
}
}
}
if (contract.ssrMustNotContain) {
for (const marker of contract.ssrMustNotContain) {
if (typeof marker === 'string') {
if (html.includes(marker)) {
throw new Error(formatFailure(`SSR HTML contains forbidden marker: "${marker}"`));
}
} else if (marker instanceof RegExp) {
if (marker.test(html)) {
throw new Error(formatFailure(`SSR HTML contains forbidden regex marker: ${marker}`));
}
}
}
}
if (contract.minTextLength && html.length < contract.minTextLength) {
throw new Error(formatFailure(`SSR HTML length ${html.length} is less than minimum ${contract.minTextLength}`));
}
}
}, 30000);
});