add website tests

This commit is contained in:
2025-12-28 21:02:32 +01:00
parent 6edf12fda8
commit 2f6657f56d
20 changed files with 1868 additions and 97 deletions

View File

@@ -100,7 +100,6 @@ describe('LeagueService', () => {
const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) }; const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) };
const withdrawFromLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ success: true })) }; const withdrawFromLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) }; const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) };
const leagueRacesPresenter = { getViewModel: vi.fn(() => ([])) };
const createLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) }; const createLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) };
const updateLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) }; const updateLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) };
@@ -165,7 +164,6 @@ describe('LeagueService', () => {
getLeagueWalletPresenter as any, getLeagueWalletPresenter as any,
withdrawFromLeagueWalletPresenter as any, withdrawFromLeagueWalletPresenter as any,
leagueJoinRequestsPresenter as any, leagueJoinRequestsPresenter as any,
leagueRacesPresenter as any,
createLeagueSeasonScheduleRacePresenter as any, createLeagueSeasonScheduleRacePresenter as any,
updateLeagueSeasonScheduleRacePresenter as any, updateLeagueSeasonScheduleRacePresenter as any,
deleteLeagueSeasonScheduleRacePresenter as any, deleteLeagueSeasonScheduleRacePresenter as any,

View File

@@ -114,7 +114,7 @@ import { GetSeasonSponsorshipsPresenter } from './presenters/GetSeasonSponsorshi
import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter'; import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter';
import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter'; import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter';
import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter'; import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter';
import { LeagueSchedulePresenter, LeagueRacesPresenter } from './presenters/LeagueSchedulePresenter'; import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter';
import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter'; import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter';
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter'; import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter'; import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter';
@@ -265,7 +265,6 @@ export class LeagueService {
@Inject(GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter, @Inject(GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter,
@Inject(WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter, @Inject(WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter,
@Inject(LeagueJoinRequestsPresenter) private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter, @Inject(LeagueJoinRequestsPresenter) private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter,
@Inject(LeagueRacesPresenter) private readonly leagueRacesPresenter: LeagueRacesPresenter,
// Schedule mutation presenters // Schedule mutation presenters
@Inject(CreateLeagueSeasonScheduleRacePresenter) @Inject(CreateLeagueSeasonScheduleRacePresenter)
@@ -842,9 +841,13 @@ export class LeagueService {
async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> { async getRaces(leagueId: string): Promise<GetLeagueRacesOutputDTO> {
this.logger.debug('Getting league races', { leagueId }); this.logger.debug('Getting league races', { leagueId });
// `GetLeagueScheduleUseCase` is wired to `LeagueSchedulePresenter` (not `LeagueRacesPresenter`),
// so `LeagueRacesPresenter.getViewModel()` can be null at runtime.
this.leagueSchedulePresenter.reset?.();
await this.getLeagueScheduleUseCase.execute({ leagueId }); await this.getLeagueScheduleUseCase.execute({ leagueId });
return { return {
races: this.leagueRacesPresenter.getViewModel()!, races: this.leagueSchedulePresenter.getViewModel()?.races ?? [],
}; };
} }

View File

@@ -0,0 +1,16 @@
export const runtime = 'nodejs';
const ONE_BY_ONE_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pS0kAAAAASUVORK5CYII=';
export async function GET(): Promise<Response> {
const body = Buffer.from(ONE_BY_ONE_PNG_BASE64, 'base64');
return new Response(body, {
status: 200,
headers: {
'content-type': 'image/png',
'cache-control': 'public, max-age=60',
},
});
}

View File

@@ -22,6 +22,7 @@ import { useServices } from '@/lib/services/ServiceProvider';
export default function LeaderboardsPage() { export default function LeaderboardsPage() {
const router = useRouter(); const router = useRouter();
const { driverService, teamService } = useServices();
const [drivers, setDrivers] = useState<DriverLeaderboardItemViewModel[]>([]); const [drivers, setDrivers] = useState<DriverLeaderboardItemViewModel[]>([]);
const [teams, setTeams] = useState<TeamSummaryViewModel[]>([]); const [teams, setTeams] = useState<TeamSummaryViewModel[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -29,7 +30,6 @@ export default function LeaderboardsPage() {
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
const { driverService, teamService } = useServices();
const driversViewModel = await driverService.getDriverLeaderboard(); const driversViewModel = await driverService.getDriverLeaderboard();
const teams = await teamService.getAllTeams(); const teams = await teamService.getAllTeams();

View File

@@ -13,6 +13,8 @@ export default function LeagueRulebookPage() {
const params = useParams(); const params = useParams();
const leagueId = params.id as string; const leagueId = params.id as string;
const { leagueService } = useServices();
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null); const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<RulebookSection>('scoring'); const [activeSection, setActiveSection] = useState<RulebookSection>('scoring');
@@ -20,7 +22,6 @@ export default function LeagueRulebookPage() {
useEffect(() => { useEffect(() => {
async function loadData() { async function loadData() {
try { try {
const { leagueService } = useServices();
const data = await leagueService.getLeagueDetailPageData(leagueId); const data = await leagueService.getLeagueDetailPageData(leagueId);
if (!data) { if (!data) {
setLoading(false); setLoading(false);
@@ -36,7 +37,7 @@ export default function LeagueRulebookPage() {
} }
loadData(); loadData();
}, [leagueId]); }, [leagueId, leagueService]);
if (loading) { if (loading) {
return ( return (

View File

@@ -2,6 +2,7 @@
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import OnboardingWizard from '@/components/onboarding/OnboardingWizard'; import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
import { useCurrentDriver } from '@/hooks/useDriverService'; import { useCurrentDriver } from '@/hooks/useDriverService';
@@ -12,13 +13,23 @@ export default function OnboardingPage() {
const { session } = useAuth(); const { session } = useAuth();
const { data: driver, isLoading } = useCurrentDriver(); const { data: driver, isLoading } = useCurrentDriver();
// If user is not authenticated, redirect to login const shouldRedirectToLogin = !session;
if (!session) { const shouldRedirectToDashboard = !isLoading && Boolean(driver);
router.replace('/auth/login?returnTo=/onboarding');
useEffect(() => {
if (shouldRedirectToLogin) {
router.replace('/auth/login?returnTo=/onboarding');
return;
}
if (shouldRedirectToDashboard) {
router.replace('/dashboard');
}
}, [router, shouldRedirectToLogin, shouldRedirectToDashboard]);
if (shouldRedirectToLogin) {
return null; return null;
} }
// Show loading while checking driver data
if (isLoading) { if (isLoading) {
return ( return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center"> <main className="min-h-screen bg-deep-graphite flex items-center justify-center">
@@ -27,9 +38,7 @@ export default function OnboardingPage() {
); );
} }
// If driver profile exists, onboarding is complete go to dashboard if (shouldRedirectToDashboard) {
if (driver) {
router.replace('/dashboard');
return null; return null;
} }

View File

@@ -269,10 +269,14 @@ export default function ProfilePage() {
const isOwnProfile = true; // This page is always your own profile const isOwnProfile = true; // This page is always your own profile
useEffect(() => { useEffect(() => {
if (!effectiveDriverId) {
return;
}
const loadData = async () => { const loadData = async () => {
setLoading(true);
try { try {
const currentDriverId = effectiveDriverId; const profileViewModel = await driverService.getDriverProfile(effectiveDriverId);
const profileViewModel = await driverService.getDriverProfile(currentDriverId);
setProfileData(profileViewModel); setProfileData(profileViewModel);
} catch (error) { } catch (error) {
console.error('Failed to load profile:', error); console.error('Failed to load profile:', error);
@@ -280,6 +284,7 @@ export default function ProfilePage() {
setLoading(false); setLoading(false);
} }
}; };
void loadData(); void loadData();
}, [effectiveDriverId, driverService]); }, [effectiveDriverId, driverService]);

View File

@@ -22,7 +22,9 @@ interface EntitySection {
export default function SponsorshipRequestsPage() { export default function SponsorshipRequestsPage() {
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices();
const [sections, setSections] = useState<EntitySection[]>([]); const [sections, setSections] = useState<EntitySection[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,7 +34,10 @@ export default function SponsorshipRequestsPage() {
setError(null); setError(null);
try { try {
const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices(); if (!currentDriverId) {
setSections([]);
return;
}
const allSections: EntitySection[] = []; const allSections: EntitySection[] = [];
@@ -107,20 +112,20 @@ export default function SponsorshipRequestsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [currentDriverId]); }, [currentDriverId, sponsorshipService, driverService, leagueService, teamService, leagueMembershipService]);
useEffect(() => { useEffect(() => {
loadAllRequests(); loadAllRequests();
}, [loadAllRequests]); }, [loadAllRequests]);
const handleAccept = async (requestId: string) => { const handleAccept = async (requestId: string) => {
const { sponsorshipService } = useServices(); if (!currentDriverId) return;
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId); await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
await loadAllRequests(); await loadAllRequests();
}; };
const handleReject = async (requestId: string, reason?: string) => { const handleReject = async (requestId: string, reason?: string) => {
const { sponsorshipService } = useServices(); if (!currentDriverId) return;
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason); await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
await loadAllRequests(); await loadAllRequests();
}; };

View File

@@ -23,7 +23,9 @@ export class AuthApiClient extends BaseApiClient {
/** Get current session */ /** Get current session */
getSession(): Promise<AuthSessionDTO | null> { getSession(): Promise<AuthSessionDTO | null> {
return this.get<AuthSessionDTO | null>('/auth/session'); return this.request<AuthSessionDTO | null>('GET', '/auth/session', undefined, {
allowUnauthenticated: true,
});
} }
/** Logout */ /** Logout */

View File

@@ -19,7 +19,12 @@ export class BaseApiClient {
this.logger = logger; this.logger = logger;
} }
protected async request<T>(method: string, path: string, data?: object | FormData): Promise<T> { protected async request<T>(
method: string,
path: string,
data?: object | FormData,
options?: { allowUnauthenticated?: boolean },
): Promise<T> {
this.logger.info(`${method} ${path}`); this.logger.info(`${method} ${path}`);
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
@@ -43,6 +48,15 @@ export class BaseApiClient {
const response = await fetch(`${this.baseUrl}${path}`, config); const response = await fetch(`${this.baseUrl}${path}`, config);
if (!response.ok) { if (!response.ok) {
if (
options?.allowUnauthenticated &&
(response.status === 401 || response.status === 403)
) {
// For "auth probe" endpoints (e.g. session/policy checks), 401/403 is an expected state
// in public context and should not be logged as an application error.
return null as T;
}
let errorData: { message?: string } = { message: response.statusText }; let errorData: { message?: string } = { message: response.statusText };
try { try {
errorData = await response.json(); errorData = await response.json();

View File

@@ -24,6 +24,20 @@ import type { UpdateLeagueMemberRoleOutputDTO } from '../../types/generated/Upda
import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO'; import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO';
import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO'; import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isRaceDTO(value: unknown): value is RaceDTO {
if (!isRecord(value)) return false;
return typeof value.id === 'string' && typeof value.name === 'string' && typeof value.date === 'string';
}
function parseRaceDTOArray(value: unknown): RaceDTO[] {
if (!Array.isArray(value)) return [];
return value.filter(isRaceDTO);
}
/** /**
* Leagues API Client * Leagues API Client
* *
@@ -145,8 +159,9 @@ export class LeaguesApiClient extends BaseApiClient {
} }
/** Get races for a league */ /** Get races for a league */
getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> { async getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> {
return this.get<{ races: RaceDTO[] }>(`/leagues/${leagueId}/races`); const response = await this.get<{ races?: unknown }>(`/leagues/${leagueId}/races`);
return { races: parseRaceDTOArray(response?.races) };
} }
/** Admin roster: list current members (admin/owner only; actor derived from session) */ /** Admin roster: list current members (admin/owner only; actor derived from session) */

View File

@@ -4,7 +4,6 @@ import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO"; import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO";
import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO"; import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO";
import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel"; import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel"; import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel";
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel"; import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
@@ -109,8 +108,9 @@ export class LeagueService {
*/ */
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> { async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await this.apiClient.getAllWithCapacityAndScoring(); const dto = await this.apiClient.getAllWithCapacityAndScoring();
const leagues = Array.isArray((dto as any)?.leagues) ? ((dto as any).leagues as any[]) : [];
return dto.leagues.map((league) => ({ return leagues.map((league) => ({
id: league.id, id: league.id,
name: league.name, name: league.name,
description: league.description, description: league.description,
@@ -414,7 +414,8 @@ export class LeagueService {
// Since API may not have detailed league, we'll mock or assume // Since API may not have detailed league, we'll mock or assume
// In real implementation, add getLeagueDetail to API // In real implementation, add getLeagueDetail to API
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const leagueDto = allLeagues.leagues.find((l) => l.id === leagueId); const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : [];
const leagueDto = leagues.find((l) => l?.id === leagueId);
if (!leagueDto) return null; if (!leagueDto) return null;
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
@@ -431,7 +432,8 @@ export class LeagueService {
// Get membership // Get membership
const membershipsDto = await this.apiClient.getMemberships(leagueId); const membershipsDto = await this.apiClient.getMemberships(leagueId);
const membership = membershipsDto.members.find((m: any) => m.driverId === currentDriverId); const members = Array.isArray((membershipsDto as any)?.members) ? ((membershipsDto as any).members as any[]) : [];
const membership = members.find((m: any) => m?.driverId === currentDriverId);
const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false; const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false;
// Get main sponsor // Get main sponsor
@@ -439,20 +441,26 @@ export class LeagueService {
if (this.sponsorsApiClient) { if (this.sponsorsApiClient) {
try { try {
const seasons = await this.apiClient.getSeasons(leagueId); const seasons = await this.apiClient.getSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0]; const seasonList = Array.isArray(seasons) ? (seasons as any[]) : [];
const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0];
if (activeSeason) { if (activeSeason) {
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active'); const sponsorships = Array.isArray((sponsorshipsDto as any)?.sponsorships)
? ((sponsorshipsDto as any).sponsorships as any[])
: [];
const mainSponsorship = sponsorships.find((s: any) => s?.tier === 'main' && s?.status === 'active');
if (mainSponsorship) { if (mainSponsorship) {
const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id; const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id;
const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId); if (sponsorId) {
const sponsor = (sponsorResult as any)?.sponsor ?? null; const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId);
if (sponsor) { const sponsor = (sponsorResult as any)?.sponsor ?? null;
mainSponsor = { if (sponsor) {
name: sponsor.name, mainSponsor = {
logoUrl: sponsor.logoUrl ?? '', name: sponsor.name,
websiteUrl: sponsor.websiteUrl ?? '', logoUrl: sponsor.logoUrl ?? '',
}; websiteUrl: sponsor.websiteUrl ?? '',
};
}
} }
} }
} }
@@ -497,7 +505,8 @@ export class LeagueService {
try { try {
// Get league basic info // Get league basic info
const allLeagues = await this.apiClient.getAllWithCapacityAndScoring(); const allLeagues = await this.apiClient.getAllWithCapacityAndScoring();
const league = allLeagues.leagues.find((l) => l.id === leagueId); const leagues = Array.isArray((allLeagues as any)?.leagues) ? ((allLeagues as any).leagues as any[]) : [];
const league = leagues.find((l) => l?.id === leagueId);
if (!league) return null; if (!league) return null;
// Get owner // Get owner
@@ -508,13 +517,15 @@ export class LeagueService {
// Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists // Drivers list is limited to those present in memberships until a dedicated league-drivers endpoint exists
const memberships = await this.apiClient.getMemberships(leagueId); const memberships = await this.apiClient.getMemberships(leagueId);
const driverIds = memberships.members.map((m: any) => m.driverId); const membershipMembers = Array.isArray((memberships as any)?.members) ? ((memberships as any).members as any[]) : [];
const driverIds = membershipMembers.map((m: any) => m?.driverId).filter((id: any): id is string => typeof id === 'string');
const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id))); const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient!.getDriver(id)));
const drivers = driverDtos.filter((d: any): d is NonNullable<typeof d> => d !== null); const drivers = driverDtos.filter((d: any): d is NonNullable<typeof d> => d !== null);
// Get all races for this league via the leagues API helper // Get all races for this league via the leagues API helper
// Service boundary hardening: tolerate `null/undefined` arrays from API.
const leagueRaces = await this.apiClient.getRaces(leagueId); const leagueRaces = await this.apiClient.getRaces(leagueId);
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO)); const allRaces = (leagueRaces.races ?? []).map((race) => new RaceViewModel(race));
// League stats endpoint currently returns global league statistics rather than per-league values // League stats endpoint currently returns global league statistics rather than per-league values
const leagueStats: LeagueStatsDTO = { const leagueStats: LeagueStatsDTO = {
@@ -550,12 +561,16 @@ export class LeagueService {
try { try {
const seasons = await this.apiClient.getSeasons(leagueId); const seasons = await this.apiClient.getSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0]; const seasonList = Array.isArray(seasons) ? (seasons as any[]) : [];
const activeSeason = seasonList.find((s) => s?.status === 'active') ?? seasonList[0];
if (!activeSeason) return []; if (!activeSeason) return [];
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId); const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active'); const sponsorshipList = Array.isArray((sponsorships as any)?.sponsorships)
? ((sponsorships as any).sponsorships as any[])
: [];
const activeSponsorships = sponsorshipList.filter((s: any) => s?.status === 'active');
const sponsorInfos: SponsorInfo[] = []; const sponsorInfos: SponsorInfo[] = [];
for (const sponsorship of activeSponsorships) { for (const sponsorship of activeSponsorships) {

View File

@@ -5,7 +5,7 @@
* the generated API types without type errors. * the generated API types without type errors.
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
import { glob } from 'glob'; import { glob } from 'glob';
@@ -22,6 +22,7 @@ import type { UpdateAvatarInputDTO } from './generated/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './generated/UpdateAvatarOutputDTO'; import type { UpdateAvatarOutputDTO } from './generated/UpdateAvatarOutputDTO';
import type { RaceDTO } from './generated/RaceDTO'; import type { RaceDTO } from './generated/RaceDTO';
import type { DriverDTO } from './generated/DriverDTO'; import type { DriverDTO } from './generated/DriverDTO';
import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient';
describe('Website Contract Consumption', () => { describe('Website Contract Consumption', () => {
const generatedTypesDir = path.join(__dirname, 'generated'); const generatedTypesDir = path.join(__dirname, 'generated');
@@ -196,6 +197,27 @@ describe('Website Contract Consumption', () => {
}); });
describe('Error Handling', () => { describe('Error Handling', () => {
it('should normalize null races arrays to empty list', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn(async () => {
return new Response(JSON.stringify({ races: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}) as any;
const client = new LeaguesApiClient(
'http://example.test',
{ report: vi.fn() } as any,
{ info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
);
await expect(client.getRaces('league-1')).resolves.toEqual({ races: [] });
globalThis.fetch = originalFetch;
});
it('should handle missing optional fields', () => { it('should handle missing optional fields', () => {
// Test that optional fields can be omitted // Test that optional fields can be omitted
const minimalOutput: RequestAvatarGenerationOutputDTO = { const minimalOutput: RequestAvatarGenerationOutputDTO = {

View File

@@ -9,6 +9,13 @@ export class SessionViewModel {
this.userId = dto.userId; this.userId = dto.userId;
this.email = dto.email; this.email = dto.email;
this.displayName = dto.displayName; this.displayName = dto.displayName;
const anyDto = dto as unknown as { primaryDriverId?: unknown; driverId?: unknown };
if (typeof anyDto.primaryDriverId === 'string' && anyDto.primaryDriverId) {
this.driverId = anyDto.primaryDriverId;
} else if (typeof anyDto.driverId === 'string' && anyDto.driverId) {
this.driverId = anyDto.driverId;
}
} }
// Note: The generated DTO doesn't have these fields // Note: The generated DTO doesn't have these fields

View File

@@ -15,7 +15,7 @@ services:
[ [
"sh", "sh",
"-lc", "-lc",
"set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps\"; rm -rf apps/api/node_modules apps/website/node_modules; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi", "set -e; LOCK_HASH=\"$$(sha1sum package-lock.json | awk '{print $$1}')\"; MARKER=\"node_modules/.gridpilot_lock_hash_test\"; if [ -f \"$$MARKER\" ] && [ \"$$(cat \"$$MARKER\")\" = \"$$LOCK_HASH\" ]; then echo \"[deps] node_modules up-to-date\"; else echo \"[deps] installing workspace deps\"; rm -rf apps/api/node_modules apps/website/node_modules apps/companion/node_modules; npm install --no-package-lock --include-workspace-root --no-audit --fund=false --prefer-offline; echo \"$$LOCK_HASH\" > \"$$MARKER\"; fi",
] ]
networks: networks:
- gridpilot-test-network - gridpilot-test-network
@@ -23,16 +23,15 @@ services:
api: api:
image: node:20-alpine image: node:20-alpine
working_dir: /app
environment: environment:
- NODE_ENV=test - NODE_ENV=test
- PORT=3000
ports: ports:
- "3101:3000" - "3101:3000"
command: volumes:
[ - ./:/app
"sh", command: ["sh", "-lc", "node testing/mock-api-server.cjs"]
"-lc",
"node -e \"const http=require('http'); const baseCors={ 'Access-Control-Allow-Credentials':'true','Access-Control-Allow-Headers':'Content-Type','Access-Control-Allow-Methods':'GET,POST,PUT,PATCH,DELETE,OPTIONS' }; 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 send=(code,obj)=>{ res.statusCode=code; res.setHeader('content-type','application/json'); res.end(JSON.stringify(obj)); }; if(url.pathname==='/health'){ return send(200,{status:'ok'});} if(url.pathname==='/auth/session'){ res.statusCode=200; res.setHeader('content-type','application/json'); return res.end('null'); } if(url.pathname==='/races/page-data'){ return send(200,{races:[]}); } if(url.pathname==='/leagues/all-with-capacity'){ return send(200,{leagues:[], totalCount:0}); } if(url.pathname==='/teams/all'){ return send(200,{teams:[], totalCount:0}); } return send(404,{message:'Not Found', path:url.pathname}); }); server.listen(3000,()=>console.log('[api-mock] listening on 3000'));\"",
]
networks: networks:
- gridpilot-test-network - gridpilot-test-network
restart: unless-stopped restart: unless-stopped

View File

@@ -91,8 +91,9 @@
"docker:prod:down": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml down", "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:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f",
"docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps", "docker:test:deps": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml run --rm deps",
"docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d --build api website", "docker:test:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-test -f docker-compose.test.yml up -d api website",
"docker:test:down": "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: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: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: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'); await wait('http://localhost:3100','website');})();\"", "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'); await wait('http://localhost:3100','website');})();\"",
"smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.ts", "smoke:website:docker": "DOCKER_SMOKE=true npx playwright test -c playwright.website.config.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; 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; npm run smoke:website:docker\"",

1073
testing/mock-api-server.cjs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,55 +1,359 @@
import { test, expect } from '@playwright/test'; 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';
test.describe('Website smoke - core pages render', () => { type SmokeScenario = {
const routes = process.env.DOCKER_SMOKE scenarioName: string;
? [ auth: WebsiteAuthContext;
{ path: '/', name: 'landing' }, expectAuthRedirect: boolean;
{ path: '/leagues', name: 'leagues list' }, };
{ path: '/teams', name: 'teams list' },
]
: [
{ path: '/', name: 'landing' },
{ path: '/dashboard', name: 'dashboard' },
{ path: '/drivers', name: 'drivers list' },
{ path: '/leagues', name: 'leagues list' },
{ path: '/profile', name: 'profile' },
{ path: '/teams', name: 'teams list' },
];
for (const route of routes) { type AuthOptions = {
test(`renders ${route.name} page without console errors (${route.path})`, async ({ page }) => { sessionDrift?: WebsiteSessionDriftMode;
const consoleMessages: string[] = []; faultMode?: WebsiteFaultMode;
};
page.on('console', (msg) => { function toRegexEscaped(value: string): string {
const type = msg.type(); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (type === 'error') { }
consoleMessages.push(`[${type}] ${msg.text()}`);
}
});
const apiCallPromise = function urlToKey(rawUrl: string): string {
route.path === '/leagues' try {
? page.waitForResponse((resp) => { const parsed = new URL(rawUrl);
return resp.url().includes('/leagues/all-with-capacity') && resp.status() === 200; return `${parsed.origin}${parsed.pathname}${parsed.search}`;
}) } catch {
: route.path === '/teams' return rawUrl;
? page.waitForResponse((resp) => { }
return resp.url().includes('/teams/all') && resp.status() === 200; }
})
: null;
await page.goto(route.path, { waitUntil: 'networkidle' }); 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;
if (apiCallPromise) { await setWebsiteAuthContext(context, scenario.auth, authOptions);
await apiCallPromise;
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)}`);
});
});
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());
});
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;
} }
await expect(page).toHaveTitle(/GridPilot/i); // Next.js RSC/data fetches are frequently aborted during redirects.
if (resourceType === 'fetch' && url.includes('_rsc=')) {
return;
}
}
requestFailures.push({
url: req.url(),
method: req.method(),
resourceType: req.resourceType(),
errorText,
});
});
page.on('response', (resp) => {
const status = resp.status();
const url = resp.url();
const resourceType = resp.request().resourceType();
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) });
}),
);
}
}
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' });
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( expect(
consoleMessages.length, navResponse?.status(),
`Console errors on route ${route.path}:\n${consoleMessages.join('\n')}`, `Expected protected route ${resolvedPath} to redirect to auth or return 200 when public; ended at ${finalPathname}`,
).toBe(0); ).toBe(200);
}
} else if (route.allowNotFound) {
if (finalPathname === '/404') {
// ok
} else {
await expect(page).toHaveURL(new RegExp(`${toRegexEscaped(expectedPath)}(\\?.*)?$`));
}
} 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);
await Promise.all(responseChecks);
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);
}
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 });
}
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.describe('Website smoke - param edge cases', () => {
const edgeRoutes = getWebsiteParamEdgeCases();
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 } });
});
}
}
});
test.describe('Website smoke - mock fault injection (curated subset)', () => {
const faultRoutes = getWebsiteFaultInjectionRoutes();
const faultModes: WebsiteFaultMode[] = ['null-array', 'missing-field', 'invalid-date'];
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 } });
});
}
}
}); });

View File

@@ -0,0 +1,93 @@
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 `gridpilot_demo_mode` cookie to switch identity modes without OAuth.
// We keep these cookies consistent across smoke tests.
const cookies =
auth === 'public'
? [
{ ...base, name: 'gridpilot_demo_mode', value: 'none' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
]
: auth === 'sponsor'
? [
{ ...base, name: 'gridpilot_demo_mode', value: 'sponsor' },
{ ...base, name: 'gridpilot_sponsor_id', value: 'demo-sponsor-1' },
{ ...base, name: 'gridpilot_sponsor_name', value: 'Demo Sponsor' },
]
: auth === 'admin'
? [
{ ...base, name: 'gridpilot_demo_mode', value: 'admin' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
]
: [
{ ...base, name: 'gridpilot_demo_mode', value: 'driver' },
{ ...base, name: 'gridpilot_sponsor_id', value: '' },
{ ...base, name: 'gridpilot_sponsor_name', value: '' },
];
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();
await context.addCookies([...cookies, ...driftCookie, ...faultCookie]);
}
export type ConsoleCapture = {
consoleErrors: string[];
pageErrors: string[];
};
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
page.on('pageerror', (err) => {
pageErrors.push(String(err));
});
page.on('console', (msg) => {
const type = msg.type();
if (type !== 'error') return;
const text = msg.text();
// Filter known benign warnings (keep small + generic).
if (text.includes('Download the React DevTools')) return;
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
if (text.includes('Image is missing required "alt" property')) return;
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
consoleErrors.push(`[${type}] ${text}`);
});
return { consoleErrors, pageErrors };
}

View File

@@ -0,0 +1,189 @@
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' },
'/auth/iracing': { access: 'public' },
'/auth/login': { 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' }];
}