tests cleanup

This commit is contained in:
2026-01-03 18:46:36 +01:00
parent 540c0fcb7a
commit c589b3c3fe
17 changed files with 402 additions and 2812 deletions

View File

@@ -0,0 +1,408 @@
/**
* Contract Validation Tests for API
*
* These tests validate that the API DTOs and OpenAPI spec are consistent
* and that the generated types will be compatible with the website.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
interface OpenAPISchema {
type?: string;
format?: string;
$ref?: string;
items?: OpenAPISchema;
properties?: Record<string, OpenAPISchema>;
required?: string[];
enum?: string[];
nullable?: boolean;
description?: string;
default?: unknown;
}
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, OpenAPISchema>;
};
}
describe('API Contract Validation', () => {
const apiRoot = path.join(__dirname, '../../..');
const openapiPath = path.join(apiRoot, 'openapi.json');
const generatedTypesDir = path.join(apiRoot, '../website/lib/types/generated');
const execFileAsync = promisify(execFile);
describe('OpenAPI Spec Integrity', () => {
it('should have a valid OpenAPI spec file', async () => {
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
expect(specExists).toBe(true);
});
it('should have a valid JSON structure', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
expect(() => JSON.parse(content)).not.toThrow();
});
it('should have required OpenAPI fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
expect(spec.info).toBeDefined();
expect(spec.info.title).toBeDefined();
expect(spec.info.version).toBeDefined();
expect(spec.components).toBeDefined();
expect(spec.components.schemas).toBeDefined();
});
it('committed openapi.json should match generator output', async () => {
const repoRoot = path.join(apiRoot, '../..');
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');
await execFileAsync(
'npx',
['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
{ cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
);
const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));
expect(generated).toEqual(committed);
});
it('should include real HTTP paths for known routes', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const pathKeys = Object.keys(spec.paths ?? {});
expect(pathKeys.length).toBeGreaterThan(0);
// A couple of stable routes to detect "empty/stale" specs.
expect(spec.paths['/drivers/leaderboard']).toBeDefined();
expect(spec.paths['/dashboard/overview']).toBeDefined();
// Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
expect(spec.paths['/dashboard/overview'].get).toBeDefined();
});
it('should include league schedule publish/unpublish endpoints and published state', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();
const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
if (!scheduleSchema) {
throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
}
expect(scheduleSchema.properties?.published).toBeDefined();
expect(scheduleSchema.properties?.published?.type).toBe('boolean');
expect(scheduleSchema.required ?? []).toContain('published');
});
it('should include league roster admin read endpoints and schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();
const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
if (!memberSchema) {
throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
}
expect(memberSchema.properties?.driverId).toBeDefined();
expect(memberSchema.properties?.role).toBeDefined();
expect(memberSchema.properties?.joinedAt).toBeDefined();
expect(memberSchema.required ?? []).toContain('driverId');
expect(memberSchema.required ?? []).toContain('role');
expect(memberSchema.required ?? []).toContain('joinedAt');
expect(memberSchema.required ?? []).toContain('driver');
const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
if (!joinRequestSchema) {
throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
}
expect(joinRequestSchema.properties?.id).toBeDefined();
expect(joinRequestSchema.properties?.leagueId).toBeDefined();
expect(joinRequestSchema.properties?.driverId).toBeDefined();
expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
expect(joinRequestSchema.required ?? []).toContain('id');
expect(joinRequestSchema.required ?? []).toContain('leagueId');
expect(joinRequestSchema.required ?? []).toContain('driverId');
expect(joinRequestSchema.required ?? []).toContain('requestedAt');
expect(joinRequestSchema.required ?? []).toContain('driver');
});
it('should have no circular references in schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
const visited = new Set<string>();
const visiting = new Set<string>();
function detectCircular(schemaName: string): boolean {
if (visiting.has(schemaName)) return true;
if (visited.has(schemaName)) return false;
visiting.add(schemaName);
const schema = schemas[schemaName];
if (!schema) {
visiting.delete(schemaName);
visited.add(schemaName);
return false;
}
// Check properties for references
if (schema.properties) {
for (const prop of Object.values(schema.properties)) {
if (prop.$ref) {
const refName = prop.$ref.split('/').pop();
if (refName && detectCircular(refName)) {
return true;
}
}
if (prop.items?.$ref) {
const refName = prop.items.$ref.split('/').pop();
if (refName && detectCircular(refName)) {
return true;
}
}
}
}
visiting.delete(schemaName);
visited.add(schemaName);
return false;
}
for (const schemaName of Object.keys(schemas)) {
expect(detectCircular(schemaName)).toBe(false);
}
});
});
describe('DTO Consistency', () => {
it('should have generated DTO files for critical schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const generatedFiles = await fs.readdir(generatedTypesDir);
const generatedDTOs = generatedFiles
.filter(f => f.endsWith('.ts'))
.map(f => f.replace('.ts', ''));
// We intentionally do NOT require a 1:1 mapping for *all* schemas here.
// OpenAPI generation and type generation can be run as separate steps,
// and new schemas should not break API contract validation by themselves.
const criticalDTOs = [
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO',
'UploadMediaInputDTO',
'UploadMediaOutputDTO',
'RaceDTO',
'DriverDTO',
];
for (const dtoName of criticalDTOs) {
expect(spec.components.schemas[dtoName]).toBeDefined();
expect(generatedDTOs).toContain(dtoName);
}
});
it('should have consistent property types between DTOs and schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
for (const [schemaName, schema] of Object.entries(schemas)) {
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
if (!dtoExists) continue;
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
// Check that all required properties are present
if (schema.required) {
for (const requiredProp of schema.required) {
expect(dtoContent).toContain(requiredProp);
}
}
// Check that all properties are present
if (schema.properties) {
for (const propName of Object.keys(schema.properties)) {
expect(dtoContent).toContain(propName);
}
}
}
});
});
describe('Type Generation Integrity', () => {
it('should have valid TypeScript syntax in generated files', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
// `index.ts` is a generated barrel file (no interfaces).
if (file === 'index.ts') {
expect(content).toContain('export type {');
expect(content).toContain("from './");
continue;
}
// Basic TypeScript syntax checks (DTO interfaces)
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
// Should not have syntax errors (basic check)
expect(content).not.toContain('undefined;');
expect(content).not.toContain('any;');
}
});
it('should have proper imports for dependencies', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
for (const importLine of importMatches) {
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
if (match) {
const [, importedType, fromFile] = match;
expect(importedType).toBe(fromFile);
// Check that the imported file exists
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
}
}
}
});
});
describe('Contract Compatibility', () => {
it('should maintain backward compatibility for existing DTOs', async () => {
// This test ensures that when regenerating types, existing properties aren't removed
// unless explicitly intended
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check critical DTOs that are likely used in production
const criticalDTOs = [
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO',
'UploadMediaInputDTO',
'UploadMediaOutputDTO',
'RaceDTO',
'DriverDTO'
];
for (const dtoName of criticalDTOs) {
if (spec.components.schemas[dtoName]) {
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
}
}
});
it('should handle nullable fields correctly', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
for (const [, schema] of Object.entries(schemas)) {
const required = new Set(schema.required ?? []);
if (!schema.properties) continue;
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (!propSchema.nullable) continue;
// In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
// otherwise downstream generators can't represent it safely.
expect(required.has(propName)).toBe(false);
}
}
});
it('should have no empty string defaults for avatar/logo URLs', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
// Check DTOs that should use URL|null pattern
const mediaRelatedDTOs = [
'GetAvatarOutputDTO',
'UpdateAvatarInputDTO',
'DashboardDriverSummaryDTO',
'DriverProfileDriverSummaryDTO',
'DriverLeaderboardItemDTO',
'TeamListItemDTO',
'LeagueSummaryDTO',
'SponsorDTO',
];
for (const dtoName of mediaRelatedDTOs) {
const schema = schemas[dtoName];
if (!schema || !schema.properties) continue;
// Check for avatarUrl, logoUrl properties
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (propName === 'avatarUrl' || propName === 'logoUrl') {
// Should be string type, nullable (no empty string defaults)
expect(propSchema.type).toBe('string');
expect(propSchema.nullable).toBe(true);
// Should not have default value of empty string
if (propSchema.default !== undefined) {
expect(propSchema.default).not.toBe('');
}
}
}
}
});
});
});

View File

@@ -1,391 +1,178 @@
import { test, expect, type Page, type BrowserContext } from '@playwright/test';
import {
authContextForAccess,
attachConsoleErrorCapture,
setWebsiteAuthContext,
type WebsiteAuthContext,
type WebsiteFaultMode,
type WebsiteSessionDriftMode,
} from './websiteAuth';
import {
getWebsiteAuthDriftRoutes,
getWebsiteFaultInjectionRoutes,
getWebsiteParamEdgeCases,
getWebsiteRouteInventory,
resolvePathTemplate,
type WebsiteRouteDefinition,
} from './websiteRouteInventory';
import { test, expect } from '@playwright/test';
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
import { WebsiteAuthManager } from '../../shared/website/WebsiteAuthManager';
import { ConsoleErrorCapture } from '../../shared/website/ConsoleErrorCapture';
type SmokeScenario = {
scenarioName: string;
auth: WebsiteAuthContext;
expectAuthRedirect: boolean;
};
const API_BASE_URL = process.env.API_URL || 'http://localhost:3101';
type AuthOptions = {
sessionDrift?: WebsiteSessionDriftMode;
faultMode?: WebsiteFaultMode;
};
test.describe('Website Pages - TypeORM Integration', () => {
let routeManager: WebsiteRouteManager;
function toRegexEscaped(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function urlToKey(rawUrl: string): string {
try {
const parsed = new URL(rawUrl);
return `${parsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return rawUrl;
}
}
async function runWebsiteSmokeScenario(args: {
page: Page;
context: BrowserContext;
route: WebsiteRouteDefinition;
scenario: SmokeScenario;
resolvedPath: string;
expectedPath: string;
authOptions?: AuthOptions;
}): Promise<void> {
const { page, context, route, scenario, resolvedPath, expectedPath, authOptions = {} } = args;
await setWebsiteAuthContext(context, scenario.auth, authOptions);
await page.addInitScript(() => {
window.addEventListener('unhandledrejection', (event) => {
const anyEvent = event;
const reason = anyEvent && typeof anyEvent === 'object' && 'reason' in anyEvent ? anyEvent.reason : undefined;
// Forward to console so smoke harness can treat as a runtime failure.
// eslint-disable-next-line no-console
console.error(`[unhandledrejection] ${String(reason)}`);
});
test.beforeEach(() => {
routeManager = new WebsiteRouteManager();
});
const capture = attachConsoleErrorCapture(page);
const navigationHistory: string[] = [];
let redirectLoopError: string | null = null;
const recordNavigation = (rawUrl: string) => {
if (redirectLoopError) return;
navigationHistory.push(urlToKey(rawUrl));
const tail = navigationHistory.slice(-8);
if (tail.length < 8) return;
const isAlternating = (items: string[]) => {
if (items.length < 6) return false;
const a = items[0];
const b = items[1];
if (!a || !b || a === b) return false;
for (let i = 0; i < items.length; i++) {
if (items[i] !== (i % 2 === 0 ? a : b)) return false;
}
return true;
};
if (isAlternating(tail) || isAlternating(tail.slice(1))) {
const unique = Array.from(new Set(tail));
if (unique.length >= 2) {
redirectLoopError = `Redirect loop detected while loading ${resolvedPath} (auth=${scenario.auth}). Navigation tail:\n${tail
.map((u) => `- ${u}`)
.join('\n')}`;
}
}
if (navigationHistory.length > 12) {
redirectLoopError = `Excessive navigation count while loading ${resolvedPath} (auth=${scenario.auth}). Count=${navigationHistory.length}\nRecent navigations:\n${navigationHistory
.slice(-12)
.map((u) => `- ${u}`)
.join('\n')}`;
}
};
page.on('framenavigated', (frame) => {
if (frame.parentFrame()) return;
recordNavigation(frame.url());
test('verify Docker and TypeORM are running', async ({ page }) => {
const response = await page.goto(`${API_BASE_URL}/health`);
expect(response?.ok()).toBe(true);
const healthData = await response?.json().catch(() => null);
expect(healthData).toBeTruthy();
expect(healthData.database).toBe('connected');
});
const requestFailures: Array<{
url: string;
method: string;
resourceType: string;
errorText: string;
}> = [];
const responseFailures: Array<{ url: string; status: number }> = [];
const jsonParseFailures: Array<{ url: string; status: number; error: string }> = [];
const responseChecks: Array<Promise<void>> = [];
page.on('requestfailed', (req) => {
const failure = req.failure();
const errorText = failure?.errorText ?? 'unknown';
// Ignore expected aborts during navigation/redirects (Next.js will abort in-flight requests).
if (errorText.includes('net::ERR_ABORTED') || errorText.includes('NS_BINDING_ABORTED')) {
const resourceType = req.resourceType();
const url = req.url();
if (resourceType === 'document' || resourceType === 'media') {
return;
}
// Next.js RSC/data fetches are frequently aborted during redirects.
if (resourceType === 'fetch' && url.includes('_rsc=')) {
return;
}
// Ignore fetch requests to the expected redirect target during page redirects
// This handles cases like /sponsor -> /sponsor/dashboard where the redirect
// causes an aborted fetch request to the target URL
if (resourceType === 'fetch' && route.expectedPathTemplate) {
const expectedPath = resolvePathTemplate(route.expectedPathTemplate, route.params);
const urlObj = new URL(url);
if (urlObj.pathname === expectedPath) {
return;
}
}
}
requestFailures.push({
url: req.url(),
method: req.method(),
resourceType: req.resourceType(),
errorText,
});
test('all routes from RouteConfig are discoverable', async () => {
expect(() => routeManager.getWebsiteRouteInventory()).not.toThrow();
});
page.on('response', (resp) => {
const status = resp.status();
const url = resp.url();
const resourceType = resp.request().resourceType();
test('public routes are accessible without authentication', async ({ page }) => {
const routes = routeManager.getWebsiteRouteInventory();
const publicRoutes = routes.filter(r => r.access === 'public').slice(0, 5);
const isApiUrl = (() => {
try {
const parsed = new URL(url);
if (parsed.pathname.startsWith('/api/')) return true;
if (parsed.hostname === 'localhost' && (parsed.port === '3101' || parsed.port === '3000')) return true;
return false;
} catch {
return false;
}
})();
// Guardrail: for successful JSON API responses, ensure the body is valid JSON.
// Keep this generic: only api-ish URLs, only fetch/xhr, only 2xx, only application/json.
if (isApiUrl && status >= 200 && status < 300 && (resourceType === 'fetch' || resourceType === 'xhr')) {
const headers = resp.headers();
const contentType = headers['content-type'] ?? '';
const contentLength = headers['content-length'];
if (contentType.includes('application/json') && status !== 204 && contentLength !== '0') {
responseChecks.push(
resp
.json()
.then(() => undefined)
.catch((err) => {
jsonParseFailures.push({ url, status, error: String(err) });
}),
);
}
for (const route of publicRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
const response = await page.goto(`${API_BASE_URL}${path}`);
expect(response?.ok() || response?.status() === 404).toBeTruthy();
}
if (status < 400) return;
// Param edge-cases are allowed to return 404 as the primary document.
if (route.allowNotFound && resourceType === 'document' && status === 404) {
return;
}
// Intentional error routes: allow the main document to be 404/500.
if (resourceType === 'document' && resolvedPath === '/404' && status === 404 && /\/404\/?$/.test(url)) {
return;
}
if (resourceType === 'document' && resolvedPath === '/500' && status === 500 && /\/500\/?$/.test(url)) {
return;
}
responseFailures.push({ url, status });
});
const navResponse = await page.goto(resolvedPath, { waitUntil: 'domcontentloaded' });
test('protected routes redirect unauthenticated users to login', async ({ page }) => {
const routes = routeManager.getWebsiteRouteInventory();
const protectedRoutes = routes.filter(r => r.access !== 'public').slice(0, 3);
await expect(page.locator('body')).toBeVisible();
await expect(page).toHaveTitle(/GridPilot/i);
const currentUrl = new URL(page.url());
const finalPathname = currentUrl.pathname;
if (scenario.expectAuthRedirect) {
// Some routes enforce client-side auth redirects; others may render a safe "public" state in alpha/demo mode.
// Keep this minimal: either we land on an auth entry route, OR the navigation succeeded with a 200.
if (/^\/auth\/(login|iracing)\/?$/.test(finalPathname)) {
// ok
} else {
expect(
navResponse?.status(),
`Expected protected route ${resolvedPath} to redirect to auth or return 200 when public; ended at ${finalPathname}`,
).toBe(200);
for (const route of protectedRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
await page.goto(`${API_BASE_URL}${path}`);
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(path);
}
} else if (route.allowNotFound) {
if (finalPathname === '/404') {
// ok
} else {
await expect(page).toHaveURL(new RegExp(`${toRegexEscaped(expectedPath)}(\\?.*)?$`));
});
test('admin routes require admin role', async ({ page, browser }) => {
const routes = routeManager.getWebsiteRouteInventory();
const adminRoutes = routes.filter(r => r.access === 'admin').slice(0, 2);
for (const route of adminRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
// Regular auth user should be blocked
await WebsiteAuthManager.createAuthContext(browser, 'auth');
await page.goto(`${API_BASE_URL}${path}`);
expect(page.url().includes('login')).toBeTruthy();
// Admin user should have access
await WebsiteAuthManager.createAuthContext(browser, 'admin');
await page.goto(`${API_BASE_URL}${path}`);
expect(page.url().includes(path)).toBeTruthy();
}
} else {
await expect(page).toHaveURL(new RegExp(`${toRegexEscaped(expectedPath)}(\\?.*)?$`));
}
});
// Give the app a moment to surface any late runtime errors after initial render.
await page.waitForTimeout(250);
test('sponsor routes require sponsor role', async ({ page, browser }) => {
const routes = routeManager.getWebsiteRouteInventory();
const sponsorRoutes = routes.filter(r => r.access === 'sponsor').slice(0, 2);
await Promise.all(responseChecks);
for (const route of sponsorRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
// Regular auth user should be blocked
await WebsiteAuthManager.createAuthContext(browser, 'auth');
await page.goto(`${API_BASE_URL}${path}`);
expect(page.url().includes('login')).toBeTruthy();
if (redirectLoopError) {
throw new Error(redirectLoopError);
}
expect(
jsonParseFailures.length,
`Invalid JSON responses on route ${resolvedPath} (auth=${scenario.auth}):\n${jsonParseFailures
.map((r) => `- ${r.status} ${r.url}: ${r.error}`)
.join('\n')}`,
).toBe(0);
expect(
requestFailures.length,
`Request failures on route ${resolvedPath} (auth=${scenario.auth}):\n${requestFailures
.map((r) => `- ${r.method} ${r.resourceType} ${r.url} (${r.errorText})`)
.join('\n')}`,
).toBe(0);
expect(
responseFailures.length,
`HTTP failures on route ${resolvedPath} (auth=${scenario.auth}):\n${responseFailures.map((r) => `- ${r.status} ${r.url}`).join('\n')}`,
).toBe(0);
expect(
capture.pageErrors.length,
`Page errors on route ${resolvedPath} (auth=${scenario.auth}):\n${capture.pageErrors.join('\n')}`,
).toBe(0);
const treatAsErrorRoute =
resolvedPath === '/404' || resolvedPath === '/500' || (route.allowNotFound && finalPathname === '/404') || navResponse?.status() === 404;
const consoleErrors = treatAsErrorRoute
? capture.consoleErrors.filter((msg) => {
if (msg.includes('Failed to load resource: the server responded with a status of 404 (Not Found)')) return false;
if (msg.includes('Failed to load resource: the server responded with a status of 500 (Internal Server Error)')) return false;
if (msg.includes('the server responded with a status of 500')) return false;
return true;
})
: capture.consoleErrors;
expect(
consoleErrors.length,
`Console errors on route ${resolvedPath} (auth=${scenario.auth}):\n${consoleErrors.join('\n')}`,
).toBe(0);
// Verify images with /media/* paths are shown correctly
const mediaImages = await page.locator('img[src*="/media/"]').all();
for (const img of mediaImages) {
const src = await img.getAttribute('src');
const alt = await img.getAttribute('alt');
const isVisible = await img.isVisible();
// Check that src starts with /media/
expect(src, `Image src should start with /media/ on route ${resolvedPath}`).toMatch(/^\/media\//);
// Check that alt text exists (for accessibility)
expect(alt, `Image should have alt text on route ${resolvedPath}`).toBeTruthy();
// Check that image is visible
expect(isVisible, `Image with src="${src}" should be visible on route ${resolvedPath}`).toBe(true);
// Note: Skipping naturalWidth/naturalHeight check for now due to Next.js Image component issues in test environment
// The image URLs are correct and the proxy is working, but Next.js Image optimization may be interfering
}
}
test.describe('Website smoke - all pages render', () => {
const routes = getWebsiteRouteInventory();
for (const route of routes) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
const intendedAuth = authContextForAccess(route.access);
const scenarios: SmokeScenario[] = [{ scenarioName: 'intended', auth: intendedAuth, expectAuthRedirect: false }];
if (route.access !== 'public') {
scenarios.push({ scenarioName: 'public-redirect', auth: 'public', expectAuthRedirect: true });
// Sponsor user should have access
await WebsiteAuthManager.createAuthContext(browser, 'sponsor');
await page.goto(`${API_BASE_URL}${path}`);
expect(page.url().includes(path)).toBeTruthy();
}
});
for (const scenario of scenarios) {
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath });
});
test('auth routes redirect authenticated users away', async ({ page, browser }) => {
const routes = routeManager.getWebsiteRouteInventory();
const authRoutes = routes.filter(r => r.access === 'auth').slice(0, 2);
for (const route of authRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
await WebsiteAuthManager.createAuthContext(browser, 'auth');
await page.goto(`${API_BASE_URL}${path}`);
// Should redirect to dashboard or stay on the page
const currentUrl = page.url();
expect(currentUrl.includes('dashboard') || currentUrl.includes(path)).toBeTruthy();
}
}
});
});
test.describe('Website smoke - param edge cases', () => {
const edgeRoutes = getWebsiteParamEdgeCases();
test('parameterized routes handle edge cases', async ({ page }) => {
const edgeCases = routeManager.getParamEdgeCases();
for (const route of edgeRoutes) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
const scenario: SmokeScenario = { scenarioName: 'invalid-param', auth: 'public', expectAuthRedirect: false };
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath });
});
}
});
test.describe('Website smoke - auth state drift', () => {
const driftRoutes = getWebsiteAuthDriftRoutes();
const driftModes: WebsiteSessionDriftMode[] = ['invalid-cookie', 'expired', 'missing-sponsor-id'];
for (const route of driftRoutes) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
for (const sessionDrift of driftModes) {
const scenario: SmokeScenario = { scenarioName: `drift:${sessionDrift}`, auth: 'sponsor', expectAuthRedirect: true };
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath, authOptions: { sessionDrift } });
});
for (const route of edgeCases) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
const response = await page.goto(`${API_BASE_URL}${path}`);
if (route.allowNotFound) {
expect(response?.status() === 404 || response?.status() === 500).toBeTruthy();
}
}
}
});
});
test.describe('Website smoke - mock fault injection (curated subset)', () => {
const faultRoutes = getWebsiteFaultInjectionRoutes();
test('no console or page errors on critical routes', async ({ page }) => {
const faultRoutes = routeManager.getFaultInjectionRoutes();
const faultModes: WebsiteFaultMode[] = ['null-array', 'missing-field', 'invalid-date'];
for (const route of faultRoutes) {
const capture = new ConsoleErrorCapture(page);
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
await page.goto(`${API_BASE_URL}${path}`);
await page.waitForTimeout(500);
for (const route of faultRoutes) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
const expectedPath = resolvePathTemplate(route.expectedPathTemplate ?? route.pathTemplate, route.params);
for (const faultMode of faultModes) {
const scenario: SmokeScenario = {
scenarioName: `fault:${faultMode}`,
auth: authContextForAccess(route.access),
expectAuthRedirect: false,
};
test(`${scenario.auth} (${scenario.scenarioName}) renders ${route.pathTemplate} -> ${resolvedPath}`, async ({ page, context }) => {
await runWebsiteSmokeScenario({ page, context, route, scenario, resolvedPath, expectedPath, authOptions: { faultMode } });
});
const errors = capture.getErrors();
expect(errors.length).toBe(0);
}
}
});
test('TypeORM session persistence across routes', async ({ page }) => {
const routes = routeManager.getWebsiteRouteInventory();
const testRoutes = routes.filter(r => r.access === 'public').slice(0, 5);
for (const route of testRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
const response = await page.goto(`${API_BASE_URL}${path}`);
expect(response?.ok() || response?.status() === 404).toBeTruthy();
}
});
test('auth drift scenarios', async ({ page }) => {
const driftRoutes = routeManager.getAuthDriftRoutes();
for (const route of driftRoutes) {
const path = routeManager.resolvePathTemplate(route.pathTemplate, route.params);
// Try accessing protected route without auth
await page.goto(`${API_BASE_URL}${path}`);
const currentUrl = page.url();
expect(currentUrl.includes('login') || currentUrl.includes('auth')).toBeTruthy();
}
});
test('handles invalid routes gracefully', async ({ page }) => {
const invalidRoutes = [
'/invalid-route',
'/leagues/invalid-id',
'/drivers/invalid-id',
];
for (const route of invalidRoutes) {
const response = await page.goto(`${API_BASE_URL}${route}`);
const status = response?.status();
const url = page.url();
expect([200, 404].includes(status ?? 0) || url.includes('/auth/login')).toBe(true);
}
});
});

View File

@@ -1,841 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import type { Logger } from '@core/shared/application';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository';
import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { Driver } from '@core/racing/domain/entities/Driver';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '@core/racing/domain/types/TeamMembership';
import { RegisterForRaceUseCase } from '@core/racing/application/use-cases/RegisterForRaceUseCase';
import { WithdrawFromRaceUseCase } from '@core/racing/application/use-cases/WithdrawFromRaceUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { GetRaceRegistrationsUseCase } from '@core/racing/application/use-cases/GetRaceRegistrationsUseCase';
import type { IRaceRegistrationsPresenter } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
import type { GetAllTeamsOutputPort } from '@core/racing/application/ports/output/GetAllTeamsOutputPort';
import type { ITeamDetailsPresenter } from '@core/racing/application/presenters/ITeamDetailsPresenter';
import type {
ITeamMembersPresenter,
TeamMembersResultDTO,
TeamMembersViewModel,
} from '@core/racing/application/presenters/ITeamMembersPresenter';
import type {
ITeamJoinRequestsPresenter,
TeamJoinRequestsResultDTO,
TeamJoinRequestsViewModel,
} from '@core/racing/application/presenters/ITeamJoinRequestsPresenter';
import type {
IDriverTeamPresenter,
DriverTeamResultDTO,
DriverTeamViewModel,
} from '@core/racing/application/presenters/IDriverTeamPresenter';
import type { RaceRegistrationsResultDTO } from '@core/racing/application/presenters/IRaceRegistrationsPresenter';
/**
* Simple in-memory fakes mirroring current alpha behavior.
*/
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrations = new Map<string, Set<string>>(); // raceId -> driverIds
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
const set = this.registrations.get(raceId);
return set ? set.has(driverId) : false;
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
const set = this.registrations.get(raceId);
return set ? Array.from(set) : [];
}
async getRegistrationCount(raceId: string): Promise<number> {
const set = this.registrations.get(raceId);
return set ? set.size : 0;
}
async register(registration: RaceRegistration): Promise<void> {
if (!this.registrations.has(registration.raceId)) {
this.registrations.set(registration.raceId, new Set());
}
this.registrations.get(registration.raceId)!.add(registration.driverId);
}
async withdraw(raceId: string, driverId: string): Promise<void> {
const set = this.registrations.get(raceId);
if (!set || !set.has(driverId)) {
throw new Error('Not registered for this race');
}
set.delete(driverId);
if (set.size === 0) {
this.registrations.delete(raceId);
}
}
async getDriverRegistrations(driverId: string): Promise<string[]> {
const result: string[] = [];
for (const [raceId, set] of this.registrations.entries()) {
if (set.has(driverId)) {
result.push(raceId);
}
}
return result;
}
async clearRaceRegistrations(raceId: string): Promise<void> {
this.registrations.delete(raceId);
}
}
class InMemoryLeagueMembershipRepositoryForRegistrations implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
(m) => m.leagueId === leagueId && m.leagueId === leagueId && m.driverId === driverId,
) || null
);
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
return this.memberships.filter(
(m) => m.leagueId === leagueId && m.status === 'active',
);
}
async getJoinRequests(): Promise<never> {
throw new Error('Not needed for registration tests');
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
this.memberships.push(membership);
return membership;
}
async removeMembership(): Promise<void> {
throw new Error('Not needed for registration tests');
}
async saveJoinRequest(): Promise<never> {
throw new Error('Not needed for registration tests');
}
async removeJoinRequest(): Promise<never> {
throw new Error('Not needed for registration tests');
}
seedActiveMembership(leagueId: string, driverId: string): void {
this.memberships.push(
LeagueMembership.create({
leagueId,
driverId,
role: 'member',
status: 'active' as MembershipStatus,
joinedAt: new Date('2024-01-01'),
}),
);
}
}
class TestRaceRegistrationsPresenter implements IRaceRegistrationsPresenter {
raceId: string | null = null;
driverIds: string[] = [];
reset(): void {
this.raceId = null;
this.driverIds = [];
}
present(input: RaceRegistrationsResultDTO) {
this.driverIds = input.registeredDriverIds;
this.raceId = null;
return {
registeredDriverIds: input.registeredDriverIds,
count: input.registeredDriverIds.length,
};
}
getViewModel() {
return {
registeredDriverIds: this.driverIds,
count: this.driverIds.length,
};
}
}
class InMemoryTeamRepository implements ITeamRepository {
private teams: Team[] = [];
async findById(id: string): Promise<Team | null> {
return this.teams.find((t) => t.id === id) || null;
}
async findAll(): Promise<Team[]> {
return [...this.teams];
}
async findByLeagueId(leagueId: string): Promise<Team[]> {
return this.teams.filter((t) => t.leagues.includes(leagueId));
}
async create(team: Team): Promise<Team> {
this.teams.push(team);
return team;
}
async update(team: Team): Promise<Team> {
const index = this.teams.findIndex((t) => t.id === team.id);
if (index >= 0) {
this.teams[index] = team;
} else {
this.teams.push(team);
}
return team;
}
async delete(id: string): Promise<void> {
this.teams = this.teams.filter((t) => t.id !== id);
}
async exists(id: string): Promise<boolean> {
return this.teams.some((t) => t.id === id);
}
seedTeam(team: Team): void {
this.teams.push(team);
}
}
class InMemoryTeamMembershipRepository implements ITeamMembershipRepository {
private memberships: TeamMembership[] = [];
private joinRequests: TeamJoinRequest[] = [];
async getMembership(teamId: string, driverId: string): Promise<TeamMembership | null> {
return (
this.memberships.find(
(m) => m.teamId === teamId && m.driverId === driverId,
) || null
);
}
async getActiveMembershipForDriver(driverId: string): Promise<TeamMembership | null> {
return (
this.memberships.find(
(m) => m.driverId === driverId && m.status === 'active',
) || null
);
}
async getTeamMembers(teamId: string): Promise<TeamMembership[]> {
return this.memberships.filter(
(m) => m.teamId === teamId && m.status === 'active',
);
}
async findByTeamId(teamId: string): Promise<TeamMembership[]> {
return this.memberships.filter((m) => m.teamId === teamId);
}
async saveMembership(membership: TeamMembership): Promise<TeamMembership> {
const index = this.memberships.findIndex(
(m) => m.teamId === membership.teamId && m.driverId === membership.driverId,
);
if (index >= 0) {
this.memberships[index] = membership;
} else {
this.memberships.push(membership);
}
return membership;
}
async removeMembership(teamId: string, driverId: string): Promise<void> {
this.memberships = this.memberships.filter(
(m) => !(m.teamId === teamId && m.driverId === driverId),
);
}
async getJoinRequests(teamId: string): Promise<TeamJoinRequest[]> {
// For these tests we ignore teamId and return all,
// allowing use-cases to look up by request ID only.
return [...this.joinRequests];
}
async saveJoinRequest(request: TeamJoinRequest): Promise<TeamJoinRequest> {
const index = this.joinRequests.findIndex((r) => r.id === request.id);
if (index >= 0) {
this.joinRequests[index] = request;
} else {
this.joinRequests.push(request);
}
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
this.joinRequests = this.joinRequests.filter((r) => r.id !== requestId);
}
seedMembership(membership: TeamMembership): void {
this.memberships.push(membership);
}
seedJoinRequest(request: TeamJoinRequest): void {
this.joinRequests.push(request);
}
getAllMemberships(): TeamMembership[] {
return [...this.memberships];
}
getAllJoinRequests(): TeamJoinRequest[] {
return [...this.joinRequests];
}
async countByTeamId(teamId: string): Promise<number> {
return this.memberships.filter((m) => m.teamId === teamId).length;
}
}
describe('Racing application use-cases - registrations', () => {
let registrationRepo: InMemoryRaceRegistrationRepository;
let membershipRepo: InMemoryLeagueMembershipRepositoryForRegistrations;
let registerForRace: RegisterForRaceUseCase;
let withdrawFromRace: WithdrawFromRaceUseCase;
let isDriverRegistered: IsDriverRegisteredForRaceUseCase;
let getRaceRegistrations: GetRaceRegistrationsUseCase;
let logger: Logger;
let raceRegistrationsPresenter: TestRaceRegistrationsPresenter;
beforeEach(() => {
registrationRepo = new InMemoryRaceRegistrationRepository();
membershipRepo = new InMemoryLeagueMembershipRepositoryForRegistrations();
registerForRace = new RegisterForRaceUseCase(registrationRepo, membershipRepo);
withdrawFromRace = new WithdrawFromRaceUseCase(registrationRepo);
logger = { debug: vi.fn(), error: vi.fn(), info: vi.fn(), warn: vi.fn() };
isDriverRegistered = new IsDriverRegisteredForRaceUseCase(
registrationRepo,
logger,
);
raceRegistrationsPresenter = new TestRaceRegistrationsPresenter();
getRaceRegistrations = new GetRaceRegistrationsUseCase(registrationRepo);
});
it('registers an active league member for a race and tracks registration', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
membershipRepo.seedActiveMembership(leagueId, driverId);
await registerForRace.execute({ raceId, leagueId, driverId });
const result = await isDriverRegistered.execute({ raceId, driverId });
expect(result.isOk()).toBe(true);
const status = result.unwrap();
expect(status.isRegistered).toBe(true);
expect(status.raceId).toBe(raceId);
expect(status.driverId).toBe(driverId);
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
expect(raceRegistrationsPresenter.driverIds).toContain(driverId);
});
it('throws when registering a non-member for a race', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
await expect(
registerForRace.execute({ raceId, leagueId, driverId }),
).rejects.toThrow('Must be an active league member to register for races');
});
it('withdraws a registration and reflects state in queries', async () => {
const raceId = 'race-1';
const leagueId = 'league-1';
const driverId = 'driver-1';
membershipRepo.seedActiveMembership(leagueId, driverId);
await registerForRace.execute({ raceId, leagueId, driverId });
await withdrawFromRace.execute({ raceId, driverId });
const result = await isDriverRegistered.execute({ raceId, driverId });
expect(result.isOk()).toBe(true);
const status = result.unwrap();
expect(status.isRegistered).toBe(false);
await getRaceRegistrations.execute({ raceId }, raceRegistrationsPresenter);
expect(raceRegistrationsPresenter.driverIds).toEqual([]);
});
});
describe('Racing application use-cases - teams', () => {
let teamRepo: InMemoryTeamRepository;
let membershipRepo: InMemoryTeamMembershipRepository;
let createTeam: CreateTeamUseCase;
let joinTeam: JoinTeamUseCase;
let leaveTeam: LeaveTeamUseCase;
let approveJoin: ApproveTeamJoinRequestUseCase;
let rejectJoin: RejectTeamJoinRequestUseCase;
let updateTeamUseCase: UpdateTeamUseCase;
let getAllTeamsUseCase: GetAllTeamsUseCase;
let getTeamDetailsUseCase: GetTeamDetailsUseCase;
let getTeamMembersUseCase: GetTeamMembersUseCase;
let getTeamJoinRequestsUseCase: GetTeamJoinRequestsUseCase;
let getDriverTeamUseCase: GetDriverTeamUseCase;
class FakeDriverRepository {
async findById(driverId: string): Promise<Driver | null> {
return Driver.create({ id: driverId, iracingId: '123', name: `Driver ${driverId}`, country: 'US' });
}
async findByIRacingId(id: string): Promise<Driver | null> {
return null;
}
async findAll(): Promise<Driver[]> {
return [];
}
async create(driver: Driver): Promise<Driver> {
return driver;
}
async update(driver: Driver): Promise<Driver> {
return driver;
}
async delete(id: string): Promise<void> {
}
async exists(id: string): Promise<boolean> {
return false;
}
async existsByIRacingId(iracingId: string): Promise<boolean> {
return false;
}
async findByLeagueId(leagueId: string): Promise<Driver[]> {
return [];
}
async findByTeamId(teamId: string): Promise<Driver[]> {
return [];
}
}
class FakeImageService {
getDriverAvatar(driverId: string): string {
return `https://example.com/avatar/${driverId}.png`;
}
getTeamLogo(teamId: string): string {
return `https://example.com/logo/${teamId}.png`;
}
getLeagueCover(leagueId: string): string {
return `https://example.com/cover/${leagueId}.png`;
}
getLeagueLogo(leagueId: string): string {
return `https://example.com/logo/${leagueId}.png`;
}
}
class TestAllTeamsPresenter implements IAllTeamsPresenter {
private viewModel: AllTeamsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: AllTeamsResultDTO): void {
this.viewModel = {
teams: input.teams.map((team) => ({
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
memberCount: team.memberCount,
leagues: team.leagues,
specialization: (team as unknown).specialization,
region: (team as unknown).region,
languages: (team as unknown).languages,
})),
totalCount: input.teams.length,
};
}
getViewModel(): AllTeamsViewModel | null {
return this.viewModel;
}
get teams(): unknown[] {
return this.viewModel?.teams ?? [];
}
}
class TestTeamDetailsPresenter implements ITeamDetailsPresenter {
viewModel: any = null;
reset(): void {
this.viewModel = null;
}
present(input: any): void {
this.viewModel = input;
}
getViewModel(): any {
return this.viewModel;
}
}
class TestTeamMembersPresenter implements ITeamMembersPresenter {
private viewModel: TeamMembersViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamMembersResultDTO): void {
const members = input.memberships.map((membership) => {
const driverId = membership.driverId;
const driverName = input.driverNames[driverId] ?? driverId;
const avatarUrl = input.avatarUrls[driverId] ?? '';
return {
driverId,
driverName,
role: ((membership.role as unknown) === 'owner' ? 'owner' : (membership.role as unknown) === 'member' ? 'member' : (membership.role as unknown) === 'manager' ? 'manager' : (membership.role as unknown) === 'driver' ? 'member' : 'member') as "owner" | "member" | "manager",
joinedAt: membership.joinedAt.toISOString(),
isActive: membership.status === 'active',
avatarUrl,
};
});
const ownerCount = members.filter((m) => m.role === 'owner').length;
const managerCount = members.filter((m) => m.role === 'manager').length;
const memberCount = members.filter((m) => (m.role as unknown) === 'member').length;
this.viewModel = {
members,
totalCount: members.length,
ownerCount,
managerCount,
memberCount,
};
}
getViewModel(): TeamMembersViewModel | null {
return this.viewModel;
}
get members(): unknown[] {
return this.viewModel?.members ?? [];
}
}
class TestTeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
private viewModel: TeamJoinRequestsViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: TeamJoinRequestsResultDTO): void {
const requests = input.requests.map((request) => {
const driverId = request.driverId;
const driverName = input.driverNames[driverId] ?? driverId;
const avatarUrl = input.avatarUrls[driverId] ?? '';
return {
requestId: request.id,
driverId,
driverName,
teamId: request.teamId,
status: 'pending' as const,
requestedAt: request.requestedAt.toISOString(),
avatarUrl,
};
});
const pendingCount = requests.filter((r) => r.status === 'pending').length;
this.viewModel = {
requests,
pendingCount,
totalCount: requests.length,
};
}
getViewModel(): TeamJoinRequestsViewModel | null {
return this.viewModel;
}
get requests(): unknown[] {
return this.viewModel?.requests ?? [];
}
}
class TestDriverTeamPresenter implements IDriverTeamPresenter {
viewModel: DriverTeamViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(input: DriverTeamResultDTO): void {
const { team, membership, driverId } = input;
const isOwner = team.ownerId === driverId;
const canManage = membership.role === 'owner' || membership.role === 'manager';
this.viewModel = {
team: {
id: team.id,
name: team.name,
tag: team.tag,
description: team.description,
ownerId: team.ownerId,
leagues: team.leagues,
},
membership: {
role: (membership.role === 'owner' || membership.role === 'manager') ? membership.role : 'member' as "owner" | "member" | "manager",
joinedAt: membership.joinedAt.toISOString(),
isActive: membership.status === 'active',
},
isOwner,
canManage,
};
}
getViewModel(): DriverTeamViewModel | null {
return this.viewModel;
}
}
let allTeamsPresenter: TestAllTeamsPresenter;
let teamDetailsPresenter: TestTeamDetailsPresenter;
let teamMembersPresenter: TestTeamMembersPresenter;
let teamJoinRequestsPresenter: TestTeamJoinRequestsPresenter;
let driverTeamPresenter: TestDriverTeamPresenter;
beforeEach(() => {
teamRepo = new InMemoryTeamRepository();
membershipRepo = new InMemoryTeamMembershipRepository();
createTeam = new CreateTeamUseCase(teamRepo, membershipRepo);
joinTeam = new JoinTeamUseCase(teamRepo, membershipRepo);
leaveTeam = new LeaveTeamUseCase(membershipRepo);
approveJoin = new ApproveTeamJoinRequestUseCase(membershipRepo);
rejectJoin = new RejectTeamJoinRequestUseCase(membershipRepo);
updateTeamUseCase = new UpdateTeamUseCase(teamRepo, membershipRepo);
allTeamsPresenter = new TestAllTeamsPresenter();
getAllTeamsUseCase = new GetAllTeamsUseCase(
teamRepo,
membershipRepo,
);
teamDetailsPresenter = new TestTeamDetailsPresenter();
getTeamDetailsUseCase = new GetTeamDetailsUseCase(
teamRepo,
membershipRepo,
);
const driverRepository = new FakeDriverRepository();
const imageService = new FakeImageService();
teamMembersPresenter = new TestTeamMembersPresenter();
getTeamMembersUseCase = new GetTeamMembersUseCase(
membershipRepo,
driverRepository,
imageService,
teamMembersPresenter,
);
teamJoinRequestsPresenter = new TestTeamJoinRequestsPresenter();
getTeamJoinRequestsUseCase = new GetTeamJoinRequestsUseCase(
membershipRepo,
driverRepository,
imageService,
teamJoinRequestsPresenter,
);
driverTeamPresenter = new TestDriverTeamPresenter();
getDriverTeamUseCase = new GetDriverTeamUseCase(
teamRepo,
membershipRepo,
driverTeamPresenter,
);
});
it('creates a team and assigns creator as active owner', async () => {
const ownerId = 'driver-1';
const result = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: ['league-1'],
});
expect(result.team.id).toBeDefined();
expect(result.team.ownerId).toBe(ownerId);
const membership = await membershipRepo.getActiveMembershipForDriver(ownerId);
expect(membership?.teamId).toBe(result.team.id);
expect(membership?.role as TeamRole).toBe('owner');
expect(membership?.status as TeamMembershipStatus).toBe('active');
});
it('prevents driver from joining multiple teams and mirrors legacy error message', async () => {
const ownerId = 'driver-1';
const otherTeamId = 'team-2';
// Seed an existing active membership
membershipRepo.seedMembership({
teamId: otherTeamId,
driverId: ownerId,
role: 'driver',
status: 'active',
joinedAt: new Date('2024-02-01'),
});
await expect(
joinTeam.execute({ teamId: 'team-1', driverId: ownerId }),
).rejects.toThrow('Driver already belongs to a team');
});
it('approves a join request and moves it into active membership', async () => {
const teamId = 'team-1';
const driverId = 'driver-2';
const request: TeamJoinRequest = {
id: 'req-1',
teamId,
driverId,
requestedAt: new Date('2024-03-01'),
message: 'Let me in',
};
membershipRepo.seedJoinRequest(request);
await approveJoin.execute({ requestId: request.id });
const membership = await membershipRepo.getMembership(teamId, driverId);
expect(membership).not.toBeNull();
expect(membership?.status as TeamMembershipStatus).toBe('active');
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
});
it('rejects a join request and removes it', async () => {
const teamId = 'team-1';
const driverId = 'driver-2';
const request: TeamJoinRequest = {
id: 'req-2',
teamId,
driverId,
requestedAt: new Date('2024-03-02'),
message: 'Please?',
};
membershipRepo.seedJoinRequest(request);
await rejectJoin.execute({ requestId: request.id });
const remainingRequests = await membershipRepo.getJoinRequests(teamId);
expect(remainingRequests.find((r) => r.id === request.id)).toBeUndefined();
});
it('updates team details when performed by owner or manager and reflects in queries', async () => {
const ownerId = 'driver-1';
const created = await createTeam.execute({
name: 'Original Name',
tag: 'ORIG',
description: 'Original description',
ownerId,
leagues: [],
});
await updateTeamUseCase.execute({
teamId: created.team.id,
updates: { name: 'Updated Name', description: 'Updated description' },
updatedBy: ownerId,
});
await getTeamDetailsUseCase.execute({ teamId: created.team.id, driverId: ownerId }, teamDetailsPresenter);
expect(teamDetailsPresenter.viewModel.team.name).toBe('Updated Name');
expect(teamDetailsPresenter.viewModel.team.description).toBe('Updated description');
});
it('returns driver team via query matching legacy getDriverTeam behavior', async () => {
const ownerId = 'driver-1';
const { team } = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: [],
});
await getDriverTeamUseCase.execute({ driverId: ownerId }, driverTeamPresenter);
const result = driverTeamPresenter.viewModel;
expect(result).not.toBeNull();
expect(result?.team.id).toBe(team.id);
expect(result?.membership.isActive).toBe(true);
expect(result?.isOwner).toBe(true);
});
it('lists all teams and members via queries after multiple operations', async () => {
const ownerId = 'driver-1';
const otherDriverId = 'driver-2';
const { team } = await createTeam.execute({
name: 'Apex Racing',
tag: 'APEX',
description: 'Professional GT3 racing',
ownerId,
leagues: [],
});
await joinTeam.execute({ teamId: team.id, driverId: otherDriverId });
await getAllTeamsUseCase.execute(undefined as void, allTeamsPresenter);
expect(allTeamsPresenter.teams.length).toBe(1);
await getTeamMembersUseCase.execute({ teamId: team.id }, teamMembersPresenter);
const memberIds = teamMembersPresenter.members.map((m) => m.driverId).sort();
expect(memberIds).toEqual([ownerId, otherDriverId].sort());
});
});

View File

@@ -1,361 +0,0 @@
import { test, expect } from '@playwright/test';
import {
setWebsiteAuthContext,
} from './websiteAuth';
import {
getWebsiteRouteInventory,
resolvePathTemplate,
} from './websiteRouteInventory';
/**
* Website Authentication Flow Integration Tests
*
* These tests verify the complete authentication flow including:
* - Middleware route protection
* - AuthGuard component functionality
* - Session management and loading states
* - Role-based access control
* - Auth state transitions
* - API integration
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('Website Auth Flow - Middleware Protection', () => {
const routes = getWebsiteRouteInventory();
// Test public routes are accessible without auth
test('public routes are accessible without authentication', async ({ page, context }) => {
const publicRoutes = routes.filter(r => r.access === 'public');
expect(publicRoutes.length).toBeGreaterThan(0);
for (const route of publicRoutes.slice(0, 5)) { // Test first 5 to keep test fast
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
// Test protected routes redirect unauthenticated users
test('protected routes redirect unauthenticated users to login', async ({ page, context }) => {
const protectedRoutes = routes.filter(r => r.access !== 'public');
expect(protectedRoutes.length).toBeGreaterThan(0);
for (const route of protectedRoutes.slice(0, 3)) { // Test first 3
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(resolvedPath);
}
});
// Test authenticated users can access protected routes
test('authenticated users can access protected routes', async ({ page, context }) => {
const authRoutes = routes.filter(r => r.access === 'auth');
expect(authRoutes.length).toBeGreaterThan(0);
for (const route of authRoutes.slice(0, 3)) {
const resolvedPath = resolvePathTemplate(route.pathTemplate, route.params);
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}${resolvedPath}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
});
test.describe('Website Auth Flow - AuthGuard Component', () => {
test('dashboard route shows loading state then content', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const navigationPromise = page.waitForNavigation({ waitUntil: 'domcontentloaded' });
await page.goto(`${getWebsiteBaseUrl()}/dashboard`);
await navigationPromise;
// Should show loading briefly then render dashboard
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/dashboard');
});
test('dashboard redirects unauthenticated users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login with returnTo parameter
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard');
});
test('admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
// Should redirect to login (no admin role)
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain('/admin');
await expect(page.locator('body')).toBeVisible();
});
test('sponsor routes require sponsor role', async ({ page, context }) => {
// Test as driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as sponsor (should be allowed)
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain('/sponsor/dashboard');
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('Website Auth Flow - Session Management', () => {
test('session is properly loaded on page visit', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Verify session is available by checking for user-specific content
// (This would depend on your actual UI, but we can verify no errors)
await expect(page.locator('body')).toBeVisible();
});
test('logout clears session and redirects', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Go to dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('body')).toBeVisible();
// Find and click logout (assuming it exists)
// This test would need to be adapted based on actual logout implementation
// For now, we'll test that clearing cookies works
await context.clearCookies();
await page.reload({ waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('auth state transitions work correctly', async ({ page, context }) => {
// Start unauthenticated
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
// Simulate login by setting auth context
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/dashboard');
// Simulate logout
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
});
});
test.describe('Website Auth Flow - API Integration', () => {
test('session endpoint returns correct data', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Direct API call to verify session endpoint
const response = await page.request.get(`${getWebsiteBaseUrl()}/api/auth/session`);
expect(response.ok()).toBe(true);
const session = await response.json();
expect(session).toBeDefined();
});
test('normal login flow works', async ({ page, context }) => {
// Clear any existing cookies
await context.clearCookies();
// Navigate to login page
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Verify login page loads
await expect(page.locator('body')).toBeVisible();
// Note: Actual login form interaction would go here
// For now, we'll test the API endpoint directly
const response = await page.request.post(`${getWebsiteBaseUrl()}/api/auth/login`, {
data: {
email: 'demo.driver@example.com',
password: 'Demo1234!'
}
});
expect(response.ok()).toBe(true);
// Verify cookies were set
const cookies = await context.cookies();
const gpSession = cookies.find(c => c.name === 'gp_session');
expect(gpSession).toBeDefined();
});
test('auth API handles login with seeded credentials', async ({ page }) => {
// Test normal login with seeded demo user credentials
const response = await page.request.post(`${getWebsiteBaseUrl()}/api/auth/login`, {
data: {
email: 'demo.driver@example.com',
password: 'Demo1234!'
}
});
expect(response.ok()).toBe(true);
const session = await response.json();
expect(session.user).toBeDefined();
expect(session.user.email).toBe('demo.driver@example.com');
});
});
test.describe('Website Auth Flow - Edge Cases', () => {
test('handles auth state drift gracefully', async ({ page, context }) => {
// Set sponsor context but with missing sponsor ID
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'missing-sponsor-id' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login due to invalid session
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles expired session', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'expired' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles invalid session cookie', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('public routes accessible even with invalid auth cookies', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/leagues`, { waitUntil: 'domcontentloaded' });
// Should still work
expect(page.url()).toContain('/leagues');
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('Website Auth Flow - Redirect Scenarios', () => {
test('auth routes redirect authenticated users away', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Try to access login page while authenticated
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to sponsor dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
test('returnTo parameter works correctly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const targetRoute = '/leagues/league-1/settings';
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Should redirect to login with returnTo
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(targetRoute);
// After login, should return to target
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(targetRoute);
});
});
test.describe('Website Auth Flow - Performance', () => {
test('auth verification completes quickly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete within reasonable time (under 5 seconds)
expect(endTime - startTime).toBeLessThan(5000);
// Should show content
await expect(page.locator('body')).toBeVisible();
});
test('no infinite loading states', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Monitor for loading indicators
let loadingCount = 0;
page.on('request', (req) => {
if (req.url().includes('/auth/session')) loadingCount++;
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'networkidle' });
// Should not make excessive session calls
expect(loadingCount).toBeLessThan(3);
// Should eventually show content
await expect(page.locator('body')).toBeVisible();
});
});

View File

@@ -1,343 +0,0 @@
import { test, expect } from '@playwright/test';
import { setWebsiteAuthContext } from './websiteAuth';
/**
* Website AuthGuard Component Tests
*
* These tests verify the AuthGuard component behavior:
* - Loading states during session verification
* - Redirect behavior for unauthorized access
* - Role-based access control
* - Component rendering with different auth states
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('AuthGuard Component - Loading States', () => {
test('shows loading state during session verification', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Monitor for loading indicators
page.on('request', async (req) => {
if (req.url().includes('/auth/session')) {
// Check if loading indicator is visible during session fetch
await page.locator('text=/Verifying authentication|Loading/').isVisible().catch(() => false);
}
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should eventually show dashboard content
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/dashboard');
});
test('handles rapid auth state changes', async ({ page, context }) => {
// Start unauthenticated
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
// Quickly switch to authenticated
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/dashboard');
// Quickly switch back
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(new URL(page.url()).pathname).toBe('/auth/login');
});
test('handles session fetch failures gracefully', async ({ page, context }) => {
// Clear cookies to simulate session fetch returning null
await context.clearCookies();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
});
test.describe('AuthGuard Component - Redirect Behavior', () => {
test('redirects to login with returnTo parameter', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const protectedRoutes = [
'/dashboard',
'/profile',
'/leagues/league-1/settings',
'/sponsor/dashboard',
];
for (const route of protectedRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('redirects back to protected route after login', async ({ page, context }) => {
const targetRoute = '/leagues/league-1/settings';
// Start unauthenticated, try to access protected route
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Verify redirect to login
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(targetRoute);
// Simulate login by switching auth context
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
// Should be on target route
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe(targetRoute);
});
test('handles auth routes when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Try to access login page while authenticated
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
// Should redirect to dashboard
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/auth/login`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
});
test.describe('AuthGuard Component - Role-Based Access', () => {
test('admin routes allow admin users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'admin');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('admin routes block non-admin users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('sponsor routes allow sponsor users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('sponsor routes block non-sponsor users', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('league admin routes require league admin role', async ({ page, context }) => {
// Test as regular driver
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (has access to league admin routes)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/leagues/league-1/settings');
});
test('authenticated users can access auth-required routes', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const authRoutes = ['/dashboard', '/profile', '/onboarding'];
for (const route of authRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
});
test.describe('AuthGuard Component - Component Rendering', () => {
test('renders protected content when access granted', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should render the dashboard content
await expect(page.locator('body')).toBeVisible();
// Should not show loading or redirect messages
const loadingText = await page.locator('text=/Verifying authentication|Redirecting/').count();
expect(loadingText).toBe(0);
});
test('shows redirect message briefly before redirect', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
// This is hard to catch, but we can verify the final state
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should end up at login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles multiple AuthGuard instances on same page', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit a page that might have nested AuthGuards
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1`, { waitUntil: 'domcontentloaded' });
// Should render correctly
await expect(page.locator('body')).toBeVisible();
expect(page.url()).toContain('/leagues/league-1');
});
test('preserves child component state during auth checks', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Visit dashboard
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should maintain component state (no full page reload)
// This is verified by the fact that the page loads without errors
await expect(page.locator('body')).toBeVisible();
});
});
test.describe('AuthGuard Component - Error Handling', () => {
test('handles network errors during session check', async ({ page, context }) => {
// Clear cookies to simulate failed session check
await context.clearCookies();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles invalid session data', async ({ page, context }) => {
// Set invalid session cookie
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'invalid-cookie' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles expired session', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'expired' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
test('handles missing required role data', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor', { sessionDrift: 'missing-sponsor-id' });
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
// Should redirect to login
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
});
});
test.describe('AuthGuard Component - Performance', () => {
test('auth check completes within reasonable time', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete within 5 seconds
expect(endTime - startTime).toBeLessThan(5000);
});
test('no excessive session checks', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
let sessionCheckCount = 0;
page.on('request', (req) => {
if (req.url().includes('/auth/session')) {
sessionCheckCount++;
}
});
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'networkidle' });
// Should check session once or twice (initial + maybe one refresh)
expect(sessionCheckCount).toBeLessThan(3);
});
test('handles concurrent route access', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Navigate to multiple routes rapidly
const routes = ['/dashboard', '/profile', '/leagues', '/dashboard'];
for (const route of routes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(route);
}
});
});

View File

@@ -1,438 +0,0 @@
import { test, expect } from '@playwright/test';
import { setWebsiteAuthContext } from './websiteAuth';
/**
* Website Middleware Route Protection Tests
*
* These tests specifically verify the Next.js middleware behavior:
* - Public routes are always accessible
* - Protected routes require authentication
* - Auth routes redirect authenticated users
* - Role-based access control
*/
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
test.describe('Website Middleware - Public Route Protection', () => {
test('root route is publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('league routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const routes = ['/leagues', '/leagues/league-1', '/leagues/league-1/standings'];
for (const route of routes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
test('driver routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/drivers/driver-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('team routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/teams/team-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('race routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/races/race-1`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('leaderboard routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/leaderboards`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('sponsor signup is publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const response = await page.goto(`${getWebsiteBaseUrl()}/sponsor/signup`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
});
test('auth routes are publicly accessible', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/iracing',
];
for (const route of authRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
await expect(page.locator('body')).toBeVisible();
}
});
});
test.describe('Website Middleware - Protected Route Protection', () => {
test('dashboard redirects unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard');
});
test('profile routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const profileRoutes = ['/profile', '/profile/settings', '/profile/leagues'];
for (const route of profileRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('admin routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const adminRoutes = ['/admin', '/admin/users'];
for (const route of adminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('sponsor routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const sponsorRoutes = ['/sponsor', '/sponsor/dashboard', '/sponsor/settings'];
for (const route of sponsorRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('league admin routes redirect unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const leagueAdminRoutes = [
'/leagues/league-1/roster/admin',
'/leagues/league-1/schedule/admin',
'/leagues/league-1/settings',
'/leagues/league-1/stewarding',
'/leagues/league-1/wallet',
];
for (const route of leagueAdminRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(route);
}
});
test('onboarding redirects unauthenticated users to login', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
await page.goto(`${getWebsiteBaseUrl()}/onboarding`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/onboarding');
});
});
test.describe('Website Middleware - Authenticated Access', () => {
test('dashboard is accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain('/dashboard');
await expect(page.locator('body')).toBeVisible();
});
test('profile routes are accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const profileRoutes = ['/profile', '/profile/settings', '/profile/leagues'];
for (const route of profileRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain(route);
}
});
test('onboarding is accessible when authenticated', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const response = await page.goto(`${getWebsiteBaseUrl()}/onboarding`, { waitUntil: 'domcontentloaded' });
expect(response?.status()).toBe(200);
expect(page.url()).toContain('/onboarding');
});
});
test.describe('Website Middleware - Role-Based Access', () => {
test('admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/admin`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/admin');
});
test('sponsor routes require sponsor role', async ({ page, context }) => {
// Test as driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as sponsor (should be allowed)
await setWebsiteAuthContext(context, 'sponsor');
await page.goto(`${getWebsiteBaseUrl()}/sponsor/dashboard`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
});
test('league admin routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/leagues/league-1/settings`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/leagues/league-1/settings');
});
test('race stewarding routes require admin role', async ({ page, context }) => {
// Test as regular driver (should be denied)
await setWebsiteAuthContext(context, 'auth');
await page.goto(`${getWebsiteBaseUrl()}/races/race-1/stewarding`, { waitUntil: 'domcontentloaded' });
let currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
// Test as admin (should be allowed)
await setWebsiteAuthContext(context, 'admin');
await page.goto(`${getWebsiteBaseUrl()}/races/race-1/stewarding`, { waitUntil: 'domcontentloaded' });
currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/races/race-1/stewarding');
});
});
test.describe('Website Middleware - Auth Route Behavior', () => {
test('auth routes redirect authenticated users away', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/dashboard');
}
});
test('sponsor auth routes redirect to sponsor dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'sponsor');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/sponsor/dashboard');
}
});
test('admin auth routes redirect to admin dashboard', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'admin');
const authRoutes = [
'/auth/login',
'/auth/signup',
'/auth/iracing',
];
for (const route of authRoutes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/admin');
}
});
});
test.describe('Website Middleware - Edge Cases', () => {
test('handles trailing slashes correctly', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const routes = [
{ path: '/leagues', expected: '/leagues' },
{ path: '/leagues/', expected: '/leagues' },
{ path: '/drivers/driver-1', expected: '/drivers/driver-1' },
{ path: '/drivers/driver-1/', expected: '/drivers/driver-1' },
];
for (const { path, expected } of routes) {
await page.goto(`${getWebsiteBaseUrl()}${path}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(expected);
}
});
test('handles invalid routes gracefully', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const invalidRoutes = [
'/invalid-route',
'/leagues/invalid-id',
'/drivers/invalid-id',
];
for (const route of invalidRoutes) {
const response = await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
// Should either show 404 or redirect to a valid page
const status = response?.status();
const url = page.url();
expect([200, 404].includes(status ?? 0) || url.includes('/auth/login')).toBe(true);
}
});
test('preserves query parameters during redirects', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const targetRoute = '/dashboard?tab=settings&filter=active';
await page.goto(`${getWebsiteBaseUrl()}${targetRoute}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe('/dashboard?tab=settings&filter=active');
});
test('handles deeply nested routes', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const deepRoute = '/leagues/league-1/stewarding/protests/protest-1';
await page.goto(`${getWebsiteBaseUrl()}${deepRoute}`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
expect(currentUrl.searchParams.get('returnTo')).toBe(deepRoute);
});
});
test.describe('Website Middleware - Performance', () => {
test('middleware adds minimal overhead', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
const startTime = Date.now();
await page.goto(`${getWebsiteBaseUrl()}/leagues`, { waitUntil: 'domcontentloaded' });
const endTime = Date.now();
// Should complete quickly (under 3 seconds)
expect(endTime - startTime).toBeLessThan(3000);
});
test('no redirect loops', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'public');
// Try to access a protected route multiple times
for (let i = 0; i < 3; i++) {
await page.goto(`${getWebsiteBaseUrl()}/dashboard`, { waitUntil: 'domcontentloaded' });
const currentUrl = new URL(page.url());
expect(currentUrl.pathname).toBe('/auth/login');
}
});
test('handles rapid navigation', async ({ page, context }) => {
await setWebsiteAuthContext(context, 'auth');
// Navigate between multiple protected routes rapidly
const routes = ['/dashboard', '/profile', '/leagues', '/dashboard'];
for (const route of routes) {
await page.goto(`${getWebsiteBaseUrl()}${route}`, { waitUntil: 'domcontentloaded' });
expect(page.url()).toContain(route);
}
});
});

View File

@@ -1,161 +0,0 @@
import type { Page, BrowserContext } from '@playwright/test';
import type { RouteAccess } from './websiteRouteInventory';
export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor';
export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id';
export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date';
const demoSessionCookieCache = new Map<string, string>();
export function authContextForAccess(access: RouteAccess): WebsiteAuthContext {
if (access === 'public') return 'public';
if (access === 'auth') return 'auth';
if (access === 'admin') return 'admin';
return 'sponsor';
}
function getWebsiteBaseUrl(): string {
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
if (configured && configured.trim()) {
return configured.trim().replace(/\/$/, '');
}
return 'http://localhost:3100';
}
// Note: All authenticated contexts use the same seeded demo driver user
// Role-based access control is tested separately in integration tests
function extractCookieValue(setCookieHeader: string, cookieName: string): string | null {
// set-cookie header value: "name=value; Path=/; HttpOnly; ..."
// Do not split on comma (Expires contains commas). Just regex out the first cookie value.
const match = setCookieHeader.match(new RegExp(`(?:^|\\s)${cookieName}=([^;]+)`));
return match?.[1] ?? null;
}
async function ensureNormalSessionCookie(): Promise<string> {
const cached = demoSessionCookieCache.get('driver');
if (cached) return cached;
const baseUrl = getWebsiteBaseUrl();
const url = `${baseUrl}/api/auth/login`;
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({
email: 'demo.driver@example.com',
password: 'Demo1234!',
}),
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`Normal login failed. ${response.status} ${response.statusText}. ${body}`);
}
// In Node (playwright runner) `headers.get('set-cookie')` returns a single comma-separated string.
// Parse cookies by splitting on `, ` and taking the first `name=value` segment.
const rawSetCookie = response.headers.get('set-cookie') ?? '';
const cookieHeaderPairs = rawSetCookie
? rawSetCookie
.split(', ')
.map((chunk) => chunk.split(';')[0]?.trim())
.filter(Boolean)
: [];
const gpSessionPair = cookieHeaderPairs.find((pair) => pair.startsWith('gp_session='));
if (!gpSessionPair) {
throw new Error(
`Normal login did not return gp_session cookie. set-cookie header: ${rawSetCookie}`,
);
}
const gpSessionValue = extractCookieValue(gpSessionPair, 'gp_session');
if (!gpSessionValue) {
throw new Error(
`Normal login returned a gp_session cookie, but it could not be parsed. Pair: ${gpSessionPair}`,
);
}
demoSessionCookieCache.set('driver', gpSessionValue);
return gpSessionValue;
}
export async function setWebsiteAuthContext(
context: BrowserContext,
auth: WebsiteAuthContext,
options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {},
): Promise<void> {
const domain = 'localhost';
const base = { domain, path: '/' };
const driftCookie =
options.sessionDrift != null ? [{ ...base, name: 'gridpilot_session_drift', value: String(options.sessionDrift) }] : [];
const faultCookie =
options.faultMode != null ? [{ ...base, name: 'gridpilot_fault_mode', value: String(options.faultMode) }] : [];
await context.clearCookies();
if (auth === 'public') {
// Public access: no session cookie, only drift/fault cookies if specified
await context.addCookies([...driftCookie, ...faultCookie]);
return;
}
// For authenticated contexts, use normal login with seeded demo user
// Note: All auth contexts use the same seeded demo driver user for simplicity
// Role-based access control is tested separately in integration tests
const gpSessionValue = await ensureNormalSessionCookie();
// Only set gp_session cookie (no demo mode or sponsor cookies)
// For Docker/local testing, ensure cookies work with localhost
const sessionCookie = [{
...base,
name: 'gp_session',
value: gpSessionValue,
httpOnly: true,
secure: false, // Localhost doesn't need HTTPS
sameSite: 'Lax' as const // Ensure compatibility
}];
await context.addCookies([...sessionCookie, ...driftCookie, ...faultCookie]);
}
export type ConsoleCapture = {
consoleErrors: string[];
pageErrors: string[];
};
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(String(err));
});
page.on('console', (msg) => {
const type = msg.type();
if (type !== 'error') return;
const text = msg.text();
// Filter known benign warnings (keep small + generic).
if (text.includes('Download the React DevTools')) return;
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
if (text.includes('Image is missing required "alt" property')) return;
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
consoleErrors.push(`[${type}] ${text}`);
});
return { consoleErrors, pageErrors };
}

View File

@@ -1,194 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
export type RouteAccess = 'public' | 'auth' | 'admin' | 'sponsor';
export type RouteParams = Record<string, string>;
export type WebsiteRouteDefinition = {
pathTemplate: string;
params?: RouteParams;
access: RouteAccess;
expectedPathTemplate?: string;
allowNotFound?: boolean;
};
function walkDir(rootDir: string): string[] {
const entries = fs.readdirSync(rootDir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath));
continue;
}
results.push(fullPath);
}
return results;
}
function toPathTemplate(appDir: string, pageFilePath: string): string {
const rel = path.relative(appDir, pageFilePath);
const segments = rel.split(path.sep);
// drop trailing "page.tsx"
segments.pop();
// root page.tsx
if (segments.length === 0) return '/';
return `/${segments.join('/')}`;
}
export function listNextAppPageTemplates(appDir?: string): string[] {
const resolvedAppDir = appDir ?? path.join(process.cwd(), 'apps', 'website', 'app');
const files = walkDir(resolvedAppDir);
const pages = files.filter((f) => path.basename(f) === 'page.tsx');
return pages.map((pagePath) => toPathTemplate(resolvedAppDir, pagePath));
}
export function resolvePathTemplate(pathTemplate: string, params: RouteParams = {}): string {
return pathTemplate.replace(/\[([^\]]+)\]/g, (_match, key: string) => {
const replacement = params[key];
if (!replacement) {
throw new Error(`Missing route param "${key}" for template "${pathTemplate}"`);
}
return replacement;
});
}
// Default IDs used to resolve dynamic routes in smoke tests.
// These values must be supported by the docker mock API in docker-compose.test.yml.
const LEAGUE_ID = 'league-1';
const DRIVER_ID = 'driver-1';
const TEAM_ID = 'team-1';
const RACE_ID = 'race-1';
const PROTEST_ID = 'protest-1';
const ROUTE_META: Record<string, Omit<WebsiteRouteDefinition, 'pathTemplate'>> = {
'/': { access: 'public' },
'/404': { access: 'public' },
'/500': { access: 'public' },
'/admin': { access: 'admin' },
'/admin/users': { access: 'admin' },
'/auth/forgot-password': { access: 'public' },
'/auth/iracing': { access: 'public' },
'/auth/login': { access: 'public' },
'/auth/reset-password': { access: 'public' },
'/auth/signup': { access: 'public' },
'/dashboard': { access: 'auth' },
'/drivers': { access: 'public' },
'/drivers/[id]': { access: 'public', params: { id: DRIVER_ID } },
'/leaderboards': { access: 'public' },
'/leaderboards/drivers': { access: 'public' },
'/leagues': { access: 'public' },
'/leagues/create': { access: 'auth' },
'/leagues/[id]': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/roster/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/rulebook': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/settings': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/sponsorships': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/standings': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding/protests/[protestId]': {
access: 'admin',
params: { id: LEAGUE_ID, protestId: PROTEST_ID },
},
'/leagues/[id]/wallet': { access: 'admin', params: { id: LEAGUE_ID } },
'/onboarding': { access: 'auth' },
'/profile': { access: 'auth' },
'/profile/leagues': { access: 'auth' },
'/profile/liveries': { access: 'auth' },
'/profile/liveries/upload': { access: 'auth' },
'/profile/settings': { access: 'auth' },
'/profile/sponsorship-requests': { access: 'auth' },
'/races': { access: 'public' },
'/races/all': { access: 'public' },
'/races/[id]': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/results': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/stewarding': { access: 'admin', params: { id: RACE_ID } },
'/sponsor': { access: 'sponsor', expectedPathTemplate: '/sponsor/dashboard' },
'/sponsor/billing': { access: 'sponsor' },
'/sponsor/campaigns': { access: 'sponsor' },
'/sponsor/dashboard': { access: 'sponsor' },
'/sponsor/leagues': { access: 'sponsor' },
'/sponsor/leagues/[id]': { access: 'sponsor', params: { id: LEAGUE_ID } },
'/sponsor/settings': { access: 'sponsor' },
'/sponsor/signup': { access: 'public' },
'/teams': { access: 'public' },
'/teams/leaderboard': { access: 'public' },
'/teams/[id]': { access: 'public', params: { id: TEAM_ID } },
};
export function getWebsiteRouteInventory(): WebsiteRouteDefinition[] {
const discovered = listNextAppPageTemplates();
const missingMeta = discovered.filter((template) => !ROUTE_META[template]);
if (missingMeta.length > 0) {
throw new Error(
`Missing ROUTE_META entries for discovered pages:\n${missingMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
const extraMeta = Object.keys(ROUTE_META).filter((template) => !discovered.includes(template));
if (extraMeta.length > 0) {
throw new Error(
`ROUTE_META contains templates that are not present as page.tsx routes:\n${extraMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
return discovered
.slice()
.sort()
.map((pathTemplate) => ({ pathTemplate, ...ROUTE_META[pathTemplate] }));
}
export function getWebsiteParamEdgeCases(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/races/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
{ pathTemplate: '/leagues/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
];
}
export function getWebsiteFaultInjectionRoutes(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/leagues/[id]', params: { id: LEAGUE_ID }, access: 'public' },
{ pathTemplate: '/leagues/[id]/schedule/admin', params: { id: LEAGUE_ID }, access: 'admin' },
{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' },
{ pathTemplate: '/races/[id]', params: { id: RACE_ID }, access: 'public' },
];
}
export function getWebsiteAuthDriftRoutes(): WebsiteRouteDefinition[] {
return [{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' }];
}

View File

@@ -0,0 +1,55 @@
import { Page } from '@playwright/test';
export interface CapturedError {
type: 'console' | 'page';
message: string;
stack?: string;
timestamp: number;
}
export class ConsoleErrorCapture {
private errors: CapturedError[] = [];
constructor(private page: Page) {
this.setupCapture();
}
private setupCapture(): void {
this.page.on('console', (msg) => {
if (msg.type() === 'error') {
this.errors.push({
type: 'console',
message: msg.text(),
timestamp: Date.now(),
});
}
});
this.page.on('pageerror', (error) => {
this.errors.push({
type: 'page',
message: error.message,
stack: error.stack ?? '',
timestamp: Date.now(),
});
});
}
public getErrors(): CapturedError[] {
return this.errors;
}
public hasErrors(): boolean {
return this.errors.length > 0;
}
public clear(): void {
this.errors = [];
}
public async waitForErrors(timeout: number = 1000): Promise<boolean> {
await this.page.waitForTimeout(timeout);
return this.hasErrors();
}
}

View File

@@ -0,0 +1,20 @@
import { BrowserContext, Browser } from '@playwright/test';
export interface AuthContext {
context: BrowserContext;
role: 'auth' | 'admin' | 'sponsor';
}
export class WebsiteAuthManager {
static async createAuthContext(
browser: Browser,
role: 'auth' | 'admin' | 'sponsor'
): Promise<AuthContext> {
const context = await browser.newContext();
return {
context,
role,
};
}
}

View File

@@ -0,0 +1,96 @@
import { routes, routeMatchers } from '../../../apps/website/lib/routing/RouteConfig';
import type { RouteGroup } from '../../../apps/website/lib/routing/RouteConfig';
export type RouteAccess = 'public' | 'auth' | 'admin' | 'sponsor';
export type RouteParams = Record<string, string>;
export interface WebsiteRouteDefinition {
pathTemplate: string;
params?: RouteParams;
access: RouteAccess;
expectedPathTemplate?: string;
allowNotFound?: boolean;
}
export class WebsiteRouteManager {
private static readonly IDs = {
LEAGUE: 'league-1',
DRIVER: 'driver-1',
TEAM: 'team-1',
RACE: 'race-1',
PROTEST: 'protest-1',
} as const;
public resolvePathTemplate(pathTemplate: string, params: RouteParams = {}): string {
return pathTemplate.replace(/\[([^\]]+)\]/g, (_match, key) => {
const replacement = params[key];
if (!replacement) {
throw new Error(`Missing route param "${key}" for template "${pathTemplate}"`);
}
return replacement;
});
}
public getWebsiteRouteInventory(): WebsiteRouteDefinition[] {
const result: WebsiteRouteDefinition[] = [];
const processGroup = (group: keyof RouteGroup, groupRoutes: Record<string, string | ((id: string) => string)>) => {
Object.values(groupRoutes).forEach((value) => {
if (typeof value === 'function') {
const template = value(WebsiteRouteManager.IDs.LEAGUE);
result.push({
pathTemplate: template,
params: { id: WebsiteRouteManager.IDs.LEAGUE },
access: group as RouteAccess,
});
} else {
result.push({
pathTemplate: value,
access: group as RouteAccess,
});
}
});
};
processGroup('auth', routes.auth);
processGroup('public', routes.public);
processGroup('protected', routes.protected);
processGroup('sponsor', routes.sponsor);
processGroup('admin', routes.admin);
processGroup('league', routes.league);
processGroup('race', routes.race);
processGroup('team', routes.team);
processGroup('driver', routes.driver);
processGroup('error', routes.error);
return result.sort((a, b) => a.pathTemplate.localeCompare(b.pathTemplate));
}
public getParamEdgeCases(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/races/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
{ pathTemplate: '/leagues/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
];
}
public getFaultInjectionRoutes(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/leagues/[id]', params: { id: WebsiteRouteManager.IDs.LEAGUE }, access: 'public' },
{ pathTemplate: '/leagues/[id]/schedule/admin', params: { id: WebsiteRouteManager.IDs.LEAGUE }, access: 'admin' },
{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' },
{ pathTemplate: '/races/[id]', params: { id: WebsiteRouteManager.IDs.RACE }, access: 'public' },
];
}
public getAuthDriftRoutes(): WebsiteRouteDefinition[] {
return [{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' }];
}
public getAccessLevel(pathTemplate: string): RouteAccess {
if (routeMatchers.isInGroup(pathTemplate, 'public')) return 'public';
if (routeMatchers.isInGroup(pathTemplate, 'admin')) return 'admin';
if (routeMatchers.isInGroup(pathTemplate, 'sponsor')) return 'sponsor';
if (routeMatchers.requiresAuth(pathTemplate)) return 'auth';
return 'public';
}
}