From 2f6657f56da0b473497502d26108b86cc1a098f1 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Sun, 28 Dec 2025 21:02:32 +0100 Subject: [PATCH] add website tests --- .../src/domain/league/LeagueService.test.ts | 2 - apps/api/src/domain/league/LeagueService.ts | 9 +- .../app/api/media/avatar/[driverId]/route.ts | 16 + apps/website/app/leaderboards/page.tsx | 2 +- .../app/leagues/[id]/rulebook/page.tsx | 5 +- apps/website/app/onboarding/page.tsx | 23 +- apps/website/app/profile/page.tsx | 9 +- .../app/profile/sponsorship-requests/page.tsx | 15 +- apps/website/lib/api/auth/AuthApiClient.ts | 4 +- apps/website/lib/api/base/BaseApiClient.ts | 16 +- .../lib/api/leagues/LeaguesApiClient.ts | 19 +- .../lib/services/leagues/LeagueService.ts | 53 +- .../lib/types/contractConsumption.test.ts | 24 +- .../lib/view-models/SessionViewModel.ts | 7 + docker-compose.test.yml | 13 +- package.json | 5 +- testing/mock-api-server.cjs | 1073 +++++++++++++++++ tests/smoke/website-pages.test.ts | 388 +++++- tests/smoke/websiteAuth.ts | 93 ++ tests/smoke/websiteRouteInventory.ts | 189 +++ 20 files changed, 1868 insertions(+), 97 deletions(-) create mode 100644 apps/website/app/api/media/avatar/[driverId]/route.ts create mode 100644 testing/mock-api-server.cjs create mode 100644 tests/smoke/websiteAuth.ts create mode 100644 tests/smoke/websiteRouteInventory.ts diff --git a/apps/api/src/domain/league/LeagueService.test.ts b/apps/api/src/domain/league/LeagueService.test.ts index 6e5b859cf..a8b608793 100644 --- a/apps/api/src/domain/league/LeagueService.test.ts +++ b/apps/api/src/domain/league/LeagueService.test.ts @@ -100,7 +100,6 @@ describe('LeagueService', () => { const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) }; const withdrawFromLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ success: true })) }; const leagueJoinRequestsPresenter = { getViewModel: vi.fn(() => ({ joinRequests: [] })) }; - const leagueRacesPresenter = { getViewModel: vi.fn(() => ([])) }; const createLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ raceId: 'race-1' })) }; const updateLeagueSeasonScheduleRacePresenter = { getResponseModel: vi.fn(() => ({ success: true })) }; @@ -165,7 +164,6 @@ describe('LeagueService', () => { getLeagueWalletPresenter as any, withdrawFromLeagueWalletPresenter as any, leagueJoinRequestsPresenter as any, - leagueRacesPresenter as any, createLeagueSeasonScheduleRacePresenter as any, updateLeagueSeasonScheduleRacePresenter as any, deleteLeagueSeasonScheduleRacePresenter as any, diff --git a/apps/api/src/domain/league/LeagueService.ts b/apps/api/src/domain/league/LeagueService.ts index 5b5b603dc..937b80f78 100644 --- a/apps/api/src/domain/league/LeagueService.ts +++ b/apps/api/src/domain/league/LeagueService.ts @@ -114,7 +114,7 @@ import { GetSeasonSponsorshipsPresenter } from './presenters/GetSeasonSponsorshi import { JoinLeaguePresenter } from './presenters/JoinLeaguePresenter'; import { LeagueConfigPresenter } from './presenters/LeagueConfigPresenter'; import { LeagueJoinRequestsPresenter } from './presenters/LeagueJoinRequestsPresenter'; -import { LeagueSchedulePresenter, LeagueRacesPresenter } from './presenters/LeagueSchedulePresenter'; +import { LeagueSchedulePresenter } from './presenters/LeagueSchedulePresenter'; import { LeagueScoringConfigPresenter } from './presenters/LeagueScoringConfigPresenter'; import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter'; import { LeagueStandingsPresenter } from './presenters/LeagueStandingsPresenter'; @@ -265,7 +265,6 @@ export class LeagueService { @Inject(GET_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly getLeagueWalletPresenter: GetLeagueWalletPresenter, @Inject(WITHDRAW_FROM_LEAGUE_WALLET_OUTPUT_PORT_TOKEN) private readonly withdrawFromLeagueWalletPresenter: WithdrawFromLeagueWalletPresenter, @Inject(LeagueJoinRequestsPresenter) private readonly leagueJoinRequestsPresenter: LeagueJoinRequestsPresenter, - @Inject(LeagueRacesPresenter) private readonly leagueRacesPresenter: LeagueRacesPresenter, // Schedule mutation presenters @Inject(CreateLeagueSeasonScheduleRacePresenter) @@ -842,9 +841,13 @@ export class LeagueService { async getRaces(leagueId: string): Promise { 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 }); + return { - races: this.leagueRacesPresenter.getViewModel()!, + races: this.leagueSchedulePresenter.getViewModel()?.races ?? [], }; } diff --git a/apps/website/app/api/media/avatar/[driverId]/route.ts b/apps/website/app/api/media/avatar/[driverId]/route.ts new file mode 100644 index 000000000..8095c2616 --- /dev/null +++ b/apps/website/app/api/media/avatar/[driverId]/route.ts @@ -0,0 +1,16 @@ +export const runtime = 'nodejs'; + +const ONE_BY_ONE_PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO0pS0kAAAAASUVORK5CYII='; + +export async function GET(): Promise { + 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', + }, + }); +} \ No newline at end of file diff --git a/apps/website/app/leaderboards/page.tsx b/apps/website/app/leaderboards/page.tsx index a5d259a8d..3ad439681 100644 --- a/apps/website/app/leaderboards/page.tsx +++ b/apps/website/app/leaderboards/page.tsx @@ -22,6 +22,7 @@ import { useServices } from '@/lib/services/ServiceProvider'; export default function LeaderboardsPage() { const router = useRouter(); + const { driverService, teamService } = useServices(); const [drivers, setDrivers] = useState([]); const [teams, setTeams] = useState([]); const [loading, setLoading] = useState(true); @@ -29,7 +30,6 @@ export default function LeaderboardsPage() { useEffect(() => { const load = async () => { try { - const { driverService, teamService } = useServices(); const driversViewModel = await driverService.getDriverLeaderboard(); const teams = await teamService.getAllTeams(); diff --git a/apps/website/app/leagues/[id]/rulebook/page.tsx b/apps/website/app/leagues/[id]/rulebook/page.tsx index 6b04c5ae6..3c097d873 100644 --- a/apps/website/app/leagues/[id]/rulebook/page.tsx +++ b/apps/website/app/leagues/[id]/rulebook/page.tsx @@ -13,6 +13,8 @@ export default function LeagueRulebookPage() { const params = useParams(); const leagueId = params.id as string; + const { leagueService } = useServices(); + const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); const [activeSection, setActiveSection] = useState('scoring'); @@ -20,7 +22,6 @@ export default function LeagueRulebookPage() { useEffect(() => { async function loadData() { try { - const { leagueService } = useServices(); const data = await leagueService.getLeagueDetailPageData(leagueId); if (!data) { setLoading(false); @@ -36,7 +37,7 @@ export default function LeagueRulebookPage() { } loadData(); - }, [leagueId]); + }, [leagueId, leagueService]); if (loading) { return ( diff --git a/apps/website/app/onboarding/page.tsx b/apps/website/app/onboarding/page.tsx index 2a2680c75..210ff593e 100644 --- a/apps/website/app/onboarding/page.tsx +++ b/apps/website/app/onboarding/page.tsx @@ -2,6 +2,7 @@ import { Loader2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; import OnboardingWizard from '@/components/onboarding/OnboardingWizard'; import { useCurrentDriver } from '@/hooks/useDriverService'; @@ -12,13 +13,23 @@ export default function OnboardingPage() { const { session } = useAuth(); const { data: driver, isLoading } = useCurrentDriver(); - // If user is not authenticated, redirect to login - if (!session) { - router.replace('/auth/login?returnTo=/onboarding'); + const shouldRedirectToLogin = !session; + const shouldRedirectToDashboard = !isLoading && Boolean(driver); + + useEffect(() => { + if (shouldRedirectToLogin) { + router.replace('/auth/login?returnTo=/onboarding'); + return; + } + if (shouldRedirectToDashboard) { + router.replace('/dashboard'); + } + }, [router, shouldRedirectToLogin, shouldRedirectToDashboard]); + + if (shouldRedirectToLogin) { return null; } - // Show loading while checking driver data if (isLoading) { return (
@@ -27,9 +38,7 @@ export default function OnboardingPage() { ); } - // If driver profile exists, onboarding is complete – go to dashboard - if (driver) { - router.replace('/dashboard'); + if (shouldRedirectToDashboard) { return null; } diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index edefa2893..3cb944650 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -269,10 +269,14 @@ export default function ProfilePage() { const isOwnProfile = true; // This page is always your own profile useEffect(() => { + if (!effectiveDriverId) { + return; + } + const loadData = async () => { + setLoading(true); try { - const currentDriverId = effectiveDriverId; - const profileViewModel = await driverService.getDriverProfile(currentDriverId); + const profileViewModel = await driverService.getDriverProfile(effectiveDriverId); setProfileData(profileViewModel); } catch (error) { console.error('Failed to load profile:', error); @@ -280,6 +284,7 @@ export default function ProfilePage() { setLoading(false); } }; + void loadData(); }, [effectiveDriverId, driverService]); diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 7a0ea3f36..3bc4c81c3 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -22,7 +22,9 @@ interface EntitySection { export default function SponsorshipRequestsPage() { const currentDriverId = useEffectiveDriverId(); - + + const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices(); + const [sections, setSections] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -32,7 +34,10 @@ export default function SponsorshipRequestsPage() { setError(null); try { - const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices(); + if (!currentDriverId) { + setSections([]); + return; + } const allSections: EntitySection[] = []; @@ -107,20 +112,20 @@ export default function SponsorshipRequestsPage() { } finally { setLoading(false); } - }, [currentDriverId]); + }, [currentDriverId, sponsorshipService, driverService, leagueService, teamService, leagueMembershipService]); useEffect(() => { loadAllRequests(); }, [loadAllRequests]); const handleAccept = async (requestId: string) => { - const { sponsorshipService } = useServices(); + if (!currentDriverId) return; await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId); await loadAllRequests(); }; const handleReject = async (requestId: string, reason?: string) => { - const { sponsorshipService } = useServices(); + if (!currentDriverId) return; await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason); await loadAllRequests(); }; diff --git a/apps/website/lib/api/auth/AuthApiClient.ts b/apps/website/lib/api/auth/AuthApiClient.ts index 31bc5bcac..f8da91661 100644 --- a/apps/website/lib/api/auth/AuthApiClient.ts +++ b/apps/website/lib/api/auth/AuthApiClient.ts @@ -23,7 +23,9 @@ export class AuthApiClient extends BaseApiClient { /** Get current session */ getSession(): Promise { - return this.get('/auth/session'); + return this.request('GET', '/auth/session', undefined, { + allowUnauthenticated: true, + }); } /** Logout */ diff --git a/apps/website/lib/api/base/BaseApiClient.ts b/apps/website/lib/api/base/BaseApiClient.ts index a1a958c5d..1d0d53928 100644 --- a/apps/website/lib/api/base/BaseApiClient.ts +++ b/apps/website/lib/api/base/BaseApiClient.ts @@ -19,7 +19,12 @@ export class BaseApiClient { this.logger = logger; } - protected async request(method: string, path: string, data?: object | FormData): Promise { + protected async request( + method: string, + path: string, + data?: object | FormData, + options?: { allowUnauthenticated?: boolean }, + ): Promise { this.logger.info(`${method} ${path}`); const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; @@ -43,6 +48,15 @@ export class BaseApiClient { const response = await fetch(`${this.baseUrl}${path}`, config); 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 }; try { errorData = await response.json(); diff --git a/apps/website/lib/api/leagues/LeaguesApiClient.ts b/apps/website/lib/api/leagues/LeaguesApiClient.ts index e50c4ad98..8daa8067b 100644 --- a/apps/website/lib/api/leagues/LeaguesApiClient.ts +++ b/apps/website/lib/api/leagues/LeaguesApiClient.ts @@ -24,6 +24,20 @@ import type { UpdateLeagueMemberRoleOutputDTO } from '../../types/generated/Upda import type { RemoveLeagueMemberOutputDTO } from '../../types/generated/RemoveLeagueMemberOutputDTO'; import type { AllLeaguesWithCapacityAndScoringDTO } from '../../types/AllLeaguesWithCapacityAndScoringDTO'; +function isRecord(value: unknown): value is Record { + 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 * @@ -145,8 +159,9 @@ export class LeaguesApiClient extends BaseApiClient { } /** Get races for a league */ - getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> { - return this.get<{ races: RaceDTO[] }>(`/leagues/${leagueId}/races`); + async getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> { + 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) */ diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index ad3ddda1c..a4347f638 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -4,7 +4,6 @@ import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient"; import { RacesApiClient } from "@/lib/api/races/RacesApiClient"; import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO"; import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO"; -import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO"; import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel"; import { LeagueAdminScheduleViewModel } from "@/lib/view-models/LeagueAdminScheduleViewModel"; import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel"; @@ -109,8 +108,9 @@ export class LeagueService { */ async getAllLeagues(): Promise { 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, name: league.name, description: league.description, @@ -414,7 +414,8 @@ export class LeagueService { // Since API may not have detailed league, we'll mock or assume // In real implementation, add getLeagueDetail to API 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; // LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided @@ -431,7 +432,8 @@ export class LeagueService { // Get membership 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; // Get main sponsor @@ -439,20 +441,26 @@ export class LeagueService { if (this.sponsorsApiClient) { try { 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) { 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) { const sponsorId = (mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id; - const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId); - const sponsor = (sponsorResult as any)?.sponsor ?? null; - if (sponsor) { - mainSponsor = { - name: sponsor.name, - logoUrl: sponsor.logoUrl ?? '', - websiteUrl: sponsor.websiteUrl ?? '', - }; + if (sponsorId) { + const sponsorResult = await this.sponsorsApiClient.getSponsor(sponsorId); + const sponsor = (sponsorResult as any)?.sponsor ?? null; + if (sponsor) { + mainSponsor = { + name: sponsor.name, + logoUrl: sponsor.logoUrl ?? '', + websiteUrl: sponsor.websiteUrl ?? '', + }; + } } } } @@ -497,7 +505,8 @@ export class LeagueService { try { // Get league basic info 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; // 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 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 drivers = driverDtos.filter((d: any): d is NonNullable => d !== null); // 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 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 const leagueStats: LeagueStatsDTO = { @@ -550,12 +561,16 @@ export class LeagueService { try { 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 []; 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[] = []; for (const sponsorship of activeSponsorships) { diff --git a/apps/website/lib/types/contractConsumption.test.ts b/apps/website/lib/types/contractConsumption.test.ts index 6c85abda6..7e8d7366f 100644 --- a/apps/website/lib/types/contractConsumption.test.ts +++ b/apps/website/lib/types/contractConsumption.test.ts @@ -5,7 +5,7 @@ * 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 path from 'path'; import { glob } from 'glob'; @@ -22,6 +22,7 @@ import type { UpdateAvatarInputDTO } from './generated/UpdateAvatarInputDTO'; import type { UpdateAvatarOutputDTO } from './generated/UpdateAvatarOutputDTO'; import type { RaceDTO } from './generated/RaceDTO'; import type { DriverDTO } from './generated/DriverDTO'; +import { LeaguesApiClient } from '../api/leagues/LeaguesApiClient'; describe('Website Contract Consumption', () => { const generatedTypesDir = path.join(__dirname, 'generated'); @@ -196,6 +197,27 @@ describe('Website Contract Consumption', () => { }); 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', () => { // Test that optional fields can be omitted const minimalOutput: RequestAvatarGenerationOutputDTO = { diff --git a/apps/website/lib/view-models/SessionViewModel.ts b/apps/website/lib/view-models/SessionViewModel.ts index 45e7b1ad4..1a91b4b5d 100644 --- a/apps/website/lib/view-models/SessionViewModel.ts +++ b/apps/website/lib/view-models/SessionViewModel.ts @@ -9,6 +9,13 @@ export class SessionViewModel { this.userId = dto.userId; this.email = dto.email; 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 diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 64a477e22..3bd416873 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -15,7 +15,7 @@ services: [ "sh", "-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: - gridpilot-test-network @@ -23,16 +23,15 @@ services: api: image: node:20-alpine + working_dir: /app environment: - NODE_ENV=test + - PORT=3000 ports: - "3101:3000" - command: - [ - "sh", - "-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'));\"", - ] + volumes: + - ./:/app + command: ["sh", "-lc", "node testing/mock-api-server.cjs"] networks: - gridpilot-test-network restart: unless-stopped diff --git a/package.json b/package.json index 1a2eecd5f..632dd2ac3 100644 --- a/package.json +++ b/package.json @@ -91,8 +91,9 @@ "docker:prod:down": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml down", "docker:prod:logs": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml logs -f", "docker:test: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: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: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 --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');})();\"", "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\"", diff --git a/testing/mock-api-server.cjs b/testing/mock-api-server.cjs new file mode 100644 index 000000000..1d026d4a9 --- /dev/null +++ b/testing/mock-api-server.cjs @@ -0,0 +1,1073 @@ +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 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', + }, + }; + } + + if (mode === 'sponsor') { + return { + token: 'test-token-sponsor', + user: { + userId: 'user-sponsor', + email: 'sponsor@gridpilot.test', + displayName: 'Demo Sponsor User', + primaryDriverId: 'driver-sponsor', + sponsorId, + }, + }; + } + + return { + token: 'test-token-driver', + user: { + userId: 'user-driver', + email: 'driver@gridpilot.test', + displayName: 'Demo Driver', + primaryDriverId: 'driver-1', + }, + }; +} + +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', + ownerId: 'driver-admin', + createdAt: nowIso(), + memberCount: 2, + }, + ], + 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 === '/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); + } + + 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) { + 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) { + 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) 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) { + 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) return send(200, buildSponsorSettings(sponsorSettings)); + + const sponsorLeagueAvailable = pathname === '/sponsors/leagues/available'; + if (sponsorLeagueAvailable) { + 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) { + 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}`); +}); \ No newline at end of file diff --git a/tests/smoke/website-pages.test.ts b/tests/smoke/website-pages.test.ts index ef238ffdd..ca939165e 100644 --- a/tests/smoke/website-pages.test.ts +++ b/tests/smoke/website-pages.test.ts @@ -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', () => { - const routes = process.env.DOCKER_SMOKE - ? [ - { path: '/', name: 'landing' }, - { 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' }, - ]; +type SmokeScenario = { + scenarioName: string; + auth: WebsiteAuthContext; + expectAuthRedirect: boolean; +}; - for (const route of routes) { - test(`renders ${route.name} page without console errors (${route.path})`, async ({ page }) => { - const consoleMessages: string[] = []; +type AuthOptions = { + sessionDrift?: WebsiteSessionDriftMode; + faultMode?: WebsiteFaultMode; +}; - page.on('console', (msg) => { - const type = msg.type(); - if (type === 'error') { - consoleMessages.push(`[${type}] ${msg.text()}`); - } - }); +function toRegexEscaped(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} - const apiCallPromise = - route.path === '/leagues' - ? page.waitForResponse((resp) => { - return resp.url().includes('/leagues/all-with-capacity') && resp.status() === 200; - }) - : route.path === '/teams' - ? page.waitForResponse((resp) => { - return resp.url().includes('/teams/all') && resp.status() === 200; - }) - : null; +function urlToKey(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + return `${parsed.origin}${parsed.pathname}${parsed.search}`; + } catch { + return rawUrl; + } +} - 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 { + const { page, context, route, scenario, resolvedPath, expectedPath, authOptions = {} } = args; - if (apiCallPromise) { - await apiCallPromise; + await setWebsiteAuthContext(context, scenario.auth, authOptions); + + await page.addInitScript(() => { + window.addEventListener('unhandledrejection', (event) => { + const anyEvent = event; + const reason = anyEvent && typeof anyEvent === 'object' && 'reason' in anyEvent ? anyEvent.reason : undefined; + // Forward to console so smoke harness can treat as a runtime failure. + // eslint-disable-next-line no-console + console.error(`[unhandledrejection] ${String(reason)}`); + }); + }); + + 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> = []; + + 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( - consoleMessages.length, - `Console errors on route ${route.path}:\n${consoleMessages.join('\n')}`, - ).toBe(0); + navResponse?.status(), + `Expected protected route ${resolvedPath} to redirect to auth or return 200 when public; ended at ${finalPathname}`, + ).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 } }); + }); + } + } }); \ No newline at end of file diff --git a/tests/smoke/websiteAuth.ts b/tests/smoke/websiteAuth.ts new file mode 100644 index 000000000..f50db3832 --- /dev/null +++ b/tests/smoke/websiteAuth.ts @@ -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 { + 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 instead of setting `selected` on