refactor dtos to ports

This commit is contained in:
2025-12-19 15:07:53 +01:00
parent 499562c456
commit 8116fe888f
46 changed files with 718 additions and 266 deletions

View File

@@ -45,9 +45,6 @@ export * from './use-cases/RejectSponsorshipRequestUseCase';
export * from './use-cases/GetPendingSponsorshipRequestsUseCase'; export * from './use-cases/GetPendingSponsorshipRequestsUseCase';
export * from './use-cases/GetEntitySponsorshipPricingUseCase'; export * from './use-cases/GetEntitySponsorshipPricingUseCase';
// Export ports
export * from './ports/DriverRatingProvider';
// Re-export domain types for legacy callers (type-only) // Re-export domain types for legacy callers (type-only)
export type { export type {
LeagueMembership, LeagueMembership,

View File

@@ -1,5 +1,3 @@
import type { LeagueVisibilityInput } from '../../dtos/LeagueVisibilityInput';
export interface CreateLeagueWithSeasonAndScoringInputPort { export interface CreateLeagueWithSeasonAndScoringInputPort {
name: string; name: string;
description?: string; description?: string;
@@ -8,7 +6,7 @@ export interface CreateLeagueWithSeasonAndScoringInputPort {
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers. * - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/ */
visibility: LeagueVisibilityInput; visibility: 'ranked' | 'unranked' | 'public' | 'private';
ownerId: string; ownerId: string;
gameId: string; gameId: string;
maxDrivers?: number; maxDrivers?: number;

View File

@@ -1,10 +1,14 @@
import { ProtestIncident } from "@/racing/domain/entities/ProtestIncident";
export interface FileProtestInputPort { export interface FileProtestInputPort {
raceId: string; raceId: string;
protestingDriverId: string; protestingDriverId: string;
accusedDriverId: string; accusedDriverId: string;
incident: ProtestIncident; incident: {
sessionType: string;
lapNumber: number;
cornerNumber?: number;
description: string;
severity: 'minor' | 'major' | 'severe';
};
comment?: string; comment?: string;
proofVideoUrl?: string; proofVideoUrl?: string;
} }

View File

@@ -1,6 +1,4 @@
import type { SponsorableEntityType } from '../../../domain/entities/SponsorshipRequest';
export interface GetEntitySponsorshipPricingInputPort { export interface GetEntitySponsorshipPricingInputPort {
entityType: SponsorableEntityType; entityType: 'league' | 'team' | 'driver';
entityId: string; entityId: string;
} }

View File

@@ -0,0 +1,4 @@
export interface IsDriverRegisteredForRaceInputPort {
raceId: string;
driverId: string;
}

View File

@@ -3,4 +3,6 @@
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers. * - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/ */
export type LeagueVisibilityInputPort = 'ranked' | 'unranked' | 'public' | 'private'; export interface LeagueVisibilityInputPort {
visibility: 'ranked' | 'unranked' | 'public' | 'private';
}

View File

@@ -1,8 +1,3 @@
export interface IsDriverRegisteredForRaceInputPort {
raceId: string;
driverId: string;
}
export interface GetRaceRegistrationsInputPort { export interface GetRaceRegistrationsInputPort {
raceId: string; raceId: string;
} }

View File

@@ -1,7 +1,8 @@
import type { Money } from '../../domain/value-objects/Money';
export interface RefundPaymentInputPort { export interface RefundPaymentInputPort {
originalTransactionId: string; originalTransactionId: string;
amount: Money; amount: {
value: number;
currency: string;
};
reason: string; reason: string;
} }

View File

@@ -1,7 +1,10 @@
import type { Team } from '../../../domain/entities/Team';
export interface UpdateTeamInputPort { export interface UpdateTeamInputPort {
teamId: string; teamId: string;
updates: Partial<Pick<Team, 'name' | 'tag' | 'description' | 'leagues'>>; updates: {
name?: string;
tag?: string;
description?: string;
leagues?: string[];
};
updatedBy: string; updatedBy: string;
} }

View File

@@ -1,8 +1,16 @@
import type { ChampionshipStandingsRowOutputPort } from './ChampionshipStandingsRowOutputPort';
export interface ChampionshipStandingsOutputPort { export interface ChampionshipStandingsOutputPort {
seasonId: string; seasonId: string;
championshipId: string; championshipId: string;
championshipName: string; championshipName: string;
rows: ChampionshipStandingsRowOutputPort[]; rows: {
participant: {
id: string;
type: 'driver' | 'team';
name: string;
};
position: number;
totalPoints: number;
resultsCounted: number;
resultsDropped: number;
}[];
} }

View File

@@ -1,7 +1,9 @@
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
export interface ChampionshipStandingsRowOutputPort { export interface ChampionshipStandingsRowOutputPort {
participant: ParticipantRef; participant: {
id: string;
type: 'driver' | 'team';
name: string;
};
position: number; position: number;
totalPoints: number; totalPoints: number;
resultsCounted: number; resultsCounted: number;

View File

@@ -1,5 +1,11 @@
import type { Team } from '../../../domain/entities/Team';
export interface CreateTeamOutputPort { export interface CreateTeamOutputPort {
team: Team; team: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
};
} }

View File

@@ -0,0 +1,4 @@
export interface DriverOutputPort {
id: string;
name: string;
}

View File

@@ -1,3 +1,11 @@
import type { Team } from '../../../domain/entities/Team'; export interface GetAllTeamsOutputPort {
teams: Array<{
export type GetAllTeamsOutputPort = Team[]; id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
}>;
}

View File

@@ -1,7 +1,17 @@
import type { Team } from '../../../domain/entities/Team';
import type { TeamMembership } from '../../../domain/types/TeamMembership';
export interface GetDriverTeamOutputPort { export interface GetDriverTeamOutputPort {
team: Team; team: {
membership: TeamMembership; id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
};
membership: {
driverId: string;
teamId: string;
role: 'member' | 'captain' | 'admin';
joinedAt: Date;
};
} }

View File

@@ -1,11 +1,28 @@
import type { SponsorableEntityType } from '../../../domain/entities/SponsorshipRequest';
import type { SponsorshipSlotDTO } from './SponsorshipSlotOutputPort';
export interface GetEntitySponsorshipPricingOutputPort { export interface GetEntitySponsorshipPricingOutputPort {
entityType: SponsorableEntityType; entityType: 'league' | 'team' | 'driver';
entityId: string; entityId: string;
acceptingApplications: boolean; acceptingApplications: boolean;
customRequirements?: string; customRequirements?: string;
mainSlot?: SponsorshipSlotDTO; mainSlot?: {
secondarySlot?: SponsorshipSlotDTO; tier: string;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
};
secondarySlot?: {
tier: string;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
};
} }

View File

@@ -1,6 +1,10 @@
import type { LeagueMembership } from '../../../domain/entities/LeagueMembership';
export interface GetLeagueMembershipsOutputPort { export interface GetLeagueMembershipsOutputPort {
memberships: LeagueMembership[]; memberships: Array<{
id: string;
leagueId: string;
driverId: string;
role: 'member' | 'admin' | 'owner';
joinedAt: Date;
}>;
drivers: { id: string; name: string }[]; drivers: { id: string; name: string }[];
} }

View File

@@ -1,18 +1,20 @@
import type { ProtestOutputPort } from './ProtestOutputPort';
export interface RaceOutputPort {
id: string;
name: string;
date: string;
}
export interface DriverOutputPort {
id: string;
name: string;
}
export interface GetLeagueProtestsOutputPort { export interface GetLeagueProtestsOutputPort {
protests: ProtestOutputPort[]; protests: Array<{
races: RaceOutputPort[]; id: string;
drivers: DriverOutputPort[]; raceId: string;
protestingDriverId: string;
accusedDriverId: string;
submittedAt: Date;
description: string;
status: string;
}>;
races: Array<{
id: string;
name: string;
date: string;
}>;
drivers: Array<{
id: string;
name: string;
}>;
} }

View File

@@ -1,7 +1,17 @@
import type { Team } from '../../../domain/entities/Team';
import type { TeamMembership } from '../../../domain/types/TeamMembership';
export interface GetTeamDetailsOutputPort { export interface GetTeamDetailsOutputPort {
team: Team; team: {
membership: TeamMembership | null; id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
};
membership: {
driverId: string;
teamId: string;
role: 'member' | 'captain' | 'admin';
joinedAt: Date;
} | null;
} }

View File

@@ -1,18 +1,11 @@
import type { Weekday } from '../../../domain/types/Weekday';
export interface LeagueScheduleOutputPort { export interface LeagueScheduleOutputPort {
seasonStartDate: string; seasonStartDate: string;
raceStartTime: string; raceStartTime: string;
timezoneId: string; timezoneId: string;
recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday'; recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday';
intervalWeeks?: number; intervalWeeks?: number;
weekdays?: Weekday[]; weekdays?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday')[];
monthlyOrdinal?: 1 | 2 | 3 | 4; monthlyOrdinal?: 1 | 2 | 3 | 4;
monthlyWeekday?: Weekday; monthlyWeekday?: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday';
plannedRounds: number; plannedRounds: number;
}
export interface LeagueSchedulePreviewOutputPort {
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
summary: string;
} }

View File

@@ -0,0 +1,4 @@
export interface LeagueSchedulePreviewOutputPort {
rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>;
summary: string;
}

View File

@@ -0,0 +1,9 @@
export interface LeagueScoringChampionshipOutputPort {
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy';
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}

View File

@@ -1,13 +1,3 @@
export interface LeagueScoringChampionshipOutputPort {
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy';
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigOutputPort { export interface LeagueScoringConfigOutputPort {
leagueId: string; leagueId: string;
seasonId: string; seasonId: string;
@@ -16,5 +6,13 @@ export interface LeagueScoringConfigOutputPort {
scoringPresetId?: string; scoringPresetId?: string;
scoringPresetName?: string; scoringPresetName?: string;
dropPolicySummary: string; dropPolicySummary: string;
championships: LeagueScoringChampionshipOutputPort[]; championships: Array<{
id: string;
name: string;
type: 'driver' | 'team' | 'nations' | 'trophy';
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}>;
} }

View File

@@ -1,14 +1,8 @@
export type LeagueScoringPresetPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export interface LeagueScoringPresetOutputPort { export interface LeagueScoringPresetOutputPort {
id: string; id: string;
name: string; name: string;
description: string; description: string;
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType; primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
sessionSummary: string; sessionSummary: string;
bonusSummary: string; bonusSummary: string;
dropPolicySummary: string; dropPolicySummary: string;

View File

@@ -1,13 +1,3 @@
export interface LeagueSummaryScoringOutputPort {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
}
export interface LeagueSummaryOutputPort { export interface LeagueSummaryOutputPort {
id: string; id: string;
name: string; name: string;
@@ -21,5 +11,13 @@ export interface LeagueSummaryOutputPort {
structureSummary?: string; structureSummary?: string;
scoringPatternSummary?: string; scoringPatternSummary?: string;
timingSummary?: string; timingSummary?: string;
scoring?: LeagueSummaryScoringOutputPort; scoring?: {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
};
} }

View File

@@ -0,0 +1,9 @@
export interface LeagueSummaryScoringOutputPort {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
}

View File

@@ -0,0 +1,5 @@
export interface RaceOutputPort {
id: string;
name: string;
date: string;
}

View File

@@ -1,7 +1,5 @@
import type { SponsorshipTier } from '../../../domain/entities/SeasonSponsorship';
export interface SponsorshipSlotOutputPort { export interface SponsorshipSlotOutputPort {
tier: SponsorshipTier; tier: string;
price: number; price: number;
currency: string; currency: string;
formattedPrice: string; formattedPrice: string;

View File

@@ -4,7 +4,6 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { INotificationService } from '@core/notifications/application/ports/INotificationService'; import type { INotificationService } from '@core/notifications/application/ports/INotificationService';
import type { IPaymentGateway } from '../ports/IPaymentGateway';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
@@ -27,9 +26,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
let mockNotificationService: { let mockNotificationService: {
sendNotification: Mock; sendNotification: Mock;
}; };
let mockPaymentGateway: { let processPayment: Mock;
processPayment: Mock;
};
let mockWalletRepo: { let mockWalletRepo: {
findById: Mock; findById: Mock;
update: Mock; update: Mock;
@@ -59,9 +56,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
mockNotificationService = { mockNotificationService = {
sendNotification: vi.fn(), sendNotification: vi.fn(),
}; };
mockPaymentGateway = { processPayment = vi.fn();
processPayment: vi.fn(),
};
mockWalletRepo = { mockWalletRepo = {
findById: vi.fn(), findById: vi.fn(),
update: vi.fn(), update: vi.fn(),
@@ -84,7 +79,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
mockSeasonRepo as unknown as ISeasonRepository, mockSeasonRepo as unknown as ISeasonRepository,
mockNotificationService as unknown as INotificationService, mockNotificationService as unknown as INotificationService,
mockPaymentGateway as unknown as IPaymentGateway, processPayment,
mockWalletRepo as unknown as IWalletRepository, mockWalletRepo as unknown as IWalletRepository,
mockLeagueWalletRepo as unknown as ILeagueWalletRepository, mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
mockLogger as unknown as Logger, mockLogger as unknown as Logger,
@@ -112,7 +107,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
mockSponsorshipRequestRepo.findById.mockResolvedValue(request); mockSponsorshipRequestRepo.findById.mockResolvedValue(request);
mockSeasonRepo.findById.mockResolvedValue(season); mockSeasonRepo.findById.mockResolvedValue(season);
mockNotificationService.sendNotification.mockResolvedValue(undefined); mockNotificationService.sendNotification.mockResolvedValue(undefined);
mockPaymentGateway.processPayment.mockResolvedValue({ processPayment.mockResolvedValue({
success: true, success: true,
transactionId: 'txn1', transactionId: 'txn1',
timestamp: new Date(), timestamp: new Date(),
@@ -154,11 +149,13 @@ describe('AcceptSponsorshipRequestUseCase', () => {
sponsorshipId: dto.sponsorshipId, sponsorshipId: dto.sponsorshipId,
}, },
}); });
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith( expect(processPayment).toHaveBeenCalledWith(
Money.create(1000), {
'sponsor1', amount: Money.create(1000),
'Sponsorship payment for season season1', payerId: 'sponsor1',
{ requestId: 'req1' } description: 'Sponsorship payment for season season1',
metadata: { requestId: 'req1' }
}
); );
expect(mockWalletRepo.update).toHaveBeenCalledWith( expect(mockWalletRepo.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({

View File

@@ -4,7 +4,6 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
describe('CompleteRaceUseCase', () => { describe('CompleteRaceUseCase', () => {
@@ -23,9 +22,7 @@ describe('CompleteRaceUseCase', () => {
findByDriverIdAndLeagueId: Mock; findByDriverIdAndLeagueId: Mock;
save: Mock; save: Mock;
}; };
let driverRatingProvider: { let getDriverRating: Mock;
getRatings: Mock;
};
beforeEach(() => { beforeEach(() => {
raceRepository = { raceRepository = {
@@ -42,15 +39,13 @@ describe('CompleteRaceUseCase', () => {
findByDriverIdAndLeagueId: vi.fn(), findByDriverIdAndLeagueId: vi.fn(),
save: vi.fn(), save: vi.fn(),
}; };
driverRatingProvider = { getDriverRating = vi.fn();
getRatings: vi.fn(),
};
useCase = new CompleteRaceUseCase( useCase = new CompleteRaceUseCase(
raceRepository as unknown as IRaceRepository, raceRepository as unknown as IRaceRepository,
raceRegistrationRepository as unknown as IRaceRegistrationRepository, raceRegistrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository, resultRepository as unknown as IResultRepository,
standingRepository as unknown as IStandingRepository, standingRepository as unknown as IStandingRepository,
driverRatingProvider as unknown as DriverRatingProvider, getDriverRating,
); );
}); });
@@ -67,7 +62,11 @@ describe('CompleteRaceUseCase', () => {
}; };
raceRepository.findById.mockResolvedValue(mockRace); raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600], ['driver-2', 1500]])); getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1600, ratingChange: null });
if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1500, ratingChange: null });
return Promise.resolve({ rating: null, ratingChange: null });
});
resultRepository.create.mockResolvedValue(undefined); resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null); standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined); standingRepository.save.mockResolvedValue(undefined);
@@ -79,7 +78,7 @@ describe('CompleteRaceUseCase', () => {
expect(result.unwrap()).toEqual({}); expect(result.unwrap()).toEqual({});
expect(raceRepository.findById).toHaveBeenCalledWith('race-1'); expect(raceRepository.findById).toHaveBeenCalledWith('race-1');
expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1'); expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1');
expect(driverRatingProvider.getRatings).toHaveBeenCalledWith(['driver-1', 'driver-2']); expect(getDriverRating).toHaveBeenCalledTimes(2);
expect(resultRepository.create).toHaveBeenCalledTimes(2); expect(resultRepository.create).toHaveBeenCalledTimes(2);
expect(standingRepository.save).toHaveBeenCalledTimes(2); expect(standingRepository.save).toHaveBeenCalledTimes(2);
expect(mockRace.complete).toHaveBeenCalled(); expect(mockRace.complete).toHaveBeenCalled();
@@ -132,7 +131,7 @@ describe('CompleteRaceUseCase', () => {
}; };
raceRepository.findById.mockResolvedValue(mockRace); raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']); raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]])); getDriverRating.mockResolvedValue({ rating: 1600, ratingChange: null });
resultRepository.create.mockRejectedValue(new Error('DB error')); resultRepository.create.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute(command); const result = await useCase.execute(command);

View File

@@ -2,7 +2,8 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { Result } from '../../domain/entities/Result'; import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing'; import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
@@ -28,7 +29,7 @@ export class CompleteRaceUseCase
private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository, private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider, private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
) {} ) {}
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, ApplicationErrorCode<string>>> { async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, ApplicationErrorCode<string>>> {
@@ -46,8 +47,20 @@ export class CompleteRaceUseCase
return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' }); return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' });
} }
// Get driver ratings // Get driver ratings using clean ports
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); const ratingPromises = registeredDriverIds.map(driverId =>
this.getDriverRating({ driverId })
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = new Map<string, number>();
registeredDriverIds.forEach((driverId, index) => {
const rating = ratingResults[index].rating;
if (rating !== null) {
driverRatings.set(driverId, rating);
}
});
// Generate realistic race results // Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings); const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);

View File

@@ -4,7 +4,6 @@ import type { CreateLeagueWithSeasonAndScoringCommand } from '../dto/CreateLeagu
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
describe('CreateLeagueWithSeasonAndScoringUseCase', () => { describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
@@ -18,10 +17,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
let leagueScoringConfigRepository: { let leagueScoringConfigRepository: {
save: Mock; save: Mock;
}; };
let presetProvider: { let getLeagueScoringPresetById: Mock;
getPresetById: Mock;
createScoringConfigFromPreset: Mock;
};
let logger: { let logger: {
debug: Mock; debug: Mock;
info: Mock; info: Mock;
@@ -39,10 +35,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
leagueScoringConfigRepository = { leagueScoringConfigRepository = {
save: vi.fn(), save: vi.fn(),
}; };
presetProvider = { getLeagueScoringPresetById = vi.fn();
getPresetById: vi.fn(),
createScoringConfigFromPreset: vi.fn(),
};
logger = { logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -53,7 +46,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
leagueRepository as unknown as ILeagueRepository, leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository, seasonRepository as unknown as ISeasonRepository,
leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository,
presetProvider as unknown as LeagueScoringPresetProvider, getLeagueScoringPresetById,
logger as unknown as Logger, logger as unknown as Logger,
); );
}); });
@@ -79,8 +72,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
name: 'Club Default', name: 'Club Default',
}; };
presetProvider.getPresetById.mockReturnValue(mockPreset); getLeagueScoringPresetById.mockResolvedValue(mockPreset);
presetProvider.createScoringConfigFromPreset.mockReturnValue({ id: 'config-1' });
leagueRepository.create.mockResolvedValue(undefined); leagueRepository.create.mockResolvedValue(undefined);
seasonRepository.create.mockResolvedValue(undefined); seasonRepository.create.mockResolvedValue(undefined);
leagueScoringConfigRepository.save.mockResolvedValue(undefined); leagueScoringConfigRepository.save.mockResolvedValue(undefined);
@@ -226,7 +218,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
scoringPresetId: 'unknown-preset', scoringPresetId: 'unknown-preset',
}; };
presetProvider.getPresetById.mockReturnValue(undefined); getLeagueScoringPresetById.mockResolvedValue(undefined);
const result = await useCase.execute(command); const result = await useCase.execute(command);
@@ -252,8 +244,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
name: 'Club Default', name: 'Club Default',
}; };
presetProvider.getPresetById.mockReturnValue(mockPreset); getLeagueScoringPresetById.mockResolvedValue(mockPreset);
presetProvider.createScoringConfigFromPreset.mockReturnValue({ id: 'config-1' });
leagueRepository.create.mockRejectedValue(new Error('DB error')); leagueRepository.create.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute(command); const result = await useCase.execute(command);

View File

@@ -6,10 +6,8 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import type { import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort';
LeagueScoringPresetProvider, import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
LeagueScoringPresetDTO,
} from '../ports/LeagueScoringPresetProvider';
import { import {
LeagueVisibility, LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS, MIN_RANKED_LEAGUE_DRIVERS,
@@ -45,7 +43,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository, private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly presetProvider: LeagueScoringPresetProvider, private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise<LeagueScoringPresetOutputPort | undefined>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -96,8 +94,8 @@ export class CreateLeagueWithSeasonAndScoringUseCase
const presetId = command.scoringPresetId ?? 'club-default'; const presetId = command.scoringPresetId ?? 'club-default';
this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`); this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`);
const preset: LeagueScoringPresetDTO | undefined = const preset: LeagueScoringPresetOutputPort | undefined =
this.presetProvider.getPresetById(presetId); await this.getLeagueScoringPresetById({ presetId });
if (!preset) { if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`); this.logger.error(`Unknown scoring preset: ${presetId}`);
@@ -105,8 +103,19 @@ export class CreateLeagueWithSeasonAndScoringUseCase
} }
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
// Note: createScoringConfigFromPreset business logic should be moved to domain layer
const finalConfig = this.presetProvider.createScoringConfigFromPreset(preset.id, seasonId); // For now, we'll create a basic config structure
const finalConfig = {
id: uuidv4(),
seasonId,
scoringPresetId: preset.id,
championships: {
driver: command.enableDriverChampionship,
team: command.enableTeamChampionship,
nations: command.enableNationsChampionship,
trophy: command.enableTrophyChampionship,
},
};
this.logger.debug(`Scoring configuration created from preset ${preset.id}.`); this.logger.debug(`Scoring configuration created from preset ${preset.id}.`);
await this.leagueScoringConfigRepository.save(finalConfig); await this.leagueScoringConfigRepository.save(finalConfig);

View File

@@ -5,7 +5,8 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IImageServicePort } from '../ports/IImageServicePort'; import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
@@ -50,7 +51,7 @@ export class DashboardOverviewUseCase {
private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly feedRepository: IFeedRepository, private readonly feedRepository: IFeedRepository,
private readonly socialRepository: ISocialGraphRepository, private readonly socialRepository: ISocialGraphRepository,
private readonly imageService: IImageServicePort, private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null, private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {} ) {}
@@ -75,7 +76,7 @@ export class DashboardOverviewUseCase {
id: driver.id, id: driver.id,
name: driver.name, name: driver.name,
country: driver.country, country: driver.country,
avatarUrl: this.imageService.getDriverAvatar(driver.id), avatarUrl: (await this.getDriverAvatar({ driverId: driver.id })).avatarUrl,
rating: driverStats?.rating ?? null, rating: driverStats?.rating ?? null,
globalRank: driverStats?.overallRank ?? null, globalRank: driverStats?.overallRank ?? null,
totalRaces: driverStats?.totalRaces ?? 0, totalRaces: driverStats?.totalRaces ?? 0,
@@ -125,7 +126,7 @@ export class DashboardOverviewUseCase {
const feedSummary = this.buildFeedSummary(feedItems); const feedSummary = this.buildFeedSummary(feedItems);
const friendsSummary = this.buildFriendsSummary(friends); const friendsSummary = await this.buildFriendsSummary(friends);
const viewModel: DashboardOverviewViewModel = { const viewModel: DashboardOverviewViewModel = {
currentDriver, currentDriver,
@@ -302,12 +303,19 @@ export class DashboardOverviewUseCase {
}; };
} }
private buildFriendsSummary(friends: Driver[]): DashboardFriendSummaryViewModel[] { private async buildFriendsSummary(friends: Driver[]): Promise<DashboardFriendSummaryViewModel[]> {
return friends.map(friend => ({ const friendSummaries: DashboardFriendSummaryViewModel[] = [];
id: friend.id,
name: friend.name, for (const friend of friends) {
country: friend.country, const avatarResult = await this.getDriverAvatar({ driverId: friend.id });
avatarUrl: this.imageService.getDriverAvatar(friend.id), friendSummaries.push({
})); id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: avatarResult.avatarUrl,
});
}
return friendSummaries;
} }
} }

View File

@@ -3,7 +3,6 @@ import { GetDriversLeaderboardUseCase } from './GetDriversLeaderboardUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService'; import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
describe('GetDriversLeaderboardUseCase', () => { describe('GetDriversLeaderboardUseCase', () => {
@@ -27,11 +26,7 @@ describe('GetDriversLeaderboardUseCase', () => {
getDriverStats: mockDriverStatsGetDriverStats, getDriverStats: mockDriverStatsGetDriverStats,
}; };
const mockImageGetDriverAvatar = vi.fn(); const mockGetDriverAvatar = vi.fn();
const mockImageService: IImageServicePort = {
getDriverAvatar: mockImageGetDriverAvatar,
};
const mockLogger: Logger = { const mockLogger: Logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -48,7 +43,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverRepo, mockDriverRepo,
mockRankingService, mockRankingService,
mockDriverStatsService, mockDriverStatsService,
mockImageService, mockGetDriverAvatar,
mockLogger, mockLogger,
); );
@@ -65,7 +60,11 @@ describe('GetDriversLeaderboardUseCase', () => {
if (id === 'driver2') return stats2; if (id === 'driver2') return stats2;
return null; return null;
}); });
mockImageGetDriverAvatar.mockImplementation((id) => `avatar-${id}`); mockGetDriverAvatar.mockImplementation((input) => {
if (input.driverId === 'driver1') return Promise.resolve({ avatarUrl: 'avatar-driver1' });
if (input.driverId === 'driver2') return Promise.resolve({ avatarUrl: 'avatar-driver2' });
return Promise.resolve({ avatarUrl: 'avatar-default' });
});
const result = await useCase.execute(); const result = await useCase.execute();
@@ -83,7 +82,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverRepo, mockDriverRepo,
mockRankingService, mockRankingService,
mockDriverStatsService, mockDriverStatsService,
mockImageService, mockGetDriverAvatar,
mockLogger, mockLogger,
); );
@@ -106,7 +105,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverRepo, mockDriverRepo,
mockRankingService, mockRankingService,
mockDriverStatsService, mockDriverStatsService,
mockImageService, mockGetDriverAvatar,
mockLogger, mockLogger,
); );
@@ -116,7 +115,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverFindAll.mockResolvedValue([driver1]); mockDriverFindAll.mockResolvedValue([driver1]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings); mockRankingGetAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsGetDriverStats.mockReturnValue(null); mockDriverStatsGetDriverStats.mockReturnValue(null);
mockImageGetDriverAvatar.mockReturnValue('avatar-driver1'); mockGetDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver1' });
const result = await useCase.execute(); const result = await useCase.execute();
@@ -134,7 +133,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverRepo, mockDriverRepo,
mockRankingService, mockRankingService,
mockDriverStatsService, mockDriverStatsService,
mockImageService, mockGetDriverAvatar,
mockLogger, mockLogger,
); );

View File

@@ -1,7 +1,8 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService'; import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort'; import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter'; import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application'; import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
@@ -18,7 +19,7 @@ export class GetDriversLeaderboardUseCase
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService, private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService, private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageServicePort, private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -36,7 +37,9 @@ export class GetDriversLeaderboardUseCase
if (driverStats) { if (driverStats) {
stats[driver.id] = driverStats; stats[driver.id] = driverStats;
} }
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
const avatarResult = await this.getDriverAvatar({ driverId: driver.id });
avatarUrls[driver.id] = avatarResult.avatarUrl;
} }
const dto: DriversLeaderboardResultDTO = { const dto: DriversLeaderboardResultDTO = {

View File

@@ -4,7 +4,6 @@ import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import { IGameRepository } from '../../domain/repositories/IGameRepository'; import { IGameRepository } from '../../domain/repositories/IGameRepository';
import { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
describe('GetLeagueScoringConfigUseCase', () => { describe('GetLeagueScoringConfigUseCase', () => {
let useCase: GetLeagueScoringConfigUseCase; let useCase: GetLeagueScoringConfigUseCase;
@@ -12,20 +11,20 @@ describe('GetLeagueScoringConfigUseCase', () => {
let seasonRepository: { findByLeagueId: Mock }; let seasonRepository: { findByLeagueId: Mock };
let leagueScoringConfigRepository: { findBySeasonId: Mock }; let leagueScoringConfigRepository: { findBySeasonId: Mock };
let gameRepository: { findById: Mock }; let gameRepository: { findById: Mock };
let presetProvider: { getPresetById: Mock; listPresets: Mock; createScoringConfigFromPreset: Mock }; let getLeagueScoringPresetById: Mock;
beforeEach(() => { beforeEach(() => {
leagueRepository = { findById: vi.fn() }; leagueRepository = { findById: vi.fn() };
seasonRepository = { findByLeagueId: vi.fn() }; seasonRepository = { findByLeagueId: vi.fn() };
leagueScoringConfigRepository = { findBySeasonId: vi.fn() }; leagueScoringConfigRepository = { findBySeasonId: vi.fn() };
gameRepository = { findById: vi.fn() }; gameRepository = { findById: vi.fn() };
presetProvider = { getPresetById: vi.fn(), listPresets: vi.fn(), createScoringConfigFromPreset: vi.fn() }; getLeagueScoringPresetById = vi.fn();
useCase = new GetLeagueScoringConfigUseCase( useCase = new GetLeagueScoringConfigUseCase(
leagueRepository as unknown as ILeagueRepository, leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository, seasonRepository as unknown as ISeasonRepository,
leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository,
gameRepository as unknown as IGameRepository, gameRepository as unknown as IGameRepository,
presetProvider as LeagueScoringPresetProvider, getLeagueScoringPresetById,
); );
}); });
@@ -41,7 +40,7 @@ describe('GetLeagueScoringConfigUseCase', () => {
seasonRepository.findByLeagueId.mockResolvedValue([season]); seasonRepository.findByLeagueId.mockResolvedValue([season]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig); leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig);
gameRepository.findById.mockResolvedValue(game); gameRepository.findById.mockResolvedValue(game);
presetProvider.getPresetById.mockReturnValue(preset); getLeagueScoringPresetById.mockResolvedValue(preset);
const result = await useCase.execute({ leagueId }); const result = await useCase.execute({ leagueId });

View File

@@ -2,7 +2,8 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort';
import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
import type { LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter'; import type { LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
@@ -26,7 +27,7 @@ export class GetLeagueScoringConfigUseCase
private readonly seasonRepository: ISeasonRepository, private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository, private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider, private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise<LeagueScoringPresetOutputPort | undefined>,
) {} ) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigData, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> { async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigData, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> {
@@ -61,7 +62,7 @@ export class GetLeagueScoringConfigUseCase
} }
const presetId = scoringConfig.scoringPresetId; const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined; const preset = presetId ? await this.getLeagueScoringPresetById({ presetId }) : undefined;
const data: LeagueScoringConfigData = { const data: LeagueScoringConfigData = {
leagueId: league.id, leagueId: league.id,

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase'; import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { DriverRatingProvider } from '../ports/DriverRatingProvider';
describe('GetLeagueStatsUseCase', () => { describe('GetLeagueStatsUseCase', () => {
let useCase: GetLeagueStatsUseCase; let useCase: GetLeagueStatsUseCase;
@@ -12,9 +11,7 @@ describe('GetLeagueStatsUseCase', () => {
let raceRepository: { let raceRepository: {
findByLeagueId: Mock; findByLeagueId: Mock;
}; };
let driverRatingProvider: { let getDriverRating: Mock;
getRatings: Mock;
};
beforeEach(() => { beforeEach(() => {
leagueMembershipRepository = { leagueMembershipRepository = {
@@ -23,13 +20,11 @@ describe('GetLeagueStatsUseCase', () => {
raceRepository = { raceRepository = {
findByLeagueId: vi.fn(), findByLeagueId: vi.fn(),
}; };
driverRatingProvider = { getDriverRating = vi.fn();
getRatings: vi.fn(),
};
useCase = new GetLeagueStatsUseCase( useCase = new GetLeagueStatsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository,
raceRepository as unknown as IRaceRepository, raceRepository as unknown as IRaceRepository,
driverRatingProvider as unknown as DriverRatingProvider, getDriverRating,
); );
}); });
@@ -41,15 +36,15 @@ describe('GetLeagueStatsUseCase', () => {
{ driverId: 'driver-3' }, { driverId: 'driver-3' },
]; ];
const races = [{ id: 'race-1' }, { id: 'race-2' }]; const races = [{ id: 'race-1' }, { id: 'race-2' }];
const ratings = new Map([
['driver-1', 1500],
['driver-2', 1600],
['driver-3', null],
]);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races); raceRepository.findByLeagueId.mockResolvedValue(races);
driverRatingProvider.getRatings.mockReturnValue(ratings); getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1500, ratingChange: null });
if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
if (input.driverId === 'driver-3') return Promise.resolve({ rating: null, ratingChange: null });
return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ leagueId }); const result = await useCase.execute({ leagueId });
@@ -65,11 +60,10 @@ describe('GetLeagueStatsUseCase', () => {
const leagueId = 'league-1'; const leagueId = 'league-1';
const memberships = [{ driverId: 'driver-1' }]; const memberships = [{ driverId: 'driver-1' }];
const races = [{ id: 'race-1' }]; const races = [{ id: 'race-1' }];
const ratings = new Map([['driver-1', null]]);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races); raceRepository.findByLeagueId.mockResolvedValue(races);
driverRatingProvider.getRatings.mockReturnValue(ratings); getDriverRating.mockResolvedValue({ rating: null, ratingChange: null });
const result = await useCase.execute({ leagueId }); const result = await useCase.execute({ leagueId });

View File

@@ -1,7 +1,8 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter'; import type { LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -13,7 +14,7 @@ export class GetLeagueStatsUseCase {
constructor( constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly driverRatingProvider: DriverRatingProvider, private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
) {} ) {}
async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> { async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
@@ -21,9 +22,19 @@ export class GetLeagueStatsUseCase {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const races = await this.raceRepository.findByLeagueId(params.leagueId); const races = await this.raceRepository.findByLeagueId(params.leagueId);
const driverIds = memberships.map(m => m.driverId); const driverIds = memberships.map(m => m.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const validRatings = Array.from(ratings.values()).filter(r => r !== null) as number[]; // Get ratings for all drivers using clean ports
const ratingPromises = driverIds.map(driverId =>
this.getDriverRating({ driverId })
);
const ratingResults = await Promise.all(ratingPromises);
const validRatings = ratingResults
.map(result => result.rating)
.filter((rating): rating is number => rating !== null);
const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0; const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0;
const viewModel: LeagueStatsViewModel = { const viewModel: LeagueStatsViewModel = {
totalMembers: memberships.length, totalMembers: memberships.length,
totalRaces: races.length, totalRaces: races.length,

View File

@@ -3,7 +3,6 @@ import { GetRaceWithSOFUseCase } from './GetRaceWithSOFUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import { IResultRepository } from '../../domain/repositories/IResultRepository'; import { IResultRepository } from '../../domain/repositories/IResultRepository';
import { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Race } from '../../domain/entities/Race'; import { Race } from '../../domain/entities/Race';
import { SessionType } from '../../domain/value-objects/SessionType'; import { SessionType } from '../../domain/value-objects/SessionType';
@@ -18,9 +17,7 @@ describe('GetRaceWithSOFUseCase', () => {
let resultRepository: { let resultRepository: {
findByRaceId: Mock; findByRaceId: Mock;
}; };
let driverRatingProvider: { let getDriverRating: Mock;
getRatings: Mock;
};
beforeEach(() => { beforeEach(() => {
raceRepository = { raceRepository = {
@@ -32,14 +29,12 @@ describe('GetRaceWithSOFUseCase', () => {
resultRepository = { resultRepository = {
findByRaceId: vi.fn(), findByRaceId: vi.fn(),
}; };
driverRatingProvider = { getDriverRating = vi.fn();
getRatings: vi.fn(),
};
useCase = new GetRaceWithSOFUseCase( useCase = new GetRaceWithSOFUseCase(
raceRepository as unknown as IRaceRepository, raceRepository as unknown as IRaceRepository,
registrationRepository as unknown as IRaceRegistrationRepository, registrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository, resultRepository as unknown as IResultRepository,
driverRatingProvider as unknown as DriverRatingProvider, getDriverRating,
); );
}); });
@@ -96,10 +91,11 @@ describe('GetRaceWithSOFUseCase', () => {
raceRepository.findById.mockResolvedValue(race); raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([ getDriverRating.mockImplementation((input) => {
['driver-1', 1400], if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
['driver-2', 1600], if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
])); return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' }); const result = await useCase.execute({ raceId: 'race-1' });
@@ -127,10 +123,11 @@ describe('GetRaceWithSOFUseCase', () => {
{ driverId: 'driver-1' }, { driverId: 'driver-1' },
{ driverId: 'driver-2' }, { driverId: 'driver-2' },
]); ]);
driverRatingProvider.getRatings.mockReturnValue(new Map([ getDriverRating.mockImplementation((input) => {
['driver-1', 1400], if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
['driver-2', 1600], if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
])); return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' }); const result = await useCase.execute({ raceId: 'race-1' });
@@ -155,10 +152,11 @@ describe('GetRaceWithSOFUseCase', () => {
raceRepository.findById.mockResolvedValue(race); raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([ getDriverRating.mockImplementation((input) => {
['driver-1', 1400], if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
// driver-2 missing // driver-2 missing
])); return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' }); const result = await useCase.execute({ raceId: 'race-1' });

View File

@@ -11,7 +11,8 @@ import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { import {
AverageStrengthOfFieldCalculator, AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator, type StrengthOfFieldCalculator,
@@ -46,7 +47,7 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository, private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
private readonly driverRatingProvider: DriverRatingProvider, private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
sofCalculator?: StrengthOfFieldCalculator, sofCalculator?: StrengthOfFieldCalculator,
) { ) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
@@ -76,10 +77,18 @@ export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryPa
let strengthOfField = race.strengthOfField ?? null; let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) { if (strengthOfField === null && participantIds.length > 0) {
const ratings = this.driverRatingProvider.getRatings(participantIds); // Get ratings for all participants using clean ports
const ratingPromises = participantIds.map(driverId =>
this.getDriverRating({ driverId })
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = participantIds const driverRatings = participantIds
.filter(id => ratings.has(id)) .filter((_, index) => ratingResults[index].rating !== null)
.map(id => ({ driverId: id, rating: ratings.get(id)! })); .map((driverId, index) => ({
driverId,
rating: ratingResults[index].rating!
}));
strengthOfField = this.sofCalculator.calculate(driverRatings); strengthOfField = this.sofCalculator.calculate(driverRatings);
} }

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetTeamJoinRequestsUseCase } from './GetTeamJoinRequestsUseCase'; import { GetTeamJoinRequestsUseCase } from './GetTeamJoinRequestsUseCase';
import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { IImageServicePort } from '../ports/IImageServicePort';
import { Driver } from '../../domain/entities/Driver'; import { Driver } from '../../domain/entities/Driver';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
@@ -14,9 +13,7 @@ describe('GetTeamJoinRequestsUseCase', () => {
let driverRepository: { let driverRepository: {
findById: Mock; findById: Mock;
}; };
let imageService: { let getDriverAvatar: Mock;
getDriverAvatar: Mock;
};
let logger: { let logger: {
debug: Mock; debug: Mock;
info: Mock; info: Mock;
@@ -31,9 +28,7 @@ describe('GetTeamJoinRequestsUseCase', () => {
driverRepository = { driverRepository = {
findById: vi.fn(), findById: vi.fn(),
}; };
imageService = { getDriverAvatar = vi.fn();
getDriverAvatar: vi.fn(),
};
logger = { logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -43,7 +38,7 @@ describe('GetTeamJoinRequestsUseCase', () => {
useCase = new GetTeamJoinRequestsUseCase( useCase = new GetTeamJoinRequestsUseCase(
membershipRepository as unknown as ITeamMembershipRepository, membershipRepository as unknown as ITeamMembershipRepository,
driverRepository as unknown as IDriverRepository, driverRepository as unknown as IDriverRepository,
imageService as unknown as IImageServicePort, getDriverAvatar,
logger as unknown as Logger, logger as unknown as Logger,
); );
}); });
@@ -73,7 +68,11 @@ describe('GetTeamJoinRequestsUseCase', () => {
if (id === 'driver-2') return Promise.resolve(driver2); if (id === 'driver-2') return Promise.resolve(driver2);
return Promise.resolve(null); return Promise.resolve(null);
}); });
imageService.getDriverAvatar.mockImplementation((id: string) => `avatar-${id}`); getDriverAvatar.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ avatarUrl: 'avatar-driver-1' });
if (input.driverId === 'driver-2') return Promise.resolve({ avatarUrl: 'avatar-driver-2' });
return Promise.resolve({ avatarUrl: 'avatar-default' });
});
const result = await useCase.execute({ teamId }); const result = await useCase.execute({ teamId });
@@ -99,7 +98,7 @@ describe('GetTeamJoinRequestsUseCase', () => {
membershipRepository.getJoinRequests.mockResolvedValue(joinRequests); membershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
driverRepository.findById.mockResolvedValue(null); driverRepository.findById.mockResolvedValue(null);
imageService.getDriverAvatar.mockReturnValue('avatar-driver-1'); getDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver-1' });
const result = await useCase.execute({ teamId }); const result = await useCase.execute({ teamId });

View File

@@ -1,6 +1,7 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IImageServicePort } from '../ports/IImageServicePort'; import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { TeamJoinRequestsResultDTO } from '../presenters/ITeamJoinRequestsPresenter'; import type { TeamJoinRequestsResultDTO } from '../presenters/ITeamJoinRequestsPresenter';
import { Result } from '@core/shared/application/Result'; import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
@@ -15,7 +16,7 @@ export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string
constructor( constructor(
private readonly membershipRepository: ITeamMembershipRepository, private readonly membershipRepository: ITeamMembershipRepository,
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
private readonly imageService: IImageServicePort, private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
@@ -36,7 +37,9 @@ export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string
} else { } else {
this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`); this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`);
} }
avatarUrls[request.driverId] = this.imageService.getDriverAvatar(request.driverId);
const avatarResult = await this.getDriverAvatar({ driverId: request.driverId });
avatarUrls[request.driverId] = avatarResult.avatarUrl;
this.logger.debug('Processed driver details for join request', { driverId: request.driverId }); this.logger.debug('Processed driver details for join request', { driverId: request.driverId });
} }

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetTeamMembersUseCase } from './GetTeamMembersUseCase'; import { GetTeamMembersUseCase } from './GetTeamMembersUseCase';
import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { IImageServicePort } from '../ports/IImageServicePort';
import { Driver } from '../../domain/entities/Driver'; import { Driver } from '../../domain/entities/Driver';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
@@ -14,9 +13,7 @@ describe('GetTeamMembersUseCase', () => {
let driverRepository: { let driverRepository: {
findById: Mock; findById: Mock;
}; };
let imageService: { let getDriverAvatar: Mock;
getDriverAvatar: Mock;
};
let logger: { let logger: {
debug: Mock; debug: Mock;
info: Mock; info: Mock;
@@ -31,9 +28,7 @@ describe('GetTeamMembersUseCase', () => {
driverRepository = { driverRepository = {
findById: vi.fn(), findById: vi.fn(),
}; };
imageService = { getDriverAvatar = vi.fn();
getDriverAvatar: vi.fn(),
};
logger = { logger = {
debug: vi.fn(), debug: vi.fn(),
info: vi.fn(), info: vi.fn(),
@@ -43,7 +38,7 @@ describe('GetTeamMembersUseCase', () => {
useCase = new GetTeamMembersUseCase( useCase = new GetTeamMembersUseCase(
membershipRepository as unknown as ITeamMembershipRepository, membershipRepository as unknown as ITeamMembershipRepository,
driverRepository as unknown as IDriverRepository, driverRepository as unknown as IDriverRepository,
imageService as unknown as IImageServicePort, getDriverAvatar,
logger as unknown as Logger, logger as unknown as Logger,
); );
}); });
@@ -73,7 +68,11 @@ describe('GetTeamMembersUseCase', () => {
if (id === 'driver-2') return Promise.resolve(driver2); if (id === 'driver-2') return Promise.resolve(driver2);
return Promise.resolve(null); return Promise.resolve(null);
}); });
imageService.getDriverAvatar.mockImplementation((id: string) => `avatar-${id}`); getDriverAvatar.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ avatarUrl: 'avatar-driver-1' });
if (input.driverId === 'driver-2') return Promise.resolve({ avatarUrl: 'avatar-driver-2' });
return Promise.resolve({ avatarUrl: 'avatar-default' });
});
const result = await useCase.execute({ teamId }); const result = await useCase.execute({ teamId });
@@ -99,7 +98,7 @@ describe('GetTeamMembersUseCase', () => {
membershipRepository.getTeamMembers.mockResolvedValue(memberships); membershipRepository.getTeamMembers.mockResolvedValue(memberships);
driverRepository.findById.mockResolvedValue(null); driverRepository.findById.mockResolvedValue(null);
imageService.getDriverAvatar.mockReturnValue('avatar-driver-1'); getDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver-1' });
const result = await useCase.execute({ teamId }); const result = await useCase.execute({ teamId });

339
docs/architecture/ENUMS.md Normal file
View File

@@ -0,0 +1,339 @@
Enums in Clean Architecture (Strict & Final)
This document defines how enums are modeled, placed, and used in a strict Clean Architecture setup.
Enums are one of the most common sources of architectural leakage. This guide removes all ambiguity.
1. Core Principle
Enums represent knowledge.
Knowledge must live where it is true.
Therefore:
• Not every enum is a domain enum
• Enums must never cross architectural boundaries blindly
• Ports must remain neutral
2. Enum Categories (Authoritative)
There are four and only four valid enum categories:
1. Domain Enums
2. Application (Workflow) Enums
3. Transport Enums (API)
4. UI Enums (Frontend)
Each category has strict placement and usage rules.
3. Domain Enums
Definition
A Domain Enum represents a business concept that:
• has meaning in the domain
• affects rules or invariants
• is part of the ubiquitous language
Examples:
• LeagueVisibility
• MembershipRole
• RaceStatus
• SponsorshipTier
• PenaltyType
Placement
core/<context>/domain/
├── value-objects/
│ └── LeagueVisibility.ts
└── entities/
Preferred: model domain enums as Value Objects instead of enum keywords.
Example (Value Object)
export class LeagueVisibility {
private constructor(private readonly value: 'public' | 'private') {}
static from(value: string): LeagueVisibility {
if (value !== 'public' && value !== 'private') {
throw new DomainError('Invalid LeagueVisibility');
}
return new LeagueVisibility(value);
}
isPublic(): boolean {
return this.value === 'public';
}
}
Usage Rules
Allowed:
• Domain
• Use Cases
Forbidden:
• Ports
• Adapters
• API DTOs
• Frontend
Domain enums must never cross a Port boundary.
4. Application Enums (Workflow Enums)
Definition
Application Enums represent internal workflow or state coordination.
They are not business truth and must not leak.
Examples:
• LeagueSetupStep
• ImportPhase
• ProcessingState
Placement
core/<context>/application/internal/
└── LeagueSetupStep.ts
Example
export enum LeagueSetupStep {
CreateLeague,
CreateSeason,
AssignOwner,
Notify
}
Usage Rules
Allowed:
• Application Services
• Use Cases
Forbidden:
• Domain
• Ports
• Adapters
• Frontend
These enums must remain strictly internal.
5. Transport Enums (API DTOs)
Definition
Transport Enums describe allowed values in HTTP contracts.
They exist purely to constrain transport data, not to encode business rules.
Naming rule:
Transport enums MUST end with Enum.
This makes enums immediately recognizable in code reviews and prevents silent leakage.
Examples:
• LeagueVisibilityEnum
• SponsorshipStatusEnum
• PenaltyTypeEnum
Placement
apps/api/<feature>/dto/
└── LeagueVisibilityEnum.ts
Website mirrors the same naming:
apps/website/lib/dtos/
└── LeagueVisibilityEnum.ts
Example
export enum LeagueVisibilityEnum {
Public = 'public',
Private = 'private'
}
Usage Rules
Allowed:
• API Controllers
• API Presenters
• Website API DTOs
Forbidden:
• Core Domain
• Use Cases
Transport enums are copies, never reexports of domain enums.
Placement
apps/api/<feature>/dto/
└── LeagueVisibilityDto.ts
or inline as union types in DTOs.
Example
export type LeagueVisibilityDto = 'public' | 'private';
Usage Rules
Allowed:
• API Controllers
• API Presenters
• Website API DTOs
Forbidden:
• Core Domain
• Use Cases
Transport enums are copies, never reexports of domain enums.
6. UI Enums (Frontend)
Definition
UI Enums describe presentation or interaction state.
They have no business meaning.
Examples:
• WizardStep
• SortOrder
• ViewMode
• TabKey
Placement
apps/website/lib/ui/
└── LeagueWizardStep.ts
Example
export enum LeagueWizardStep {
Basics,
Structure,
Scoring,
Review
}
Usage Rules
Allowed:
• Frontend only
Forbidden:
• Core
• API
7. Absolute Prohibitions
❌ Enums in Ports
// ❌ forbidden
export interface CreateLeagueInputPort {
visibility: LeagueVisibility;
}
✅ Correct
export interface CreateLeagueInputPort {
visibility: 'public' | 'private';
}
Mapping happens inside the Use Case:
const visibility = LeagueVisibility.from(input.visibility);
8. Decision Checklist
Ask these questions:
1. Does changing this enum change business rules?
• Yes → Domain Enum
• No → continue
2. Is it only needed for internal workflow coordination?
• Yes → Application Enum
• No → continue
3. Is it part of an HTTP contract?
• Yes → Transport Enum
• No → continue
4. Is it purely for UI state?
• Yes → UI Enum
9. Summary Table
Enum Type Location May Cross Ports Scope
Domain Enum core/domain ❌ No Business rules
Application Enum core/application ❌ No Workflow only
Transport Enum apps/api + website ❌ No HTTP contracts
UI Enum apps/website ❌ No Presentation only
10. Final Rule (Non-Negotiable)
If an enum crosses a boundary, it is in the wrong place.
This rule alone prevents most long-term architectural decay.