tests cleanup
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
13
package.json
13
package.json
@@ -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/",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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' }];
|
||||
}
|
||||
55
tests/shared/website/ConsoleErrorCapture.ts
Normal file
55
tests/shared/website/ConsoleErrorCapture.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
tests/shared/website/WebsiteAuthManager.ts
Normal file
20
tests/shared/website/WebsiteAuthManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
96
tests/shared/website/WebsiteRouteManager.ts
Normal file
96
tests/shared/website/WebsiteRouteManager.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user