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

@@ -11,7 +11,6 @@ import { AuthorizationGuard } from '../auth/AuthorizationGuard';
import type { AuthorizationService } from '../auth/AuthorizationService';
import { FeatureAvailabilityGuard } from '../policy/FeatureAvailabilityGuard';
import type { PolicyService, PolicySnapshot } from '../policy/PolicyService';
import { createHttpContractHarness } from '../../shared/testing/httpContractHarness';
describe('LeagueController', () => {
let controller: LeagueController;
@@ -143,18 +142,21 @@ describe('LeagueController', () => {
transferLeagueOwnership: vi.fn(async () => ({ success: true })),
};
const harness = await createHttpContractHarness({
const module = await Test.createTestingModule({
controllers: [LeagueController],
providers: [{ provide: LeagueService, useValue: leagueService }],
});
}).compile();
const app = module.createNestApplication();
await app.init();
try {
await harness.http
await request(app.getHttpServer())
.post('/leagues/l1/transfer-ownership')
.send({ currentOwnerId: 'spoof', newOwnerId: 'o2' })
.expect(400);
} finally {
await harness.close();
await app.close();
}
});
});

View File

@@ -1,57 +0,0 @@
import { ValidationPipe } from '@nestjs/common';
import type { Provider, Type } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
import request from 'supertest';
export type ProviderOverride = {
provide: unknown;
useValue: unknown;
};
export type HttpContractHarness = {
// Avoid exporting INestApplication here because this repo can end up with
// multiple @nestjs/common type roots (workspace hoisting), which makes the
// INestApplication types incompatible.
app: unknown;
module: TestingModule;
http: ReturnType<typeof request>;
close: () => Promise<void>;
};
export async function createHttpContractHarness(options: {
controllers: Array<Type<unknown>>;
providers?: Provider[];
overrides?: ProviderOverride[];
}): Promise<HttpContractHarness> {
let moduleBuilder = Test.createTestingModule({
controllers: options.controllers,
providers: options.providers ?? [],
});
for (const override of options.overrides ?? []) {
moduleBuilder = moduleBuilder.overrideProvider(override.provide).useValue(override.useValue);
}
const module = await moduleBuilder.compile();
const app = module.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
return {
app,
module,
http: request(app.getHttpServer()),
close: async () => {
await app.close();
},
};
}

View File

@@ -21,12 +21,12 @@ export class AdminUserOrmEntity {
@Column({ type: 'text', nullable: true })
primaryDriverId?: string;
@Column({ type: 'datetime', nullable: true })
@Column({ type: 'timestamp', nullable: true })
lastLoginAt?: Date;
@CreateDateColumn({ type: 'datetime' })
@CreateDateColumn({ type: 'timestamp' })
createdAt!: Date;
@UpdateDateColumn({ type: 'datetime' })
@UpdateDateColumn({ type: 'timestamp' })
updatedAt!: Date;
}

View File

@@ -78,41 +78,6 @@ services:
retries: 30
start_period: 10s
# Website server for integration tests
website:
image: node:20-alpine
working_dir: /app/apps/website
environment:
- NODE_ENV=test
- NEXT_TELEMETRY_DISABLED=1
- API_BASE_URL=http://api:3000
ports:
- "3100:3000"
volumes:
- ./:/app
- /Users/marcmintel/Projects/gridpilot/node_modules:/app/node_modules:ro
command: ["sh", "-lc", "echo '[website] Waiting for API...'; npm run dev --workspace=@gridpilot/website"]
depends_on:
ready:
condition: service_completed_successfully
api:
condition: service_healthy
networks:
- gridpilot-test-network
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 5s
timeout: 10s
retries: 15
start_period: 30s
networks:
gridpilot-test-network:
driver: bridge

View File

@@ -97,10 +97,10 @@
"docker:prod:down": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml down",
"docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f",
"docker:test:clean": "sh -lc \"docker-compose -p gridpilot-test -f docker-compose.test.yml down -v --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fsv || true\"",
"docker:test:deps": "echo '[docker:test] Dependencies check (using host node_modules)...' && test -d node_modules && test -f node_modules/.package-lock.json && echo '[docker:test] ✓ Dependencies ready' || (echo '[docker:test] ✗ Dependencies missing - run: npm install' && exit 1)",
"docker:test:down": "sh -lc \"docker-compose -p gridpilot-test -f docker-compose.test.yml down --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fs || true\"",
"docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d ready api",
"docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[wait] '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[wait] '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3101/health','api');})();\"",
"docker:test:deps": "echo '[docker] Checking dependencies...'; test -d node_modules && test -f node_modules/.package-lock.json && echo '[docker] ✓ Dependencies ready' || (echo '[docker] ✗ Dependencies missing - run: npm install' && exit 1)",
"docker:test:down": "sh -lc \"echo '[docker] Stopping test environment...'; docker-compose -p gridpilot-test -f docker-compose.test.yml down --remove-orphans || true; docker-compose -p gridpilot-test -f docker-compose.test.yml rm -fs || true; echo '[docker] Test environment stopped'\"",
"docker:test:up": "sh -lc \"echo '[docker] Starting test environment...'; COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d ready api; echo '[docker] Services starting... (this may take a moment)'\"",
"docker:test:wait": "node -e \"const sleep=(ms)=>new Promise(r=>setTimeout(r,ms)); const wait=async(url,label)=>{console.log('[docker] Waiting for '+label+'...'); for(let i=0;i<90;i++){try{const r=await fetch(url); if(r.ok){console.log('[docker] ✓ '+label+' ready'); return;} }catch{} await sleep(1000);} console.error('[docker] ✗ '+label+' not ready: '+url); process.exit(1);}; (async()=>{await wait('http://localhost:3101/health','API');})();\"",
"dom:process": "npx tsx scripts/dom-export/processWorkflows.ts",
"env:website:merge": "node scripts/merge-website-env.js",
"generate-templates": "npx tsx scripts/generate-templates/index.ts",
@@ -112,13 +112,14 @@
"minify-fixtures:force": "npx tsx scripts/minify-fixtures.ts --force",
"prepare": "husky install || true",
"smoke:website": "npm run website:build && npx playwright test -c playwright.website.config.ts",
"smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts",
"smoke:website:docker": "npx playwright test -c playwright.website.config.ts",
"test": "vitest run \"$@\"",
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
"test:companion-hosted": "vitest run --config vitest.e2e.config.ts tests/e2e/companion/companion-ui-full-workflow.e2e.test.ts",
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
"test:contracts": "tsx scripts/run-contract-tests.ts",
"test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; echo '[docker:test] Setup complete - ready for tests'; npm run smoke:website:docker\"",
"test:docker:website": "sh -lc \"set -e; trap 'npm run docker:test:down' EXIT; npm run docker:test:deps; npm run docker:test:up; npm run docker:test:wait; echo '[docker] Running Playwright tests...'; npx playwright test -c playwright.website.config.ts\"",
"test:e2e:website": "sh -lc \"echo '🚀 Starting website e2e tests with Docker (TypeORM/PostgreSQL)...'; npm run test:docker:website\"",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:docker": "vitest run --config vitest.e2e.config.ts tests/e2e/docker/",
"test:hosted-real": "vitest run --config vitest.e2e.config.ts tests/e2e/hosted-real/",

View File

@@ -1,19 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
import { execSync } from 'child_process';
/**
* Playwright configuration for website smoke tests
*
*
* Purpose: Verify all website pages load without runtime errors
* Scope: Page rendering, console errors, React hydration
*
*
* Critical Detection:
* - Console errors during page load
* - React hydration mismatches
* - Navigation failures
* - Missing content
*
* IMPORTANT: This test requires Docker to run against real TypeORM/PostgreSQL
*/
// Enforce Docker usage
function validateDockerEnvironment(): void {
// Skip validation if explicitly requested (for CI or advanced users)
if (process.env.PLAYWRIGHT_SKIP_DOCKER_CHECK === 'true') {
console.warn('⚠️ Skipping Docker validation - assuming services are running');
return;
}
try {
// Check if Docker is running
execSync('docker info', { stdio: 'pipe' });
// Check if the required Docker services are running
const services = execSync('docker ps --format "{{.Names}}"', { encoding: 'utf8' });
// Look for services that match our test pattern
const testServices = services.split('\n').filter(s => s.includes('gridpilot-test'));
if (testServices.length === 0) {
console.error('❌ No test Docker services found running');
console.error('💡 Please run: docker-compose -f docker-compose.test.yml up -d');
console.error(' This will start:');
console.error(' - PostgreSQL database');
console.error(' - API server with TypeORM/PostgreSQL');
console.error(' - Website server');
process.exit(1);
}
// Check for specific required services (website runs locally, not in Docker)
const hasApi = testServices.some(s => s.includes('api'));
const hasDb = testServices.some(s => s.includes('db'));
if (!hasApi || !hasDb) {
console.error('❌ Missing required Docker services');
console.error(' Found:', testServices.join(', '));
console.error(' Required: api, db');
console.error('💡 Please run: docker-compose -f docker-compose.test.yml up -d');
process.exit(1);
}
console.log('✅ Docker environment validated');
} catch (error) {
console.error('❌ Docker is not available or not running');
console.error('💡 Please ensure Docker is installed and running, then run:');
console.error(' docker-compose -f docker-compose.test.yml up -d');
console.error('');
console.error(' Or skip this check with: PLAYWRIGHT_SKIP_DOCKER_CHECK=true npx playwright test');
process.exit(1);
}
}
// Run validation before config (unless in CI or explicitly skipped)
if (!process.env.CI && !process.env.PLAYWRIGHT_SKIP_DOCKER_CHECK) {
validateDockerEnvironment();
}
export default defineConfig({
testDir: './tests/smoke',
testDir: './tests/e2e/website',
testMatch: ['**/website-pages.test.ts'],
testIgnore: ['**/electron-build.smoke.test.ts'],
@@ -27,9 +87,9 @@ export default defineConfig({
// Timeout: Pages should load quickly
timeout: 30_000,
// Base URL for the website
// Base URL for the website (local dev server)
use: {
baseURL: 'http://localhost:3000',
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000', // Local website dev server
screenshot: 'only-on-failure',
video: 'retain-on-failure',
trace: 'retain-on-failure',
@@ -45,8 +105,7 @@ export default defineConfig({
retries: 0,
// Web server configuration
// Always start Next dev server locally (works on all architectures)
// API calls will be proxied to Docker API at localhost:3101
// Start local Next.js dev server that connects to Docker API
webServer: {
command: 'npm run dev -w @gridpilot/website',
url: 'http://localhost:3000',

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