This commit is contained in:
2025-12-16 21:44:20 +01:00
parent 7532c7ed6d
commit 8c67081953
38 changed files with 818 additions and 1321 deletions

View File

@@ -15,7 +15,7 @@ import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand';
import type { ApplyPenaltyCommand } from '../dto/ApplyPenaltyCommand';
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }, string> {

View File

@@ -3,7 +3,7 @@ import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
import { randomUUID } from 'crypto';
import type { ApproveLeagueJoinRequestUseCaseParams } from './ApproveLeagueJoinRequestUseCaseParams';
import type { ApproveLeagueJoinRequestUseCaseParams } from '../dto/ApproveLeagueJoinRequestUseCaseParams';
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, string> {

View File

@@ -7,7 +7,7 @@ import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CloseRaceEventStewardingCommand } from './CloseRaceEventStewardingCommand';
import type { CloseRaceEventStewardingCommand } from '../dto/CloseRaceEventStewardingCommand';
import type { RaceEvent } from '../../domain/entities/RaceEvent';
/**

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteDriverOnboardingUseCase } from './CompleteDriverOnboardingUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand';
describe('CompleteDriverOnboardingUseCase', () => {
let useCase: CompleteDriverOnboardingUseCase;

View File

@@ -3,7 +3,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand';
/**
* Use Case for completing driver onboarding.

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase';
import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringUseCase';
import type { CreateLeagueWithSeasonAndScoringCommand } from '../dto/CreateLeagueWithSeasonAndScoringCommand';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';

View File

@@ -16,7 +16,7 @@ import {
} from '../../domain/value-objects/LeagueVisibility';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { LeagueVisibilityInput } from './LeagueVisibilityInput';
import type { LeagueVisibilityInput } from '../dto/LeagueVisibilityInput';
import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO';
export interface CreateLeagueWithSeasonAndScoringCommand {

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';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
@@ -11,23 +8,6 @@ import {
type CreateSeasonForLeagueCommand,
} from '@core/racing/application/use-cases/CreateSeasonForLeagueUseCase';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import type { Logger } from '@core/shared/application';
const logger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
return {
@@ -86,129 +66,40 @@ function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>)
};
}
describe('InMemorySeasonRepository', () => {
it('add and findById provide a roundtrip for Season', async () => {
const repo = new InMemorySeasonRepository(logger);
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(logger);
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(logger);
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: Season) => s.id).sort()).toEqual(['s1', 's2']);
expect(league2Seasons.map((s: Season) => s.id)).toEqual(['s3']);
});
it('listActiveByLeague returns only active seasons for a league', async () => {
const repo = new InMemorySeasonRepository(logger);
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: Season) => s.id)).toEqual(['s2']);
});
});
describe('CreateSeasonForLeagueUseCase', () => {
it('creates a planned Season for an existing league with config-derived props', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
const mockLeagueFindById = vi.fn();
const mockLeagueRepo: ILeagueRepository = {
findById: mockLeagueFindById,
findAll: vi.fn(),
findByOwnerId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
searchByName: vi.fn(),
};
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const mockSeasonFindById = vi.fn();
const mockSeasonAdd = vi.fn();
const mockSeasonRepo: ISeasonRepository = {
findById: mockSeasonFindById,
findByLeagueId: vi.fn(),
create: vi.fn(),
add: mockSeasonAdd,
update: vi.fn(),
listByLeague: vi.fn(),
listActiveByLeague: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a planned Season for an existing league with config-derived props', async () => {
mockLeagueFindById.mockResolvedValue({ id: 'league-1' });
mockSeasonAdd.mockResolvedValue(undefined);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo);
const config = createLeagueConfigFormModel({
basics: {
@@ -243,35 +134,10 @@ describe('CreateSeasonForLeagueUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.value.seasonId).toBeDefined();
const created = await seasonRepo.findById(result.value.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
expect(season.status).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);
expect(result.value!.seasonId).toBeDefined();
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
const sourceSeason = Season.create({
id: 'source-season',
leagueId: 'league-1',
@@ -280,9 +146,11 @@ describe('CreateSeasonForLeagueUseCase', () => {
status: 'planned',
}).withMaxDrivers(40);
await seasonRepo.add(sourceSeason);
mockLeagueFindById.mockResolvedValue({ id: 'league-1' });
mockSeasonFindById.mockResolvedValue(sourceSeason);
mockSeasonAdd.mockResolvedValue(undefined);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo);
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
@@ -292,20 +160,8 @@ describe('CreateSeasonForLeagueUseCase', () => {
};
const result = await useCase.execute(command);
const created = await seasonRepo.findById(result.value.seasonId);
expect(result.isOk()).toBe(true);
expect(created).not.toBeNull();
const season = created!;
expect(season.id).not.toBe(sourceSeason.id);
expect(season.leagueId).toBe(sourceSeason.leagueId);
expect(season.gameId).toBe(sourceSeason.gameId);
expect(season.status).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);
expect(result.value!.seasonId).toBeDefined();
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateSponsorUseCase, type CreateSponsorCommand } from './CreateSponsorUseCase';
import { CreateSponsorUseCase } from './CreateSponsorUseCase';
import type { CreateSponsorCommand } from '../dto/CreateSponsorCommand';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { Logger } from '@core/shared/application';

View File

@@ -1,29 +1,52 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllRacesPageDataUseCase } from './GetAllRacesPageDataUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application';
describe('GetAllRacesPageDataUseCase', () => {
let mockRaceRepo: { findAll: Mock };
let mockLeagueRepo: { findAll: Mock };
let mockLogger: Logger;
const mockRaceFindAll = vi.fn();
const mockRaceRepo: IRaceRepository = {
findById: vi.fn(),
findAll: mockRaceFindAll,
findByLeagueId: vi.fn(),
findUpcomingByLeagueId: vi.fn(),
findCompletedByLeagueId: vi.fn(),
findByStatus: vi.fn(),
findByDateRange: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
const mockLeagueFindAll = vi.fn();
const mockLeagueRepo: ILeagueRepository = {
findById: vi.fn(),
findAll: mockLeagueFindAll,
findByOwnerId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
searchByName: vi.fn(),
};
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
mockRaceRepo = { findAll: vi.fn() };
mockLeagueRepo = { findAll: vi.fn() };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.clearAllMocks();
});
it('should return races and filters data', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
@@ -48,8 +71,8 @@ describe('GetAllRacesPageDataUseCase', () => {
const league1 = { id: 'league1', name: 'League One' };
const league2 = { id: 'league2', name: 'League Two' };
mockRaceRepo.findAll.mockResolvedValue([race1, race2]);
mockLeagueRepo.findAll.mockResolvedValue([league1, league2]);
mockRaceFindAll.mockResolvedValue([race1, race2]);
mockLeagueFindAll.mockResolvedValue([league1, league2]);
const result = await useCase.execute();
@@ -95,13 +118,13 @@ describe('GetAllRacesPageDataUseCase', () => {
it('should return empty result when no races or leagues', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
mockRaceRepo.findAll.mockResolvedValue([]);
mockLeagueRepo.findAll.mockResolvedValue([]);
mockRaceFindAll.mockResolvedValue([]);
mockLeagueFindAll.mockResolvedValue([]);
const result = await useCase.execute();
@@ -123,17 +146,18 @@ describe('GetAllRacesPageDataUseCase', () => {
it('should return error when repository throws', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
const error = new Error('Repository error');
mockRaceRepo.findAll.mockRejectedValue(error);
mockRaceFindAll.mockRejectedValue(error);
const result = await useCase.execute();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -8,18 +8,18 @@ import type {
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, AllRacesPageResultDTO, string> {
implements AsyncUseCase<void, AllRacesPageResultDTO, 'REPOSITORY_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<AllRacesPageResultDTO, ApplicationErrorCode<string>>> {
async execute(): Promise<Result<AllRacesPageResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetAllRacesPageDataUseCase');
try {
const [allRaces, allLeagues] = await Promise.all([
@@ -69,7 +69,10 @@ export class GetAllRacesPageDataUseCase
return Result.ok(viewModel);
} catch (error) {
this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}

View File

@@ -1,29 +1,52 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllRacesUseCase } from './GetAllRacesUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application';
describe('GetAllRacesUseCase', () => {
let mockRaceRepo: { findAll: Mock };
let mockLeagueRepo: { findAll: Mock };
let mockLogger: Logger;
const mockRaceFindAll = vi.fn();
const mockRaceRepo: IRaceRepository = {
findById: vi.fn(),
findAll: mockRaceFindAll,
findByLeagueId: vi.fn(),
findUpcomingByLeagueId: vi.fn(),
findCompletedByLeagueId: vi.fn(),
findByStatus: vi.fn(),
findByDateRange: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
const mockLeagueFindAll = vi.fn();
const mockLeagueRepo: ILeagueRepository = {
findById: vi.fn(),
findAll: mockLeagueFindAll,
findByOwnerId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
searchByName: vi.fn(),
};
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
mockRaceRepo = { findAll: vi.fn() };
mockLeagueRepo = { findAll: vi.fn() };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.clearAllMocks();
});
it('should return races data', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
@@ -44,8 +67,8 @@ describe('GetAllRacesUseCase', () => {
const league1 = { id: 'league1', name: 'League One' };
const league2 = { id: 'league2', name: 'League Two' };
mockRaceRepo.findAll.mockResolvedValue([race1, race2]);
mockLeagueRepo.findAll.mockResolvedValue([league1, league2]);
mockRaceFindAll.mockResolvedValue([race1, race2]);
mockLeagueFindAll.mockResolvedValue([league1, league2]);
const result = await useCase.execute();
@@ -71,13 +94,13 @@ describe('GetAllRacesUseCase', () => {
it('should return empty result when no races or leagues', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
mockRaceRepo.findAll.mockResolvedValue([]);
mockLeagueRepo.findAll.mockResolvedValue([]);
mockRaceFindAll.mockResolvedValue([]);
mockLeagueFindAll.mockResolvedValue([]);
const result = await useCase.execute();
@@ -90,17 +113,18 @@ describe('GetAllRacesUseCase', () => {
it('should return error when repository throws', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo as unknown as IRaceRepository,
mockLeagueRepo as unknown as ILeagueRepository,
mockRaceRepo,
mockLeagueRepo,
mockLogger,
);
const error = new Error('Repository error');
mockRaceRepo.findAll.mockRejectedValue(error);
mockRaceFindAll.mockRejectedValue(error);
const result = await useCase.execute();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -2,17 +2,17 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetAllRacesUseCase implements AsyncUseCase<void, Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
export class GetAllRacesUseCase implements AsyncUseCase<void, GetAllRacesResultDTO, 'REPOSITORY_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
async execute(): Promise<Result<GetAllRacesResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetAllRacesUseCase');
try {
const races = await this.raceRepository.findAll();
@@ -35,7 +35,10 @@ export class GetAllRacesUseCase implements AsyncUseCase<void, Result<GetAllRaces
return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}

View File

@@ -1,29 +1,49 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllTeamsUseCase } from './GetAllTeamsUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
describe('GetAllTeamsUseCase', () => {
let mockTeamRepo: { findAll: Mock };
let mockTeamMembershipRepo: { countByTeamId: Mock };
let mockLogger: Logger;
const mockTeamFindAll = vi.fn();
const mockTeamRepo: ITeamRepository = {
findById: vi.fn(),
findAll: mockTeamFindAll,
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
const mockTeamMembershipCountByTeamId = vi.fn();
const mockTeamMembershipRepo: ITeamMembershipRepository = {
getMembership: vi.fn(),
getActiveMembershipForDriver: vi.fn(),
getTeamMembers: vi.fn(),
saveMembership: vi.fn(),
removeMembership: vi.fn(),
countByTeamId: mockTeamMembershipCountByTeamId,
getJoinRequests: vi.fn(),
saveJoinRequest: vi.fn(),
removeJoinRequest: vi.fn(),
};
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
mockTeamRepo = { findAll: vi.fn() };
mockTeamMembershipRepo = { countByTeamId: vi.fn() };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.clearAllMocks();
});
it('should return teams data', async () => {
const useCase = new GetAllTeamsUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockTeamMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
);
@@ -46,8 +66,8 @@ describe('GetAllTeamsUseCase', () => {
createdAt: new Date('2023-01-02T00:00:00Z'),
};
mockTeamRepo.findAll.mockResolvedValue([team1, team2]);
mockTeamMembershipRepo.countByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3));
mockTeamFindAll.mockResolvedValue([team1, team2]);
mockTeamMembershipCountByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3));
const result = await useCase.execute();
@@ -80,12 +100,12 @@ describe('GetAllTeamsUseCase', () => {
it('should return empty result when no teams', async () => {
const useCase = new GetAllTeamsUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockTeamMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
);
mockTeamRepo.findAll.mockResolvedValue([]);
mockTeamFindAll.mockResolvedValue([]);
const result = await useCase.execute();
@@ -97,17 +117,18 @@ describe('GetAllTeamsUseCase', () => {
it('should return error when repository throws', async () => {
const useCase = new GetAllTeamsUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockTeamMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
);
const error = new Error('Repository error');
mockTeamRepo.findAll.mockRejectedValue(error);
mockTeamFindAll.mockRejectedValue(error);
const result = await useCase.execute();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -2,20 +2,20 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving all teams.
*/
export class GetAllTeamsUseCase implements AsyncUseCase<void, Result<AllTeamsResultDTO, RacingDomainValidationError>> {
export class GetAllTeamsUseCase implements AsyncUseCase<void, AllTeamsResultDTO, 'REPOSITORY_ERROR'> {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<AllTeamsResultDTO, RacingDomainValidationError>> {
async execute(): Promise<Result<AllTeamsResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetAllTeamsUseCase');
try {
@@ -45,7 +45,10 @@ export class GetAllTeamsUseCase implements AsyncUseCase<void, Result<AllTeamsRes
return Result.ok(dto);
} catch (error) {
this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}

View File

@@ -1,46 +1,43 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetDriverTeamUseCase } from './GetDriverTeamUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
describe('GetDriverTeamUseCase', () => {
let mockTeamRepo: ITeamRepository;
let mockMembershipRepo: ITeamMembershipRepository;
let mockLogger: Logger;
let mockFindById: Mock;
let mockGetActiveMembershipForDriver: Mock;
const mockFindById = vi.fn();
const mockGetActiveMembershipForDriver = vi.fn();
const mockTeamRepo: ITeamRepository = {
findById: mockFindById,
findAll: vi.fn(),
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
const mockMembershipRepo: ITeamMembershipRepository = {
getActiveMembershipForDriver: mockGetActiveMembershipForDriver,
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
saveMembership: vi.fn(),
removeMembership: vi.fn(),
getJoinRequests: vi.fn(),
countByTeamId: vi.fn(),
saveJoinRequest: vi.fn(),
removeJoinRequest: vi.fn(),
};
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
mockFindById = vi.fn();
mockGetActiveMembershipForDriver = vi.fn();
mockTeamRepo = {
findById: mockFindById,
findAll: vi.fn(),
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
} as ITeamRepository;
mockMembershipRepo = {
getActiveMembershipForDriver: mockGetActiveMembershipForDriver,
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
saveMembership: vi.fn(),
removeMembership: vi.fn(),
getJoinRequests: vi.fn(),
getMembershipsForDriver: vi.fn(),
countByTeamId: vi.fn(),
saveJoinRequest: vi.fn(),
removeJoinRequest: vi.fn(),
} as ITeamMembershipRepository;
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.clearAllMocks();
});
it('should return driver team data when membership and team exist', async () => {
@@ -69,8 +66,8 @@ describe('GetDriverTeamUseCase', () => {
it('should return error when no active membership found', async () => {
const useCase = new GetDriverTeamUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockMembershipRepo,
mockLogger,
);
@@ -87,8 +84,8 @@ describe('GetDriverTeamUseCase', () => {
it('should return error when team not found', async () => {
const useCase = new GetDriverTeamUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockMembershipRepo,
mockLogger,
);
@@ -107,8 +104,8 @@ describe('GetDriverTeamUseCase', () => {
it('should return error when repository throws', async () => {
const useCase = new GetDriverTeamUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockMembershipRepo,
mockLogger,
);

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetDriversLeaderboardUseCase } from './GetDriversLeaderboardUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
@@ -7,31 +7,48 @@ import type { IImageServicePort } from '../ports/IImageServicePort';
import type { Logger } from '@core/shared/application';
describe('GetDriversLeaderboardUseCase', () => {
let mockDriverRepo: { findAll: Mock };
let mockRankingService: { getAllDriverRankings: Mock };
let mockDriverStatsService: { getDriverStats: Mock };
let mockImageService: { getDriverAvatar: Mock };
let mockLogger: Logger;
const mockDriverFindAll = vi.fn();
const mockDriverRepo: IDriverRepository = {
findById: vi.fn(),
findAll: mockDriverFindAll,
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
const mockRankingGetAllDriverRankings = vi.fn();
const mockRankingService: IRankingService = {
getAllDriverRankings: mockRankingGetAllDriverRankings,
};
const mockDriverStatsGetDriverStats = vi.fn();
const mockDriverStatsService: IDriverStatsService = {
getDriverStats: mockDriverStatsGetDriverStats,
};
const mockImageGetDriverAvatar = vi.fn();
const mockImageService: IImageServicePort = {
getDriverAvatar: mockImageGetDriverAvatar,
};
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
beforeEach(() => {
mockDriverRepo = { findAll: vi.fn() };
mockRankingService = { getAllDriverRankings: vi.fn() };
mockDriverStatsService = { getDriverStats: vi.fn() };
mockImageService = { getDriverAvatar: vi.fn() };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.clearAllMocks();
});
it('should return drivers leaderboard data', async () => {
const useCase = new GetDriversLeaderboardUseCase(
mockDriverRepo as unknown as IDriverRepository,
mockRankingService as unknown as IRankingService,
mockDriverStatsService as unknown as IDriverStatsService,
mockImageService as unknown as IImageServicePort,
mockDriverRepo,
mockRankingService,
mockDriverStatsService,
mockImageService,
mockLogger,
);
@@ -41,14 +58,14 @@ describe('GetDriversLeaderboardUseCase', () => {
const stats1 = { wins: 5, losses: 2 };
const stats2 = { wins: 3, losses: 1 };
mockDriverRepo.findAll.mockResolvedValue([driver1, driver2]);
mockRankingService.getAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsService.getDriverStats.mockImplementation((id) => {
mockDriverFindAll.mockResolvedValue([driver1, driver2]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsGetDriverStats.mockImplementation((id) => {
if (id === 'driver1') return stats1;
if (id === 'driver2') return stats2;
return null;
});
mockImageService.getDriverAvatar.mockImplementation((id) => `avatar-${id}`);
mockImageGetDriverAvatar.mockImplementation((id) => `avatar-${id}`);
const result = await useCase.execute();
@@ -59,21 +76,19 @@ describe('GetDriversLeaderboardUseCase', () => {
stats: { driver1: stats1, driver2: stats2 },
avatarUrls: { driver1: 'avatar-driver1', driver2: 'avatar-driver2' },
});
expect(mockLogger.debug).toHaveBeenCalledWith('Executing GetDriversLeaderboardUseCase');
expect(mockLogger.debug).toHaveBeenCalledWith('Successfully retrieved drivers leaderboard.');
});
it('should return empty result when no drivers', async () => {
const useCase = new GetDriversLeaderboardUseCase(
mockDriverRepo as unknown as IDriverRepository,
mockRankingService as unknown as IRankingService,
mockDriverStatsService as unknown as IDriverStatsService,
mockImageService as unknown as IImageServicePort,
mockDriverRepo,
mockRankingService,
mockDriverStatsService,
mockImageService,
mockLogger,
);
mockDriverRepo.findAll.mockResolvedValue([]);
mockRankingService.getAllDriverRankings.mockReturnValue({});
mockDriverFindAll.mockResolvedValue([]);
mockRankingGetAllDriverRankings.mockReturnValue({});
const result = await useCase.execute();
@@ -88,20 +103,20 @@ describe('GetDriversLeaderboardUseCase', () => {
it('should handle drivers without stats', async () => {
const useCase = new GetDriversLeaderboardUseCase(
mockDriverRepo as unknown as IDriverRepository,
mockRankingService as unknown as IRankingService,
mockDriverStatsService as unknown as IDriverStatsService,
mockImageService as unknown as IImageServicePort,
mockDriverRepo,
mockRankingService,
mockDriverStatsService,
mockImageService,
mockLogger,
);
const driver1 = { id: 'driver1', name: 'Driver One' };
const rankings = { driver1: 1 };
mockDriverRepo.findAll.mockResolvedValue([driver1]);
mockRankingService.getAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsService.getDriverStats.mockReturnValue(null);
mockImageService.getDriverAvatar.mockReturnValue('avatar-driver1');
mockDriverFindAll.mockResolvedValue([driver1]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsGetDriverStats.mockReturnValue(null);
mockImageGetDriverAvatar.mockReturnValue('avatar-driver1');
const result = await useCase.execute();
@@ -116,20 +131,20 @@ describe('GetDriversLeaderboardUseCase', () => {
it('should return error when repository throws', async () => {
const useCase = new GetDriversLeaderboardUseCase(
mockDriverRepo as unknown as IDriverRepository,
mockRankingService as unknown as IRankingService,
mockDriverStatsService as unknown as IDriverStatsService,
mockImageService as unknown as IImageServicePort,
mockDriverRepo,
mockRankingService,
mockDriverStatsService,
mockImageService,
mockLogger,
);
const error = new Error('Repository error');
mockDriverRepo.findAll.mockRejectedValue(error);
mockDriverFindAll.mockRejectedValue(error);
const result = await useCase.execute();
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(mockLogger.error).toHaveBeenCalledWith('Error executing GetDriversLeaderboardUseCase', error);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -4,15 +4,15 @@ import type { IDriverStatsService } from '../../domain/services/IDriverStatsServ
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and returns result.
*/
export class GetDriversLeaderboardUseCase
implements AsyncUseCase<void, Result<DriversLeaderboardResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<void, DriversLeaderboardResultDTO, 'REPOSITORY_ERROR'>
{
constructor(
private readonly driverRepository: IDriverRepository,
@@ -22,7 +22,7 @@ export class GetDriversLeaderboardUseCase
private readonly logger: Logger,
) {}
async execute(): Promise<Result<DriversLeaderboardResultDTO, RacingDomainValidationError>> {
async execute(): Promise<Result<DriversLeaderboardResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing GetDriversLeaderboardUseCase');
try {
const drivers = await this.driverRepository.findAll();
@@ -50,7 +50,10 @@ export class GetDriversLeaderboardUseCase
return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}

View File

@@ -7,6 +7,12 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { DriverRatingPort } from '../ports/DriverRatingPort';
describe('GetLeagueDriverSeasonStatsUseCase', () => {
const mockStandingFindByLeagueId = vi.fn();
const mockResultFindByDriverIdAndLeagueId = vi.fn();
const mockPenaltyFindByRaceId = vi.fn();
const mockRaceFindByLeagueId = vi.fn();
const mockDriverRatingGetRating = vi.fn();
let useCase: GetLeagueDriverSeasonStatsUseCase;
let standingRepository: IStandingRepository;
let resultRepository: IResultRepository;
@@ -16,20 +22,38 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
beforeEach(() => {
standingRepository = {
findByLeagueId: vi.fn(),
} as any;
resultRepository = {
findByLeagueId: mockStandingFindByLeagueId,
findByDriverIdAndLeagueId: vi.fn(),
} as any;
findAll: vi.fn(),
save: vi.fn(),
saveMany: vi.fn(),
delete: vi.fn(),
deleteByLeagueId: vi.fn(),
exists: vi.fn(),
recalculate: vi.fn(),
};
resultRepository = {
findByDriverIdAndLeagueId: mockResultFindByDriverIdAndLeagueId,
};
penaltyRepository = {
findByRaceId: vi.fn(),
} as any;
findByRaceId: mockPenaltyFindByRaceId,
};
raceRepository = {
findByLeagueId: vi.fn(),
} as any;
findById: vi.fn(),
findAll: vi.fn(),
findByLeagueId: mockRaceFindByLeagueId,
findUpcomingByLeagueId: vi.fn(),
findCompletedByLeagueId: vi.fn(),
findByStatus: vi.fn(),
findByDateRange: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
};
driverRatingPort = {
getRating: vi.fn(),
} as any;
getRating: mockDriverRatingGetRating,
};
useCase = new GetLeagueDriverSeasonStatsUseCase(
standingRepository,
@@ -69,7 +93,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
const dto = result.value!;
expect(dto.leagueId).toBe('league-1');
expect(dto.standings).toEqual([
{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 },
@@ -97,7 +121,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
const dto = result.value!;
expect(dto.penalties.size).toBe(0);
});
});

View File

@@ -124,7 +124,7 @@ describe('GetLeagueFullConfigUseCase', () => {
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
const viewModel = result.value!;
expect(viewModel).toEqual(mockViewModel);
expect(presenter.reset).toHaveBeenCalled();
expect(presenter.present).toHaveBeenCalledWith({
@@ -145,7 +145,7 @@ describe('GetLeagueFullConfigUseCase', () => {
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details.message).toBe('League with id league-1 not found');
expect(error.details!.message).toBe('League with id league-1 not found');
});
it('should handle no active season', async () => {

View File

@@ -10,7 +10,7 @@ export interface GetLeagueSeasonsUseCaseParams {
export class GetLeagueSeasonsUseCase {
constructor(private readonly seasonRepository: ISeasonRepository) {}
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
try {
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const activeCount = seasons.filter(s => s.status === 'active').length;
@@ -19,15 +19,15 @@ export class GetLeagueSeasonsUseCase {
seasonId: s.id,
name: s.name,
status: s.status,
startDate: s.startDate,
endDate: s.endDate,
startDate: s.startDate ?? new Date(),
endDate: s.endDate ?? new Date(),
isPrimary: false,
isParallelActive: s.status === 'active' && activeCount > 1
}))
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch seasons' });
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to fetch seasons' } });
}
}
}

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceWithSOFUseCase } from './GetRaceWithSOFUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import { IResultRepository } from '../../domain/repositories/IResultRepository';
import { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Race } from '../../domain/entities/Race';
import { SessionType } from '../../domain/value-objects/SessionType';
describe('GetRaceWithSOFUseCase', () => {
let useCase: GetRaceWithSOFUseCase;
let raceRepository: {
findById: Mock;
};
let registrationRepository: {
getRegisteredDrivers: Mock;
};
let resultRepository: {
findByRaceId: Mock;
};
let driverRatingProvider: {
getRatings: Mock;
};
beforeEach(() => {
raceRepository = {
findById: vi.fn(),
};
registrationRepository = {
getRegisteredDrivers: vi.fn(),
};
resultRepository = {
findByRaceId: vi.fn(),
};
driverRatingProvider = {
getRatings: vi.fn(),
};
useCase = new GetRaceWithSOFUseCase(
raceRepository as unknown as IRaceRepository,
registrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository,
driverRatingProvider as unknown as DriverRatingProvider,
);
});
it('should return error when race not found', async () => {
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' });
});
it('should return race with stored SOF when available', async () => {
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
sessionType: SessionType.main(),
status: 'scheduled',
strengthOfField: 1500,
registeredCount: 10,
maxParticipants: 20,
});
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2', 'driver-3', 'driver-4', 'driver-5', 'driver-6', 'driver-7', 'driver-8', 'driver-9', 'driver-10']);
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.raceId).toBe('race-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.strengthOfField).toBe(1500);
expect(dto.registeredCount).toBe(10);
expect(dto.maxParticipants).toBe(20);
expect(dto.participantCount).toBe(10);
expect(dto.sessionType).toBe('main');
expect(dto.status).toBe('scheduled');
});
it('should calculate SOF for upcoming race using registrations', async () => {
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
sessionType: SessionType.main(),
status: 'scheduled',
});
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([
['driver-1', 1400],
['driver-2', 1600],
]));
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1500); // average
expect(dto.participantCount).toBe(2);
expect(registrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1');
expect(resultRepository.findByRaceId).not.toHaveBeenCalled();
});
it('should calculate SOF for completed race using results', async () => {
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
sessionType: SessionType.main(),
status: 'completed',
});
raceRepository.findById.mockResolvedValue(race);
resultRepository.findByRaceId.mockResolvedValue([
{ driverId: 'driver-1' },
{ driverId: 'driver-2' },
]);
driverRatingProvider.getRatings.mockReturnValue(new Map([
['driver-1', 1400],
['driver-2', 1600],
]));
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1500);
expect(dto.participantCount).toBe(2);
expect(resultRepository.findByRaceId).toHaveBeenCalledWith('race-1');
expect(registrationRepository.getRegisteredDrivers).not.toHaveBeenCalled();
});
it('should handle missing ratings gracefully', async () => {
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
sessionType: SessionType.main(),
status: 'scheduled',
});
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([
['driver-1', 1400],
// driver-2 missing
]));
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1400); // only one rating
expect(dto.participantCount).toBe(2);
});
it('should return null SOF when no participants', async () => {
const race = Race.create({
id: 'race-1',
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
sessionType: SessionType.main(),
status: 'scheduled',
});
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue([]);
const result = await useCase.execute({ raceId: 'race-1' });
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(null);
expect(dto.participantCount).toBe(0);
});
});

View File

@@ -92,8 +92,8 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
trackId: race.trackId ?? '',
car: race.car ?? '',
carId: race.carId ?? '',
sessionType: race.sessionType as string,
status: race.status as string,
sessionType: race.sessionType.props,
status: race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,

View File

@@ -1,36 +1,33 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
GetSeasonDetailsUseCase,
} from '@core/racing/application/use-cases/GetSeasonDetailsUseCase';
import type { Logger } from '@core/shared/application';
const logger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetSeasonDetailsUseCase } from './GetSeasonDetailsUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
describe('GetSeasonDetailsUseCase', () => {
it('returns full details for a season belonging to the league', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
let useCase: GetSeasonDetailsUseCase;
let leagueRepository: {
findById: Mock;
};
let seasonRepository: {
findById: Mock;
};
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
seasonRepository = {
findById: vi.fn(),
};
useCase = new GetSeasonDetailsUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
);
});
it('returns full details for a season belonging to the league', async () => {
const league = { id: 'league-1' };
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
@@ -39,9 +36,8 @@ describe('GetSeasonDetailsUseCase', () => {
status: 'planned',
}).withMaxDrivers(24);
await seasonRepo.add(season);
const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo);
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(season);
const result = await useCase.execute({
leagueId: 'league-1',
@@ -49,7 +45,7 @@ describe('GetSeasonDetailsUseCase', () => {
});
expect(result.isOk()).toBe(true);
const dto = result.value;
const dto = result.unwrap();
expect(dto.seasonId).toBe('season-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.gameId).toBe('iracing');
@@ -57,4 +53,61 @@ describe('GetSeasonDetailsUseCase', () => {
expect(dto.status).toBe('planned');
expect(dto.maxDrivers).toBe(24);
});
it('returns error when league not found', async () => {
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found: league-1' },
});
});
it('returns error when season not found', async () => {
const league = { id: 'league-1' };
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season season-1 does not belong to league league-1' },
});
});
it('returns error when season belongs to different league', async () => {
const league = { id: 'league-1' };
const season = Season.create({
id: 'season-1',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Season',
status: 'active',
});
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(season);
const result = await useCase.execute({
leagueId: 'league-1',
seasonId: 'season-1',
});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season season-1 does not belong to league league-1' },
});
});
});

View File

@@ -1,71 +1,74 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
ListSeasonsForLeagueUseCase,
} from '@core/racing/application/use-cases/ListSeasonsForLeagueUseCase';
import type { Logger } from '@core/shared/application';
const logger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ListSeasonsForLeagueUseCase } from './ListSeasonsForLeagueUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
describe('ListSeasonsForLeagueUseCase', () => {
let useCase: ListSeasonsForLeagueUseCase;
let leagueRepository: {
findById: Mock;
};
let seasonRepository: {
listByLeague: Mock;
};
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
seasonRepository = {
listByLeague: vi.fn(),
};
useCase = new ListSeasonsForLeagueUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
);
});
it('lists seasons for a league with summaries', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
const league = { id: 'league-1' };
const seasons = [
Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season One',
status: 'planned',
}),
Season.create({
id: 'season-2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Season Two',
status: 'active',
}),
];
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',
});
await seasonRepo.add(s1);
await seasonRepo.add(s2);
await seasonRepo.add(sOtherLeague);
const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo);
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.listByLeague.mockResolvedValue(seasons);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isOk()).toBe(true);
expect(result.value.items.map((i) => i.seasonId).sort()).toEqual([
const dto = result.unwrap();
expect(dto.items.map((i) => i.seasonId).sort()).toEqual([
'season-1',
'season-2',
]);
expect(result.value.items.every((i) => i.leagueId === 'league-1')).toBe(true);
expect(dto.items.every((i) => i.leagueId === 'league-1')).toBe(true);
});
it('returns error when league not found', async () => {
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found: league-1' },
});
});
});

View File

@@ -1,37 +1,83 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
ManageSeasonLifecycleUseCase,
type ManageSeasonLifecycleCommand,
} from '@core/racing/application/use-cases/ManageSeasonLifecycleUseCase';
import type { Logger } from '@core/shared/application';
const logger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ManageSeasonLifecycleUseCase, type ManageSeasonLifecycleCommand } from './ManageSeasonLifecycleUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
describe('ManageSeasonLifecycleUseCase', () => {
function setupLifecycleTest() {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
let useCase: ManageSeasonLifecycleUseCase;
let leagueRepository: {
findById: Mock;
};
let seasonRepository: {
findById: Mock;
update: Mock;
};
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
seasonRepository = {
findById: vi.fn(),
update: vi.fn(),
};
useCase = new ManageSeasonLifecycleUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
);
});
it('applies activate → complete → archive transitions and persists state', async () => {
const league = { id: 'league-1' };
let currentSeason = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Lifecycle Season',
status: 'planned',
});
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockImplementation(() => Promise.resolve(currentSeason));
seasonRepository.update.mockImplementation((s) => {
currentSeason = s;
return Promise.resolve(s);
});
const activateCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: currentSeason.id,
transition: 'activate',
};
const activated = await useCase.execute(activateCommand);
expect(activated.isOk()).toBe(true);
expect(activated.unwrap().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);
expect(completed.unwrap().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);
expect(archived.unwrap().status).toBe('archived');
});
it('propagates domain invariant errors for invalid transitions', async () => {
const league = { id: 'league-1' };
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
@@ -40,52 +86,8 @@ describe('ManageSeasonLifecycleUseCase', () => {
status: 'planned',
});
seasonRepo.seed(season);
const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo);
return { leagueRepo, seasonRepo, useCase, season };
}
it('applies activate → complete → archive transitions and persists state', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
const activateCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'activate',
};
const activated = await useCase.execute(activateCommand);
expect(activated.isOk()).toBe(true);
expect(activated.value.status).toBe('active');
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'complete',
};
const completed = await useCase.execute(completeCommand);
expect(completed.isOk()).toBe(true);
expect(completed.value.status).toBe('completed');
const archiveCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: season.id,
transition: 'archive',
};
const archived = await useCase.execute(archiveCommand);
expect(archived.isOk()).toBe(true);
expect(archived.value.status).toBe('archived');
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('archived');
});
it('propagates domain invariant errors for invalid transitions', async () => {
const { useCase, seasonRepo, season } = setupLifecycleTest();
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(season);
const completeCommand: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
@@ -95,9 +97,42 @@ describe('ManageSeasonLifecycleUseCase', () => {
const result = await useCase.execute(completeCommand);
expect(result.isErr()).toBe(true);
expect(result.error.code).toBe('INVALID_TRANSITION');
expect(result.unwrapErr().code).toEqual('INVALID_TRANSITION');
});
const persisted = await seasonRepo.findById(season.id);
expect(persisted!.status).toBe('planned');
it('returns error when league not found', async () => {
leagueRepository.findById.mockResolvedValue(null);
const command: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: 'season-1',
transition: 'activate',
};
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found: league-1' },
});
});
it('returns error when season not found', async () => {
const league = { id: 'league-1' };
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(null);
const command: ManageSeasonLifecycleCommand = {
leagueId: 'league-1',
seasonId: 'season-1',
transition: 'activate',
};
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season season-1 does not belong to league league-1' },
});
});
});

View File

@@ -1,134 +0,0 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { Logger } from '@core/shared/application';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import type { JoinRequest } from '@core/racing/domain/entities/LeagueMembership';
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
private joinRequests: JoinRequest[] = [];
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
(m) => m.leagueId === leagueId && m.driverId === driverId,
) || null
);
}
async getLeagueMembers(leagueId: string): Promise<LeagueMembership[]> {
return this.memberships.filter(
(m) => m.leagueId === leagueId && m.status === 'active',
);
}
async getJoinRequests(leagueId: string): Promise<JoinRequest[]> {
return this.joinRequests.filter(
(r) => r.leagueId === leagueId,
);
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
const existingIndex = this.memberships.findIndex(
(m) => m.leagueId === membership.leagueId && m.driverId === membership.driverId,
);
if (existingIndex >= 0) {
this.memberships[existingIndex] = membership;
} else {
this.memberships.push(membership);
}
return membership;
}
async removeMembership(leagueId: string, driverId: string): Promise<void> {
this.memberships = this.memberships.filter(
(m) => !(m.leagueId === leagueId && m.driverId === driverId),
);
}
async saveJoinRequest(request: JoinRequest): Promise<JoinRequest> {
this.joinRequests.push(request);
return request;
}
async removeJoinRequest(requestId: string): Promise<void> {
this.joinRequests = this.joinRequests.filter(
(r) => r.id !== requestId,
);
}
seedMembership(membership: LeagueMembership): void {
this.memberships.push(membership);
}
getAllMemberships(): LeagueMembership[] {
return [...this.memberships];
}
}
describe('Membership use-cases', () => {
describe('JoinLeagueUseCase', () => {
let repository: InMemoryLeagueMembershipRepository;
let useCase: JoinLeagueUseCase;
let logger: {
debug: Mock;
info: Mock;
warn: Mock;
error: Mock;
};
beforeEach(() => {
repository = new InMemoryLeagueMembershipRepository();
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
useCase = new JoinLeagueUseCase(
repository as unknown as ILeagueMembershipRepository,
logger as unknown as Logger,
);
});
it('creates an active member when driver has no membership', async () => {
const leagueId = 'league-1';
const driverId = 'driver-1';
const result = await useCase.execute({ leagueId, driverId });
expect(result.isOk()).toBe(true);
const membership = result.unwrap();
expect(membership.leagueId).toBe(leagueId);
expect(membership.driverId).toBe(driverId);
expect(membership.role).toBe('member');
expect(membership.status).toBe('active');
expect(membership.joinedAt).toBeInstanceOf(Date);
});
it('returns error when driver already has membership for league', async () => {
const leagueId = 'league-1';
const driverId = 'driver-1';
repository.seedMembership(LeagueMembership.create({
leagueId,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
const result = await useCase.execute({ leagueId, driverId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'ALREADY_MEMBER',
details: { message: 'Already a member or have a pending request' },
});
});
});
});

View File

@@ -1,624 +0,0 @@
import { describe, it, expect } from 'vitest';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import type { Logger } from '@core/shared/application';
import { Race } from '@core/racing/domain/entities/Race';
import { League } from '@core/racing/domain/entities/League';
import { Result } from '@core/racing/domain/entities/Result';
import { Driver } from '@core/racing/domain/entities/Driver';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { GetRaceDetailUseCase } from '@core/racing/application/use-cases/GetRaceDetailUseCase';
import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase';
class InMemoryRaceRepository implements IRaceRepository {
private races = new Map<string, Race>();
constructor(races: Race[]) {
for (const race of races) {
this.races.set(race.id, race);
}
}
async findById(id: string): Promise<Race | null> {
return this.races.get(id) ?? null;
}
async findAll(): Promise<Race[]> {
return [...this.races.values()];
}
async findByLeagueId(): Promise<Race[]> {
return [];
}
async findUpcomingByLeagueId(): Promise<Race[]> {
return [];
}
async findCompletedByLeagueId(): Promise<Race[]> {
return [];
}
async findByStatus(): Promise<Race[]> {
return [];
}
async findByDateRange(): Promise<Race[]> {
return [];
}
async create(race: Race): Promise<Race> {
this.races.set(race.id, race);
return race;
}
async update(race: Race): Promise<Race> {
this.races.set(race.id, race);
return race;
}
async delete(id: string): Promise<void> {
this.races.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.races.has(id);
}
getStored(id: string): Race | null {
return this.races.get(id) ?? null;
}
}
class InMemoryLeagueRepository implements ILeagueRepository {
private leagues = new Map<string, League>();
constructor(leagues: League[]) {
for (const league of leagues) {
this.leagues.set(league.id, league);
}
}
async findById(id: string): Promise<League | null> {
return this.leagues.get(id) ?? null;
}
async findAll(): Promise<League[]> {
return [...this.leagues.values()];
}
async findByOwnerId(): Promise<League[]> {
return [];
}
async create(league: League): Promise<League> {
this.leagues.set(league.id, league);
return league;
}
async update(league: League): Promise<League> {
this.leagues.set(league.id, league);
return league;
}
async delete(id: string): Promise<void> {
this.leagues.delete(id);
}
async exists(id: string): Promise<boolean> {
return this.leagues.has(id);
}
async searchByName(): Promise<League[]> {
return [];
}
}
class InMemoryDriverRepository implements IDriverRepository {
private drivers = new Map<string, Driver>();
constructor(drivers: Array<{ id: string; name: string; country: string }>) {
for (const driver of drivers) {
this.drivers.set(driver.id, Driver.create({
id: driver.id,
iracingId: `iracing-${driver.id}`,
name: driver.name,
country: driver.country,
joinedAt: new Date('2024-01-01'),
}));
}
}
async findById(id: string): Promise<Driver | null> {
return this.drivers.get(id) ?? null;
}
async findAll(): Promise<Driver[]> {
return [...this.drivers.values()];
}
async findByIds(ids: string[]): Promise<Driver[]> {
return ids
.map(id => this.drivers.get(id))
.filter((d): d is Driver => !!d);
}
async create(): Promise<Driver> {
throw new Error('Not needed for these tests');
}
async update(): Promise<Driver> {
throw new Error('Not needed for these tests');
}
async delete(): Promise<void> {
throw new Error('Not needed for these tests');
}
async exists(): Promise<boolean> {
return false;
}
async findByIRacingId(): Promise<Driver | null> {
return null;
}
async existsByIRacingId(): Promise<boolean> {
return false;
}
}
class InMemoryRaceRegistrationRepository implements IRaceRegistrationRepository {
private registrations = new Map<string, Set<string>>();
constructor(seed: Array<{ raceId: string; driverId: string }> = []) {
for (const { raceId, driverId } of seed) {
if (!this.registrations.has(raceId)) {
this.registrations.set(raceId, new Set());
}
this.registrations.get(raceId)!.add(driverId);
}
}
async isRegistered(raceId: string, driverId: string): Promise<boolean> {
return this.registrations.get(raceId)?.has(driverId) ?? false;
}
async getRegisteredDrivers(raceId: string): Promise<string[]> {
return Array.from(this.registrations.get(raceId) ?? []);
}
async getRegistrationCount(raceId: string): Promise<number> {
return this.registrations.get(raceId)?.size ?? 0;
}
async register(registration: { raceId: string; driverId: string }): Promise<void> {
if (!this.registrations.has(registration.raceId)) {
this.registrations.set(registration.raceId, new Set());
}
this.registrations.get(registration.raceId)!.add(registration.driverId);
}
async withdraw(raceId: string, driverId: string): Promise<void> {
this.registrations.get(raceId)?.delete(driverId);
}
async getDriverRegistrations(): Promise<string[]> {
return [];
}
async clearRaceRegistrations(): Promise<void> {
return;
}
}
class InMemoryResultRepository implements IResultRepository {
private results = new Map<string, Result[]>();
constructor(results: Result[]) {
for (const result of results) {
const list = this.results.get(result.raceId) ?? [];
list.push(result);
this.results.set(result.raceId, list);
}
}
async findByRaceId(raceId: string): Promise<Result[]> {
return this.results.get(raceId) ?? [];
}
async findById(): Promise<Result | null> {
return null;
}
async findAll(): Promise<Result[]> {
return [];
}
async findByDriverId(): Promise<Result[]> {
return [];
}
async findByDriverIdAndLeagueId(): Promise<Result[]> {
return [];
}
async create(result: Result): Promise<Result> {
const list = this.results.get(result.raceId) ?? [];
list.push(result);
this.results.set(result.raceId, list);
return result;
}
async createMany(results: Result[]): Promise<Result[]> {
for (const result of results) {
await this.create(result);
}
return results;
}
async update(): Promise<Result> {
throw new Error('Not needed for these tests');
}
async delete(): Promise<void> {
throw new Error('Not needed for these tests');
}
async deleteByRaceId(): Promise<void> {
throw new Error('Not needed for these tests');
}
async exists(): Promise<boolean> {
return false;
}
async existsByRaceId(): Promise<boolean> {
return false;
}
}
class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository {
private memberships: LeagueMembership[] = [];
seedMembership(membership: LeagueMembership): void {
this.memberships.push(membership);
}
async getMembership(leagueId: string, driverId: string): Promise<LeagueMembership | null> {
return (
this.memberships.find(
m => m.leagueId === leagueId && m.driverId === driverId,
) ?? null
);
}
async getLeagueMembers(): Promise<LeagueMembership[]> {
return [];
}
async getJoinRequests(): Promise<never> {
throw new Error('Not needed for these tests');
}
async saveMembership(membership: LeagueMembership): Promise<LeagueMembership> {
this.memberships.push(membership);
return membership;
}
async removeMembership(): Promise<void> {
return;
}
async saveJoinRequest(): Promise<never> {
throw new Error('Not needed for these tests');
}
async removeJoinRequest(): Promise<never> {
throw new Error('Not needed for these tests');
}
}
class TestDriverRatingProvider implements DriverRatingProvider {
private ratings = new Map<string, number>();
seed(driverId: string, rating: number): void {
this.ratings.set(driverId, rating);
}
getRating(driverId: string): number | null {
return this.ratings.get(driverId) ?? null;
}
getRatings(driverIds: string[]): Map<string, number> {
const map = new Map<string, number>();
for (const id of driverIds) {
const rating = this.ratings.get(id);
if (rating != null) {
map.set(id, rating);
}
}
return map;
}
}
class TestImageService implements IImageServicePort {
getDriverAvatar(driverId: string): string {
return `avatar-${driverId}`;
}
getTeamLogo(teamId: string): string {
return `team-logo-${teamId}`;
}
getLeagueCover(leagueId: string): string {
return `league-cover-${leagueId}`;
}
getLeagueLogo(leagueId: string): string {
return `league-logo-${leagueId}`;
}
}
class MockLogger implements Logger {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
}
describe('GetRaceDetailUseCase', () => {
it('builds entry list and registration flags for an upcoming race', async () => {
// Given (arrange a scheduled race with one registered driver)
const league = League.create({
id: 'league-1',
name: 'Test League',
description: 'League for testing',
ownerId: 'owner-1',
});
const race = Race.create({
id: 'race-1',
leagueId: league.id,
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
track: 'Test Track',
car: 'GT3',
sessionType: 'main',
status: 'scheduled',
});
const driverId = 'driver-1';
const otherDriverId = 'driver-2';
const raceRepo = new InMemoryRaceRepository([race]);
const leagueRepo = new InMemoryLeagueRepository([league]);
const driverRepo = new InMemoryDriverRepository([
{ id: driverId, name: 'Alice Racer', country: 'US' },
{ id: otherDriverId, name: 'Bob Driver', country: 'GB' },
]);
const registrationRepo = new InMemoryRaceRegistrationRepository([
{ raceId: race.id, driverId },
{ raceId: race.id, driverId: otherDriverId },
]);
const resultRepo = new InMemoryResultRepository([]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
membershipRepo.seedMembership(LeagueMembership.create({
leagueId: league.id,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
const ratingProvider = new TestDriverRatingProvider();
ratingProvider.seed(driverId, 1500);
ratingProvider.seed(otherDriverId, 1600);
const imageService = new TestImageService();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When (execute the query for the current driver)
const result = await useCase.execute({ raceId: race.id, driverId });
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
// Then (verify race, league and registration flags)
expect(viewModel!.race?.id).toBe(race.id);
expect(viewModel!.league?.id).toBe(league.id);
expect(viewModel!.registration.isUserRegistered).toBe(true);
expect(viewModel!.registration.canRegister).toBe(true);
// Then (entry list contains both drivers with rating and avatar)
expect(viewModel!.entryList.length).toBe(2);
const currentDriver = viewModel!.entryList.find(e => e.id === driverId);
const otherDriver = viewModel!.entryList.find(e => e.id === otherDriverId);
expect(currentDriver).toBeDefined();
expect(currentDriver!.isCurrentUser).toBe(true);
expect(currentDriver!.rating).toBe(1500);
expect(currentDriver!.avatarUrl).toBe(`avatar-${driverId}`);
expect(otherDriver).toBeDefined();
expect(otherDriver!.isCurrentUser).toBe(false);
expect(otherDriver!.rating).toBe(1600);
});
it('computes rating change for a completed race result using legacy formula', async () => {
// Given (a completed race with a result for the current driver)
const league = League.create({
id: 'league-2',
name: 'Results League',
description: 'League with results',
ownerId: 'owner-2',
});
const race = Race.create({
id: 'race-2',
leagueId: league.id,
scheduledAt: new Date(Date.now() - 2 * 60 * 60 * 1000),
track: 'Historic Circuit',
car: 'LMP2',
sessionType: 'main',
status: 'completed',
});
const driverId = 'driver-results';
const raceRepo = new InMemoryRaceRepository([race]);
const leagueRepo = new InMemoryLeagueRepository([league]);
const driverRepo = new InMemoryDriverRepository([
{ id: driverId, name: 'Result Hero', country: 'DE' },
]);
const registrationRepo = new InMemoryRaceRegistrationRepository([
{ raceId: race.id, driverId },
]);
const resultEntity = Result.create({
id: 'result-1',
raceId: race.id,
driverId,
position: 1,
fastestLap: 90.123,
incidents: 0,
startPosition: 3,
});
const resultRepo = new InMemoryResultRepository([resultEntity]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
membershipRepo.seedMembership(LeagueMembership.create({
leagueId: league.id,
driverId,
role: 'member',
status: 'active',
joinedAt: new Date('2024-01-01'),
}));
const ratingProvider = new TestDriverRatingProvider();
ratingProvider.seed(driverId, 2000);
const imageService = new TestImageService();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When (executing the query for the completed race)
const result = await useCase.execute({ raceId: race.id, driverId });
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.userResult).not.toBeNull();
// Then (rating change uses the same formula as the legacy UI)
// For P1: baseChange = 25, positionBonus = (20 - 1) * 2 = 38, total = 63
expect(viewModel!.userResult!.ratingChange).toBe(63);
expect(viewModel!.userResult!.position).toBe(1);
expect(viewModel!.userResult!.startPosition).toBe(3);
expect(viewModel!.userResult!.positionChange).toBe(2);
expect(viewModel!.userResult!.isPodium).toBe(true);
expect(viewModel!.userResult!.isClean).toBe(true);
});
it('presents an error when race does not exist', async () => {
// Given (no race in the repository)
const raceRepo = new InMemoryRaceRepository([]);
const leagueRepo = new InMemoryLeagueRepository([]);
const driverRepo = new InMemoryDriverRepository([]);
const registrationRepo = new InMemoryRaceRegistrationRepository();
const resultRepo = new InMemoryResultRepository([]);
const membershipRepo = new InMemoryLeagueMembershipRepository();
const ratingProvider = new TestDriverRatingProvider();
const imageService = new TestImageService();
const useCase = new GetRaceDetailUseCase(
raceRepo,
leagueRepo,
driverRepo,
registrationRepo,
resultRepo,
membershipRepo,
ratingProvider,
imageService,
);
// When
const result = await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' });
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' });
});
});
describe('CancelRaceUseCase', () => {
it('cancels a scheduled race and persists it via the repository', async () => {
// Given (a scheduled race in the repository)
const race = Race.create({
id: 'cancel-me',
leagueId: 'league-cancel',
scheduledAt: new Date(Date.now() + 60 * 60 * 1000),
track: 'Cancel Circuit',
car: 'GT4',
sessionType: 'main',
status: 'scheduled',
});
const raceRepo = new InMemoryRaceRepository([race]);
const logger = new MockLogger();
const useCase = new CancelRaceUseCase(raceRepo, logger);
// When
const result = await useCase.execute({ raceId: race.id });
// Then (the stored race is now cancelled)
expect(result.isOk()).toBe(true);
const updated = raceRepo.getStored(race.id);
expect(updated).not.toBeNull();
expect(updated!.status).toBe('cancelled');
});
it('returns error when trying to cancel a non-existent race', async () => {
// Given
const raceRepo = new InMemoryRaceRepository([]);
const logger = new MockLogger();
const useCase = new CancelRaceUseCase(raceRepo, logger);
// When
const result = await useCase.execute({ raceId: 'does-not-exist' });
// Then
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' });
});
});

View File

@@ -1,2 +0,0 @@
export { ImportRaceResultsApiUseCase } from './ImportRaceResultsApiUseCase';
export { ImportRaceResultsUseCase } from './ImportRaceResultsUseCase';