import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import LeagueAdminSchedulePage from './page'; 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, }, }; vi.mock('@/lib/services/ServiceProvider', () => ({ useServices: () => mockServices, })); 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(); 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(); }); });