277 lines
9.4 KiB
TypeScript
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();
|
|
});
|
|
}); |