1192 lines
35 KiB
JavaScript
1192 lines
35 KiB
JavaScript
const http = require('http');
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
|
|
const baseCors = {
|
|
'Access-Control-Allow-Credentials': 'true',
|
|
'Access-Control-Allow-Headers': 'Content-Type',
|
|
'Access-Control-Allow-Methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
|
};
|
|
|
|
function nowIso() {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function parseCookies(cookieHeader) {
|
|
if (!cookieHeader) return {};
|
|
const out = {};
|
|
const parts = String(cookieHeader).split(';');
|
|
for (const part of parts) {
|
|
const [rawKey, ...rest] = part.trim().split('=');
|
|
if (!rawKey) continue;
|
|
const rawValue = rest.join('=');
|
|
out[rawKey] = decodeURIComponent(rawValue || '');
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function getDemoMode(req) {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const raw = cookies.gridpilot_demo_mode || 'none';
|
|
if (raw === 'admin' || raw === 'driver' || raw === 'sponsor' || raw === 'none') return raw;
|
|
return 'none';
|
|
}
|
|
|
|
function getFaultMode(req) {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const raw = cookies.gridpilot_fault_mode || '';
|
|
if (raw === 'null-array' || raw === 'missing-field' || raw === 'invalid-date') return raw;
|
|
return null;
|
|
}
|
|
|
|
function getSessionDriftMode(req) {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const raw = cookies.gridpilot_session_drift || '';
|
|
if (raw === 'invalid-cookie' || raw === 'expired' || raw === 'missing-sponsor-id') return raw;
|
|
return null;
|
|
}
|
|
|
|
function sendJson(res, code, obj) {
|
|
res.statusCode = code;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify(obj));
|
|
}
|
|
|
|
function sendNull(res) {
|
|
res.statusCode = 200;
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end('null');
|
|
}
|
|
|
|
function readRequestBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let body = '';
|
|
req.on('data', (chunk) => {
|
|
body += chunk;
|
|
});
|
|
req.on('end', () => resolve(body));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
async function readJsonBody(req) {
|
|
const text = await readRequestBody(req);
|
|
if (!text) return null;
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizeArrayFields(obj, fields) {
|
|
if (!obj || typeof obj !== 'object') return obj;
|
|
const out = { ...obj };
|
|
for (const field of fields) {
|
|
if (out[field] == null) {
|
|
out[field] = [];
|
|
continue;
|
|
}
|
|
if (!Array.isArray(out[field])) {
|
|
out[field] = [];
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const ONE_BY_ONE_PNG_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pS0kAAAAASUVORK5CYII=';
|
|
|
|
function sendPng(res, code = 200) {
|
|
res.statusCode = code;
|
|
res.setHeader('content-type', 'image/png');
|
|
res.end(Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64'));
|
|
}
|
|
|
|
function escapeRegExp(value) {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function matchPathname(pathname, template) {
|
|
const re = new RegExp(`^${template.split('/').map(escapeRegExp).join('/')}$`);
|
|
return re.test(pathname);
|
|
}
|
|
|
|
function getPathParam(pathname, matcher) {
|
|
const match = pathname.match(matcher);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
function getSessionForMode(mode, req) {
|
|
if (mode === 'none') return null;
|
|
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
const sponsorId = cookies.gridpilot_sponsor_id || 'demo-sponsor-1';
|
|
|
|
if (mode === 'admin') {
|
|
return {
|
|
token: 'test-token-admin',
|
|
user: {
|
|
userId: 'user-admin',
|
|
email: 'admin@gridpilot.test',
|
|
displayName: 'Demo Admin',
|
|
primaryDriverId: 'driver-admin',
|
|
role: 'league-admin', // MATCH WEBSITE EXPECTATIONS
|
|
},
|
|
};
|
|
}
|
|
|
|
if (mode === 'sponsor') {
|
|
return {
|
|
token: 'test-token-sponsor',
|
|
user: {
|
|
userId: 'user-sponsor',
|
|
email: 'sponsor@gridpilot.test',
|
|
displayName: 'Demo Sponsor User',
|
|
primaryDriverId: 'driver-sponsor',
|
|
sponsorId,
|
|
role: 'sponsor', // MATCH WEBSITE EXPECTATIONS
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
token: 'test-token-driver',
|
|
user: {
|
|
userId: 'user-driver',
|
|
email: 'driver@gridpilot.test',
|
|
displayName: 'Demo Driver',
|
|
primaryDriverId: 'driver-1',
|
|
role: 'driver', // MATCH WEBSITE EXPECTATIONS
|
|
},
|
|
};
|
|
}
|
|
|
|
const DEMO = {
|
|
leagueId: 'league-1',
|
|
teamId: 'team-1',
|
|
raceId: 'race-1',
|
|
protestId: 'protest-1',
|
|
seasonId: 'season-1',
|
|
sponsorId: 'demo-sponsor-1',
|
|
};
|
|
|
|
function buildLeagueList() {
|
|
return {
|
|
leagues: [
|
|
{
|
|
id: DEMO.leagueId,
|
|
name: 'Demo League',
|
|
description: 'Demo league for docker smoke tests',
|
|
ownerId: 'driver-admin',
|
|
createdAt: nowIso(),
|
|
usedSlots: 2,
|
|
timingSummary: 'Weekly',
|
|
settings: { maxDrivers: 50 },
|
|
scoring: {
|
|
scoringPresetName: 'Demo rules',
|
|
scoringPatternSummary: 'Standard',
|
|
},
|
|
},
|
|
],
|
|
totalCount: 1,
|
|
};
|
|
}
|
|
|
|
function buildTeamsList() {
|
|
return {
|
|
teams: [
|
|
{
|
|
id: DEMO.teamId,
|
|
name: 'Demo Team',
|
|
tag: 'DEMO',
|
|
description: 'Demo team for docker smoke tests',
|
|
ownerId: 'driver-admin',
|
|
createdAt: nowIso(),
|
|
memberCount: 2,
|
|
leagues: [DEMO.leagueId],
|
|
isRecruiting: true,
|
|
totalWins: 5,
|
|
totalRaces: 20,
|
|
rating: 2500,
|
|
logoUrl: `/media/teams/${DEMO.teamId}/logo`,
|
|
},
|
|
],
|
|
totalCount: 1,
|
|
};
|
|
}
|
|
|
|
function buildRaceSchedule(seasonId) {
|
|
const date = nowIso();
|
|
return {
|
|
seasonId,
|
|
published: true,
|
|
races: [
|
|
{
|
|
id: DEMO.raceId,
|
|
name: 'Demo Race',
|
|
date,
|
|
scheduledAt: date,
|
|
track: 'Demo Track',
|
|
car: 'Demo Car',
|
|
sessionType: 'race',
|
|
status: 'scheduled',
|
|
isRegistered: false,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildStandings() {
|
|
return {
|
|
standings: [
|
|
{ driverId: 'driver-1', points: 100, position: 1 },
|
|
{ driverId: 'driver-admin', points: 90, position: 2 },
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildMemberships() {
|
|
return {
|
|
members: [
|
|
{ driverId: 'driver-admin', role: 'owner', joinedAt: nowIso() },
|
|
{ driverId: 'driver-1', role: 'member', joinedAt: nowIso() },
|
|
{ driverId: 'driver-sponsor', role: 'member', joinedAt: nowIso() },
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildDriver(driverId) {
|
|
return {
|
|
currentDriver: {
|
|
id: driverId,
|
|
name: driverId === 'driver-admin' ? 'Demo Admin Driver' : 'Demo Driver',
|
|
country: 'DE',
|
|
createdAt: nowIso(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildDriverProfile(driverId) {
|
|
return {
|
|
currentDriver: {
|
|
id: driverId,
|
|
name: driverId === 'driver-admin' ? 'Demo Admin Driver' : 'Demo Driver',
|
|
country: 'DE',
|
|
avatarUrl: '/images/avatars/neutral-default-avatar.jpeg',
|
|
iracingId: driverId === 'driver-admin' ? '1002' : '1001',
|
|
joinedAt: nowIso(),
|
|
rating: 2500,
|
|
globalRank: 42,
|
|
consistency: 78,
|
|
bio: '',
|
|
totalDrivers: 1000,
|
|
},
|
|
stats: {
|
|
totalRaces: 12,
|
|
wins: 2,
|
|
podiums: 5,
|
|
dnfs: 1,
|
|
avgFinish: 6.3,
|
|
bestFinish: 1,
|
|
worstFinish: 18,
|
|
finishRate: 91.7,
|
|
winRate: 16.7,
|
|
podiumRate: 41.7,
|
|
percentile: 42,
|
|
rating: 2500,
|
|
consistency: 78,
|
|
overallRank: 42,
|
|
},
|
|
finishDistribution: {
|
|
totalRaces: 12,
|
|
wins: 2,
|
|
podiums: 5,
|
|
topTen: 8,
|
|
dnfs: 1,
|
|
other: 3,
|
|
},
|
|
teamMemberships: [],
|
|
socialSummary: {
|
|
friendsCount: 1,
|
|
friends: [
|
|
{
|
|
id: 'driver-admin',
|
|
name: 'Demo Admin Driver',
|
|
country: 'DE',
|
|
avatarUrl: '/images/avatars/male-default-avatar.jpg',
|
|
},
|
|
],
|
|
},
|
|
extendedProfile: {
|
|
socialHandles: [],
|
|
achievements: [],
|
|
racingStyle: 'Balanced',
|
|
favoriteTrack: 'Spa',
|
|
favoriteCar: 'Porsche 992 Cup',
|
|
timezone: 'Europe/Berlin',
|
|
availableHours: 'Evenings',
|
|
lookingForTeam: false,
|
|
openToRequests: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildTeamDetails(teamId) {
|
|
return {
|
|
team: {
|
|
id: teamId,
|
|
name: 'Demo Team',
|
|
ownerId: 'driver-admin',
|
|
createdAt: nowIso(),
|
|
description: '',
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildTeamMembers(teamId) {
|
|
return {
|
|
teamId,
|
|
members: [
|
|
{ driverId: 'driver-admin', role: 'owner', joinedAt: nowIso(), driver: { id: 'driver-admin', name: 'Demo Admin Driver' } },
|
|
{ driverId: 'driver-1', role: 'member', joinedAt: nowIso(), driver: { id: 'driver-1', name: 'Demo Driver' } },
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildRacePageData() {
|
|
const date = nowIso();
|
|
return {
|
|
races: [
|
|
{
|
|
id: DEMO.raceId,
|
|
name: 'Demo Race',
|
|
date,
|
|
scheduledAt: date,
|
|
leagueId: DEMO.leagueId,
|
|
leagueName: 'Demo League',
|
|
track: 'Demo Track',
|
|
car: 'Demo Car',
|
|
status: 'scheduled',
|
|
strengthOfField: null,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function buildRaceDetail(raceId) {
|
|
const date = nowIso();
|
|
return {
|
|
race: {
|
|
id: raceId,
|
|
name: 'Demo Race',
|
|
date,
|
|
track: 'Demo Track',
|
|
car: 'Demo Car',
|
|
status: 'scheduled',
|
|
leagueId: DEMO.leagueId,
|
|
},
|
|
league: { id: DEMO.leagueId, name: 'Demo League' },
|
|
entryList: [],
|
|
registration: { isRegistered: false },
|
|
userResult: null,
|
|
};
|
|
}
|
|
|
|
function buildRaceResults(raceId) {
|
|
return {
|
|
raceId,
|
|
results: [],
|
|
};
|
|
}
|
|
|
|
function buildSponsorDashboard(sponsorId) {
|
|
return {
|
|
sponsorId,
|
|
sponsor: { id: sponsorId, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' },
|
|
stats: { impressions: 0, clicks: 0 },
|
|
activeSponsorships: [],
|
|
recentCampaigns: [],
|
|
};
|
|
}
|
|
|
|
function buildSponsorSponsorships(sponsorId) {
|
|
return {
|
|
sponsorId,
|
|
sponsorships: [],
|
|
};
|
|
}
|
|
|
|
function buildSponsorSettings(sponsorId) {
|
|
return {
|
|
profile: { sponsorId, name: 'Demo Sponsor', websiteUrl: '', logoUrl: '' },
|
|
notifications: {},
|
|
privacy: {},
|
|
};
|
|
}
|
|
|
|
function buildPendingSponsorshipRequests() {
|
|
return {
|
|
requests: [],
|
|
};
|
|
}
|
|
|
|
function buildDashboardOverview() {
|
|
const scheduledAt = nowIso();
|
|
|
|
return {
|
|
currentDriver: {
|
|
id: 'driver-1',
|
|
name: 'Demo Driver',
|
|
country: 'DE',
|
|
avatarUrl: '/images/avatars/neutral-default-avatar.jpeg',
|
|
rating: 2500,
|
|
globalRank: 42,
|
|
totalRaces: 12,
|
|
wins: 2,
|
|
podiums: 5,
|
|
consistency: 78,
|
|
},
|
|
myUpcomingRaces: [
|
|
{
|
|
id: DEMO.raceId,
|
|
leagueId: DEMO.leagueId,
|
|
leagueName: 'Demo League',
|
|
track: 'Spa',
|
|
car: 'Porsche 992 Cup',
|
|
scheduledAt,
|
|
status: 'scheduled',
|
|
isMyLeague: true,
|
|
},
|
|
],
|
|
otherUpcomingRaces: [],
|
|
upcomingRaces: [
|
|
{
|
|
id: DEMO.raceId,
|
|
leagueId: DEMO.leagueId,
|
|
leagueName: 'Demo League',
|
|
track: 'Spa',
|
|
car: 'Porsche 992 Cup',
|
|
scheduledAt,
|
|
status: 'scheduled',
|
|
isMyLeague: true,
|
|
},
|
|
],
|
|
activeLeaguesCount: 1,
|
|
nextRace: {
|
|
id: DEMO.raceId,
|
|
leagueId: DEMO.leagueId,
|
|
leagueName: 'Demo League',
|
|
track: 'Spa',
|
|
car: 'Porsche 992 Cup',
|
|
scheduledAt,
|
|
status: 'scheduled',
|
|
isMyLeague: true,
|
|
},
|
|
recentResults: [],
|
|
leagueStandingsSummaries: [
|
|
{ leagueId: DEMO.leagueId, leagueName: 'Demo League', position: 1, totalDrivers: 10, points: 100 },
|
|
],
|
|
feedSummary: {
|
|
items: [
|
|
{
|
|
id: 'feed-1',
|
|
type: 'info',
|
|
headline: 'Welcome to GridPilot',
|
|
body: 'Demo data from the docker test API.',
|
|
timestamp: nowIso(),
|
|
ctaLabel: 'Browse leagues',
|
|
ctaHref: '/leagues',
|
|
},
|
|
],
|
|
},
|
|
friends: [
|
|
{
|
|
id: 'driver-admin',
|
|
name: 'Demo Admin Driver',
|
|
avatarUrl: '/images/avatars/male-default-avatar.jpg',
|
|
country: 'DE',
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const origin = req.headers.origin || 'http://localhost:3100';
|
|
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
res.setHeader('Vary', 'Origin');
|
|
for (const [k, v] of Object.entries(baseCors)) res.setHeader(k, v);
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.statusCode = 204;
|
|
return res.end();
|
|
}
|
|
|
|
const url = new URL(req.url, 'http://localhost');
|
|
const { pathname: rawPathname, searchParams } = url;
|
|
|
|
// Normalize trailing slashes so `/foo` and `/foo/` behave the same in mocks.
|
|
// This prevents false-negative 404s caused by minor URL formatting differences.
|
|
const pathname = rawPathname !== '/' ? rawPathname.replace(/\/+$/, '') || '/' : rawPathname;
|
|
|
|
const demoMode = getDemoMode(req);
|
|
const faultMode = getFaultMode(req);
|
|
const sessionDriftMode = getSessionDriftMode(req);
|
|
|
|
const send = (code, obj) => sendJson(res, code, obj);
|
|
|
|
if (pathname === '/health') return send(200, { status: 'ok' });
|
|
|
|
if (pathname === '/auth/demo-login' && req.method === 'POST') {
|
|
return readJsonBody(req)
|
|
.then((body) => {
|
|
const role = body && typeof body.role === 'string' ? body.role : 'driver';
|
|
|
|
// Map role to mode for session lookup
|
|
// The role parameter from tests should match what website expects
|
|
let mode;
|
|
if (role === 'sponsor') {
|
|
mode = 'sponsor';
|
|
} else if (role === 'league-admin' || role === 'league-owner' || role === 'league-steward' || role === 'super-admin' || role === 'system-owner') {
|
|
mode = 'admin'; // All admin-like roles use admin mode
|
|
} else {
|
|
mode = 'driver'; // Default to driver
|
|
}
|
|
|
|
const session = getSessionForMode(mode, req);
|
|
|
|
// For the docker smoke environment, the website middleware checks gp_session to
|
|
// allow protected routes, while the mock session endpoint uses gridpilot_demo_mode.
|
|
const gpSessionValue = `demo-${mode}-session`;
|
|
|
|
// Set cookies with proper domain for Docker environment
|
|
// In Docker tests, both website (3100) and API (3101) are on localhost
|
|
// so we need to set cookies for localhost domain
|
|
const domain = 'localhost';
|
|
const cookies = [
|
|
`gp_session=${encodeURIComponent(gpSessionValue)}; Path=/; HttpOnly; Domain=${domain}`,
|
|
`gridpilot_demo_mode=${encodeURIComponent(mode)}; Path=/; Domain=${domain}`,
|
|
];
|
|
|
|
if (mode === 'sponsor') {
|
|
cookies.push(`gridpilot_sponsor_id=${encodeURIComponent(DEMO.sponsorId)}; Path=/; Domain=${domain}`);
|
|
cookies.push(`gridpilot_sponsor_name=${encodeURIComponent('Demo Sponsor')}; Path=/; Domain=${domain}`);
|
|
}
|
|
|
|
res.setHeader('Set-Cookie', cookies);
|
|
return send(200, session);
|
|
})
|
|
.catch((err) => {
|
|
return send(500, { message: String(err?.message || err || 'demo-login failed') });
|
|
});
|
|
}
|
|
|
|
if (pathname === '/policy/snapshot') {
|
|
return send(200, {
|
|
policyVersion: 1,
|
|
operationalMode: 'test',
|
|
maintenanceAllowlist: { view: [], mutate: [] },
|
|
capabilities: {},
|
|
loadedFrom: 'defaults',
|
|
loadedAtIso: nowIso(),
|
|
});
|
|
}
|
|
|
|
if (pathname === '/auth/session') {
|
|
const session = getSessionForMode(demoMode, req);
|
|
|
|
// Test-mock behavior: "public" mode returns 200 with a null session so the browser
|
|
// does not emit noisy "Failed to load resource 401/403" console errors.
|
|
if (!session) return sendNull(res);
|
|
|
|
// Drift injection is only enabled when explicitly requested via cookie.
|
|
if (sessionDriftMode === 'expired') {
|
|
return sendNull(res);
|
|
}
|
|
|
|
if (sessionDriftMode === 'invalid-cookie') {
|
|
return send(200, { token: 'invalid', user: session.user });
|
|
}
|
|
|
|
if (sessionDriftMode === 'missing-sponsor-id') {
|
|
if (session.user && typeof session.user === 'object' && 'sponsorId' in session.user) {
|
|
const { sponsorId: _omit, ...restUser } = session.user;
|
|
return send(200, { token: session.token, user: restUser });
|
|
}
|
|
return send(200, session);
|
|
}
|
|
|
|
return send(200, session);
|
|
}
|
|
|
|
const avatarPath = getPathParam(pathname, /^\/media\/avatar\/([^/]+)$/);
|
|
if (avatarPath) return sendPng(res, 200);
|
|
|
|
const leagueMedia = pathname.match(/^\/media\/leagues\/([^/]+)\/(cover|logo)$/);
|
|
if (leagueMedia) return sendPng(res, 200);
|
|
|
|
const teamMedia = pathname.match(/^\/media\/teams\/([^/]+)\/logo$/);
|
|
if (teamMedia) return sendPng(res, 200);
|
|
|
|
const sponsorMedia = pathname.match(/^\/media\/sponsors\/([^/]+)\/logo$/);
|
|
if (sponsorMedia) return sendPng(res, 200);
|
|
|
|
if (pathname === '/leagues/all-with-capacity') {
|
|
const payload = normalizeArrayFields(buildLeagueList(), ['leagues']);
|
|
if (faultMode === 'null-array') payload.leagues = null;
|
|
return send(200, payload);
|
|
}
|
|
if (pathname === '/leagues/all-with-capacity-and-scoring') {
|
|
const payload = normalizeArrayFields(buildLeagueList(), ['leagues']);
|
|
if (faultMode === 'null-array') payload.leagues = null;
|
|
return send(200, payload);
|
|
}
|
|
if (pathname === '/teams/all') {
|
|
const payload = normalizeArrayFields(buildTeamsList(), ['teams']);
|
|
if (faultMode === 'null-array') payload.teams = null;
|
|
return send(200, payload);
|
|
}
|
|
|
|
if (pathname === '/leagues/scoring-presets') {
|
|
return send(200, {
|
|
presets: [
|
|
{
|
|
id: 'preset-1',
|
|
name: 'Demo Scoring',
|
|
description: 'Demo scoring preset for docker smoke tests',
|
|
primaryChampionshipType: 'driver',
|
|
sessionSummary: 'Main race',
|
|
bonusSummary: '',
|
|
dropPolicySummary: 'All results count',
|
|
defaultTimings: {
|
|
practiceMinutes: 15,
|
|
qualifyingMinutes: 10,
|
|
sprintRaceMinutes: 0,
|
|
mainRaceMinutes: 30,
|
|
sessionCount: 1,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
if (pathname === '/dashboard/overview') {
|
|
const payload = buildDashboardOverview();
|
|
|
|
if (faultMode === 'null-array') {
|
|
if (payload.feedSummary && payload.feedSummary.items) payload.feedSummary.items = null;
|
|
if (payload.friends) payload.friends = null;
|
|
if (payload.leagueStandingsSummaries) payload.leagueStandingsSummaries = null;
|
|
if (payload.myUpcomingRaces) payload.myUpcomingRaces = null;
|
|
if (payload.otherUpcomingRaces) payload.otherUpcomingRaces = null;
|
|
if (payload.upcomingRaces) payload.upcomingRaces = null;
|
|
}
|
|
|
|
if (faultMode === 'invalid-date') {
|
|
if (payload.nextRace && payload.nextRace.scheduledAt) payload.nextRace.scheduledAt = 'not-a-date';
|
|
if (Array.isArray(payload.upcomingRaces) && payload.upcomingRaces[0]?.scheduledAt) payload.upcomingRaces[0].scheduledAt = 'not-a-date';
|
|
if (Array.isArray(payload.myUpcomingRaces) && payload.myUpcomingRaces[0]?.scheduledAt) payload.myUpcomingRaces[0].scheduledAt = 'not-a-date';
|
|
}
|
|
|
|
return send(200, payload);
|
|
}
|
|
|
|
// Admin dashboard stats endpoint
|
|
if (pathname === '/admin/dashboard/stats') {
|
|
// Check authorization - only admin roles can access
|
|
if (demoMode !== 'admin') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, {
|
|
totalLeagues: 1,
|
|
totalMembers: 10,
|
|
totalRevenue: 5000,
|
|
activeSponsorships: 2,
|
|
});
|
|
}
|
|
|
|
if (pathname === '/drivers/leaderboard') return send(200, { drivers: [] });
|
|
if (pathname === '/drivers/current')
|
|
return send(200, buildDriver(getSessionForMode(demoMode, req)?.user?.primaryDriverId || 'driver-1'));
|
|
|
|
if (pathname === '/races/page-data') {
|
|
const payload = normalizeArrayFields(buildRacePageData(), ['races']);
|
|
if (faultMode === 'null-array') payload.races = null;
|
|
if (faultMode === 'invalid-date' && Array.isArray(payload.races) && payload.races[0]) {
|
|
payload.races[0].date = 'not-a-date';
|
|
payload.races[0].scheduledAt = 'not-a-date';
|
|
}
|
|
return send(200, payload);
|
|
}
|
|
|
|
if (pathname === '/races/reference/penalty-types') {
|
|
return send(200, {
|
|
penaltyTypes: [
|
|
{ type: 'time_penalty', requiresValue: true, valueKind: 'seconds' },
|
|
{ type: 'grid_penalty', requiresValue: true, valueKind: 'grid_positions' },
|
|
{ type: 'points_deduction', requiresValue: true, valueKind: 'points' },
|
|
{ type: 'disqualification', requiresValue: false, valueKind: 'none' },
|
|
{ type: 'warning', requiresValue: false, valueKind: 'none' },
|
|
{ type: 'license_points', requiresValue: true, valueKind: 'points' },
|
|
],
|
|
defaultReasons: {
|
|
upheld: 'Protest upheld based on steward review.',
|
|
dismissed: 'Protest dismissed due to insufficient evidence.',
|
|
},
|
|
});
|
|
}
|
|
|
|
const leagueProtestsMatch = pathname.match(/^\/leagues\/([^/]+)\/protests(?:\/([^/]+))?$/);
|
|
if (leagueProtestsMatch) {
|
|
const leagueId = leagueProtestsMatch[1];
|
|
const protestId = leagueProtestsMatch[2] || DEMO.protestId;
|
|
|
|
return send(200, {
|
|
protests: [
|
|
{
|
|
id: protestId,
|
|
leagueId,
|
|
raceId: DEMO.raceId,
|
|
protestingDriverId: 'driver-1',
|
|
accusedDriverId: 'driver-admin',
|
|
submittedAt: nowIso(),
|
|
description: 'Demo protest for docker smoke tests',
|
|
status: 'pending',
|
|
},
|
|
],
|
|
racesById: {
|
|
[DEMO.raceId]: {
|
|
id: DEMO.raceId,
|
|
name: 'Demo Race',
|
|
date: nowIso(),
|
|
leagueName: 'Demo League',
|
|
},
|
|
},
|
|
driversById: {
|
|
'driver-1': {
|
|
id: 'driver-1',
|
|
iracingId: '1001',
|
|
name: 'Demo Driver',
|
|
country: 'DE',
|
|
joinedAt: nowIso(),
|
|
},
|
|
'driver-admin': {
|
|
id: 'driver-admin',
|
|
iracingId: '1002',
|
|
name: 'Demo Admin Driver',
|
|
country: 'DE',
|
|
joinedAt: nowIso(),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
const raceIdProtests = getPathParam(pathname, /^\/races\/([^/]+)\/protests$/);
|
|
if (raceIdProtests) {
|
|
return send(200, {
|
|
protests: [
|
|
{
|
|
id: DEMO.protestId,
|
|
protestingDriverId: 'driver-1',
|
|
accusedDriverId: 'driver-admin',
|
|
incident: { type: 'contact' },
|
|
lap: 1,
|
|
description: 'Demo incident',
|
|
status: 'pending',
|
|
filedAt: nowIso(),
|
|
},
|
|
],
|
|
driverMap: {
|
|
'driver-1': 'Demo Driver',
|
|
'driver-admin': 'Demo Admin Driver',
|
|
},
|
|
});
|
|
}
|
|
|
|
const raceIdPenalties = getPathParam(pathname, /^\/races\/([^/]+)\/penalties$/);
|
|
if (raceIdPenalties) {
|
|
return send(200, {
|
|
penalties: [],
|
|
driverMap: {},
|
|
});
|
|
}
|
|
|
|
const leagueIdFromMemberships = getPathParam(pathname, /^\/leagues\/([^/]+)\/memberships$/);
|
|
if (leagueIdFromMemberships) {
|
|
if (leagueIdFromMemberships !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
const payload = normalizeArrayFields(buildMemberships(), ['members']);
|
|
if (faultMode === 'null-array') payload.members = null;
|
|
return send(200, payload);
|
|
}
|
|
|
|
const leagueIdFromStandings = getPathParam(pathname, /^\/leagues\/([^/]+)\/standings$/);
|
|
if (leagueIdFromStandings) {
|
|
if (leagueIdFromStandings !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
const payload = normalizeArrayFields(buildStandings(), ['standings']);
|
|
if (faultMode === 'null-array') payload.standings = null;
|
|
return send(200, payload);
|
|
}
|
|
|
|
const leagueIdFromSchedule = getPathParam(pathname, /^\/leagues\/([^/]+)\/schedule$/);
|
|
if (leagueIdFromSchedule) {
|
|
if (leagueIdFromSchedule !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
const seasonId = searchParams.get('seasonId') || DEMO.seasonId;
|
|
const payload = normalizeArrayFields(buildRaceSchedule(seasonId), ['races']);
|
|
|
|
if (faultMode === 'null-array') payload.races = null;
|
|
|
|
if (faultMode === 'invalid-date' && Array.isArray(payload.races) && payload.races[0]) {
|
|
payload.races[0].date = 'not-a-date';
|
|
payload.races[0].scheduledAt = 'not-a-date';
|
|
}
|
|
|
|
if (faultMode === 'missing-field' && Array.isArray(payload.races) && payload.races[0]) {
|
|
delete payload.races[0].track;
|
|
}
|
|
|
|
return send(200, payload);
|
|
}
|
|
|
|
const leagueIdFromWallet = getPathParam(pathname, /^\/leagues\/([^/]+)\/wallet$/);
|
|
if (leagueIdFromWallet) {
|
|
if (leagueIdFromWallet !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
const date = nowIso();
|
|
const payload = {
|
|
balance: 2880,
|
|
currency: 'USD',
|
|
totalRevenue: 3200,
|
|
totalFees: 320,
|
|
totalWithdrawals: 0,
|
|
pendingPayouts: 0,
|
|
canWithdraw: true,
|
|
transactions: [
|
|
{
|
|
id: 'wallet-tx-1',
|
|
type: 'sponsorship',
|
|
description: 'Demo sponsorship revenue',
|
|
amount: 1600,
|
|
fee: 160,
|
|
netAmount: 1440,
|
|
date,
|
|
status: 'completed',
|
|
reference: 'sponsorship-1',
|
|
},
|
|
],
|
|
};
|
|
|
|
if (faultMode === 'null-array') payload.transactions = null;
|
|
|
|
if (faultMode === 'invalid-date' && Array.isArray(payload.transactions) && payload.transactions[0]) {
|
|
payload.transactions[0].date = 'not-a-date';
|
|
}
|
|
|
|
return send(200, payload);
|
|
}
|
|
|
|
const leagueIdFromWalletWithdraw = getPathParam(pathname, /^\/leagues\/([^/]+)\/wallet\/withdraw$/);
|
|
if (leagueIdFromWalletWithdraw && req.method === 'POST') {
|
|
if (leagueIdFromWalletWithdraw !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
return send(200, { success: true });
|
|
}
|
|
|
|
const leagueIdFromRaces = getPathParam(pathname, /^\/leagues\/([^/]+)\/races$/);
|
|
if (leagueIdFromRaces) {
|
|
if (leagueIdFromRaces !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
return send(200, { races: [buildRaceDetail(DEMO.raceId).race] });
|
|
}
|
|
|
|
const leagueIdFromSeasons = getPathParam(pathname, /^\/leagues\/([^/]+)\/seasons$/);
|
|
if (leagueIdFromSeasons) {
|
|
if (leagueIdFromSeasons !== DEMO.leagueId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
return send(200, [
|
|
{ seasonId: DEMO.seasonId, name: 'Season 1', status: 'active', startDate: nowIso(), endDate: nowIso() },
|
|
]);
|
|
}
|
|
|
|
const leagueIdFromRosterMembers = getPathParam(pathname, /^\/leagues\/([^/]+)\/admin\/roster\/members$/);
|
|
if (leagueIdFromRosterMembers) {
|
|
// Check authorization - only admin roles can access
|
|
if (demoMode !== 'admin') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, [
|
|
{
|
|
driverId: 'driver-admin',
|
|
role: 'owner',
|
|
joinedAt: nowIso(),
|
|
driver: { id: 'driver-admin', name: 'Demo Admin Driver' },
|
|
},
|
|
{
|
|
driverId: 'driver-1',
|
|
role: 'member',
|
|
joinedAt: nowIso(),
|
|
driver: { id: 'driver-1', name: 'Demo Driver' },
|
|
},
|
|
]);
|
|
}
|
|
|
|
const leagueIdFromJoinRequests = getPathParam(pathname, /^\/leagues\/([^/]+)\/admin\/roster\/join-requests$/);
|
|
if (leagueIdFromJoinRequests) {
|
|
// Check authorization - only admin roles can access
|
|
if (demoMode !== 'admin') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, [
|
|
{
|
|
id: 'join-request-1',
|
|
leagueId: leagueIdFromJoinRequests,
|
|
driverId: 'driver-sponsor',
|
|
requestedAt: nowIso(),
|
|
message: 'Please approve my join request',
|
|
driver: { id: 'driver-sponsor', name: 'Demo Sponsor Driver' },
|
|
},
|
|
]);
|
|
}
|
|
|
|
const seasonIdFromSponsorships = getPathParam(pathname, /^\/leagues\/seasons\/([^/]+)\/sponsorships$/);
|
|
if (seasonIdFromSponsorships) {
|
|
return send(200, {
|
|
sponsorships: [
|
|
{ id: 'sponsorship-1', seasonId: seasonIdFromSponsorships, sponsorId: DEMO.sponsorId, tier: 'main', status: 'active' },
|
|
],
|
|
});
|
|
}
|
|
|
|
const driverId = getPathParam(pathname, /^\/drivers\/([^/]+)$/);
|
|
if (driverId) return send(200, buildDriver(driverId));
|
|
|
|
const driverIdProfile = getPathParam(pathname, /^\/drivers\/([^/]+)\/profile$/);
|
|
if (driverIdProfile) {
|
|
// This endpoint is public, no auth required
|
|
return send(200, buildDriverProfile(driverIdProfile));
|
|
}
|
|
|
|
const teamIdDetails = getPathParam(pathname, /^\/teams\/([^/]+)$/);
|
|
if (teamIdDetails) return send(200, buildTeamDetails(teamIdDetails));
|
|
|
|
const teamIdMembers = getPathParam(pathname, /^\/teams\/([^/]+)\/members$/);
|
|
if (teamIdMembers) return send(200, buildTeamMembers(teamIdMembers));
|
|
|
|
const teamIdMembership = getPathParam(pathname, /^\/teams\/([^/]+)\/members\/([^/]+)$/);
|
|
if (teamIdMembership) {
|
|
const parts = pathname.split('/');
|
|
const teamId = parts[2];
|
|
const memberDriverId = parts[4];
|
|
return send(200, { teamId, driverId: memberDriverId, role: memberDriverId === 'driver-admin' ? 'owner' : 'member' });
|
|
}
|
|
|
|
const raceIdDetail = getPathParam(pathname, /^\/races\/([^/]+)$/);
|
|
if (raceIdDetail) {
|
|
if (raceIdDetail !== DEMO.raceId) return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
|
|
const driverIdForRace =
|
|
searchParams.get('driverId') || (getSessionForMode(demoMode, req)?.user?.primaryDriverId || 'driver-1');
|
|
void driverIdForRace;
|
|
|
|
const payload = buildRaceDetail(raceIdDetail);
|
|
|
|
if (faultMode === 'invalid-date' && payload.race) {
|
|
payload.race.date = 'not-a-date';
|
|
}
|
|
|
|
if (faultMode === 'null-array') {
|
|
payload.entryList = null;
|
|
}
|
|
|
|
return send(200, payload);
|
|
}
|
|
|
|
const raceIdSof = getPathParam(pathname, /^\/races\/([^/]+)\/sof$/);
|
|
if (raceIdSof) return send(200, { raceId: raceIdSof, strengthOfField: 2500 });
|
|
|
|
const raceIdResults = getPathParam(pathname, /^\/races\/([^/]+)\/results$/);
|
|
if (raceIdResults) return send(200, buildRaceResults(raceIdResults));
|
|
|
|
const sponsorDashboard = getPathParam(pathname, /^\/sponsors\/dashboard\/([^/]+)$/);
|
|
if (sponsorDashboard) {
|
|
const payload = buildSponsorDashboard(sponsorDashboard);
|
|
|
|
if (faultMode === 'null-array') {
|
|
payload.activeSponsorships = null;
|
|
payload.recentCampaigns = null;
|
|
}
|
|
|
|
if (faultMode === 'missing-field' && payload.sponsor) {
|
|
delete payload.sponsor.name;
|
|
}
|
|
|
|
return send(200, payload);
|
|
}
|
|
|
|
const sponsorSponsorships = getPathParam(pathname, /^\/sponsors\/([^/]+)\/sponsorships$/);
|
|
if (sponsorSponsorships) {
|
|
const payload = buildSponsorSponsorships(sponsorSponsorships);
|
|
if (faultMode === 'null-array') payload.sponsorships = null;
|
|
return send(200, payload);
|
|
}
|
|
|
|
const sponsorGet = getPathParam(pathname, /^\/sponsors\/([^/]+)$/);
|
|
if (sponsorGet) return send(200, { sponsor: { id: sponsorGet, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' } });
|
|
|
|
if (matchPathname(pathname, '/sponsors/pricing')) return send(200, { pricing: [] });
|
|
if (matchPathname(pathname, '/sponsors')) return send(200, { sponsors: [{ id: DEMO.sponsorId, name: 'Demo Sponsor', logoUrl: '', websiteUrl: '' }] });
|
|
|
|
if (pathname === '/sponsors/requests') return send(200, buildPendingSponsorshipRequests());
|
|
|
|
const sponsorBilling = getPathParam(pathname, /^\/sponsors\/billing\/([^/]+)$/);
|
|
if (sponsorBilling) {
|
|
// Check authorization - only sponsor role can access
|
|
if (demoMode !== 'sponsor') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
const today = new Date();
|
|
const invoiceDate = new Date(today.getFullYear(), today.getMonth(), 1).toISOString();
|
|
const dueDate = new Date(today.getFullYear(), today.getMonth(), 15).toISOString();
|
|
const nextPaymentDate = new Date(today.getFullYear(), today.getMonth() + 1, 1).toISOString();
|
|
|
|
return send(200, {
|
|
paymentMethods: [
|
|
{
|
|
id: 'pm-1',
|
|
type: 'card',
|
|
last4: '4242',
|
|
brand: 'Visa',
|
|
isDefault: true,
|
|
expiryMonth: 12,
|
|
expiryYear: 2030,
|
|
},
|
|
],
|
|
invoices: [
|
|
{
|
|
id: 'inv-1',
|
|
invoiceNumber: 'GP-0001',
|
|
date: invoiceDate,
|
|
dueDate,
|
|
amount: 100,
|
|
vatAmount: 20,
|
|
totalAmount: 120,
|
|
status: 'paid',
|
|
description: 'Demo sponsorship invoice',
|
|
sponsorshipType: 'league',
|
|
pdfUrl: '/billing/invoices/inv-1.pdf',
|
|
},
|
|
],
|
|
stats: {
|
|
totalSpent: 120,
|
|
pendingAmount: 0,
|
|
nextPaymentDate,
|
|
nextPaymentAmount: 0,
|
|
activeSponsorships: 0,
|
|
averageMonthlySpend: 20,
|
|
},
|
|
});
|
|
}
|
|
|
|
const sponsorSettings = getPathParam(pathname, /^\/sponsors\/settings\/([^/]+)$/);
|
|
if (sponsorSettings) {
|
|
// Check authorization - only sponsor role can access
|
|
if (demoMode !== 'sponsor') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, buildSponsorSettings(sponsorSettings));
|
|
}
|
|
|
|
const sponsorLeagueAvailable = pathname === '/sponsors/leagues/available';
|
|
if (sponsorLeagueAvailable) {
|
|
// Check authorization - only sponsor role can access
|
|
if (demoMode !== 'sponsor') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, [
|
|
{
|
|
id: DEMO.leagueId,
|
|
name: 'Demo League',
|
|
game: 'iRacing',
|
|
drivers: 24,
|
|
avgViewsPerRace: 3200,
|
|
mainSponsorSlot: { available: true, price: 1500 },
|
|
secondarySlots: { available: 2, total: 4, price: 500 },
|
|
rating: 4.6,
|
|
tier: 'standard',
|
|
nextRace: 'Sunday 19:00',
|
|
seasonStatus: 'active',
|
|
description: 'Demo league available for sponsorship (docker smoke tests).',
|
|
},
|
|
]);
|
|
}
|
|
|
|
const sponsorLeagueDetail = getPathParam(pathname, /^\/sponsors\/leagues\/([^/]+)\/detail$/);
|
|
if (sponsorLeagueDetail) {
|
|
// Check authorization - only sponsor role can access
|
|
if (demoMode !== 'sponsor') {
|
|
return send(403, { message: 'Forbidden' });
|
|
}
|
|
return send(200, {
|
|
league: {
|
|
id: sponsorLeagueDetail,
|
|
name: 'Demo League',
|
|
game: 'iRacing',
|
|
tier: 'standard',
|
|
season: '2025 S1',
|
|
description: 'Demo league detail for sponsor pages (docker smoke tests).',
|
|
drivers: 24,
|
|
races: 10,
|
|
completedRaces: 2,
|
|
totalImpressions: 42000,
|
|
avgViewsPerRace: 3200,
|
|
engagement: 78,
|
|
rating: 4.6,
|
|
seasonStatus: 'active',
|
|
seasonDates: { start: nowIso(), end: nowIso() },
|
|
nextRace: { name: 'Demo Race 3', date: nowIso() },
|
|
sponsorSlots: {
|
|
main: {
|
|
available: true,
|
|
price: 1500,
|
|
benefits: ['Logo on broadcast overlay', 'Mentioned in race intro'],
|
|
},
|
|
secondary: {
|
|
available: 2,
|
|
total: 4,
|
|
price: 500,
|
|
benefits: ['Logo on results page', 'Listed on sponsor board'],
|
|
},
|
|
},
|
|
},
|
|
drivers: [
|
|
{
|
|
id: 'driver-1',
|
|
name: 'Demo Driver',
|
|
country: 'DE',
|
|
position: 1,
|
|
races: 2,
|
|
impressions: 6400,
|
|
team: 'Demo Team',
|
|
},
|
|
],
|
|
races: [
|
|
{
|
|
id: DEMO.raceId,
|
|
name: 'Demo Race',
|
|
date: nowIso(),
|
|
views: 3200,
|
|
status: 'completed',
|
|
},
|
|
{
|
|
id: 'race-2',
|
|
name: 'Demo Race 2',
|
|
date: nowIso(),
|
|
views: 0,
|
|
status: 'upcoming',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
return send(404, { message: 'Not Found', path: pathname, rawPath: rawPathname });
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[api-mock] listening on ${PORT}`);
|
|
}); |