add website tests
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user