578 lines
18 KiB
TypeScript
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');
|
|
});
|
|
});
|