tests cleanup

This commit is contained in:
2026-01-03 16:51:40 +01:00
parent e151fe02d0
commit 540c0fcb7a
34 changed files with 395 additions and 4402 deletions

View File

@@ -1,170 +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';
export function authContextForAccess(access: RouteAccess): WebsiteAuthContext {
if (access === 'public') return 'public';
if (access === 'auth') return 'auth';
if (access === 'admin') return 'admin';
return 'sponsor';
}
export async function setWebsiteAuthContext(
context: BrowserContext,
auth: WebsiteAuthContext,
options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {},
): Promise<void> {
const domain = 'localhost';
const base = { domain, path: '/' };
// The website uses `gp_session` cookie for authentication
// For smoke tests, we use normal login API with seeded demo user credentials
// to get real session cookies
if (auth === 'public') {
// No authentication needed
await context.clearCookies();
return;
}
// For authenticated contexts, we need to perform a normal login
// This ensures we get real session cookies with proper structure
// Note: All auth contexts use the same seeded demo driver user for simplicity
// Role-based access control is tested separately in integration tests
// Call the normal login API with seeded demo user credentials
// Use demo.driver@example.com for all auth contexts (driver role)
const response = await fetch('http://localhost:3101/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: 'demo.driver@example.com',
password: 'Demo1234!',
}),
credentials: 'include',
});
if (!response.ok) {
throw new Error(`Normal login failed: ${response.status}`);
}
// Extract cookies from the response
const setCookieHeader = response.headers.get('set-cookie');
if (!setCookieHeader) {
throw new Error('No cookies set by normal login');
}
// Parse the Set-Cookie headers
const cookies = setCookieHeader.split(',').map(cookieStr => {
const parts = cookieStr.split(';').map(p => p.trim());
const [nameValue, ...attributes] = parts;
const [name, value] = nameValue.split('=');
const cookie: any = {
name,
value: decodeURIComponent(value),
domain: 'localhost',
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600, // 1 hour
httpOnly: false,
secure: false,
sameSite: 'Lax' as const
};
for (const attr of attributes) {
const [attrName, attrValue] = attr.split('=');
const lowerName = attrName.toLowerCase();
if (lowerName === 'path') cookie.path = attrValue;
else if (lowerName === 'httponly') cookie.httpOnly = true;
else if (lowerName === 'secure') cookie.secure = true;
else if (lowerName === 'samesite') cookie.sameSite = attrValue as any;
else if (lowerName === 'domain') {
// Skip domain from API - we'll use localhost
}
else if (lowerName === 'max-age') cookie.expires = Math.floor(Date.now() / 1000) + parseInt(attrValue);
}
// For Docker/local testing, ensure cookies work with localhost
// Playwright's context.addCookies requires specific settings for localhost
if (cookie.domain === 'localhost') {
cookie.secure = false; // Localhost doesn't need HTTPS
// Keep sameSite as provided by API, but ensure it's compatible
if (cookie.sameSite === 'None') {
// For SameSite=None, we need Secure=true, but localhost doesn't support it
// So we fall back to Lax for local testing
cookie.sameSite = 'Lax';
}
}
return cookie;
});
// Apply session drift or fault modes if specified
if (options.sessionDrift || options.faultMode) {
const sessionCookie = cookies.find(c => c.name === 'gp_session');
if (sessionCookie) {
if (options.sessionDrift) {
sessionCookie.value = `drift-${options.sessionDrift}-${sessionCookie.value}`;
}
if (options.faultMode) {
cookies.push({
name: 'gridpilot_fault_mode',
value: options.faultMode,
domain,
path: '/',
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: false,
secure: false,
sameSite: 'Lax' as const
});
}
}
}
// Clear existing cookies and add the new ones
await context.clearCookies();
await context.addCookies(cookies);
}
export type ConsoleCapture = {
consoleErrors: string[];
pageErrors: string[];
};
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(String(err));
});
page.on('console', (msg) => {
const type = msg.type();
if (type !== 'error') return;
const text = msg.text();
// Filter known benign warnings (keep small + generic).
if (text.includes('Download the React DevTools')) return;
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
if (text.includes('Image is missing required "alt" property')) return;
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
consoleErrors.push(`[${type}] ${text}`);
});
return { consoleErrors, pageErrors };
}

View File

@@ -1,193 +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/login': { access: 'public' },
'/auth/reset-password': { access: 'public' },
'/auth/signup': { access: 'public' },
'/dashboard': { access: 'auth' },
'/drivers': { access: 'public' },
'/drivers/[id]': { access: 'public', params: { id: DRIVER_ID } },
'/leaderboards': { access: 'public' },
'/leaderboards/drivers': { access: 'public' },
'/leagues': { access: 'public' },
'/leagues/create': { access: 'auth' },
'/leagues/[id]': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/roster/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/rulebook': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/schedule/admin': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/settings': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/sponsorships': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/standings': { access: 'public', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding': { access: 'admin', params: { id: LEAGUE_ID } },
'/leagues/[id]/stewarding/protests/[protestId]': {
access: 'admin',
params: { id: LEAGUE_ID, protestId: PROTEST_ID },
},
'/leagues/[id]/wallet': { access: 'admin', params: { id: LEAGUE_ID } },
'/onboarding': { access: 'auth' },
'/profile': { access: 'auth' },
'/profile/leagues': { access: 'auth' },
'/profile/liveries': { access: 'auth' },
'/profile/liveries/upload': { access: 'auth' },
'/profile/settings': { access: 'auth' },
'/profile/sponsorship-requests': { access: 'auth' },
'/races': { access: 'public' },
'/races/all': { access: 'public' },
'/races/[id]': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/results': { access: 'public', params: { id: RACE_ID } },
'/races/[id]/stewarding': { access: 'admin', params: { id: RACE_ID } },
'/sponsor': { access: 'sponsor', expectedPathTemplate: '/sponsor/dashboard' },
'/sponsor/billing': { access: 'sponsor' },
'/sponsor/campaigns': { access: 'sponsor' },
'/sponsor/dashboard': { access: 'sponsor' },
'/sponsor/leagues': { access: 'sponsor' },
'/sponsor/leagues/[id]': { access: 'sponsor', params: { id: LEAGUE_ID } },
'/sponsor/settings': { access: 'sponsor' },
'/sponsor/signup': { access: 'public' },
'/teams': { access: 'public' },
'/teams/leaderboard': { access: 'public' },
'/teams/[id]': { access: 'public', params: { id: TEAM_ID } },
};
export function getWebsiteRouteInventory(): WebsiteRouteDefinition[] {
const discovered = listNextAppPageTemplates();
const missingMeta = discovered.filter((template) => !ROUTE_META[template]);
if (missingMeta.length > 0) {
throw new Error(
`Missing ROUTE_META entries for discovered pages:\n${missingMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
const extraMeta = Object.keys(ROUTE_META).filter((template) => !discovered.includes(template));
if (extraMeta.length > 0) {
throw new Error(
`ROUTE_META contains templates that are not present as page.tsx routes:\n${extraMeta
.slice()
.sort()
.map((t) => `- ${t}`)
.join('\n')}`,
);
}
return discovered
.slice()
.sort()
.map((pathTemplate) => ({ pathTemplate, ...ROUTE_META[pathTemplate] }));
}
export function getWebsiteParamEdgeCases(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/races/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
{ pathTemplate: '/leagues/[id]', params: { id: 'does-not-exist' }, access: 'public', allowNotFound: true },
];
}
export function getWebsiteFaultInjectionRoutes(): WebsiteRouteDefinition[] {
return [
{ pathTemplate: '/leagues/[id]', params: { id: LEAGUE_ID }, access: 'public' },
{ pathTemplate: '/leagues/[id]/schedule/admin', params: { id: LEAGUE_ID }, access: 'admin' },
{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' },
{ pathTemplate: '/races/[id]', params: { id: RACE_ID }, access: 'public' },
];
}
export function getWebsiteAuthDriftRoutes(): WebsiteRouteDefinition[] {
return [{ pathTemplate: '/sponsor/dashboard', access: 'sponsor' }];
}

View File

@@ -1,262 +0,0 @@
/**
* TDD Tests for MediaResolverPort interface contract
*
* Tests cover:
* - Interface contract compliance
* - Method signatures
* - Return types
* - Error handling behavior
*/
import { MediaReference } from '@core/domain/media/MediaReference';
// Mock interface for testing
interface MediaResolverPort {
resolve(ref: MediaReference, baseUrl: string): Promise<string | null>;
}
describe('MediaResolverPort', () => {
let mockResolver: MediaResolverPort;
beforeEach(() => {
// Create a mock implementation for testing
mockResolver = {
resolve: jest.fn(async (ref: MediaReference, baseUrl: string): Promise<string | null> => {
// Mock implementation that returns different URLs based on type
switch (ref.type) {
case 'system-default':
return `${baseUrl}/defaults/${ref.variant}`;
case 'generated':
return `${baseUrl}/generated/${ref.generationRequestId}`;
case 'uploaded':
return `${baseUrl}/media/${ref.mediaId}`;
case 'none':
return null;
default:
return null;
}
})
};
});
describe('Interface Contract', () => {
it('should have a resolve method', () => {
expect(typeof mockResolver.resolve).toBe('function');
});
it('should accept MediaReference and string parameters', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com';
await expect(mockResolver.resolve(ref, baseUrl)).resolves.toBeDefined();
});
it('should return Promise<string | null>', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result === null || typeof result === 'string').toBe(true);
});
});
describe('System Default Resolution', () => {
it('should resolve system-default avatar to correct URL', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/defaults/avatar');
});
it('should resolve system-default logo to correct URL', async () => {
const ref = MediaReference.createSystemDefault('logo');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/defaults/logo');
});
});
describe('Generated Resolution', () => {
it('should resolve generated reference to correct URL', async () => {
const ref = MediaReference.createGenerated('req-123');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/generated/req-123');
});
it('should handle generated reference with special characters', async () => {
const ref = MediaReference.createGenerated('req-abc-123_XYZ');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/generated/req-abc-123_XYZ');
});
});
describe('Uploaded Resolution', () => {
it('should resolve uploaded reference to correct URL', async () => {
const ref = MediaReference.createUploaded('media-456');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/media/media-456');
});
it('should handle uploaded reference with special characters', async () => {
const ref = MediaReference.createUploaded('media-abc-456_XYZ');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/media/media-abc-456_XYZ');
});
});
describe('None Resolution', () => {
it('should resolve none reference to null', async () => {
const ref = MediaReference.createNone();
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBeNull();
});
});
describe('Base URL Handling', () => {
it('should handle base URL without trailing slash', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/defaults/avatar');
});
it('should handle base URL with trailing slash', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com/';
const result = await mockResolver.resolve(ref, baseUrl);
// Implementation should handle this consistently
expect(result).toBeTruthy();
});
it('should handle localhost URLs', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'http://localhost:3000';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('http://localhost:3000/defaults/avatar');
});
it('should handle relative URLs', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = '/api';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('/api/defaults/avatar');
});
});
describe('Error Handling', () => {
it('should handle null baseUrl gracefully', async () => {
const ref = MediaReference.createSystemDefault('avatar');
// This should not throw but handle gracefully
await expect(mockResolver.resolve(ref, null as any)).resolves.toBeDefined();
});
it('should handle empty baseUrl gracefully', async () => {
const ref = MediaReference.createSystemDefault('avatar');
// This should not throw but handle gracefully
await expect(mockResolver.resolve(ref, '')).resolves.toBeDefined();
});
it('should handle undefined baseUrl gracefully', async () => {
const ref = MediaReference.createSystemDefault('avatar');
// This should not throw but handle gracefully
await expect(mockResolver.resolve(ref, undefined as any)).resolves.toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle very long media IDs', async () => {
const longId = 'a'.repeat(1000);
const ref = MediaReference.createUploaded(longId);
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe(`https://api.example.com/media/${longId}`);
});
it('should handle Unicode characters in IDs', async () => {
const ref = MediaReference.createUploaded('media-日本語-123');
const baseUrl = 'https://api.example.com';
const result = await mockResolver.resolve(ref, baseUrl);
expect(result).toBe('https://api.example.com/media/media-日本語-123');
});
it('should handle multiple calls with different references', async () => {
const refs = [
MediaReference.createSystemDefault('avatar'),
MediaReference.createGenerated('req-123'),
MediaReference.createUploaded('media-456'),
MediaReference.createNone()
];
const baseUrl = 'https://api.example.com';
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref, baseUrl)));
expect(results).toEqual([
'https://api.example.com/defaults/avatar',
'https://api.example.com/generated/req-123',
'https://api.example.com/media/media-456',
null
]);
});
});
describe('Performance Considerations', () => {
it('should resolve quickly for simple cases', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const baseUrl = 'https://api.example.com';
const start = Date.now();
await mockResolver.resolve(ref, baseUrl);
const duration = Date.now() - start;
expect(duration).toBeLessThan(100); // Should be very fast
});
it('should handle concurrent resolutions', async () => {
const refs = Array.from({ length: 100 }, (_, i) =>
MediaReference.createUploaded(`media-${i}`)
);
const baseUrl = 'https://api.example.com';
const start = Date.now();
const results = await Promise.all(refs.map(ref => mockResolver.resolve(ref, baseUrl)));
const duration = Date.now() - start;
expect(results.length).toBe(100);
expect(duration).toBeLessThan(1000); // Should handle 100 concurrent calls quickly
});
});
});