website refactor

This commit is contained in:
2026-01-18 00:17:01 +01:00
parent 69d4cce7f1
commit 4b66c682a0
18 changed files with 847 additions and 87 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { getWebsiteRouteContracts } from '../../shared/website/RouteContractSpec';
import { getWebsiteRouteContracts, ScenarioRole } from '../../shared/website/RouteContractSpec';
import { WebsiteRouteManager } from '../../shared/website/WebsiteRouteManager';
import { RouteScenarioMatrix } from '../../shared/website/RouteScenarioMatrix';
describe('RouteContractSpec', () => {
const contracts = getWebsiteRouteContracts();
@@ -24,7 +25,46 @@ describe('RouteContractSpec', () => {
it('should have expectedStatus set for every contract', () => {
contracts.forEach(contract => {
expect(contract.expectedStatus).toBeDefined();
expect(['ok', 'redirect', 'notFoundAllowed', 'errorRoute']).toContain(contract.expectedStatus);
expect(['ok', 'redirect', 'forbidden', 'notFoundAllowed', 'errorRoute']).toContain(contract.expectedStatus);
});
});
it('should have required scenarios based on access level', () => {
contracts.forEach(contract => {
const scenarios = Object.keys(contract.scenarios) as ScenarioRole[];
// All routes must have unauth, auth, admin, sponsor scenarios
expect(scenarios).toContain('unauth');
expect(scenarios).toContain('auth');
expect(scenarios).toContain('admin');
expect(scenarios).toContain('sponsor');
// Admin and Sponsor routes must also have wrong-role scenario
if (contract.accessLevel === 'admin' || contract.accessLevel === 'sponsor') {
expect(scenarios).toContain('wrong-role');
}
});
});
it('should have correct scenario expectations for admin routes', () => {
const adminContracts = contracts.filter(c => c.accessLevel === 'admin');
adminContracts.forEach(contract => {
expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect');
expect(contract.scenarios.auth?.expectedStatus).toBe('redirect');
expect(contract.scenarios.admin?.expectedStatus).toBe('ok');
expect(contract.scenarios.sponsor?.expectedStatus).toBe('redirect');
expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect');
});
});
it('should have correct scenario expectations for sponsor routes', () => {
const sponsorContracts = contracts.filter(c => c.accessLevel === 'sponsor');
sponsorContracts.forEach(contract => {
expect(contract.scenarios.unauth?.expectedStatus).toBe('redirect');
expect(contract.scenarios.auth?.expectedStatus).toBe('redirect');
expect(contract.scenarios.admin?.expectedStatus).toBe('redirect');
expect(contract.scenarios.sponsor?.expectedStatus).toBe('ok');
expect(contract.scenarios['wrong-role']?.expectedStatus).toBe('redirect');
});
});
@@ -50,4 +90,20 @@ describe('RouteContractSpec', () => {
expect(contract.ssrMustNotContain).toContain('Application error: a client-side exception has occurred');
});
});
describe('RouteScenarioMatrix', () => {
it('should match the number of contracts', () => {
expect(RouteScenarioMatrix.length).toBe(contracts.length);
});
it('should correctly identify routes with param edge cases', () => {
const edgeCaseRoutes = RouteScenarioMatrix.filter(m => m.hasParamEdgeCases);
// Based on WebsiteRouteManager.getParamEdgeCases(), we expect at least /races/[id] and /leagues/[id]
expect(edgeCaseRoutes.length).toBeGreaterThanOrEqual(2);
const paths = edgeCaseRoutes.map(m => m.path);
expect(paths.some(p => p.startsWith('/races/'))).toBe(true);
expect(paths.some(p => p.startsWith('/leagues/'))).toBe(true);
});
});
});

View File

@@ -0,0 +1,139 @@
import { describe, test, beforeAll, afterAll, expect } from 'vitest';
import { getWebsiteRouteContracts, RouteContract } from '../../shared/website/RouteContractSpec';
import { WebsiteServerHarness } from '../harness/WebsiteServerHarness';
import { ApiServerHarness } from '../harness/ApiServerHarness';
import { HttpDiagnostics } from '../../shared/website/HttpDiagnostics';
const WEBSITE_BASE_URL = process.env.WEBSITE_BASE_URL || 'http://localhost:3005';
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3006';
// Ensure WebsiteRouteManager uses the same persistence mode as the API harness
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
describe('Website SSR Integration', () => {
let websiteHarness: WebsiteServerHarness | null = null;
let apiHarness: ApiServerHarness | null = null;
const contracts = getWebsiteRouteContracts();
beforeAll(async () => {
// 1. Start API
console.log(`[WebsiteSSR] Starting API harness on ${API_BASE_URL}...`);
apiHarness = new ApiServerHarness({
port: parseInt(new URL(API_BASE_URL).port) || 3006,
});
await apiHarness.start();
console.log(`[WebsiteSSR] API Harness started.`);
// 2. Start Website
console.log(`[WebsiteSSR] Starting website harness on ${WEBSITE_BASE_URL}...`);
websiteHarness = new WebsiteServerHarness({
port: parseInt(new URL(WEBSITE_BASE_URL).port) || 3005,
env: {
PORT: '3005',
API_BASE_URL: API_BASE_URL,
NEXT_PUBLIC_API_BASE_URL: API_BASE_URL,
NODE_ENV: 'test',
},
});
await websiteHarness.start();
console.log(`[WebsiteSSR] Website Harness started.`);
}, 180000);
afterAll(async () => {
if (websiteHarness) {
await websiteHarness.stop();
}
if (apiHarness) {
await apiHarness.stop();
}
});
test.each(contracts)('SSR for $path ($accessLevel)', async (contract: RouteContract) => {
const url = `${WEBSITE_BASE_URL}${contract.path}`;
const response = await fetch(url, {
method: 'GET',
redirect: 'manual',
});
const status = response.status;
const location = response.headers.get('location');
const html = await response.text();
const failureContext = {
url,
status,
location,
html: html.substring(0, 1000), // Limit HTML in logs
serverLogs: websiteHarness?.getLogTail(60),
};
const formatFailure = (extra: string) => HttpDiagnostics.formatHttpFailure({ ...failureContext, extra });
// 1. Assert Status
if (contract.expectedStatus === 'ok') {
if (status !== 200) {
throw new Error(formatFailure(`Expected status 200 OK, but got ${status}`));
}
} else if (contract.expectedStatus === 'redirect') {
if (status !== 302 && status !== 307) {
throw new Error(formatFailure(`Expected redirect status (302/307), but got ${status}`));
}
// 2. Assert Redirect Location
if (contract.expectedRedirectTo) {
if (!location) {
throw new Error(formatFailure(`Expected redirect to ${contract.expectedRedirectTo}, but got no Location header`));
}
const locationPathname = new URL(location, WEBSITE_BASE_URL).pathname;
if (locationPathname !== contract.expectedRedirectTo) {
throw new Error(formatFailure(`Expected redirect to pathname "${contract.expectedRedirectTo}", but got "${locationPathname}" (full: ${location})`));
}
}
} else if (contract.expectedStatus === 'notFoundAllowed') {
if (status !== 404 && status !== 200) {
throw new Error(formatFailure(`Expected 404 or 200 (notFoundAllowed), but got ${status}`));
}
} else if (contract.expectedStatus === 'errorRoute') {
// Error routes themselves should return 200 or their respective error codes (like 500)
if (status >= 600) {
throw new Error(formatFailure(`Error route returned unexpected status ${status}`));
}
}
// 3. Assert SSR HTML Markers (only if not a redirect)
if (status === 200 || status === 404) {
if (contract.ssrMustContain) {
for (const marker of contract.ssrMustContain) {
if (typeof marker === 'string') {
if (!html.includes(marker)) {
throw new Error(formatFailure(`SSR HTML missing expected marker: "${marker}"`));
}
} else if (marker instanceof RegExp) {
if (!marker.test(html)) {
throw new Error(formatFailure(`SSR HTML missing expected regex marker: ${marker}`));
}
}
}
}
if (contract.ssrMustNotContain) {
for (const marker of contract.ssrMustNotContain) {
if (typeof marker === 'string') {
if (html.includes(marker)) {
throw new Error(formatFailure(`SSR HTML contains forbidden marker: "${marker}"`));
}
} else if (marker instanceof RegExp) {
if (marker.test(html)) {
throw new Error(formatFailure(`SSR HTML contains forbidden regex marker: ${marker}`));
}
}
}
}
if (contract.minTextLength && html.length < contract.minTextLength) {
throw new Error(formatFailure(`SSR HTML length ${html.length} is less than minimum ${contract.minTextLength}`));
}
}
}, 30000);
});