refactor racing use cases

This commit is contained in:
2025-12-21 00:43:42 +01:00
parent e9d6f90bb2
commit c12656d671
308 changed files with 14401 additions and 7419 deletions

View File

@@ -1,8 +1,5 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/season/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
@@ -13,8 +10,18 @@ import {
ManageSeasonLifecycleUseCase,
type CreateSeasonForLeagueCommand,
type ManageSeasonLifecycleCommand,
type CreateSeasonForLeagueResult,
type ListSeasonsForLeagueResult,
type GetSeasonDetailsResult,
type ManageSeasonLifecycleResult,
type CreateSeasonForLeagueErrorCode,
type ListSeasonsForLeagueErrorCode,
type GetSeasonDetailsErrorCode,
type ManageSeasonLifecycleErrorCode,
} from '@core/racing/application/use-cases/SeasonUseCases';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
@@ -82,129 +89,28 @@ function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>)
};
}
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 () => {
function setup() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const output: UseCaseOutputPort<CreateSeasonForLeagueResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo, output);
return { leagueRepo, seasonRepo, output, useCase };
}
it('creates a planned Season for an existing league with config-derived props', async () => {
const { seasonRepo, output, useCase } = setup();
const config = createLeagueConfigFormModel({
basics: {
@@ -229,6 +135,8 @@ describe('CreateSeasonForLeagueUseCase', () => {
},
});
(seasonRepo.add as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Season from Config',
@@ -238,12 +146,13 @@ describe('CreateSeasonForLeagueUseCase', () => {
const result = await useCase.execute(command);
expect(result.seasonId).toBeDefined();
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(output.present).toHaveBeenCalledTimes(1);
const payload = (output.present as ReturnType<typeof vi.fn>).mock.calls[0][0] as CreateSeasonForLeagueResult;
const season = payload.season;
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
@@ -264,8 +173,7 @@ describe('CreateSeasonForLeagueUseCase', () => {
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const { seasonRepo, output, useCase } = setup();
const sourceSeason = Season.create({
id: 'source-season',
@@ -275,9 +183,8 @@ describe('CreateSeasonForLeagueUseCase', () => {
status: 'planned',
}).withMaxDrivers(40);
await seasonRepo.add(sourceSeason);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
(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',
@@ -287,11 +194,14 @@ describe('CreateSeasonForLeagueUseCase', () => {
};
const result = await useCase.execute(command);
const created = await seasonRepo.findById(result.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const payload = (output.present as ReturnType<typeof vi.fn>).mock.calls[0][0] as CreateSeasonForLeagueResult;
const season = payload.season;
expect(season.id).not.toBe(sourceSeason.id);
expect(season.leagueId).toBe(sourceSeason.leagueId);
expect(season.gameId).toBe(sourceSeason.gameId);
@@ -302,12 +212,62 @@ describe('CreateSeasonForLeagueUseCase', () => {
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: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<CreateSeasonForLeagueResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo, output);
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');
expect(output.present).not.toHaveBeenCalled();
});
});
describe('ListSeasonsForLeagueUseCase', () => {
it('lists seasons for a league with summaries', async () => {
function setup() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<ListSeasonsForLeagueResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo, output);
return { leagueRepo, seasonRepo, output, useCase };
}
it('lists seasons for a league with summaries', async () => {
const { seasonRepo, output, useCase } = setup();
const s1 = Season.create({
id: 'season-1',
@@ -331,26 +291,73 @@ describe('ListSeasonsForLeagueUseCase', () => {
status: 'planned',
});
await seasonRepo.add(s1);
await seasonRepo.add(s2);
await seasonRepo.add(sOtherLeague);
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
(seasonRepo.listByLeague as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
s1,
s2,
sOtherLeague,
]);
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);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const payload = (output.present as ReturnType<typeof vi.fn>).mock.calls[0][0] as ListSeasonsForLeagueResult;
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: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<ListSeasonsForLeagueResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo, output);
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');
expect(output.present).not.toHaveBeenCalled();
});
});
describe('GetSeasonDetailsUseCase', () => {
function setup(leagueSeed: Array<{ id: string }>) {
const leagueRepo = createFakeLeagueRepository(leagueSeed);
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<GetSeasonDetailsResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo, output);
return { leagueRepo, seasonRepo, output, useCase };
}
it('returns full details for a season belonging to the league', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const { seasonRepo, output, useCase } = setup([{ id: 'league-1' }]);
const season = Season.create({
id: 'season-1',
@@ -360,28 +367,147 @@ describe('GetSeasonDetailsUseCase', () => {
status: 'planned',
}).withMaxDrivers(24);
await seasonRepo.add(season);
(seasonRepo.findById as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(season);
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
const dto = await useCase.execute({
const result = 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);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const payload = (output.present as ReturnType<typeof vi.fn>).mock.calls[0][0] as GetSeasonDetailsResult;
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).toBe('planned');
expect(payload.season.maxDrivers).toBe(24);
});
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
const { output, 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');
expect(output.present).not.toHaveBeenCalled();
});
it('returns SEASON_NOT_FOUND when season does not belong to league', async () => {
const { seasonRepo, output, 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');
expect(output.present).not.toHaveBeenCalled();
});
});
describe('ManageSeasonLifecycleUseCase', () => {
function setupLifecycleTest() {
function setup() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository();
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<ManageSeasonLifecycleResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output);
return { leagueRepo, seasonRepo, output, useCase };
}
it('applies activate → complete → archive transitions and persists state', async () => {
const { seasonRepo, output, 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 = (output.present as ReturnType<typeof vi.fn>).mock.calls[0][0] as ManageSeasonLifecycleResult;
expect(activatePayload.season.status).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 = (output.present as ReturnType<typeof vi.fn>).mock.calls[1][0] as ManageSeasonLifecycleResult;
expect(completePayload.season.status).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 = (output.present as ReturnType<typeof vi.fn>).mock.calls[2][0] as ManageSeasonLifecycleResult;
expect(archivePayload.season.status).toBe('archived');
expect(currentSeason.status).toBe('archived');
});
it('returns INVALID_LIFECYCLE_TRANSITION for invalid transitions and does not call output', async () => {
const { seasonRepo, output, useCase } = setup();
const season = Season.create({
id: 'season-1',
@@ -391,59 +517,92 @@ describe('ManageSeasonLifecycleUseCase', () => {
status: 'planned',
});
seasonRepo.seed(season);
(seasonRepo.findById as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(season);
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
const invalidCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
return { leagueRepo, seasonRepo, useCase, season };
}
const result = await useCase.execute(invalidCommand);
it('applies activate → complete → archive transitions and persists state', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
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();
expect(output.present).not.toHaveBeenCalled();
});
const activateCommand: ManageSeasonLifecycleCommand = {
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
const leagueRepo = createFakeLeagueRepository([]);
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<ManageSeasonLifecycleResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output);
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');
expect(output.present).not.toHaveBeenCalled();
});
it('returns SEASON_NOT_FOUND when season does not belong to league', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo: ISeasonRepository = {
add: vi.fn(),
findById: vi.fn(),
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
} as unknown as ISeasonRepository;
const output: UseCaseOutputPort<ManageSeasonLifecycleResult> & { present: ReturnType<typeof vi.fn> } = {
present: vi.fn(),
} as any;
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output);
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 activated = await useCase.execute(activateCommand);
expect(activated.status).toBe('active');
const result = await useCase.execute(command);
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');
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');
expect(output.present).not.toHaveBeenCalled();
});
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');
});
});
});