import { describe, it, expect } from 'vitest'; import { InMemorySeasonRepository, } from '@gridpilot/racing/infrastructure/repositories/InMemoryScoringRepositories'; import { Season } from '@gridpilot/racing/domain/entities/Season'; import type { ISeasonRepository } from '@gridpilot/racing/domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository'; import { CreateSeasonForLeagueUseCase, ListSeasonsForLeagueUseCase, GetSeasonDetailsUseCase, ManageSeasonLifecycleUseCase, type CreateSeasonForLeagueCommand, type ManageSeasonLifecycleCommand, } from '@gridpilot/racing/application/use-cases/SeasonUseCases'; import type { LeagueConfigFormModel } from '@gridpilot/racing/application/dto/LeagueConfigFormDTO'; function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { return { findById: async (id: string) => seed.find((l) => l.id === id) ?? null, findAll: async () => seed, create: async (league: any) => league, update: async (league: any) => league, } as unknown as ILeagueRepository; } function createLeagueConfigFormModel(overrides?: Partial): LeagueConfigFormModel { return { basics: { name: 'Test League', visibility: 'ranked', gameId: 'iracing', ...overrides?.basics, }, structure: { mode: 'solo', maxDrivers: 30, ...overrides?.structure, }, championships: { enableDriverChampionship: true, enableTeamChampionship: false, enableNationsChampionship: false, enableTrophyChampionship: false, ...overrides?.championships, }, scoring: { patternId: 'sprint-main-driver', customScoringEnabled: false, ...overrides?.scoring, }, dropPolicy: { strategy: 'bestNResults', n: 3, ...overrides?.dropPolicy, }, timings: { qualifyingMinutes: 10, mainRaceMinutes: 30, sessionCount: 8, seasonStartDate: '2025-01-01', raceStartTime: '20:00', timezoneId: 'UTC', recurrenceStrategy: 'weekly', weekdays: ['Mon'], ...overrides?.timings, }, stewarding: { decisionMode: 'steward_vote', requiredVotes: 3, requireDefense: true, defenseTimeLimit: 24, voteTimeLimit: 24, protestDeadlineHours: 48, stewardingClosesHours: 72, notifyAccusedOnProtest: true, notifyOnVoteRequired: true, ...overrides?.stewarding, }, ...overrides, }; } describe('InMemorySeasonRepository', () => { it('add and findById provide a roundtrip for Season', async () => { const repo = new InMemorySeasonRepository(); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Test Season', status: 'planned', }); await repo.add(season); const loaded = await repo.findById(season.id); expect(loaded).not.toBeNull(); expect(loaded!.id).toBe(season.id); expect(loaded!.leagueId).toBe(season.leagueId); expect(loaded!.status).toBe('planned'); }); it('update persists changed Season state', async () => { const repo = new InMemorySeasonRepository(); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Initial Season', status: 'planned', }); await repo.add(season); const activated = season.activate(); await repo.update(activated); const loaded = await repo.findById(season.id); expect(loaded).not.toBeNull(); expect(loaded!.status).toBe('active'); }); it('listByLeague returns only seasons for that league', async () => { const repo = new InMemorySeasonRepository(); const s1 = Season.create({ id: 's1', leagueId: 'league-1', gameId: 'iracing', name: 'L1 S1', status: 'planned', }); const s2 = Season.create({ id: 's2', leagueId: 'league-1', gameId: 'iracing', name: 'L1 S2', status: 'active', }); const s3 = Season.create({ id: 's3', leagueId: 'league-2', gameId: 'iracing', name: 'L2 S1', status: 'planned', }); await repo.add(s1); await repo.add(s2); await repo.add(s3); const league1Seasons = await repo.listByLeague('league-1'); const league2Seasons = await repo.listByLeague('league-2'); expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']); expect(league2Seasons.map((s) => s.id)).toEqual(['s3']); }); it('listActiveByLeague returns only active seasons for a league', async () => { const repo = new InMemorySeasonRepository(); const s1 = Season.create({ id: 's1', leagueId: 'league-1', gameId: 'iracing', name: 'Planned', status: 'planned', }); const s2 = Season.create({ id: 's2', leagueId: 'league-1', gameId: 'iracing', name: 'Active', status: 'active', }); const s3 = Season.create({ id: 's3', leagueId: 'league-1', gameId: 'iracing', name: 'Completed', status: 'completed', }); const s4 = Season.create({ id: 's4', leagueId: 'league-2', gameId: 'iracing', name: 'Other League Active', status: 'active', }); await repo.add(s1); await repo.add(s2); await repo.add(s3); await repo.add(s4); const activeInLeague1 = await repo.listActiveByLeague('league-1'); expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']); }); }); describe('CreateSeasonForLeagueUseCase', () => { it('creates a planned Season for an existing league with config-derived props', async () => { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo = new InMemorySeasonRepository(); const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); const config = createLeagueConfigFormModel({ basics: { name: 'League With Config', visibility: 'ranked', gameId: 'iracing', }, scoring: { patternId: 'club-default', customScoringEnabled: true, }, dropPolicy: { strategy: 'dropWorstN', n: 2, }, // Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation, // focusing this test on scoring/drop/stewarding/maxDrivers mapping. timings: { qualifyingMinutes: 10, mainRaceMinutes: 30, sessionCount: 8, }, }); const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', name: 'Season from Config', gameId: 'iracing', config, }; const result = await useCase.execute(command); expect(result.seasonId).toBeDefined(); const created = await seasonRepo.findById(result.seasonId); expect(created).not.toBeNull(); const season = created!; expect(season.leagueId).toBe('league-1'); expect(season.gameId).toBe('iracing'); expect(season.name).toBe('Season from Config'); expect(season.status).toBe('planned'); // Schedule is optional when timings lack seasonStartDate / raceStartTime. expect(season.schedule).toBeUndefined(); expect(season.scoringConfig).toBeDefined(); expect(season.scoringConfig!.scoringPresetId).toBe('club-default'); expect(season.scoringConfig!.customScoringEnabled).toBe(true); expect(season.dropPolicy).toBeDefined(); expect(season.dropPolicy!.strategy).toBe('dropWorstN'); expect(season.dropPolicy!.n).toBe(2); expect(season.stewardingConfig).toBeDefined(); expect(season.maxDrivers).toBe(30); }); it('clones configuration from a source season when sourceSeasonId is provided', async () => { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo = new InMemorySeasonRepository(); const sourceSeason = Season.create({ id: 'source-season', leagueId: 'league-1', gameId: 'iracing', name: 'Source Season', status: 'planned', }).withMaxDrivers(40); await seasonRepo.add(sourceSeason); const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', name: 'Cloned Season', gameId: 'iracing', sourceSeasonId: 'source-season', }; const result = await useCase.execute(command); const created = await seasonRepo.findById(result.seasonId); expect(created).not.toBeNull(); const season = created!; expect(season.id).not.toBe(sourceSeason.id); expect(season.leagueId).toBe(sourceSeason.leagueId); expect(season.gameId).toBe(sourceSeason.gameId); expect(season.status).toBe('planned'); expect(season.maxDrivers).toBe(sourceSeason.maxDrivers); expect(season.schedule).toBe(sourceSeason.schedule); expect(season.scoringConfig).toBe(sourceSeason.scoringConfig); expect(season.dropPolicy).toBe(sourceSeason.dropPolicy); expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig); }); }); describe('ListSeasonsForLeagueUseCase', () => { it('lists seasons for a league with summaries', async () => { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo = new InMemorySeasonRepository(); const s1 = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Season One', status: 'planned', }); const s2 = Season.create({ id: 'season-2', leagueId: 'league-1', gameId: 'iracing', name: 'Season Two', status: 'active', }); const sOtherLeague = Season.create({ id: 'season-3', leagueId: 'league-2', gameId: 'iracing', name: 'Season Other', status: 'planned', }); await seasonRepo.add(s1); await seasonRepo.add(s2); await seasonRepo.add(sOtherLeague); const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo); const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.items.map((i) => i.seasonId).sort()).toEqual([ 'season-1', 'season-2', ]); expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true); }); }); describe('GetSeasonDetailsUseCase', () => { it('returns full details for a season belonging to the league', async () => { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo = new InMemorySeasonRepository(); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Detailed Season', status: 'planned', }).withMaxDrivers(24); await seasonRepo.add(season); const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo); const dto = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1', }); expect(dto.seasonId).toBe('season-1'); expect(dto.leagueId).toBe('league-1'); expect(dto.gameId).toBe('iracing'); expect(dto.name).toBe('Detailed Season'); expect(dto.status).toBe('planned'); expect(dto.maxDrivers).toBe(24); }); }); describe('ManageSeasonLifecycleUseCase', () => { function setupLifecycleTest() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo = new InMemorySeasonRepository(); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Lifecycle Season', status: 'planned', }); seasonRepo.seed(season); const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); return { leagueRepo, seasonRepo, useCase, season }; } it('applies activate → complete → archive transitions and persists state', async () => { const { useCase, seasonRepo, season } = setupLifecycleTest(); const activateCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'activate', }; const activated = await useCase.execute(activateCommand); expect(activated.status).toBe('active'); const completeCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'complete', }; const completed = await useCase.execute(completeCommand); expect(completed.status).toBe('completed'); const archiveCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'archive', }; const archived = await useCase.execute(archiveCommand); expect(archived.status).toBe('archived'); const persisted = await seasonRepo.findById(season.id); expect(persisted!.status).toBe('archived'); }); it('propagates domain invariant errors for invalid transitions', async () => { const { useCase, seasonRepo, season } = setupLifecycleTest(); const completeCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'complete', }; await expect(useCase.execute(completeCommand)).rejects.toThrow(); const persisted = await seasonRepo.findById(season.id); expect(persisted!.status).toBe('planned'); }); });