Files
gridpilot.gg/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx
2025-12-28 12:04:12 +01:00

277 lines
9.4 KiB
TypeScript

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<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,
},
};
vi.mock('@/lib/services/ServiceProvider', () => ({
useServices: () => mockServices,
}));
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();
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();
});
});