import { describe, expect, it, vi } from 'vitest'; import { CreateSeasonForLeagueUseCase, GetSeasonDetailsUseCase, ListSeasonsForLeagueUseCase, ManageSeasonLifecycleUseCase, type CreateSeasonForLeagueCommand, type CreateSeasonForLeagueErrorCode, type GetSeasonDetailsErrorCode, type LeagueConfigFormModel, type ListSeasonsForLeagueErrorCode, type ManageSeasonLifecycleCommand, type ManageSeasonLifecycleErrorCode, } from '@core/racing/application/use-cases/SeasonUseCases'; import { League } from '@core/racing/domain/entities/League'; import { Season } from '@core/racing/domain/entities/season/Season'; import type { LeagueRepository } from '@core/racing/domain/repositories/LeagueRepository'; import type { SeasonRepository } from '@core/racing/domain/repositories/SeasonRepository'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; function getUnknownString(value: unknown): string | null { if (typeof value === 'string') return value; if ( value && typeof value === 'object' && 'toString' in value && typeof (value as { toString: unknown }).toString === 'function' ) { return (value as { toString: () => string }).toString(); } return null; } function createFakeLeagueRepository(seed: Array<{ id: string }>): LeagueRepository { const leagues: League[] = seed.map(({ id }) => League.create({ id, name: `League ${id}`, description: 'Test league', ownerId: 'owner-1', }), ); return { findById: async (id: string) => leagues.find((league) => league.id.toString() === id) ?? null, findAll: async () => leagues, findByOwnerId: async (ownerId: string) => leagues.filter((league) => getUnknownString((league as unknown as { ownerId: unknown }).ownerId) === ownerId), create: async (league: League) => league, update: async (league: League) => league, delete: async () => undefined, exists: async (id: string) => leagues.some((league) => league.id.toString() === id), searchByName: async () => [], }; } 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('CreateSeasonForLeagueUseCase', () => { function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); return { leagueRepo, seasonRepo, useCase }; } it('creates a planned Season for an existing league with config-derived props', async () => { const { seasonRepo, useCase } = setup(); const config = createLeagueConfigFormModel({ basics: { name: 'League With Config', visibility: 'ranked', gameId: 'iracing', }, scoring: { patternId: 'club-default', customScoringEnabled: true, }, dropPolicy: { strategy: 'dropWorstN', n: 2, }, timings: { qualifyingMinutes: 10, mainRaceMinutes: 30, sessionCount: 8, }, }); (seasonRepo.add as unknown as ReturnType).mockResolvedValue(undefined); const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', name: 'Season from Config', gameId: 'iracing', config, }; const result = await useCase.execute(command); expect(result.isOk()).toBe(true); const payload = result.unwrap(); expect(payload.season).toBeDefined(); const season = payload.season; expect(season.leagueId).toBe('league-1'); expect(season.gameId).toBe('iracing'); expect(season.name).toBe('Season from Config'); expect(season.status.toString()).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 { seasonRepo, useCase } = setup(); const sourceSeason = Season.create({ id: 'source-season', leagueId: 'league-1', gameId: 'iracing', name: 'Source Season', status: 'planned', }).withMaxDrivers(40); (seasonRepo.findById as unknown as ReturnType).mockResolvedValueOnce(sourceSeason); (seasonRepo.add as unknown as ReturnType).mockResolvedValue(undefined); const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', name: 'Cloned Season', gameId: 'iracing', sourceSeasonId: 'source-season', }; const result = await useCase.execute(command); expect(result.isOk()).toBe(true); const payload = result.unwrap(); expect(payload.season).toBeDefined(); const season = payload.season; expect(season.id).not.toBe(sourceSeason.id); expect(season.leagueId).toBe(sourceSeason.leagueId); expect(season.gameId).toBe(sourceSeason.gameId); expect(season.status.toString()).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); }); it('returns LEAGUE_NOT_FOUND error when league does not exist', async () => { const leagueRepo = createFakeLeagueRepository([]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); const command: CreateSeasonForLeagueCommand = { leagueId: 'missing-league', name: 'Season', gameId: 'iracing', }; const result = await useCase.execute(command); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('LEAGUE_NOT_FOUND'); expect(error.details?.message).toContain('League not found'); }); }); describe('ListSeasonsForLeagueUseCase', () => { function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo); return { leagueRepo, seasonRepo, useCase }; } it('lists seasons for a league with summaries', async () => { const { seasonRepo, useCase } = setup(); 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', }); (seasonRepo.listByLeague as unknown as ReturnType).mockResolvedValue([ s1, s2, sOtherLeague, ]); const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isOk()).toBe(true); const payload = result.unwrap(); expect(payload.seasons).toBeDefined(); const league1Seasons = payload.seasons.filter((s) => s.leagueId === 'league-1'); expect(league1Seasons.map((s) => s.id).sort()).toEqual(['season-1', 'season-2']); }); it('returns LEAGUE_NOT_FOUND error when league does not exist', async () => { const leagueRepo = createFakeLeagueRepository([]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo); const result = await useCase.execute({ leagueId: 'missing-league' }); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('LEAGUE_NOT_FOUND'); expect(error.details?.message).toContain('League not found'); }); }); describe('GetSeasonDetailsUseCase', () => { function setup(leagueSeed: Array<{ id: string }>) { const leagueRepo = createFakeLeagueRepository(leagueSeed); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo); return { leagueRepo, seasonRepo, useCase }; } it('returns full details for a season belonging to the league', async () => { const { seasonRepo, useCase } = setup([{ id: 'league-1' }]); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Detailed Season', status: 'planned', }).withMaxDrivers(24); (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1', }); expect(result.isOk()).toBe(true); const payload = result.unwrap(); expect(payload.season).toBeDefined(); expect(payload.season.id).toBe('season-1'); expect(payload.season.leagueId).toBe('league-1'); expect(payload.season.gameId).toBe('iracing'); expect(payload.season.name).toBe('Detailed Season'); expect(payload.season.status.toString()).toBe('planned'); expect(payload.season.maxDrivers).toBe(24); }); it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { const { useCase } = setup([]); const result = await useCase.execute({ leagueId: 'missing-league', seasonId: 'season-1', }); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('LEAGUE_NOT_FOUND'); expect(error.details?.message).toContain('League not found'); }); it('returns SEASON_NOT_FOUND when season does not belong to league', async () => { const { seasonRepo, useCase } = setup([{ id: 'league-1' }]); const season = Season.create({ id: 'season-1', leagueId: 'other-league', gameId: 'iracing', name: 'Detailed Season', status: 'planned', }); (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1', }); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('SEASON_NOT_FOUND'); expect(error.details?.message).toContain('does not belong to league'); }); }); describe('ManageSeasonLifecycleUseCase', () => { function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); return { leagueRepo, seasonRepo, useCase }; } it('applies activate → complete → archive transitions and persists state', async () => { const { seasonRepo, useCase } = setup(); let currentSeason = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Lifecycle Season', status: 'planned', }); (seasonRepo.findById as unknown as ReturnType).mockImplementation(async () => currentSeason); (seasonRepo.update as unknown as ReturnType).mockImplementation(async (updated: Season) => { currentSeason = updated; return updated; }); const activateCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'activate', }; const activated = await useCase.execute(activateCommand); expect(activated.isOk()).toBe(true); const activatePayload = activated.unwrap(); expect(activatePayload.season.status.toString()).toBe('active'); const completeCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'complete', }; const completed = await useCase.execute(completeCommand); expect(completed.isOk()).toBe(true); const completePayload = completed.unwrap(); expect(completePayload.season.status.toString()).toBe('completed'); const archiveCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'archive', }; const archived = await useCase.execute(archiveCommand); expect(archived.isOk()).toBe(true); const archivePayload = archived.unwrap(); expect(archivePayload.season.status.toString()).toBe('archived'); expect(currentSeason.status.toString()).toBe('archived'); }); it('returns INVALID_LIFECYCLE_TRANSITION for invalid transitions and does not call output', async () => { const { seasonRepo, useCase } = setup(); const season = Season.create({ id: 'season-1', leagueId: 'league-1', gameId: 'iracing', name: 'Lifecycle Season', status: 'planned', }); (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); const invalidCommand: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'complete', }; const result = await useCase.execute(invalidCommand); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('INVALID_LIFECYCLE_TRANSITION'); expect(error.details?.message).toBeDefined(); }); it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { const leagueRepo = createFakeLeagueRepository([]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); const command: ManageSeasonLifecycleCommand = { leagueId: 'missing-league', seasonId: 'season-1', transition: 'activate', }; const result = await useCase.execute(command); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('LEAGUE_NOT_FOUND'); expect(error.details?.message).toContain('League not found'); }); it('returns SEASON_NOT_FOUND when season does not belong to league', async () => { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); const seasonRepo: SeasonRepository = { add: vi.fn(), findById: vi.fn(), update: vi.fn(), listByLeague: vi.fn(), listActiveByLeague: vi.fn(), } as unknown as SeasonRepository; const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); const season = Season.create({ id: 'season-1', leagueId: 'other-league', gameId: 'iracing', name: 'Lifecycle Season', status: 'planned', }); (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); const command: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'activate', }; const result = await useCase.execute(command); expect(result.isErr()).toBe(true); const error = result.unwrapErr() as ApplicationErrorCode; expect(error.code).toBe('SEASON_NOT_FOUND'); expect(error.details?.message).toContain('does not belong to league'); }); });