website refactor

This commit is contained in:
2026-01-14 12:52:30 +01:00
parent 02073f19ef
commit 5451b5b0e9
6 changed files with 0 additions and 1053 deletions

View File

@@ -1,251 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import type { Mocked } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel';
import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel';
import { RosterAdminPage } from './RosterAdminPage';
type RosterAdminLeagueService = {
getAdminRosterJoinRequests(leagueId: string): Promise<LeagueAdminRosterJoinRequestViewModel[]>;
getAdminRosterMembers(leagueId: string): Promise<LeagueAdminRosterMemberViewModel[]>;
approveJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
rejectJoinRequest(leagueId: string, joinRequestId: string): Promise<{ success: boolean }>;
updateMemberRole(leagueId: string, driverId: string, role: string): Promise<{ success: boolean }>;
removeMember(leagueId: string, driverId: string): Promise<{ success: boolean }>;
};
let mockLeagueService: Mocked<RosterAdminLeagueService>;
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'league-1' }),
}));
// Mock data storage
let mockJoinRequests: any[] = [];
let mockMembers: any[] = [];
// Mock the hooks directly
vi.mock('@/lib/hooks/league/useLeagueRosterAdmin', () => ({
useLeagueJoinRequests: (leagueId: string) => ({
data: [...mockJoinRequests],
isLoading: false,
isError: false,
isSuccess: true,
refetch: vi.fn(),
}),
useLeagueRosterAdmin: (leagueId: string) => ({
data: [...mockMembers],
isLoading: false,
isError: false,
isSuccess: true,
refetch: vi.fn(),
}),
useApproveJoinRequest: (options?: any) => ({
mutate: (params: any) => {
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
if (options?.onSuccess) options.onSuccess();
},
mutateAsync: async (params: any) => {
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
if (options?.onSuccess) options.onSuccess();
return { success: true };
},
isPending: false,
}),
useRejectJoinRequest: (options?: any) => ({
mutate: (params: any) => {
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
if (options?.onSuccess) options.onSuccess();
},
mutateAsync: async (params: any) => {
mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.requestId);
if (options?.onSuccess) options.onSuccess();
return { success: true };
},
isPending: false,
}),
useUpdateMemberRole: (options?: any) => ({
mutate: (params: any) => {
const member = mockMembers.find(m => m.driverId === params.driverId);
if (member) member.role = params.newRole;
if (options?.onError) options.onError();
},
mutateAsync: async (params: any) => {
const member = mockMembers.find(m => m.driverId === params.driverId);
if (member) member.role = params.newRole;
if (options?.onError) options.onError();
return { success: true };
},
isPending: false,
}),
useRemoveMember: (options?: any) => ({
mutate: (params: any) => {
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
if (options?.onSuccess) options.onSuccess();
},
mutateAsync: async (params: any) => {
mockMembers = mockMembers.filter(m => m.driverId !== params.driverId);
if (options?.onSuccess) options.onSuccess();
return { success: true };
},
isPending: false,
}),
}));
function makeJoinRequest(overrides: Partial<LeagueAdminRosterJoinRequestViewModel> = {}): LeagueAdminRosterJoinRequestViewModel {
return {
id: 'jr-1',
leagueId: 'league-1',
driver: {
id: 'driver-1',
name: 'Driver One',
},
requestedAt: '2025-01-01T00:00:00.000Z',
message: 'Please let me in',
...overrides,
};
}
function makeMember(overrides: Partial<LeagueAdminRosterMemberViewModel> = {}): LeagueAdminRosterMemberViewModel {
return {
driverId: 'driver-10',
driver: {
id: 'driver-10',
name: 'Member Ten',
},
role: 'member',
joinedAt: '2025-01-01T00:00:00.000Z',
...overrides,
};
}
describe('RosterAdminPage', () => {
let queryClient: QueryClient;
beforeEach(() => {
// Reset mock data
mockJoinRequests = [];
mockMembers = [];
mockLeagueService = {
getAdminRosterJoinRequests: vi.fn(),
getAdminRosterMembers: vi.fn(),
approveJoinRequest: vi.fn(),
rejectJoinRequest: vi.fn(),
updateMemberRole: vi.fn(),
removeMember: vi.fn(),
} as any;
// Create a new QueryClient for each test
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
});
const renderWithProviders = (component: React.ReactNode) => {
return render(
<QueryClientProvider client={queryClient}>
{component}
</QueryClientProvider>
);
};
it('renders join requests + members from service ViewModels', async () => {
const joinRequests: LeagueAdminRosterJoinRequestViewModel[] = [
makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } }),
makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } }),
];
const members: LeagueAdminRosterMemberViewModel[] = [
makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' }, role: 'member' }),
makeMember({ driverId: 'driver-11', driver: { id: 'driver-11', name: 'Member Eleven' }, role: 'admin' }),
];
// Set mock data for hooks
mockJoinRequests = joinRequests;
mockMembers = members;
renderWithProviders(<RosterAdminPage />);
expect(await screen.findByText('Roster Admin')).toBeInTheDocument();
expect(await screen.findByText('Driver One')).toBeInTheDocument();
expect(screen.getByText('Driver Two')).toBeInTheDocument();
expect(await screen.findByText('Member Ten')).toBeInTheDocument();
expect(screen.getByText('Member Eleven')).toBeInTheDocument();
});
it('approves a join request and removes it from the pending list', async () => {
mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driver: { id: 'driver-1', name: 'Driver One' } })];
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
renderWithProviders(<RosterAdminPage />);
expect(await screen.findByText('Driver One')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('join-request-jr-1-approve'));
await waitFor(() => {
expect(screen.queryByText('Driver One')).not.toBeInTheDocument();
});
});
it('rejects a join request and removes it from the pending list', async () => {
mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driver: { id: 'driver-2', name: 'Driver Two' } })];
mockMembers = [makeMember({ driverId: 'driver-10', driver: { id: 'driver-10', name: 'Member Ten' } })];
renderWithProviders(<RosterAdminPage />);
expect(await screen.findByText('Driver Two')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('join-request-jr-2-reject'));
await waitFor(() => {
expect(screen.queryByText('Driver Two')).not.toBeInTheDocument();
});
});
it('changes a member role via service and updates the displayed role', async () => {
mockJoinRequests = [];
mockMembers = [makeMember({ driverId: 'driver-11', driver: { id: 'driver-11', name: 'Member Eleven' }, role: 'member' })];
renderWithProviders(<RosterAdminPage />);
expect(await screen.findByText('Member Eleven')).toBeInTheDocument();
const roleSelect = screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement;
expect(roleSelect.value).toBe('member');
fireEvent.change(roleSelect, { target: { value: 'admin' } });
await waitFor(() => {
expect((screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement).value).toBe('admin');
});
});
it('removes a member via service and removes them from the list', async () => {
mockJoinRequests = [];
mockMembers = [makeMember({ driverId: 'driver-12', driver: { id: 'driver-12', name: 'Member Twelve' }, role: 'member' })];
renderWithProviders(<RosterAdminPage />);
expect(await screen.findByText('Member Twelve')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('member-driver-12-remove'));
await waitFor(() => {
expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,318 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import '@testing-library/jest-dom';
import LeagueAdminSchedulePage from './page';
// Mock useEffectiveDriverId
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
type SeasonSummaryViewModel = {
seasonId: string;
name: string;
status: string;
isPrimary: boolean;
isParallelActive: boolean;
};
type AdminScheduleRaceViewModel = {
id: string;
name: string;
scheduledAt: Date;
};
type AdminScheduleViewModel = {
seasonId: string;
published: boolean;
races: AdminScheduleRaceViewModel[];
};
const mockGetLeagueSeasonSummaries = vi.fn<() => Promise<SeasonSummaryViewModel[]>>();
const mockGetAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockGetLeagueScheduleDto = vi.fn(() => {
throw new Error('LeagueAdminSchedulePage must not call getLeagueScheduleDto (DTO boundary violation)');
});
const mockPublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockUnpublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise<AdminScheduleViewModel>>();
const mockCreateAdminScheduleRace = vi.fn<
(leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }) => Promise<AdminScheduleViewModel>
>();
const mockUpdateAdminScheduleRace = vi.fn<
(
leagueId: string,
seasonId: string,
raceId: string,
input: Partial<{ track: string; car: string; scheduledAtIso: string }>,
) => Promise<AdminScheduleViewModel>
>();
const mockDeleteAdminScheduleRace = vi.fn<(leagueId: string, seasonId: string, raceId: string) => Promise<AdminScheduleViewModel>>();
const mockFetchLeagueMemberships = vi.fn<(leagueId: string) => Promise<unknown[]>>();
const mockGetMembership = vi.fn<
(leagueId: string, driverId: string) => { role: 'admin' | 'owner' | 'member' | 'steward' } | null
>();
vi.mock('next/navigation', () => ({
useParams: () => ({ id: 'league-1' }),
}));
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
const mockServices = {
leagueService: {
getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries,
getAdminSchedule: mockGetAdminSchedule,
publishAdminSchedule: mockPublishAdminSchedule,
unpublishAdminSchedule: mockUnpublishAdminSchedule,
createAdminScheduleRace: mockCreateAdminScheduleRace,
updateAdminScheduleRace: mockUpdateAdminScheduleRace,
deleteAdminScheduleRace: mockDeleteAdminScheduleRace,
// Legacy method (should never be called by this page)
getLeagueScheduleDto: mockGetLeagueScheduleDto,
},
leagueMembershipService: {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
},
};
// Mock useInject to return mocked services
vi.mock('@/lib/di/hooks/useInject', () => ({
useInject: (token: symbol) => {
const tokenStr = token.toString();
if (tokenStr.includes('LEAGUE_SERVICE_TOKEN')) {
return {
getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries,
getAdminSchedule: mockGetAdminSchedule,
publishAdminSchedule: mockPublishAdminSchedule,
unpublishAdminSchedule: mockUnpublishAdminSchedule,
createAdminScheduleRace: mockCreateAdminScheduleRace,
updateAdminScheduleRace: mockUpdateAdminScheduleRace,
deleteAdminScheduleRace: mockDeleteAdminScheduleRace,
};
}
if (tokenStr.includes('LEAGUE_MEMBERSHIP_SERVICE_TOKEN')) {
return {
fetchLeagueMemberships: mockFetchLeagueMemberships,
getMembership: mockGetMembership,
};
}
return {};
},
}));
// Mock the static LeagueMembershipService for LeagueMembershipUtility
vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({
LeagueMembershipService: {
getMembership: mockGetMembership,
fetchLeagueMemberships: mockFetchLeagueMemberships,
setLeagueMemberships: vi.fn(),
clearLeagueMemberships: vi.fn(),
getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()),
getAllMembershipsForDriver: vi.fn(() => []),
getLeagueMembers: vi.fn(() => []),
},
}));
function createAdminScheduleViewModel(overrides: Partial<AdminScheduleViewModel> = {}): AdminScheduleViewModel {
return {
seasonId: 'season-1',
published: false,
races: [],
...overrides,
};
}
describe('LeagueAdminSchedulePage', () => {
afterEach(() => {
cleanup();
});
beforeEach(() => {
mockGetLeagueSeasonSummaries.mockReset();
mockGetAdminSchedule.mockReset();
mockGetLeagueScheduleDto.mockClear();
mockPublishAdminSchedule.mockReset();
mockUnpublishAdminSchedule.mockReset();
mockCreateAdminScheduleRace.mockReset();
mockUpdateAdminScheduleRace.mockReset();
mockDeleteAdminScheduleRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
// Set up default mock implementations
mockFetchLeagueMemberships.mockResolvedValue([]);
mockGetMembership.mockReturnValue({ role: 'admin' });
});
it('renders schedule using ViewModel fields (no DTO date field)', async () => {
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValue(
createAdminScheduleViewModel({
seasonId: 'season-1',
published: true,
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
}),
);
render(<LeagueAdminSchedulePage />);
expect(await screen.findByText('Schedule Admin')).toBeInTheDocument();
expect(await screen.findByText('Race 1')).toBeInTheDocument();
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
});
it('publish/unpublish uses admin schedule service API and updates UI status', async () => {
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
mockPublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: true }));
mockUnpublishAdminSchedule.mockResolvedValue(createAdminScheduleViewModel({ published: false }));
render(<LeagueAdminSchedulePage />);
expect(await screen.findByText(/Status:/)).toHaveTextContent('Unpublished');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Publish' })).toBeEnabled();
});
fireEvent.click(screen.getByRole('button', { name: 'Publish' }));
await waitFor(() => {
expect(mockPublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
await waitFor(() => {
expect(screen.getByText(/Status:/)).toHaveTextContent('Published');
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Unpublish' })).toBeEnabled();
});
fireEvent.click(screen.getByRole('button', { name: 'Unpublish' }));
await waitFor(() => {
expect(mockUnpublishAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
await waitFor(() => {
expect(screen.getByText(/Status:/)).toHaveTextContent('Unpublished');
});
});
it('create/update/delete uses admin schedule service API and refreshes schedule list', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
mockGetLeagueSeasonSummaries.mockResolvedValue([
{ seasonId: 'season-1', name: 'Season 1', status: 'active', isPrimary: true, isParallelActive: false },
]);
mockGetAdminSchedule.mockResolvedValueOnce(createAdminScheduleViewModel({ published: false, races: [] }));
mockCreateAdminScheduleRace.mockResolvedValueOnce(
createAdminScheduleViewModel({
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-01T12:00:00.000Z') }],
}),
);
mockUpdateAdminScheduleRace.mockResolvedValueOnce(
createAdminScheduleViewModel({
races: [{ id: 'race-1', name: 'Race 1', scheduledAt: new Date('2025-01-02T12:00:00.000Z') }],
}),
);
mockDeleteAdminScheduleRace.mockResolvedValueOnce(createAdminScheduleViewModel({ races: [] }));
render(<LeagueAdminSchedulePage />);
await screen.findByText('Schedule Admin');
await waitFor(() => {
expect(mockGetAdminSchedule).toHaveBeenCalledWith('league-1', 'season-1');
});
expect(mockGetLeagueScheduleDto).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.queryByText('Loading…')).toBeNull();
});
await screen.findByLabelText('Track');
await screen.findByLabelText('Car');
await screen.findByLabelText('Scheduled At (ISO)');
fireEvent.change(screen.getByLabelText('Track'), { target: { value: 'Laguna Seca' } });
fireEvent.change(screen.getByLabelText('Car'), { target: { value: 'MX-5' } });
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-01T12:00:00.000Z' } });
fireEvent.click(screen.getByRole('button', { name: 'Add race' }));
await waitFor(() => {
expect(mockCreateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', {
track: 'Laguna Seca',
car: 'MX-5',
scheduledAtIso: '2025-01-01T12:00:00.000Z',
});
});
expect(await screen.findByText('Race 1')).toBeInTheDocument();
expect(await screen.findByText('2025-01-01T12:00:00.000Z')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
fireEvent.change(screen.getByLabelText('Scheduled At (ISO)'), { target: { value: '2025-01-02T12:00:00.000Z' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(mockUpdateAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1', {
scheduledAtIso: '2025-01-02T12:00:00.000Z',
});
});
expect(await screen.findByText('2025-01-02T12:00:00.000Z')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(mockDeleteAdminScheduleRace).toHaveBeenCalledWith('league-1', 'season-1', 'race-1');
});
await waitFor(() => {
expect(screen.queryByText('Race 1')).toBeNull();
});
confirmSpy.mockRestore();
});
});

View File

@@ -1,146 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import ProtestReviewPage from './page';
// Mock useEffectiveDriverId
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mocks for Next.js navigation
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
useParams: () => ({ id: 'league-1', protestId: 'protest-1' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
const mockGetProtestDetailViewModel = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
// Mock useLeagueAdminStatus hook
vi.mock('@/hooks/league/useLeagueAdminStatus', () => ({
useLeagueAdminStatus: (leagueId: string, driverId: string) => ({
data: mockGetMembership.mock.results[0]?.value ?
(mockGetMembership.mock.results[0].value.role === 'admin' || mockGetMembership.mock.results[0].value.role === 'owner') : false,
isLoading: false,
isError: false,
isSuccess: true,
refetch: vi.fn(),
}),
}));
// Mock useProtestDetail hook
vi.mock('@/hooks/league/useProtestDetail', () => ({
useProtestDetail: (leagueId: string, protestId: string, enabled: boolean = true) => ({
data: mockGetProtestDetailViewModel.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockGetProtestDetailViewModel.mock.results[0]?.value,
refetch: vi.fn(),
retry: vi.fn(),
}),
}));
// Mock useInject for protest service
vi.mock('@/lib/di/hooks/useInject', () => ({
useInject: (token: symbol) => {
if (token.toString().includes('PROTEST_SERVICE_TOKEN')) {
return {
applyPenalty: vi.fn(),
requestDefense: vi.fn(),
};
}
return {};
},
}));
// Mock the static LeagueMembershipService for LeagueRoleUtility
vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({
LeagueMembershipService: {
getMembership: mockGetMembership,
fetchLeagueMemberships: mockFetchLeagueMemberships,
setLeagueMemberships: vi.fn(),
clearLeagueMemberships: vi.fn(),
getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()),
getAllMembershipsForDriver: vi.fn(() => []),
getLeagueMembers: vi.fn(() => []),
},
}));
const mockIsLeagueAdminOrHigherRole = vi.fn();
vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({
LeagueRoleUtility: {
isLeagueAdminOrHigherRole: (...args: unknown[]) => mockIsLeagueAdminOrHigherRole(...args),
},
}));
describe('ProtestReviewPage', () => {
beforeEach(() => {
mockPush.mockReset();
mockGetProtestDetailViewModel.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsLeagueAdminOrHigherRole.mockReset();
// Set up default mock implementations
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue({ role: 'admin' });
mockIsLeagueAdminOrHigherRole.mockReturnValue(true);
});
it('loads protest detail via LeagueStewardingService view model method', async () => {
mockGetProtestDetailViewModel.mockResolvedValue({
protest: {
id: 'protest-1',
raceId: 'race-1',
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
description: 'desc',
submittedAt: '2023-10-01T10:00:00Z',
status: 'pending',
incident: { lap: 1 },
},
race: {
id: 'race-1',
name: 'Test Race',
formattedDate: '10/1/2023',
},
protestingDriver: { id: 'driver-1', name: 'Driver 1' },
accusedDriver: { id: 'driver-2', name: 'Driver 2' },
penaltyTypes: [
{
type: 'time_penalty',
label: 'Time Penalty',
description: 'Add seconds to race result',
requiresValue: true,
valueLabel: 'seconds',
defaultValue: 5,
},
],
defaultReasons: { upheld: 'Upheld reason', dismissed: 'Dismissed reason' },
initialPenaltyType: 'time_penalty',
initialPenaltyValue: 5,
});
render(<ProtestReviewPage />);
await waitFor(() => {
expect(mockGetProtestDetailViewModel).toHaveBeenCalledWith('league-1', 'protest-1');
});
expect(await screen.findByText('Protest Review')).toBeInTheDocument();
});
});

View File

@@ -1,212 +0,0 @@
'use client';
/**
* LeagueDetailPresenter
* Pure client-side presenter for LeagueDetailTemplate
* Converts ViewModels to ViewData and removes DisplayObject usage
*/
import type { Presenter } from '@/lib/contracts/presenters/Presenter';
import type { DriverSummaryData, LeagueDetailViewData, LeagueInfoData, LiveRaceData, SponsorInfo, SponsorMetric } from '@/lib/view-data/LeagueDetailViewData';
import type { DriverSummary, LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
import type { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { Eye, TrendingUp, Users, Zap } from 'lucide-react';
interface SponsorshipSlot {
tier: 'main' | 'secondary';
available: boolean;
price: number;
benefits: string[];
}
interface LeagueDetailInput {
viewModel: LeagueDetailPageViewModel;
leagueId: string;
isSponsor: boolean;
}
// League role display data (moved from LeagueRoleDisplay)
const leagueRoleDisplay = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;
export class LeagueDetailPresenter implements Presenter<LeagueDetailInput, LeagueDetailViewData> {
/**
* Convert RaceViewModel[] to LiveRaceData[]
*/
private static convertRunningRaces(races: RaceViewModel[]): LiveRaceData[] {
return races.map(race => ({
id: race.id,
name: race.name,
date: race.date,
registeredCount: race.registeredCount,
strengthOfField: race.strengthOfField,
}));
}
/**
* Convert DriverSummary to DriverSummaryData with role badge info
*/
private static convertDriverSummary(
summary: DriverSummary | null,
role: 'owner' | 'admin' | 'steward' | 'member',
leagueId: string
): DriverSummaryData | null {
if (!summary) return null;
const roleDisplay = leagueRoleDisplay[role];
return {
driverId: summary.driver.id,
driverName: summary.driver.name,
avatarUrl: summary.driver.avatarUrl,
rating: summary.rating,
rank: summary.rank,
roleBadgeText: roleDisplay.text,
roleBadgeClasses: roleDisplay.badgeClasses,
profileUrl: `/drivers/${summary.driver.id}?from=league-management&leagueId=${leagueId}`,
};
}
/**
* Transform input to output
*/
present(input: LeagueDetailInput): LeagueDetailViewData {
const { viewModel, leagueId, isSponsor } = input;
// Build info data
const info: LeagueInfoData = {
name: viewModel.name,
description: viewModel.description ?? '',
membersCount: viewModel.memberships.length,
racesCount: viewModel.completedRacesCount,
avgSOF: viewModel.averageSOF,
structure: `Solo • ${viewModel.settings.maxDrivers ?? 32} max`,
scoring: viewModel.scoringConfig?.scoringPresetName ?? 'Standard',
createdAt: viewModel.createdAt,
discordUrl: viewModel.socialLinks?.discordUrl,
youtubeUrl: viewModel.socialLinks?.youtubeUrl,
websiteUrl: viewModel.socialLinks?.websiteUrl,
};
// Convert running races
const runningRaces = LeagueDetailPresenter.convertRunningRaces(viewModel.runningRaces);
// Convert sponsors
const sponsors: SponsorInfo[] = viewModel.sponsors.map(s => ({
id: s.id,
name: s.name,
tier: s.tier,
logoUrl: s.logoUrl,
websiteUrl: s.websiteUrl,
tagline: s.tagline,
}));
// Convert driver summaries with role badges
const ownerSummary = LeagueDetailPresenter.convertDriverSummary(viewModel.ownerSummary, 'owner', leagueId);
const adminSummaries = viewModel.adminSummaries
.map(s => LeagueDetailPresenter.convertDriverSummary(s, 'admin', leagueId))
.filter((s): s is DriverSummaryData => s !== null);
const stewardSummaries = viewModel.stewardSummaries
.map(s => LeagueDetailPresenter.convertDriverSummary(s, 'steward', leagueId))
.filter((s): s is DriverSummaryData => s !== null);
// Sponsor insights (only if sponsor mode)
const sponsorInsights = isSponsor ? {
avgViewsPerRace: viewModel.sponsorInsights.avgViewsPerRace,
engagementRate: viewModel.sponsorInsights.engagementRate,
estimatedReach: viewModel.sponsorInsights.estimatedReach,
tier: viewModel.sponsorInsights.tier,
trustScore: viewModel.sponsorInsights.trustScore,
discordMembers: viewModel.sponsorInsights.discordMembers,
monthlyActivity: viewModel.sponsorInsights.monthlyActivity,
mainSponsorAvailable: viewModel.sponsorInsights.mainSponsorAvailable,
secondarySlotsAvailable: viewModel.sponsorInsights.secondarySlotsAvailable,
mainSponsorPrice: viewModel.sponsorInsights.mainSponsorPrice,
secondaryPrice: viewModel.sponsorInsights.secondaryPrice,
totalImpressions: viewModel.sponsorInsights.totalImpressions,
metrics: [
{
icon: Eye,
label: 'Avg Views/Race',
value: viewModel.sponsorInsights.avgViewsPerRace,
color: 'text-primary-blue',
},
{
icon: TrendingUp,
label: 'Engagement',
value: viewModel.sponsorInsights.engagementRate,
color: 'text-performance-green',
},
{
icon: Users,
label: 'Est. Reach',
value: viewModel.sponsorInsights.estimatedReach,
color: 'text-purple-400',
},
{
icon: Zap,
label: 'Avg SOF',
value: viewModel.averageSOF ?? '—',
color: 'text-warning-amber',
},
],
slots: [
{
tier: 'main' as const,
available: viewModel.sponsorInsights.mainSponsorAvailable,
price: viewModel.sponsorInsights.mainSponsorPrice,
benefits: ['Hood placement', 'League banner', 'Prominent logo'],
},
{
tier: 'secondary' as const,
available: viewModel.sponsorInsights.secondarySlotsAvailable > 0,
price: viewModel.sponsorInsights.secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
{
tier: 'secondary' as const,
available: viewModel.sponsorInsights.secondarySlotsAvailable > 1,
price: viewModel.sponsorInsights.secondaryPrice,
benefits: ['Side logo placement', 'League page listing'],
},
],
} : null;
return {
leagueId: viewModel.id,
name: viewModel.name,
description: viewModel.description ?? '',
info,
runningRaces,
sponsors,
ownerSummary,
adminSummaries,
stewardSummaries,
sponsorInsights,
};
}
/**
* Static helper for backward compatibility
*/
static createViewData(viewModel: LeagueDetailPageViewModel, leagueId: string, isSponsor: boolean): LeagueDetailViewData {
const presenter = new LeagueDetailPresenter();
return presenter.present({ viewModel, leagueId, isSponsor });
}
}

View File

@@ -1,103 +0,0 @@
'use client';
/**
* LeagueStandingsPresenter
* Pure client-side presenter for LeagueStandingsTemplate
* Converts ViewModels to ViewData
*/
import type { Presenter } from '@/lib/contracts/presenters/Presenter';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { DriverData, LeagueMembershipData, LeagueStandingsViewData, StandingEntryData } from '@/lib/view-data/LeagueStandingsViewData';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
interface LeagueStandingsInput {
standings: StandingEntryViewModel[];
drivers: DriverViewModel[];
memberships: LeagueMembership[];
leagueId: string;
currentDriverId: string | null;
isAdmin: boolean;
}
export class LeagueStandingsPresenter implements Presenter<LeagueStandingsInput, LeagueStandingsViewData> {
/**
* Convert StandingEntryViewModel to StandingEntryData
*/
private static convertStanding(standing: StandingEntryViewModel): StandingEntryData {
return {
driverId: standing.driverId,
position: standing.position,
totalPoints: standing.points,
racesFinished: standing.races,
racesStarted: standing.races,
avgFinish: null, // Not available in current ViewModel
penaltyPoints: 0, // Not available in current ViewModel
bonusPoints: 0, // Not available in current ViewModel
};
}
/**
* Convert DriverViewModel to DriverData
*/
private static convertDriver(driver: DriverViewModel): DriverData {
return {
id: driver.id,
name: driver.name,
avatarUrl: driver.avatarUrl,
iracingId: driver.iracingId,
rating: driver.rating,
country: driver.country,
};
}
/**
* Convert LeagueMembership to LeagueMembershipData
*/
private static convertMembership(membership: LeagueMembership): LeagueMembershipData {
return {
driverId: membership.driverId,
leagueId: membership.leagueId,
role: membership.role,
joinedAt: membership.joinedAt,
status: membership.status,
};
}
/**
* Transform input to output
*/
present(input: LeagueStandingsInput): LeagueStandingsViewData {
return {
standings: input.standings.map(s => LeagueStandingsPresenter.convertStanding(s)),
drivers: input.drivers.map(d => LeagueStandingsPresenter.convertDriver(d)),
memberships: input.memberships.map(m => LeagueStandingsPresenter.convertMembership(m)),
leagueId: input.leagueId,
currentDriverId: input.currentDriverId,
isAdmin: input.isAdmin,
};
}
/**
* Static helper for backward compatibility
*/
static createViewData(
standings: StandingEntryViewModel[],
drivers: DriverViewModel[],
memberships: LeagueMembership[],
leagueId: string,
currentDriverId: string | null,
isAdmin: boolean
): LeagueStandingsViewData {
const presenter = new LeagueStandingsPresenter();
return presenter.present({
standings,
drivers,
memberships,
leagueId,
currentDriverId,
isAdmin,
});
}
}

View File

@@ -1,23 +0,0 @@
'use client';
import type { AuthSessionDTO } from '@/lib/types/generated/AuthSessionDTO';
import { SessionViewModel } from '@/lib/view-models/SessionViewModel';
/**
* Session Presenter
*
* Converts AuthSessionDTO to SessionViewModel for client-side presentation.
* Pure and deterministic - no side effects.
*/
export class SessionPresenter {
/**
* Present session data as a view model
*/
present(sessionDto: AuthSessionDTO | null): SessionViewModel | null {
if (!sessionDto || !sessionDto.user) {
return null;
}
return new SessionViewModel(sessionDto.user);
}
}