diff --git a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx deleted file mode 100644 index 6c5c3d5b7..000000000 --- a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx +++ /dev/null @@ -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; - getAdminRosterMembers(leagueId: string): Promise; - 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; - -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 { - 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 { - 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( - - {component} - - ); - }; - - 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(); - - 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(); - - 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(); - - 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(); - - 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(); - - expect(await screen.findByText('Member Twelve')).toBeInTheDocument(); - - fireEvent.click(screen.getByTestId('member-driver-12-remove')); - - await waitFor(() => { - expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument(); - }); - }); -}); \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx b/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx deleted file mode 100644 index e3f1b250b..000000000 --- a/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx +++ /dev/null @@ -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>(); - -const mockGetAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise>(); - -const mockGetLeagueScheduleDto = vi.fn(() => { - throw new Error('LeagueAdminSchedulePage must not call getLeagueScheduleDto (DTO boundary violation)'); -}); - -const mockPublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise>(); -const mockUnpublishAdminSchedule = vi.fn<(leagueId: string, seasonId: string) => Promise>(); - -const mockCreateAdminScheduleRace = vi.fn< - (leagueId: string, seasonId: string, input: { track: string; car: string; scheduledAtIso: string }) => Promise ->(); -const mockUpdateAdminScheduleRace = vi.fn< - ( - leagueId: string, - seasonId: string, - raceId: string, - input: Partial<{ track: string; car: string; scheduledAtIso: string }>, - ) => Promise ->(); -const mockDeleteAdminScheduleRace = vi.fn<(leagueId: string, seasonId: string, raceId: string) => Promise>(); - -const mockFetchLeagueMemberships = vi.fn<(leagueId: string) => Promise>(); -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 { - 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(); - - 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(); - - 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(); - - 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(); - }); -}); \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx deleted file mode 100644 index 5ad3256b7..000000000 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx +++ /dev/null @@ -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(); - - await waitFor(() => { - expect(mockGetProtestDetailViewModel).toHaveBeenCalledWith('league-1', 'protest-1'); - }); - - expect(await screen.findByText('Protest Review')).toBeInTheDocument(); - }); -}); \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueDetailPresenter.ts b/apps/website/lib/presenters/LeagueDetailPresenter.ts deleted file mode 100644 index 153adcf45..000000000 --- a/apps/website/lib/presenters/LeagueDetailPresenter.ts +++ /dev/null @@ -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 { - /** - * 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 }); - } -} \ No newline at end of file diff --git a/apps/website/lib/presenters/LeagueStandingsPresenter.ts b/apps/website/lib/presenters/LeagueStandingsPresenter.ts deleted file mode 100644 index b3be13701..000000000 --- a/apps/website/lib/presenters/LeagueStandingsPresenter.ts +++ /dev/null @@ -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 { - /** - * 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, - }); - } -} \ No newline at end of file diff --git a/apps/website/lib/presenters/SessionPresenter.ts b/apps/website/lib/presenters/SessionPresenter.ts deleted file mode 100644 index d6cb5e0c3..000000000 --- a/apps/website/lib/presenters/SessionPresenter.ts +++ /dev/null @@ -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); - } -}