resolve todos in website

This commit is contained in:
2025-12-20 12:22:48 +01:00
parent a87cf27fb9
commit 20588e1c0b
39 changed files with 1238 additions and 359 deletions

View File

@@ -0,0 +1,169 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
useParams: () => ({ id: 'race-123' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mock sponsor mode hook to avoid rendering heavy sponsor card
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
__esModule: true,
default: () => <div data-testid="sponsor-insights-mock" />,
MetricBuilders: {
views: vi.fn(() => ({ label: 'Views', value: '100' })),
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
},
SlotTemplates: {
race: vi.fn(() => []),
},
useSponsorMode: () => false,
}));
// Mock services hook to provide raceService and leagueMembershipService
const mockGetRaceDetail = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => ({
raceService: {
getRaceDetail: mockGetRaceDetail,
reopenRace: mockReopenRace,
// other methods are not used in this test
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
}),
}));
// Mock league membership utility to control admin vs non-admin behavior
const mockIsOwnerOrAdmin = vi.fn();
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
LeagueMembershipUtility: {
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
},
}));
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
race: {
id: 'race-123',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
strengthOfField: null,
registeredCount: 0,
maxParticipants: 32,
} as any,
league: {
id: 'league-1',
name: 'Test League',
description: 'Test league description',
settings: {
maxDrivers: 32,
qualifyingFormat: 'open',
},
} as any,
entryList: [],
registration: {
isRegistered: false,
canRegister: false,
} as any,
userResult: null,
});
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
mockGetRaceDetail.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsOwnerOrAdmin.mockReset();
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue(null);
});
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('completed');
// First call: initial load, second call: after re-open
mockGetRaceDetail.mockResolvedValue(viewModel);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
mockReopenRace.mockResolvedValue(undefined);
fireEvent.click(reopenButton);
await waitFor(() => {
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
});
// loadRaceData should be called again after reopening
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
confirmSpy.mockRestore();
});
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
});

View File

@@ -44,6 +44,7 @@ export default function RaceDetailPage() {
const [error, setError] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const [registering, setRegistering] = useState(false);
const [reopening, setReopening] = useState(false);
const [ratingChange, setRatingChange] = useState<number | null>(null);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const [showProtestModal, setShowProtestModal] = useState(false);
@@ -174,6 +175,27 @@ export default function RaceDetailPage() {
}
};
const handleReopenRace = async () => {
const race = viewModel?.race;
if (!race || !viewModel?.canReopenRace) return;
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
);
if (!confirmed) return;
setReopening(true);
try {
await raceService.reopenRace(race.id);
await loadRaceData();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race');
} finally {
setReopening(false);
}
};
const formatDate = (date: Date) => {
return new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
@@ -856,6 +878,19 @@ export default function RaceDetailPage() {
</>
)}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'completed' && (
<>
<Button
@@ -884,29 +919,22 @@ export default function RaceDetailPage() {
<Scale className="w-4 h-4" />
Stewarding
</Button>
{LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<>
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={async () => {
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.'
);
if (!confirmed) return;
// TODO: Implement re-open race functionality
alert('Re-open race functionality not yet implemented');
}}
>
<PlayCircle className="w-4 h-4" />
Re-open Race
</Button>
</>
)}
</>
)}
{viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="outline"
className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace}
disabled={reopening}
>
<PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'}
</Button>
)}
{race.status === 'running' && LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button
variant="primary"

View File

@@ -8,6 +8,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import type { RaceResultsDetailViewModel } from '@/lib/view-models';
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
@@ -18,7 +19,7 @@ export default function RaceResultsPage() {
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { raceResultsService } = useServices();
const { raceResultsService, leagueMembershipService } = useServices();
const [raceData, setRaceData] = useState<RaceResultsDetailViewModel | null>(null);
const [raceSOF, setRaceSOF] = useState<number | null>(null);
@@ -56,14 +57,16 @@ export default function RaceResultsPage() {
}, [raceId]);
useEffect(() => {
if (raceData?.league?.id && currentDriverId) {
const leagueId = raceData?.league?.id;
if (leagueId && currentDriverId) {
const checkAdmin = async () => {
// For now, assume admin check - this might need to be updated based on API
setIsAdmin(true); // TODO: Implement proper admin check via API
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
};
checkAdmin();
}
}, [raceData?.league?.id, currentDriverId]);
}, [raceData?.league?.id, currentDriverId, leagueMembershipService]);
const handleImportSuccess = async (importedResults: any[]) => {
setImporting(true);

View File

@@ -6,6 +6,7 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
AlertTriangle,
@@ -24,7 +25,7 @@ import { useEffect, useState } from 'react';
export default function RaceStewardingPage() {
const params = useParams();
const router = useRouter();
const { raceStewardingService } = useServices();
const { raceStewardingService, leagueMembershipService } = useServices();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
@@ -40,9 +41,11 @@ export default function RaceStewardingPage() {
const data = await raceStewardingService.getRaceStewardingData(raceId, currentDriverId);
setStewardingData(data);
if (data.league) {
// TODO: Implement admin check via API
setIsAdmin(true);
if (data.league?.id) {
const membership = await leagueMembershipService.getMembership(data.league.id, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} else {
setIsAdmin(false);
}
} catch (err) {
console.error('Failed to load data:', err);
@@ -52,7 +55,7 @@ export default function RaceStewardingPage() {
}
loadData();
}, [raceId, currentDriverId, raceStewardingService]);
}, [raceId, currentDriverId, raceStewardingService, leagueMembershipService]);
const pendingProtests = stewardingData?.pendingProtests ?? [];
const resolvedProtests = stewardingData?.resolvedProtests ?? [];