add website tests
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
apps/website/app/api/media/avatar/[driverId]/route.ts
Normal file
16
apps/website/app/api/media/avatar/[driverId]/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
1073
testing/mock-api-server.cjs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
93
tests/smoke/websiteAuth.ts
Normal file
93
tests/smoke/websiteAuth.ts
Normal 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 };
|
||||||
|
}
|
||||||
189
tests/smoke/websiteRouteInventory.ts
Normal file
189
tests/smoke/websiteRouteInventory.ts
Normal 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' }];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user