resolve todos in website
This commit is contained in:
169
apps/website/app/races/[id]/page.test.tsx
Normal file
169
apps/website/app/races/[id]/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
Reference in New Issue
Block a user