add website tests

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

View File

@@ -100,7 +100,6 @@ describe('LeagueService', () => {
const getLeagueWalletPresenter = { getResponseModel: vi.fn(() => ({ balance: 0 })) };
const 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,

View File

@@ -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<GetLeagueRacesOutputDTO> {
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 ?? [],
};
}

View File

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

View File

@@ -22,6 +22,7 @@ import { useServices } from '@/lib/services/ServiceProvider';
export default function LeaderboardsPage() {
const router = useRouter();
const { driverService, teamService } = useServices();
const [drivers, setDrivers] = useState<DriverLeaderboardItemViewModel[]>([]);
const [teams, setTeams] = useState<TeamSummaryViewModel[]>([]);
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();

View File

@@ -13,6 +13,8 @@ export default function LeagueRulebookPage() {
const params = useParams();
const leagueId = params.id as string;
const { leagueService } = useServices();
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [activeSection, setActiveSection] = useState<RulebookSection>('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 (

View File

@@ -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 (
<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 (driver) {
router.replace('/dashboard');
if (shouldRedirectToDashboard) {
return null;
}

View File

@@ -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]);

View File

@@ -22,7 +22,9 @@ interface EntitySection {
export default function SponsorshipRequestsPage() {
const currentDriverId = useEffectiveDriverId();
const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices();
const [sections, setSections] = useState<EntitySection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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();
};

View File

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

View File

@@ -19,7 +19,12 @@ export class BaseApiClient {
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}`);
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();

View File

@@ -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<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
*
@@ -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) */

View File

@@ -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<LeagueSummaryViewModel[]> {
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<typeof d> => 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) {

View File

@@ -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 = {

View File

@@ -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