Files
gridpilot.gg/core/racing/application/use-cases/SeasonUseCases.test.ts
2026-01-16 19:46:49 +01:00

578 lines
18 KiB
TypeScript

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>): 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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce(sourceSeason);
(seasonRepo.add as unknown as ReturnType<typeof vi.fn>).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<CreateSeasonForLeagueErrorCode, { message: string }>;
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<typeof vi.fn>).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<ListSeasonsForLeagueErrorCode, { message: string }>;
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<typeof vi.fn>).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<GetSeasonDetailsErrorCode, { message: string }>;
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<typeof vi.fn>).mockResolvedValue(season);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<GetSeasonDetailsErrorCode, { message: string }>;
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<typeof vi.fn>).mockImplementation(async () => currentSeason);
(seasonRepo.update as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<ManageSeasonLifecycleErrorCode, { message: string }>;
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<ManageSeasonLifecycleErrorCode, { message: string }>;
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<typeof vi.fn>).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<ManageSeasonLifecycleErrorCode, { message: string }>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(error.details?.message).toContain('does not belong to league');
});
});