refactor racing use cases
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user