diff --git a/core/racing/application/ports/input/AcceptSponsorshipInputPort.ts b/core/racing/application/ports/input/AcceptSponsorshipInputPort.ts deleted file mode 100644 index 07fc2ea24..000000000 --- a/core/racing/application/ports/input/AcceptSponsorshipInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface AcceptSponsorshipInputPort { - requestId: string; - respondedBy: string; // driverId of the person accepting -} \ No newline at end of file diff --git a/core/racing/application/ports/input/ApproveLeagueJoinRequestInputPort.ts b/core/racing/application/ports/input/ApproveLeagueJoinRequestInputPort.ts deleted file mode 100644 index 467aa6310..000000000 --- a/core/racing/application/ports/input/ApproveLeagueJoinRequestInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ApproveLeagueJoinRequestInputPort { - leagueId: string; - requestId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/ApproveTeamJoinRequestInputPort.ts b/core/racing/application/ports/input/ApproveTeamJoinRequestInputPort.ts deleted file mode 100644 index 29fe30fe3..000000000 --- a/core/racing/application/ports/input/ApproveTeamJoinRequestInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ApproveTeamJoinRequestInputPort { - teamId: string; - requestId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CancelRaceInputPort.ts b/core/racing/application/ports/input/CancelRaceInputPort.ts deleted file mode 100644 index 90a41eb35..000000000 --- a/core/racing/application/ports/input/CancelRaceInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CancelRaceInputPort { - raceId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CloseRaceEventStewardingInputPort.ts b/core/racing/application/ports/input/CloseRaceEventStewardingInputPort.ts deleted file mode 100644 index 51365537c..000000000 --- a/core/racing/application/ports/input/CloseRaceEventStewardingInputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Command for closing race event stewarding. - * - * Scheduled job that checks for race events with expired stewarding windows - * and closes them, triggering final results notifications. - */ -export interface CloseRaceEventStewardingInputPort { - // No parameters needed - finds all expired events automatically -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CompleteDriverOnboardingInputPort.ts b/core/racing/application/ports/input/CompleteDriverOnboardingInputPort.ts deleted file mode 100644 index 8db977849..000000000 --- a/core/racing/application/ports/input/CompleteDriverOnboardingInputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface CompleteDriverOnboardingInputPort { - userId: string; - firstName: string; - lastName: string; - displayName: string; - country: string; - timezone?: string; - bio?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CompleteRaceInputPort.ts b/core/racing/application/ports/input/CompleteRaceInputPort.ts deleted file mode 100644 index 7f6a6e947..000000000 --- a/core/racing/application/ports/input/CompleteRaceInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CompleteRaceInputPort { - raceId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CreateLeagueWithSeasonAndScoringInputPort.ts b/core/racing/application/ports/input/CreateLeagueWithSeasonAndScoringInputPort.ts deleted file mode 100644 index cc2022b3e..000000000 --- a/core/racing/application/ports/input/CreateLeagueWithSeasonAndScoringInputPort.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface CreateLeagueWithSeasonAndScoringInputPort { - name: string; - description?: string; - /** - * League visibility/ranking mode. - * - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers. - * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. - */ - visibility: 'ranked' | 'unranked' | 'public' | 'private'; - ownerId: string; - gameId: string; - maxDrivers?: number; - maxTeams?: number; - enableDriverChampionship: boolean; - enableTeamChampionship: boolean; - enableNationsChampionship: boolean; - enableTrophyChampionship: boolean; - scoringPresetId?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CreateSponsorInputPort.ts b/core/racing/application/ports/input/CreateSponsorInputPort.ts deleted file mode 100644 index fd281645b..000000000 --- a/core/racing/application/ports/input/CreateSponsorInputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface CreateSponsorInputPort { - name: string; - contactEmail: string; - websiteUrl?: string; - logoUrl?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/CreateTeamInputPort.ts b/core/racing/application/ports/input/CreateTeamInputPort.ts deleted file mode 100644 index 5db10dc8a..000000000 --- a/core/racing/application/ports/input/CreateTeamInputPort.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface CreateTeamInputPort { - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/DashboardOverviewInputPort.ts b/core/racing/application/ports/input/DashboardOverviewInputPort.ts deleted file mode 100644 index 253adbaaa..000000000 --- a/core/racing/application/ports/input/DashboardOverviewInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DashboardOverviewInputPort { - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/DriverTeamInputPort.ts b/core/racing/application/ports/input/DriverTeamInputPort.ts deleted file mode 100644 index 90b3caebe..000000000 --- a/core/racing/application/ports/input/DriverTeamInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DriverTeamInputPort { - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/FileProtestInputPort.ts b/core/racing/application/ports/input/FileProtestInputPort.ts deleted file mode 100644 index 39499dd40..000000000 --- a/core/racing/application/ports/input/FileProtestInputPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface FileProtestInputPort { - raceId: string; - protestingDriverId: string; - accusedDriverId: string; - incident: { - sessionType: string; - lapNumber: number; - cornerNumber?: number; - description: string; - severity: 'minor' | 'major' | 'severe'; - }; - comment?: string; - proofVideoUrl?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetDriverAvatarInputPort.ts b/core/racing/application/ports/input/GetDriverAvatarInputPort.ts deleted file mode 100644 index 3a04c4691..000000000 --- a/core/racing/application/ports/input/GetDriverAvatarInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetDriverAvatarInputPort { - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetDriverRatingInputPort.ts b/core/racing/application/ports/input/GetDriverRatingInputPort.ts deleted file mode 100644 index 16d26ff3a..000000000 --- a/core/racing/application/ports/input/GetDriverRatingInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetDriverRatingInputPort { - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetEntitySponsorshipPricingInputPort.ts b/core/racing/application/ports/input/GetEntitySponsorshipPricingInputPort.ts deleted file mode 100644 index 57763ee9d..000000000 --- a/core/racing/application/ports/input/GetEntitySponsorshipPricingInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GetEntitySponsorshipPricingInputPort { - entityType: 'league' | 'team' | 'driver'; - entityId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetLeagueCoverInputPort.ts b/core/racing/application/ports/input/GetLeagueCoverInputPort.ts deleted file mode 100644 index f694b10fd..000000000 --- a/core/racing/application/ports/input/GetLeagueCoverInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueCoverInputPort { - leagueId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetLeagueLogoInputPort.ts b/core/racing/application/ports/input/GetLeagueLogoInputPort.ts deleted file mode 100644 index 308ec5b5a..000000000 --- a/core/racing/application/ports/input/GetLeagueLogoInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueLogoInputPort { - leagueId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetLeagueScoringPresetByIdInputPort.ts b/core/racing/application/ports/input/GetLeagueScoringPresetByIdInputPort.ts deleted file mode 100644 index aa42b9c52..000000000 --- a/core/racing/application/ports/input/GetLeagueScoringPresetByIdInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueScoringPresetByIdInputPort { - presetId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/GetTeamLogoInputPort.ts b/core/racing/application/ports/input/GetTeamLogoInputPort.ts deleted file mode 100644 index de7b69cae..000000000 --- a/core/racing/application/ports/input/GetTeamLogoInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetTeamLogoInputPort { - teamId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/IsDriverRegisteredForRaceInputPort.ts b/core/racing/application/ports/input/IsDriverRegisteredForRaceInputPort.ts deleted file mode 100644 index 4bdb62cf8..000000000 --- a/core/racing/application/ports/input/IsDriverRegisteredForRaceInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IsDriverRegisteredForRaceInputPort { - raceId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/JoinLeagueInputPort.ts b/core/racing/application/ports/input/JoinLeagueInputPort.ts deleted file mode 100644 index 98cc5a709..000000000 --- a/core/racing/application/ports/input/JoinLeagueInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface JoinLeagueInputPort { - leagueId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/JoinTeamInputPort.ts b/core/racing/application/ports/input/JoinTeamInputPort.ts deleted file mode 100644 index ff5da8455..000000000 --- a/core/racing/application/ports/input/JoinTeamInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface JoinTeamInputPort { - teamId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/LeagueJoinRequestsInputPort.ts b/core/racing/application/ports/input/LeagueJoinRequestsInputPort.ts deleted file mode 100644 index f17897b08..000000000 --- a/core/racing/application/ports/input/LeagueJoinRequestsInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface LeagueJoinRequestsInputPort { - leagueId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/LeagueVisibilityInputPort.ts b/core/racing/application/ports/input/LeagueVisibilityInputPort.ts deleted file mode 100644 index e47b50b84..000000000 --- a/core/racing/application/ports/input/LeagueVisibilityInputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * League visibility/ranking mode. - * - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers. - * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. - */ -export interface LeagueVisibilityInputPort { - visibility: 'ranked' | 'unranked' | 'public' | 'private'; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/LeaveTeamInputPort.ts b/core/racing/application/ports/input/LeaveTeamInputPort.ts deleted file mode 100644 index 848b02265..000000000 --- a/core/racing/application/ports/input/LeaveTeamInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface LeaveTeamInputPort { - teamId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/ListLeagueScoringPresetsInputPort.ts b/core/racing/application/ports/input/ListLeagueScoringPresetsInputPort.ts deleted file mode 100644 index d8853845f..000000000 --- a/core/racing/application/ports/input/ListLeagueScoringPresetsInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ListLeagueScoringPresetsInputPort { - // Empty interface for query with no parameters -} \ No newline at end of file diff --git a/core/racing/application/ports/input/ProcessPaymentInputPort.ts b/core/racing/application/ports/input/ProcessPaymentInputPort.ts deleted file mode 100644 index e78ee75d3..000000000 --- a/core/racing/application/ports/input/ProcessPaymentInputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface ProcessPaymentInputPort { - amount: number; // in cents - payerId: string; - description: string; - metadata?: Record; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/RaceRegistrationInputPort.ts b/core/racing/application/ports/input/RaceRegistrationInputPort.ts deleted file mode 100644 index d47c665e4..000000000 --- a/core/racing/application/ports/input/RaceRegistrationInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetRaceRegistrationsInputPort { - raceId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/RefundPaymentInputPort.ts b/core/racing/application/ports/input/RefundPaymentInputPort.ts deleted file mode 100644 index a2ad4324f..000000000 --- a/core/racing/application/ports/input/RefundPaymentInputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface RefundPaymentInputPort { - originalTransactionId: string; - amount: { - value: number; - currency: string; - }; - reason: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/RegisterForRaceInputPort.ts b/core/racing/application/ports/input/RegisterForRaceInputPort.ts deleted file mode 100644 index 369cb9e4a..000000000 --- a/core/racing/application/ports/input/RegisterForRaceInputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface RegisterForRaceInputPort { - raceId: string; - leagueId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/RejectTeamJoinRequestInputPort.ts b/core/racing/application/ports/input/RejectTeamJoinRequestInputPort.ts deleted file mode 100644 index 6339f2003..000000000 --- a/core/racing/application/ports/input/RejectTeamJoinRequestInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface RejectTeamJoinRequestInputPort { - requestId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/TeamDetailsInputPort.ts b/core/racing/application/ports/input/TeamDetailsInputPort.ts deleted file mode 100644 index d23125957..000000000 --- a/core/racing/application/ports/input/TeamDetailsInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface TeamDetailsInputPort { - teamId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/TeamJoinRequestsInputPort.ts b/core/racing/application/ports/input/TeamJoinRequestsInputPort.ts deleted file mode 100644 index 491f05b7e..000000000 --- a/core/racing/application/ports/input/TeamJoinRequestsInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TeamJoinRequestsInputPort { - teamId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/TeamMembersInputPort.ts b/core/racing/application/ports/input/TeamMembersInputPort.ts deleted file mode 100644 index f334572e8..000000000 --- a/core/racing/application/ports/input/TeamMembersInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TeamMembersInputPort { - teamId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/UpdateTeamInputPort.ts b/core/racing/application/ports/input/UpdateTeamInputPort.ts deleted file mode 100644 index be5cfa0ab..000000000 --- a/core/racing/application/ports/input/UpdateTeamInputPort.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface UpdateTeamInputPort { - teamId: string; - updates: { - name?: string; - tag?: string; - description?: string; - leagues?: string[]; - }; - updatedBy: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/VerifyPaymentInputPort.ts b/core/racing/application/ports/input/VerifyPaymentInputPort.ts deleted file mode 100644 index 281c5395a..000000000 --- a/core/racing/application/ports/input/VerifyPaymentInputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface VerifyPaymentInputPort { - transactionId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/input/WithdrawFromRaceInputPort.ts b/core/racing/application/ports/input/WithdrawFromRaceInputPort.ts deleted file mode 100644 index 1cf357ad3..000000000 --- a/core/racing/application/ports/input/WithdrawFromRaceInputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface WithdrawFromRaceInputPort { - raceId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/AcceptSponsorshipOutputPort.ts b/core/racing/application/ports/output/AcceptSponsorshipOutputPort.ts deleted file mode 100644 index f9cfe47d4..000000000 --- a/core/racing/application/ports/output/AcceptSponsorshipOutputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface AcceptSponsorshipOutputPort { - requestId: string; - sponsorshipId: string; - status: 'accepted'; - acceptedAt: Date; - platformFee: number; - netAmount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/AllLeaguesWithCapacityAndScoringOutputPort.ts b/core/racing/application/ports/output/AllLeaguesWithCapacityAndScoringOutputPort.ts deleted file mode 100644 index 87eee4261..000000000 --- a/core/racing/application/ports/output/AllLeaguesWithCapacityAndScoringOutputPort.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { League } from '../../../domain/entities/League'; -import type { Season } from '../../../domain/entities/season/Season'; -import type { LeagueScoringConfig } from '../../../domain/entities/LeagueScoringConfig'; -import type { Game } from '../../../domain/entities/Game'; -import type { LeagueScoringPresetOutputPort } from './LeagueScoringPresetOutputPort'; - -export interface LeagueEnrichedData { - league: League; - usedDriverSlots: number; - season?: Season; - scoringConfig?: LeagueScoringConfig; - game?: Game; - preset?: LeagueScoringPresetOutputPort; -} - -export interface AllLeaguesWithCapacityAndScoringOutputPort { - leagues: LeagueEnrichedData[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/AllLeaguesWithCapacityOutputPort.ts b/core/racing/application/ports/output/AllLeaguesWithCapacityOutputPort.ts deleted file mode 100644 index 2fedf476b..000000000 --- a/core/racing/application/ports/output/AllLeaguesWithCapacityOutputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { League } from '../../domain/entities/League'; - -export interface AllLeaguesWithCapacityOutputPort { - leagues: League[]; - memberCounts: Record; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/AllRacesPageOutputPort.ts b/core/racing/application/ports/output/AllRacesPageOutputPort.ts deleted file mode 100644 index 29722bf6f..000000000 --- a/core/racing/application/ports/output/AllRacesPageOutputPort.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type AllRacesStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; - -export interface AllRacesListItem { - id: string; - track: string; - car: string; - scheduledAt: string; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - leagueId: string; - leagueName: string; - strengthOfField: number | null; -} - -export interface AllRacesFilterOptions { - statuses: { value: AllRacesStatus; label: string }[]; - leagues: { id: string; name: string }[]; -} - -export interface AllRacesPageOutputPort { - races: AllRacesListItem[]; - filters: AllRacesFilterOptions; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ApplyForSponsorshipResultPort.ts b/core/racing/application/ports/output/ApplyForSponsorshipResultPort.ts deleted file mode 100644 index 05d0e5076..000000000 --- a/core/racing/application/ports/output/ApplyForSponsorshipResultPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface ApplyForSponsorshipResultPort { - requestId: string; - status: 'pending'; - createdAt: Date; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ApproveLeagueJoinRequestOutputPort.ts b/core/racing/application/ports/output/ApproveLeagueJoinRequestOutputPort.ts deleted file mode 100644 index c9f8f1673..000000000 --- a/core/racing/application/ports/output/ApproveLeagueJoinRequestOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ApproveLeagueJoinRequestOutputPort { - success: boolean; - message: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort.ts b/core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort.ts deleted file mode 100644 index 116a0f58c..000000000 --- a/core/racing/application/ports/output/ApproveLeagueJoinRequestResultPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface ApproveLeagueJoinRequestResultPort { - success: boolean; - message: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ChampionshipStandingsOutputPort.ts b/core/racing/application/ports/output/ChampionshipStandingsOutputPort.ts deleted file mode 100644 index f9c550943..000000000 --- a/core/racing/application/ports/output/ChampionshipStandingsOutputPort.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface ChampionshipStandingsOutputPort { - seasonId: string; - championshipId: string; - championshipName: string; - rows: { - participant: { - id: string; - type: 'driver' | 'team'; - name: string; - }; - position: number; - totalPoints: number; - resultsCounted: number; - resultsDropped: number; - }[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ChampionshipStandingsRowOutputPort.ts b/core/racing/application/ports/output/ChampionshipStandingsRowOutputPort.ts deleted file mode 100644 index c025c4a52..000000000 --- a/core/racing/application/ports/output/ChampionshipStandingsRowOutputPort.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ChampionshipStandingsRowOutputPort { - participant: { - id: string; - type: 'driver' | 'team'; - name: string; - }; - position: number; - totalPoints: number; - resultsCounted: number; - resultsDropped: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/CompleteDriverOnboardingOutputPort.ts b/core/racing/application/ports/output/CompleteDriverOnboardingOutputPort.ts deleted file mode 100644 index 2d84a56a1..000000000 --- a/core/racing/application/ports/output/CompleteDriverOnboardingOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface CompleteDriverOnboardingOutputPort { - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/CreateLeagueOutputPort.ts b/core/racing/application/ports/output/CreateLeagueOutputPort.ts deleted file mode 100644 index be4f7f99a..000000000 --- a/core/racing/application/ports/output/CreateLeagueOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface CreateLeagueOutputPort { - leagueId: string; - success: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/CreateLeagueWithSeasonAndScoringOutputPort.ts b/core/racing/application/ports/output/CreateLeagueWithSeasonAndScoringOutputPort.ts deleted file mode 100644 index 6b9a99459..000000000 --- a/core/racing/application/ports/output/CreateLeagueWithSeasonAndScoringOutputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface CreateLeagueWithSeasonAndScoringOutputPort { - leagueId: string; - seasonId: string; - scoringPresetId?: string; - scoringPresetName?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/CreateSponsorOutputPort.ts b/core/racing/application/ports/output/CreateSponsorOutputPort.ts deleted file mode 100644 index bf3fa2a9c..000000000 --- a/core/racing/application/ports/output/CreateSponsorOutputPort.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface CreateSponsorOutputPort { - sponsor: { - id: string; - name: string; - contactEmail: string; - websiteUrl?: string; - logoUrl?: string; - createdAt: Date; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/CreateTeamOutputPort.ts b/core/racing/application/ports/output/CreateTeamOutputPort.ts deleted file mode 100644 index 29a4024fb..000000000 --- a/core/racing/application/ports/output/CreateTeamOutputPort.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface CreateTeamOutputPort { - team: { - id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - createdAt: Date; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DashboardOverviewOutputPort.ts b/core/racing/application/ports/output/DashboardOverviewOutputPort.ts deleted file mode 100644 index 4a3bff1c7..000000000 --- a/core/racing/application/ports/output/DashboardOverviewOutputPort.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { FeedItemType } from '@core/social/domain/types/FeedItemType'; - -export interface DashboardDriverSummaryOutputPort { - id: string; - name: string; - country: string; - avatarUrl: string; - rating: number | null; - globalRank: number | null; - totalRaces: number; - wins: number; - podiums: number; - consistency: number | null; -} - -export interface DashboardRaceSummaryOutputPort { - id: string; - leagueId: string; - leagueName: string; - track: string; - car: string; - scheduledAt: string; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - isMyLeague: boolean; -} - -export interface DashboardRecentResultOutputPort { - raceId: string; - raceName: string; - leagueId: string; - leagueName: string; - finishedAt: string; - position: number; - incidents: number; -} - -export interface DashboardLeagueStandingSummaryOutputPort { - leagueId: string; - leagueName: string; - position: number; - totalDrivers: number; - points: number; -} - -export interface DashboardFeedItemSummaryOutputPort { - id: string; - type: FeedItemType; - headline: string; - body?: string; - timestamp: string; - ctaLabel?: string; - ctaHref?: string; -} - -export interface DashboardFeedSummaryOutputPort { - notificationCount: number; - items: DashboardFeedItemSummaryOutputPort[]; -} - -export interface DashboardFriendSummaryOutputPort { - id: string; - name: string; - country: string; - avatarUrl: string; -} - -export interface DashboardOverviewOutputPort { - currentDriver: DashboardDriverSummaryOutputPort | null; - myUpcomingRaces: DashboardRaceSummaryOutputPort[]; - otherUpcomingRaces: DashboardRaceSummaryOutputPort[]; - /** - * All upcoming races for the driver, already sorted by scheduledAt ascending. - */ - upcomingRaces: DashboardRaceSummaryOutputPort[]; - /** - * Count of distinct leagues that are currently "active" for the driver, - * based on upcoming races and league standings. - */ - activeLeaguesCount: number; - nextRace: DashboardRaceSummaryOutputPort | null; - recentResults: DashboardRecentResultOutputPort[]; - leagueStandingsSummaries: DashboardLeagueStandingSummaryOutputPort[]; - feedSummary: DashboardFeedSummaryOutputPort; - friends: DashboardFriendSummaryOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DriverOutputPort.ts b/core/racing/application/ports/output/DriverOutputPort.ts deleted file mode 100644 index 1c953bb67..000000000 --- a/core/racing/application/ports/output/DriverOutputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface DriverOutputPort { - id: string; - iracingId: string; - name: string; - country: string; - bio?: string; - joinedAt: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DriverRegistrationStatusOutputPort.ts b/core/racing/application/ports/output/DriverRegistrationStatusOutputPort.ts deleted file mode 100644 index bab15b1d3..000000000 --- a/core/racing/application/ports/output/DriverRegistrationStatusOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface DriverRegistrationStatusOutputPort { - isRegistered: boolean; - raceId: string; - driverId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DriverSummaryOutputPort.ts b/core/racing/application/ports/output/DriverSummaryOutputPort.ts deleted file mode 100644 index 083419779..000000000 --- a/core/racing/application/ports/output/DriverSummaryOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface DriverOutputPort { - id: string; - name: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DriverTeamOutputPort.ts b/core/racing/application/ports/output/DriverTeamOutputPort.ts deleted file mode 100644 index d266cbfc6..000000000 --- a/core/racing/application/ports/output/DriverTeamOutputPort.ts +++ /dev/null @@ -1,19 +0,0 @@ -export interface DriverTeamOutputPort { - driverId: string; - team: { - id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - createdAt: Date; - }; - membership: { - teamId: string; - driverId: string; - role: 'owner' | 'manager' | 'driver'; - status: 'active' | 'pending' | 'none'; - joinedAt: Date; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/DriversLeaderboardOutputPort.ts b/core/racing/application/ports/output/DriversLeaderboardOutputPort.ts deleted file mode 100644 index a464d0cc7..000000000 --- a/core/racing/application/ports/output/DriversLeaderboardOutputPort.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { SkillLevel } from '../../domain/services/SkillLevelService'; - -export interface DriverLeaderboardItemOutputPort { - id: string; - name: string; - rating: number; - skillLevel: SkillLevel; - nationality: string; - racesCompleted: number; - wins: number; - podiums: number; - isActive: boolean; - rank: number; - avatarUrl: string; -} - -export interface DriversLeaderboardOutputPort { - drivers: DriverLeaderboardItemOutputPort[]; - totalRaces: number; - totalWins: number; - activeCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetAllRacesOutputPort.ts b/core/racing/application/ports/output/GetAllRacesOutputPort.ts deleted file mode 100644 index 3ef247248..000000000 --- a/core/racing/application/ports/output/GetAllRacesOutputPort.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface GetAllRacesOutputPort { - races: { - id: string; - leagueId: string; - track: string; - car: string; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - scheduledAt: string; - strengthOfField: number | null; - leagueName: string; - }[]; - totalCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetAllTeamsOutputPort.ts b/core/racing/application/ports/output/GetAllTeamsOutputPort.ts deleted file mode 100644 index b4a43a655..000000000 --- a/core/racing/application/ports/output/GetAllTeamsOutputPort.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface GetAllTeamsOutputPort { - teams: Array<{ - id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - createdAt: Date; - memberCount: number; - }>; - totalCount?: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetDriverAvatarOutputPort.ts b/core/racing/application/ports/output/GetDriverAvatarOutputPort.ts deleted file mode 100644 index 0fc7fb4ff..000000000 --- a/core/racing/application/ports/output/GetDriverAvatarOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetDriverAvatarOutputPort { - avatarUrl: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetDriverRatingOutputPort.ts b/core/racing/application/ports/output/GetDriverRatingOutputPort.ts deleted file mode 100644 index eb8663618..000000000 --- a/core/racing/application/ports/output/GetDriverRatingOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GetDriverRatingOutputPort { - rating: number | null; - ratingChange: number | null; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetEntitySponsorshipPricingOutputPort.ts b/core/racing/application/ports/output/GetEntitySponsorshipPricingOutputPort.ts deleted file mode 100644 index dc956a154..000000000 --- a/core/racing/application/ports/output/GetEntitySponsorshipPricingOutputPort.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface GetEntitySponsorshipPricingOutputPort { - entityType: 'league' | 'team' | 'driver'; - entityId: string; - acceptingApplications: boolean; - customRequirements?: string; - mainSlot?: { - 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; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueAdminOutputPort.ts b/core/racing/application/ports/output/GetLeagueAdminOutputPort.ts deleted file mode 100644 index f1cdc7c48..000000000 --- a/core/racing/application/ports/output/GetLeagueAdminOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GetLeagueAdminOutputPort { - leagueId: string; - ownerId: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueAdminPermissionsOutputPort.ts b/core/racing/application/ports/output/GetLeagueAdminPermissionsOutputPort.ts deleted file mode 100644 index 45b35179b..000000000 --- a/core/racing/application/ports/output/GetLeagueAdminPermissionsOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GetLeagueAdminPermissionsOutputPort { - canRemoveMember: boolean; - canUpdateRoles: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueCoverOutputPort.ts b/core/racing/application/ports/output/GetLeagueCoverOutputPort.ts deleted file mode 100644 index db1187e32..000000000 --- a/core/racing/application/ports/output/GetLeagueCoverOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueCoverOutputPort { - coverUrl: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueJoinRequestsOutputPort.ts b/core/racing/application/ports/output/GetLeagueJoinRequestsOutputPort.ts deleted file mode 100644 index 4a9a929a5..000000000 --- a/core/racing/application/ports/output/GetLeagueJoinRequestsOutputPort.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface LeagueJoinRequestOutputPort { - id: string; - leagueId: string; - driverId: string; - requestedAt: Date; - message: string; - driver: { id: string; name: string } | null; -} - -export interface GetLeagueJoinRequestsOutputPort { - joinRequests: LeagueJoinRequestOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueLogoOutputPort.ts b/core/racing/application/ports/output/GetLeagueLogoOutputPort.ts deleted file mode 100644 index e7af07fc9..000000000 --- a/core/racing/application/ports/output/GetLeagueLogoOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueLogoOutputPort { - logoUrl: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueMembershipsOutputPort.ts b/core/racing/application/ports/output/GetLeagueMembershipsOutputPort.ts deleted file mode 100644 index 7b27d3ee9..000000000 --- a/core/racing/application/ports/output/GetLeagueMembershipsOutputPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface LeagueMembershipOutputPort { - driverId: string; - driver: { id: string; name: string }; - role: string; - joinedAt: Date; -} - -export interface LeagueMembershipsOutputPort { - members: LeagueMembershipOutputPort[]; -} - -export interface GetLeagueMembershipsOutputPort { - memberships: LeagueMembershipsOutputPort; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort.ts b/core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort.ts deleted file mode 100644 index ac24498b5..000000000 --- a/core/racing/application/ports/output/GetLeagueOwnerSummaryOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface LeagueOwnerSummaryOutputPort { - driver: { id: string; iracingId: string; name: string; country: string; bio: string | undefined; joinedAt: string }; - rating: number; - rank: number; -} - -export interface GetLeagueOwnerSummaryOutputPort { - summary: LeagueOwnerSummaryOutputPort | null; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueProtestsOutputPort.ts b/core/racing/application/ports/output/GetLeagueProtestsOutputPort.ts deleted file mode 100644 index f8642431d..000000000 --- a/core/racing/application/ports/output/GetLeagueProtestsOutputPort.ts +++ /dev/null @@ -1,47 +0,0 @@ -export interface ProtestOutputPort { - id: string; - raceId: string; - protestingDriverId: string; - accusedDriverId: string; - incident: { lap: number; description: string; timeInRace: number | undefined }; - comment: string | undefined; - proofVideoUrl: string | undefined; - status: string; - reviewedBy: string | undefined; - decisionNotes: string | undefined; - filedAt: string; - reviewedAt: string | undefined; - defense: { statement: string; videoUrl: string | undefined; submittedAt: string } | undefined; - defenseRequestedAt: string | undefined; - defenseRequestedBy: string | undefined; -} - -export interface RaceOutputPort { - id: string; - leagueId: string; - scheduledAt: string; - track: string; - trackId: string | undefined; - car: string; - carId: string | undefined; - sessionType: string; - status: string; - strengthOfField: number | undefined; - registeredCount: number | undefined; - maxParticipants: number | undefined; -} - -export interface DriverOutputPort { - id: string; - iracingId: string; - name: string; - country: string; - bio: string | undefined; - joinedAt: string; -} - -export interface GetLeagueProtestsOutputPort { - protests: ProtestOutputPort[]; - racesById: Record; - driversById: Record; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueScheduleOutputPort.ts b/core/racing/application/ports/output/GetLeagueScheduleOutputPort.ts deleted file mode 100644 index 816075048..000000000 --- a/core/racing/application/ports/output/GetLeagueScheduleOutputPort.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface GetLeagueScheduleOutputPort { - races: Array<{ - id: string; - name: string; - scheduledAt: Date; - }>; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueSeasonsOutputPort.ts b/core/racing/application/ports/output/GetLeagueSeasonsOutputPort.ts deleted file mode 100644 index f59aefdec..000000000 --- a/core/racing/application/ports/output/GetLeagueSeasonsOutputPort.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface LeagueSeasonSummaryOutputPort { - seasonId: string; - name: string; - status: string; - startDate: Date; - endDate: Date; - isPrimary: boolean; - isParallelActive: boolean; -} - -export interface GetLeagueSeasonsOutputPort { - seasons: LeagueSeasonSummaryOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts b/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts deleted file mode 100644 index 3f1864dfb..000000000 --- a/core/racing/application/ports/output/GetLeagueWalletOutputPort.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface WalletTransactionOutputPort { - id: string; - type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize'; - description: string; - amount: number; - fee: number; - netAmount: number; - date: string; - status: 'completed' | 'pending' | 'failed'; - reference?: string; -} - -export interface GetLeagueWalletOutputPort { - balance: number; - currency: string; - totalRevenue: number; - totalFees: number; - totalWithdrawals: number; - pendingPayouts: number; - canWithdraw: boolean; - withdrawalBlockReason?: string; - transactions: WalletTransactionOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetSponsorsOutputPort.ts b/core/racing/application/ports/output/GetSponsorsOutputPort.ts deleted file mode 100644 index 46996292c..000000000 --- a/core/racing/application/ports/output/GetSponsorsOutputPort.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface GetSponsorsOutputPort { - sponsors: { - id: string; - name: string; - contactEmail: string; - websiteUrl: string | undefined; - logoUrl: string | undefined; - createdAt: Date; - }[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetSponsorshipPricingOutputPort.ts b/core/racing/application/ports/output/GetSponsorshipPricingOutputPort.ts deleted file mode 100644 index f2d67d979..000000000 --- a/core/racing/application/ports/output/GetSponsorshipPricingOutputPort.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface GetSponsorshipPricingOutputPort { - entityType: string; - entityId: string; - pricing: { - id: string; - level: string; - price: number; - currency: string; - }[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetTeamDetailsOutputPort.ts b/core/racing/application/ports/output/GetTeamDetailsOutputPort.ts deleted file mode 100644 index 1e87505ab..000000000 --- a/core/racing/application/ports/output/GetTeamDetailsOutputPort.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface GetTeamDetailsOutputPort { - team: { - id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - createdAt: Date; - }; - membership: { - role: 'owner' | 'manager' | 'member'; - joinedAt: Date; - isActive: boolean; - } | null; - canManage: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetTeamLogoOutputPort.ts b/core/racing/application/ports/output/GetTeamLogoOutputPort.ts deleted file mode 100644 index c9c457864..000000000 --- a/core/racing/application/ports/output/GetTeamLogoOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetTeamLogoOutputPort { - logoUrl: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetTotalLeaguesOutputPort.ts b/core/racing/application/ports/output/GetTotalLeaguesOutputPort.ts deleted file mode 100644 index d98c204cc..000000000 --- a/core/racing/application/ports/output/GetTotalLeaguesOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetTotalLeaguesOutputPort { - totalLeagues: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/GetTotalRacesOutputPort.ts b/core/racing/application/ports/output/GetTotalRacesOutputPort.ts deleted file mode 100644 index e93145ff9..000000000 --- a/core/racing/application/ports/output/GetTotalRacesOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetTotalRacesOutputPort { - totalRaces: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ImportRaceResultsApiOutputPort.ts b/core/racing/application/ports/output/ImportRaceResultsApiOutputPort.ts deleted file mode 100644 index cede2a043..000000000 --- a/core/racing/application/ports/output/ImportRaceResultsApiOutputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface ImportRaceResultsApiOutputPort { - success: boolean; - raceId: string; - leagueId: string; - driversProcessed: number; - resultsRecorded: number; - errors?: string[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/JoinLeagueOutputPort.ts b/core/racing/application/ports/output/JoinLeagueOutputPort.ts deleted file mode 100644 index d1dad72c1..000000000 --- a/core/racing/application/ports/output/JoinLeagueOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface JoinLeagueOutputPort { - membershipId: string; - leagueId: string; - status: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueDriverSeasonStatsOutputPort.ts b/core/racing/application/ports/output/LeagueDriverSeasonStatsOutputPort.ts deleted file mode 100644 index 597445c9d..000000000 --- a/core/racing/application/ports/output/LeagueDriverSeasonStatsOutputPort.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface LeagueDriverSeasonStatsItemOutputPort { - leagueId: string; - driverId: string; - position: number; - driverName: string; - teamId?: string; - teamName?: string; - totalPoints: number; - basePoints: number; - penaltyPoints: number; - bonusPoints: number; - pointsPerRace: number; - racesStarted: number; - racesFinished: number; - dnfs: number; - noShows: number; - avgFinish: number | null; - rating: number | null; - ratingChange: number | null; -} - -export interface LeagueDriverSeasonStatsOutputPort { - leagueId: string; - stats: LeagueDriverSeasonStatsItemOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueFullConfigOutputPort.ts b/core/racing/application/ports/output/LeagueFullConfigOutputPort.ts deleted file mode 100644 index 875f2324b..000000000 --- a/core/racing/application/ports/output/LeagueFullConfigOutputPort.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { League } from '../../domain/entities/League'; -import type { Season } from '../../domain/entities/Season'; -import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; -import type { Game } from '../../domain/entities/Game'; - -export interface LeagueFullConfigOutputPort { - league: League; - activeSeason?: Season; - scoringConfig?: LeagueScoringConfig; - game?: Game; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueOutputPort.ts b/core/racing/application/ports/output/LeagueOutputPort.ts deleted file mode 100644 index 4f176f465..000000000 --- a/core/racing/application/ports/output/LeagueOutputPort.ts +++ /dev/null @@ -1,20 +0,0 @@ -export interface LeagueOutputPort { - id: string; - name: string; - description: string; - ownerId: string; - settings: { - pointsSystem: 'f1-2024' | 'indycar' | 'custom'; - sessionDuration?: number; - qualifyingFormat?: 'single-lap' | 'open'; - customPoints?: Record; - maxDrivers?: number; - }; - createdAt: string; - socialLinks?: { - discordUrl?: string; - youtubeUrl?: string; - websiteUrl?: string; - }; - usedSlots?: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueScheduleOutputPort.ts b/core/racing/application/ports/output/LeagueScheduleOutputPort.ts deleted file mode 100644 index 997fd74a0..000000000 --- a/core/racing/application/ports/output/LeagueScheduleOutputPort.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface LeagueScheduleOutputPort { - seasonStartDate: string; - raceStartTime: string; - timezoneId: string; - recurrenceStrategy: 'weekly' | 'everyNWeeks' | 'monthlyNthWeekday'; - intervalWeeks?: number; - weekdays?: ('monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday')[]; - monthlyOrdinal?: 1 | 2 | 3 | 4; - monthlyWeekday?: 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday' | 'sunday'; - plannedRounds: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueSchedulePreviewOutputPort.ts b/core/racing/application/ports/output/LeagueSchedulePreviewOutputPort.ts deleted file mode 100644 index 99f75a391..000000000 --- a/core/racing/application/ports/output/LeagueSchedulePreviewOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface LeagueSchedulePreviewOutputPort { - rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>; - summary: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueScoringChampionshipOutputPort.ts b/core/racing/application/ports/output/LeagueScoringChampionshipOutputPort.ts deleted file mode 100644 index a7fbbb570..000000000 --- a/core/racing/application/ports/output/LeagueScoringChampionshipOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -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; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueScoringConfigOutputPort.ts b/core/racing/application/ports/output/LeagueScoringConfigOutputPort.ts deleted file mode 100644 index 05210b338..000000000 --- a/core/racing/application/ports/output/LeagueScoringConfigOutputPort.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ChampionshipConfig } from '../../domain/types/ChampionshipConfig'; -import type { LeagueScoringPresetOutputPort } from './LeagueScoringPresetOutputPort'; - -export interface LeagueScoringConfigOutputPort { - leagueId: string; - seasonId: string; - gameId: string; - gameName: string; - scoringPresetId?: string; - preset?: LeagueScoringPresetOutputPort; - championships: ChampionshipConfig[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueScoringPresetOutputPort.ts b/core/racing/application/ports/output/LeagueScoringPresetOutputPort.ts deleted file mode 100644 index 52a12878d..000000000 --- a/core/racing/application/ports/output/LeagueScoringPresetOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface LeagueScoringPresetOutputPort { - id: string; - name: string; - description: string; - primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy'; - sessionSummary: string; - bonusSummary: string; - dropPolicySummary: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueScoringPresetsOutputPort.ts b/core/racing/application/ports/output/LeagueScoringPresetsOutputPort.ts deleted file mode 100644 index 140de4a63..000000000 --- a/core/racing/application/ports/output/LeagueScoringPresetsOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { LeagueScoringPresetOutputPort } from './LeagueScoringPresetOutputPort'; - -export interface LeagueScoringPresetsOutputPort { - presets: LeagueScoringPresetOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueStandingsOutputPort.ts b/core/racing/application/ports/output/LeagueStandingsOutputPort.ts deleted file mode 100644 index f47179891..000000000 --- a/core/racing/application/ports/output/LeagueStandingsOutputPort.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface StandingItemOutputPort { - driverId: string; - driver: { id: string; name: string }; - points: number; - rank: number; -} - -export interface LeagueStandingsOutputPort { - standings: StandingItemOutputPort[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueStatsOutputPort.ts b/core/racing/application/ports/output/LeagueStatsOutputPort.ts deleted file mode 100644 index 898aded6a..000000000 --- a/core/racing/application/ports/output/LeagueStatsOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface LeagueStatsOutputPort { - totalMembers: number; - totalRaces: number; - averageRating: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueSummaryOutputPort.ts b/core/racing/application/ports/output/LeagueSummaryOutputPort.ts deleted file mode 100644 index 46f6ca2bc..000000000 --- a/core/racing/application/ports/output/LeagueSummaryOutputPort.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface LeagueSummaryOutputPort { - id: string; - name: string; - description?: string; - createdAt: Date; - ownerId: string; - maxDrivers?: number; - usedDriverSlots?: number; - maxTeams?: number; - usedTeamSlots?: number; - structureSummary?: string; - scoringPatternSummary?: string; - timingSummary?: string; - scoring?: { - gameId: string; - gameName: string; - primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy'; - scoringPresetId: string; - scoringPresetName: string; - dropPolicySummary: string; - scoringPatternSummary: string; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/LeagueSummaryScoringOutputPort.ts b/core/racing/application/ports/output/LeagueSummaryScoringOutputPort.ts deleted file mode 100644 index 6ab36e901..000000000 --- a/core/racing/application/ports/output/LeagueSummaryScoringOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface LeagueSummaryScoringOutputPort { - gameId: string; - gameName: string; - primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy'; - scoringPresetId: string; - scoringPresetName: string; - dropPolicySummary: string; - scoringPatternSummary: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/PendingSponsorshipRequestsOutputPort.ts b/core/racing/application/ports/output/PendingSponsorshipRequestsOutputPort.ts deleted file mode 100644 index 4089b7348..000000000 --- a/core/racing/application/ports/output/PendingSponsorshipRequestsOutputPort.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; -import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; - -export interface PendingSponsorshipRequestOutput { - id: string; - sponsorId: string; - sponsorName: string; - sponsorLogo?: string; - tier: SponsorshipTier; - offeredAmount: number; - currency: string; - formattedAmount: string; - message?: string; - createdAt: Date; - platformFee: number; - netAmount: number; -} - -export interface PendingSponsorshipRequestsOutputPort { - entityType: SponsorableEntityType; - entityId: string; - requests: PendingSponsorshipRequestOutput[]; - totalCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ProcessPaymentOutputPort.ts b/core/racing/application/ports/output/ProcessPaymentOutputPort.ts deleted file mode 100644 index 7b6bd41a5..000000000 --- a/core/racing/application/ports/output/ProcessPaymentOutputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface ProcessPaymentOutputPort { - success: boolean; - transactionId?: string; - error?: string; - timestamp: Date; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ProfileOverviewOutputPort.ts b/core/racing/application/ports/output/ProfileOverviewOutputPort.ts deleted file mode 100644 index 972b060df..000000000 --- a/core/racing/application/ports/output/ProfileOverviewOutputPort.ts +++ /dev/null @@ -1,78 +0,0 @@ -export interface ProfileOverviewOutputPort { - driver: { - id: string; - name: string; - country: string; - avatarUrl: string; - iracingId: string | null; - joinedAt: Date; - rating: number | null; - globalRank: number | null; - consistency: number | null; - bio: string | null; - totalDrivers: number | null; - }; - stats: { - totalRaces: number; - wins: number; - podiums: number; - dnfs: number; - avgFinish: number | null; - bestFinish: number | null; - worstFinish: number | null; - finishRate: number | null; - winRate: number | null; - podiumRate: number | null; - percentile: number | null; - rating: number | null; - consistency: number | null; - overallRank: number | null; - } | null; - finishDistribution: { - totalRaces: number; - wins: number; - podiums: number; - topTen: number; - dnfs: number; - other: number; - } | null; - teamMemberships: { - teamId: string; - teamName: string; - teamTag: string | null; - role: string; - joinedAt: Date; - isCurrent: boolean; - }[]; - socialSummary: { - friendsCount: number; - friends: { - id: string; - name: string; - country: string; - avatarUrl: string; - }[]; - }; - extendedProfile: { - socialHandles: { - platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; - handle: string; - url: string; - }[]; - achievements: { - id: string; - title: string; - description: string; - icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; - rarity: 'common' | 'rare' | 'epic' | 'legendary'; - earnedAt: string; - }[]; - racingStyle: string; - favoriteTrack: string; - favoriteCar: string; - timezone: string; - availableHours: string; - lookingForTeam: boolean; - openToRequests: boolean; - } | null; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ProtestOutputPort.ts b/core/racing/application/ports/output/ProtestOutputPort.ts deleted file mode 100644 index 0c4302995..000000000 --- a/core/racing/application/ports/output/ProtestOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ProtestOutputPort { - id: string; - raceId: string; - protestingDriverId: string; - accusedDriverId: string; - submittedAt: Date; - description: string; - status: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceDetailOutputPort.ts b/core/racing/application/ports/output/RaceDetailOutputPort.ts deleted file mode 100644 index ae20730b9..000000000 --- a/core/racing/application/ports/output/RaceDetailOutputPort.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Race } from '../../../domain/entities/Race'; -import type { League } from '../../../domain/entities/League'; -import type { RaceRegistration } from '../../../domain/entities/RaceRegistration'; -import type { Driver } from '../../../domain/entities/Driver'; -import type { Result } from '../../../domain/entities/result/Result'; - -export interface RaceDetailOutputPort { - race: Race; - league: League | null; - registrations: RaceRegistration[]; - drivers: Driver[]; - userResult: Result | null; - isUserRegistered: boolean; - canRegister: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceOutputPort.ts b/core/racing/application/ports/output/RaceOutputPort.ts deleted file mode 100644 index 03de80943..000000000 --- a/core/racing/application/ports/output/RaceOutputPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface RaceOutputPort { - id: string; - leagueId: string; - scheduledAt: string; - track: string; - trackId?: string; - car: string; - carId?: string; - sessionType: 'practice' | 'qualifying' | 'race'; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - strengthOfField?: number; - registeredCount?: number; - maxParticipants?: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RacePenaltiesOutputPort.ts b/core/racing/application/ports/output/RacePenaltiesOutputPort.ts deleted file mode 100644 index 48c02c8f7..000000000 --- a/core/racing/application/ports/output/RacePenaltiesOutputPort.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Penalty } from '../../../domain/entities/Penalty'; -import type { Driver } from '../../../domain/entities/Driver'; - -export interface RacePenaltiesOutputPort { - penalties: Penalty[]; - drivers: Driver[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceProtestsOutputPort.ts b/core/racing/application/ports/output/RaceProtestsOutputPort.ts deleted file mode 100644 index 9fc2f9c2b..000000000 --- a/core/racing/application/ports/output/RaceProtestsOutputPort.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { Protest } from '../../../domain/entities/Protest'; -import type { Driver } from '../../../domain/entities/Driver'; - -export interface RaceProtestsOutputPort { - protests: Protest[]; - drivers: Driver[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceRegistrationsOutputPort.ts b/core/racing/application/ports/output/RaceRegistrationsOutputPort.ts deleted file mode 100644 index dca2d6f9c..000000000 --- a/core/racing/application/ports/output/RaceRegistrationsOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { RaceRegistration } from '../../../domain/entities/RaceRegistration'; - -export interface RaceRegistrationsOutputPort { - registrations: RaceRegistration[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceResultsDetailOutputPort.ts b/core/racing/application/ports/output/RaceResultsDetailOutputPort.ts deleted file mode 100644 index 378c9ed88..000000000 --- a/core/racing/application/ports/output/RaceResultsDetailOutputPort.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Race } from '../../../domain/entities/Race'; -import type { League } from '../../../domain/entities/League'; -import type { Result } from '../../../domain/entities/result/Result'; -import type { Driver } from '../../../domain/entities/Driver'; -import type { Penalty } from '../../../domain/entities/Penalty'; - -export interface RaceResultsDetailOutputPort { - race: Race; - league: League | null; - results: Result[]; - drivers: Driver[]; - penalties: Penalty[]; - pointsSystem?: Record; - fastestLapTime?: number; - currentDriverId?: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceSummaryOutputPort.ts b/core/racing/application/ports/output/RaceSummaryOutputPort.ts deleted file mode 100644 index cf8307797..000000000 --- a/core/racing/application/ports/output/RaceSummaryOutputPort.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface RaceOutputPort { - id: string; - name: string; - date: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RaceWithSOFOutputPort.ts b/core/racing/application/ports/output/RaceWithSOFOutputPort.ts deleted file mode 100644 index 82b85bbe8..000000000 --- a/core/racing/application/ports/output/RaceWithSOFOutputPort.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface RaceWithSOFOutputPort { - id: string; - leagueId: string; - track: string; - car: string; - scheduledAt: Date; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - strengthOfField: number | null; - registeredCount: number; - maxParticipants: number; - participantCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RacesPageOutputPort.ts b/core/racing/application/ports/output/RacesPageOutputPort.ts deleted file mode 100644 index d7e63ac22..000000000 --- a/core/racing/application/ports/output/RacesPageOutputPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface RacesPageOutputPort { - page: number; - pageSize: number; - totalCount: number; - races: { - id: string; - leagueId: string; - track: string; - car: string; - scheduledAt: Date; - status: 'scheduled' | 'running' | 'completed' | 'cancelled'; - strengthOfField: number | null; - }[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RefundPaymentOutputPort.ts b/core/racing/application/ports/output/RefundPaymentOutputPort.ts deleted file mode 100644 index 8a217cebd..000000000 --- a/core/racing/application/ports/output/RefundPaymentOutputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface RefundPaymentOutputPort { - success: boolean; - refundId?: string; - error?: string; - timestamp: Date; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort.ts b/core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort.ts deleted file mode 100644 index 02eca0b8a..000000000 --- a/core/racing/application/ports/output/RejectLeagueJoinRequestOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface RejectLeagueJoinRequestOutputPort { - success: boolean; - message: string; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/RemoveLeagueMemberOutputPort.ts b/core/racing/application/ports/output/RemoveLeagueMemberOutputPort.ts deleted file mode 100644 index 88f9827ba..000000000 --- a/core/racing/application/ports/output/RemoveLeagueMemberOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface RemoveLeagueMemberOutputPort { - success: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/ResultOutputPort.ts b/core/racing/application/ports/output/ResultOutputPort.ts deleted file mode 100644 index bead31e99..000000000 --- a/core/racing/application/ports/output/ResultOutputPort.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ResultOutputPort { - id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/SponsorDashboardOutputPort.ts b/core/racing/application/ports/output/SponsorDashboardOutputPort.ts deleted file mode 100644 index 2e4a85b0e..000000000 --- a/core/racing/application/ports/output/SponsorDashboardOutputPort.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface SponsoredLeagueOutput { - id: string; - name: string; - tier: 'main' | 'secondary'; - drivers: number; - races: number; - impressions: number; - status: 'active' | 'upcoming' | 'completed'; -} - -export interface SponsorDashboardOutputPort { - sponsorId: string; - sponsorName: string; - metrics: { - impressions: number; - impressionsChange: number; - uniqueViewers: number; - viewersChange: number; - races: number; - drivers: number; - exposure: number; - exposureChange: number; - }; - sponsoredLeagues: SponsoredLeagueOutput[]; - investment: { - activeSponsorships: number; - totalInvestment: number; - costPerThousandViews: number; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/SponsorSponsorshipsOutputPort.ts b/core/racing/application/ports/output/SponsorSponsorshipsOutputPort.ts deleted file mode 100644 index 727a77a84..000000000 --- a/core/racing/application/ports/output/SponsorSponsorshipsOutputPort.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship'; - -export interface SponsorshipDetailOutput { - id: string; - leagueId: string; - leagueName: string; - seasonId: string; - seasonName: string; - seasonStartDate?: Date; - seasonEndDate?: Date; - tier: SponsorshipTier; - status: SponsorshipStatus; - pricing: { - amount: number; - currency: string; - }; - platformFee: { - amount: number; - currency: string; - }; - netAmount: { - amount: number; - currency: string; - }; - metrics: { - drivers: number; - races: number; - completedRaces: number; - impressions: number; - }; - createdAt: Date; - activatedAt?: Date; -} - -export interface SponsorSponsorshipsOutputPort { - sponsorId: string; - sponsorName: string; - sponsorships: SponsorshipDetailOutput[]; - summary: { - totalSponsorships: number; - activeSponsorships: number; - totalInvestment: number; - totalPlatformFees: number; - currency: string; - }; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts b/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts deleted file mode 100644 index 2c2c2d7ff..000000000 --- a/core/racing/application/ports/output/SponsorshipRejectionNotificationPort.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface SponsorshipRejectionNotificationPayload { - requestId: string; - sponsorId: string; - entityType: string; - entityId: string; - tier: string; - offeredAmountCents: number; - currency: string; - rejectedAt: Date; - rejectedBy: string; - rejectionReason?: string; -} - -export interface SponsorshipRejectionNotificationPort { - notifySponsorshipRequestRejected(payload: SponsorshipRejectionNotificationPayload): Promise; -} diff --git a/core/racing/application/ports/output/SponsorshipSlotOutputPort.ts b/core/racing/application/ports/output/SponsorshipSlotOutputPort.ts deleted file mode 100644 index aab1998f2..000000000 --- a/core/racing/application/ports/output/SponsorshipSlotOutputPort.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface SponsorshipSlotOutputPort { - tier: string; - price: number; - currency: string; - formattedPrice: string; - benefits: string[]; - available: boolean; - maxSlots: number; - filledSlots: number; - pendingRequests: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/StandingOutputPort.ts b/core/racing/application/ports/output/StandingOutputPort.ts deleted file mode 100644 index 8e9b1a158..000000000 --- a/core/racing/application/ports/output/StandingOutputPort.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface StandingOutputPort { - leagueId: string; - driverId: string; - points: number; - wins: number; - position: number; - racesCompleted: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/TeamJoinRequestsOutputPort.ts b/core/racing/application/ports/output/TeamJoinRequestsOutputPort.ts deleted file mode 100644 index b564be6a7..000000000 --- a/core/racing/application/ports/output/TeamJoinRequestsOutputPort.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface TeamJoinRequestsOutputPort { - requests: { - requestId: string; - driverId: string; - driverName: string; - teamId: string; - status: 'pending' | 'approved' | 'rejected'; - requestedAt: Date; - avatarUrl: string; - }[]; - pendingCount: number; - totalCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/TeamMembersOutputPort.ts b/core/racing/application/ports/output/TeamMembersOutputPort.ts deleted file mode 100644 index a3b79e93b..000000000 --- a/core/racing/application/ports/output/TeamMembersOutputPort.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface TeamMembersOutputPort { - members: { - driverId: string; - driverName: string; - role: 'owner' | 'manager' | 'member'; - joinedAt: Date; - isActive: boolean; - avatarUrl: string; - }[]; - totalCount: number; - ownerCount: number; - managerCount: number; - memberCount: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/TeamsLeaderboardOutputPort.ts b/core/racing/application/ports/output/TeamsLeaderboardOutputPort.ts deleted file mode 100644 index 317ea3dc1..000000000 --- a/core/racing/application/ports/output/TeamsLeaderboardOutputPort.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro'; - -export interface TeamsLeaderboardOutputPort { - teams: { - id: string; - name: string; - memberCount: number; - rating: number | null; - totalWins: number; - totalRaces: number; - performanceLevel: SkillLevel; - isRecruiting: boolean; - createdAt: Date; - description?: string; - specialization?: 'endurance' | 'sprint' | 'mixed'; - region?: string; - languages?: string[]; - }[]; - recruitingCount: number; - groupsBySkillLevel: Record; - topTeams: { - id: string; - name: string; - memberCount: number; - rating: number | null; - totalWins: number; - totalRaces: number; - performanceLevel: SkillLevel; - isRecruiting: boolean; - createdAt: Date; - description?: string; - specialization?: 'endurance' | 'sprint' | 'mixed'; - region?: string; - languages?: string[]; - }[]; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/TotalDriversOutputPort.ts b/core/racing/application/ports/output/TotalDriversOutputPort.ts deleted file mode 100644 index 9b951472d..000000000 --- a/core/racing/application/ports/output/TotalDriversOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TotalDriversOutputPort { - totalDrivers: number; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/TransferLeagueOwnershipOutputPort.ts b/core/racing/application/ports/output/TransferLeagueOwnershipOutputPort.ts deleted file mode 100644 index 1fab32f78..000000000 --- a/core/racing/application/ports/output/TransferLeagueOwnershipOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TransferLeagueOwnershipOutputPort { - success: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort.ts b/core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort.ts deleted file mode 100644 index 4515359e0..000000000 --- a/core/racing/application/ports/output/UpdateLeagueMemberRoleOutputPort.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface UpdateLeagueMemberRoleOutputPort { - success: boolean; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/VerifyPaymentOutputPort.ts b/core/racing/application/ports/output/VerifyPaymentOutputPort.ts deleted file mode 100644 index 433db40e9..000000000 --- a/core/racing/application/ports/output/VerifyPaymentOutputPort.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface VerifyPaymentOutputPort { - success: boolean; - transactionId?: string; - error?: string; - timestamp: Date; -} \ No newline at end of file diff --git a/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts b/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts deleted file mode 100644 index 392778411..000000000 --- a/core/racing/application/ports/output/WithdrawFromLeagueWalletOutputPort.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface WithdrawFromLeagueWalletOutputPort { - success: boolean; - message?: string; -} \ No newline at end of file diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts index fbe79671f..b582b3e40 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts @@ -2,8 +2,8 @@ import type { NotificationService } from '@/notifications/application/ports/Noti import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; import type { Logger } from '@core/shared/application'; import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { LeagueWallet } from '../../domain/entities/LeagueWallet'; -import { Season } from '../../domain/entities/Season'; +import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet'; +import { Season } from '../../domain/entities/season/Season'; import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; @@ -73,7 +73,11 @@ describe('AcceptSponsorshipRequestUseCase', () => { }; }); - it('should send notification to sponsor, process payment, and update wallets when accepting season sponsorship', async () => { + it('should send notification to sponsor, process payment, update wallets, and present result when accepting season sponsorship', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new AcceptSponsorshipRequestUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, @@ -83,6 +87,7 @@ describe('AcceptSponsorshipRequestUseCase', () => { mockWalletRepo as unknown as IWalletRepository, mockLeagueWalletRepo as unknown as ILeagueWalletRepository, mockLogger as unknown as Logger, + output, ); const request = SponsorshipRequest.create({ @@ -135,8 +140,8 @@ describe('AcceptSponsorshipRequestUseCase', () => { }); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto).toBeDefined(); + expect(result.unwrap()).toBeUndefined(); + expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({ recipientId: 'sponsor1', type: 'sponsorship_request_accepted', @@ -146,28 +151,35 @@ describe('AcceptSponsorshipRequestUseCase', () => { urgency: 'toast', data: { requestId: 'req1', - sponsorshipId: dto.sponsorshipId, + sponsorshipId: expect.any(String), }, }); - expect(processPayment).toHaveBeenCalledWith( - { - amount: Money.create(1000), - payerId: 'sponsor1', - description: 'Sponsorship payment for season season1', - metadata: { requestId: 'req1' } - } - ); + expect(processPayment).toHaveBeenCalledWith({ + amount: 1000, + payerId: 'sponsor1', + description: 'Sponsorship payment for season season1', + metadata: { requestId: 'req1' }, + }); expect(mockWalletRepo.update).toHaveBeenCalledWith( expect.objectContaining({ id: 'sponsor1', balance: 1000, - }) + }), ); expect(mockLeagueWalletRepo.update).toHaveBeenCalledWith( expect.objectContaining({ id: 'league1', balance: expect.objectContaining({ amount: 1400 }), - }) + }), ); + + expect(output.present).toHaveBeenCalledWith({ + requestId: 'req1', + sponsorshipId: expect.any(String), + status: 'accepted', + acceptedAt: expect.any(Date), + platformFee: expect.any(Number), + netAmount: expect.any(Number), + }); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts index 1b335f4ab..cf88a5445 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts @@ -7,62 +7,116 @@ import type { NotificationService } from '@/notifications/application/ports/NotificationService'; import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; -import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO'; -import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort'; -import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort'; -import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort'; -export class AcceptSponsorshipRequestUseCase - implements AsyncUseCase { +export interface AcceptSponsorshipRequestInput { + requestId: string; + respondedBy: string; +} + +export interface AcceptSponsorshipResult { + requestId: string; + sponsorshipId: string; + status: 'accepted'; + acceptedAt: Date; + platformFee: number; + netAmount: number; +} + +export interface ProcessPaymentInput { + amount: number; + payerId: string; + description: string; + metadata?: Record; +} + +export interface ProcessPaymentResult { + success: boolean; + transactionId?: string; + error?: string; +} + +export class AcceptSponsorshipRequestUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly seasonRepository: ISeasonRepository, private readonly notificationService: NotificationService, - private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise, + private readonly paymentProcessor: (input: ProcessPaymentInput) => Promise, private readonly walletRepository: IWalletRepository, private readonly leagueWalletRepository: ILeagueWalletRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(dto: AcceptSponsorshipRequestDTO): Promise>> { - this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy }); + async execute( + input: AcceptSponsorshipRequestInput, + ): Promise< + Result< + void, + ApplicationErrorCode< + | 'SPONSORSHIP_REQUEST_NOT_FOUND' + | 'SPONSORSHIP_REQUEST_NOT_PENDING' + | 'SEASON_NOT_FOUND' + | 'PAYMENT_PROCESSING_FAILED' + | 'SPONSOR_WALLET_NOT_FOUND' + | 'LEAGUE_WALLET_NOT_FOUND' + > + > + > { + this.logger.debug(`Attempting to accept sponsorship request: ${input.requestId}`, { + requestId: input.requestId, + respondedBy: input.respondedBy, + }); // Find the request - const request = await this.sponsorshipRequestRepo.findById(dto.requestId); + const request = await this.sponsorshipRequestRepo.findById(input.requestId); if (!request) { - this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId }); + this.logger.warn(`Sponsorship request not found: ${input.requestId}`, { requestId: input.requestId }); return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }); } if (!request.isPending()) { - this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status }); + this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${input.requestId}`, { + requestId: input.requestId, + status: request.status, + }); return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_PENDING' }); } - this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId }); + this.logger.info(`Sponsorship request ${input.requestId} found and is pending. Proceeding with acceptance.`, { + requestId: input.requestId, + }); // Accept the request - const acceptedRequest = request.accept(dto.respondedBy); + const acceptedRequest = request.accept(input.respondedBy); await this.sponsorshipRequestRepo.update(acceptedRequest); - this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId }); + this.logger.debug(`Sponsorship request ${input.requestId} accepted and updated in repository.`, { + requestId: input.requestId, + }); // If this is a season sponsorship, create the SeasonSponsorship record let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; if (request.entityType === 'season') { - this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType }); + this.logger.debug(`Sponsorship request ${input.requestId} is for a season. Creating SeasonSponsorship record.`, { + requestId: input.requestId, + entityType: request.entityType, + }); const season = await this.seasonRepository.findById(request.entityId); if (!season) { - this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId }); + this.logger.warn( + `Season not found for sponsorship request ${input.requestId} and entityId ${request.entityId}`, + { requestId: input.requestId, entityId: request.entityId }, + ); return Result.err({ code: 'SEASON_NOT_FOUND' }); } @@ -76,7 +130,10 @@ export class AcceptSponsorshipRequestUseCase status: 'active', }); await this.seasonSponsorshipRepo.create(sponsorship); - this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId }); + this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${input.requestId}.`, { + sponsorshipId, + requestId: input.requestId, + }); // Notify the sponsor await this.notificationService.sendNotification({ @@ -93,29 +150,37 @@ export class AcceptSponsorshipRequestUseCase }); // Process payment using clean input/output ports with primitive types - const paymentInput: ProcessPaymentInputPort = { - amount: request.offeredAmount.amount, // Extract primitive number from value object + const paymentInput: ProcessPaymentInput = { + amount: request.offeredAmount.amount, payerId: request.sponsorId, description: `Sponsorship payment for ${request.entityType} ${request.entityId}`, - metadata: { requestId: request.id } + metadata: { requestId: request.id }, }; const paymentResult = await this.paymentProcessor(paymentInput); if (!paymentResult.success) { - this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id }); + this.logger.error( + `Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, + undefined, + { requestId: request.id }, + ); return Result.err({ code: 'PAYMENT_PROCESSING_FAILED' }); } // Update wallets const sponsorWallet = await this.walletRepository.findById(request.sponsorId); if (!sponsorWallet) { - this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, { sponsorId: request.sponsorId }); + this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, { + sponsorId: request.sponsorId, + }); return Result.err({ code: 'SPONSOR_WALLET_NOT_FOUND' }); } const leagueWallet = await this.leagueWalletRepository.findById(season.leagueId); if (!leagueWallet) { - this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, { leagueId: season.leagueId }); + this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, { + leagueId: season.leagueId, + }); return Result.err({ code: 'LEAGUE_WALLET_NOT_FOUND' }); } @@ -133,15 +198,22 @@ export class AcceptSponsorshipRequestUseCase await this.leagueWalletRepository.update(updatedLeagueWallet); } - this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId }); + this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { + requestId: acceptedRequest.id, + sponsorshipId, + }); - return Result.ok({ + const result: AcceptSponsorshipResult = { requestId: acceptedRequest.id, sponsorshipId, status: 'accepted', acceptedAt: acceptedRequest.respondedAt!, platformFee: acceptedRequest.getPlatformFee().amount, netAmount: acceptedRequest.getNetAmount().amount, - }); + }; + + this.output.present(result); + + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts index 3cc58e561..4e709e750 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts @@ -4,6 +4,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Money } from '../../domain/value-objects/Money'; describe('ApplyForSponsorshipUseCase', () => { @@ -42,11 +43,16 @@ describe('ApplyForSponsorshipUseCase', () => { }); it('should return error when sponsor does not exist', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new ApplyForSponsorshipUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorRepo as unknown as ISponsorRepository, mockLogger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); mockSponsorRepo.findById.mockResolvedValue(null); @@ -61,14 +67,20 @@ describe('ApplyForSponsorshipUseCase', () => { expect(result.isOk()).toBe(false); expect(result.error!.code).toBe('SPONSOR_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when sponsorship pricing is not set up', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new ApplyForSponsorshipUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorRepo as unknown as ISponsorRepository, mockLogger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); @@ -84,14 +96,20 @@ describe('ApplyForSponsorshipUseCase', () => { expect(result.isOk()).toBe(false); expect(result.error!.code).toBe('SPONSORSHIP_PRICING_NOT_SETUP'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when entity is not accepting applications', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new ApplyForSponsorshipUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorRepo as unknown as ISponsorRepository, mockLogger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); @@ -111,14 +129,20 @@ describe('ApplyForSponsorshipUseCase', () => { expect(result.isOk()).toBe(false); expect(result.error!.code).toBe('ENTITY_NOT_ACCEPTING_APPLICATIONS'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when no slots are available', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new ApplyForSponsorshipUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorRepo as unknown as ISponsorRepository, mockLogger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); @@ -138,14 +162,20 @@ describe('ApplyForSponsorshipUseCase', () => { expect(result.isOk()).toBe(false); expect(result.error!.code).toBe('NO_SLOTS_AVAILABLE'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when sponsor has pending request', async () => { + const output = { + present: vi.fn(), + }; + const useCase = new ApplyForSponsorshipUseCase( mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorRepo as unknown as ISponsorRepository, mockLogger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); @@ -166,6 +196,7 @@ describe('ApplyForSponsorshipUseCase', () => { expect(result.isOk()).toBe(false); expect(result.error!.code).toBe('PENDING_REQUEST_EXISTS'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when offered amount is less than minimum', async () => { diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index 8a9e7f6c5..ca745e99a 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -10,89 +10,133 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { Money } from '../../domain/value-objects/Money'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { ApplyForSponsorshipPort } from '../ports/input/ApplyForSponsorshipPort'; -import type { ApplyForSponsorshipResultPort } from '../ports/output/ApplyForSponsorshipResultPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class ApplyForSponsorshipUseCase - implements AsyncUseCase -{ +export interface ApplyForSponsorshipInput { + sponsorId: string; + entityType: SponsorshipRequest['entityType']; + entityId: string; + tier: string; + offeredAmount: number; + currency?: string; + message?: string; +} + +export interface ApplyForSponsorshipResult { + requestId: string; + status: SponsorshipRequest['status']; + createdAt: Date; +} + +export class ApplyForSponsorshipUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorRepo: ISponsorRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(dto: ApplyForSponsorshipPort): Promise>> { - this.logger.debug('Attempting to apply for sponsorship', { dto }); + async execute( + input: ApplyForSponsorshipInput, + ): Promise< + Result< + void, + ApplicationErrorCode< + | 'SPONSOR_NOT_FOUND' + | 'SPONSORSHIP_PRICING_NOT_SETUP' + | 'ENTITY_NOT_ACCEPTING_APPLICATIONS' + | 'NO_SLOTS_AVAILABLE' + | 'PENDING_REQUEST_EXISTS' + | 'OFFERED_AMOUNT_TOO_LOW' + > + > + > { + this.logger.debug('Attempting to apply for sponsorship', { input }); // Validate sponsor exists - const sponsor = await this.sponsorRepo.findById(dto.sponsorId); + const sponsor = await this.sponsorRepo.findById(input.sponsorId); if (!sponsor) { - this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId }); + this.logger.error('Sponsor not found', undefined, { sponsorId: input.sponsorId }); return Result.err({ code: 'SPONSOR_NOT_FOUND' }); } // Check if entity accepts sponsorship applications - const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); + const pricing = await this.sponsorshipPricingRepo.findByEntity(input.entityType, input.entityId); if (!pricing) { - this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId }); + this.logger.warn('Sponsorship pricing not set up for this entity', { + entityType: input.entityType, + entityId: input.entityId, + }); return Result.err({ code: 'SPONSORSHIP_PRICING_NOT_SETUP' }); } if (!pricing.acceptingApplications) { - this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId }); + this.logger.warn('Entity not accepting sponsorship applications', { + entityType: input.entityType, + entityId: input.entityId, + }); return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' }); } // Check if the requested tier slot is available - const slotAvailable = pricing.isSlotAvailable(dto.tier); + const slotAvailable = pricing.isSlotAvailable(input.tier); if (!slotAvailable) { - this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`); + this.logger.warn(`No ${input.tier} sponsorship slots are available for entity ${input.entityId}`); return Result.err({ code: 'NO_SLOTS_AVAILABLE' }); } // Check if sponsor already has a pending request for this entity const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest( - dto.sponsorId, - dto.entityType, - dto.entityId, + input.sponsorId, + input.entityType, + input.entityId, ); if (hasPending) { - this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId }); + this.logger.warn('Sponsor already has a pending request for this entity', { + sponsorId: input.sponsorId, + entityType: input.entityType, + entityId: input.entityId, + }); return Result.err({ code: 'PENDING_REQUEST_EXISTS' }); } // Validate offered amount meets minimum price - const minPrice = pricing.getPrice(dto.tier); - if (minPrice && dto.offeredAmount < minPrice.amount) { - this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`); + const minPrice = pricing.getPrice(input.tier); + if (minPrice && input.offeredAmount < minPrice.amount) { + this.logger.warn( + `Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${input.tier}`, + ); return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' }); } // Create the sponsorship request const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD'); + const offeredAmount = Money.create(input.offeredAmount, input.currency ?? 'USD'); const request = SponsorshipRequest.create({ id: requestId, - sponsorId: dto.sponsorId, - entityType: dto.entityType, - entityId: dto.entityId, - tier: dto.tier, + sponsorId: input.sponsorId, + entityType: input.entityType, + entityId: input.entityId, + tier: input.tier, offeredAmount, - ...(dto.message !== undefined ? { message: dto.message } : {}), + ...(input.message !== undefined ? { message: input.message } : {}), }); await this.sponsorshipRequestRepo.create(request); - return Result.ok({ + const result: ApplyForSponsorshipResult = { requestId: request.id, - status: 'pending', + status: request.status, createdAt: request.createdAt, - }); + }; + + this.output.present(result); + + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts index f1f747b7d..1a0e40201 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts @@ -5,6 +5,7 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('ApplyPenaltyUseCase', () => { let mockPenaltyRepo: { diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts index 3f79cfd9c..779aca997 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts @@ -5,28 +5,56 @@ * The penalty can be standalone or linked to an upheld protest. */ -import { Penalty } from '../../domain/entities/Penalty'; +import { Penalty } from '../../domain/entities/penalty/Penalty'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { ApplyPenaltyCommandPort } from '../ports/input/ApplyPenaltyCommandPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class ApplyPenaltyUseCase - implements AsyncUseCase { +export interface ApplyPenaltyInput { + raceId: string; + driverId: string; + stewardId: string; + type: Penalty['type']; + value?: Penalty['value']; + reason: string; + protestId?: string; + notes?: string; +} + +export interface ApplyPenaltyResult { + penaltyId: string; +} + +export class ApplyPenaltyUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly protestRepository: IProtestRepository, private readonly raceRepository: IRaceRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: ApplyPenaltyCommandPort): Promise>> { + async execute( + command: ApplyPenaltyInput, + ): Promise< + Result< + void, + ApplicationErrorCode< + | 'RACE_NOT_FOUND' + | 'INSUFFICIENT_AUTHORITY' + | 'PROTEST_NOT_FOUND' + | 'PROTEST_NOT_UPHELD' + | 'PROTEST_NOT_FOR_RACE' + > + > + > { this.logger.debug('ApplyPenaltyUseCase: Executing with command', command); // Validate race exists @@ -84,8 +112,13 @@ export class ApplyPenaltyUseCase }); await this.penaltyRepository.create(penalty); - this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`); + this.logger.info( + `ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`, + ); - return Result.ok({ penaltyId: penalty.id }); + const result: ApplyPenaltyResult = { penaltyId: penalty.id }; + this.output.present(result); + + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts index 2198229a4..76b8f02cd 100644 --- a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { ApproveLeagueJoinRequestUseCase } from './ApproveLeagueJoinRequestUseCase'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('ApproveLeagueJoinRequestUseCase', () => { let mockLeagueMembershipRepo: { @@ -18,7 +19,14 @@ describe('ApproveLeagueJoinRequestUseCase', () => { }); it('should approve join request and save membership', async () => { - const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository); + const output = { + present: vi.fn(), + }; + + const useCase = new ApproveLeagueJoinRequestUseCase( + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output as unknown as UseCaseOutputPort, + ); const leagueId = 'league-1'; const requestId = 'req-1'; @@ -29,7 +37,7 @@ describe('ApproveLeagueJoinRequestUseCase', () => { const result = await useCase.execute({ leagueId, requestId }); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ success: true, message: 'Join request approved.' }); + expect(result.unwrap()).toBeUndefined(); expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId); expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({ id: expect.any(String), @@ -39,10 +47,18 @@ describe('ApproveLeagueJoinRequestUseCase', () => { status: 'active', joinedAt: expect.any(Date), }); + expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' }); }); - + it('should return error if request not found', async () => { - const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository); + const output = { + present: vi.fn(), + }; + + const useCase = new ApproveLeagueJoinRequestUseCase( + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output as unknown as UseCaseOutputPort, + ); mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]); diff --git a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts index e2d24d960..d6a698302 100644 --- a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts @@ -1,31 +1,48 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; 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 '../dto/ApproveLeagueJoinRequestUseCaseParams'; -import type { ApproveLeagueJoinRequestResultPort } from '../ports/output/ApproveLeagueJoinRequestResultPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { JoinedAt } from '../../domain/value-objects/JoinedAt'; -export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase { - constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} +export interface ApproveLeagueJoinRequestInput { + leagueId: string; + requestId: string; +} - async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise>> { - const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); - const request = requests.find(r => r.id === params.requestId); +export interface ApproveLeagueJoinRequestResult { + success: boolean; + message: string; +} + +export class ApproveLeagueJoinRequestUseCase { + constructor( + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + input: ApproveLeagueJoinRequestInput, + ): Promise>> { + const requests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId); + const request = requests.find(r => r.id === input.requestId); if (!request) { return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' }); } - await this.leagueMembershipRepository.removeJoinRequest(params.requestId); + + await this.leagueMembershipRepository.removeJoinRequest(input.requestId); await this.leagueMembershipRepository.saveMembership({ id: randomUUID(), - leagueId: params.leagueId, + leagueId: input.leagueId, driverId: request.driverId, role: 'member', status: 'active', joinedAt: JoinedAt.create(new Date()), }); - const dto: ApproveLeagueJoinRequestResultPort = { success: true, message: 'Join request approved.' }; - return Result.ok(dto); + + const result: ApproveLeagueJoinRequestResult = { success: true, message: 'Join request approved.' }; + this.output.present(result); + + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts index acda77210..246119983 100644 --- a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ApproveTeamJoinRequestUseCase } from './ApproveTeamJoinRequestUseCase'; +import { ApproveTeamJoinRequestUseCase, type ApproveTeamJoinRequestResult } from './ApproveTeamJoinRequestUseCase'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('ApproveTeamJoinRequestUseCase', () => { let useCase: ApproveTeamJoinRequestUseCase; @@ -9,6 +10,7 @@ describe('ApproveTeamJoinRequestUseCase', () => { removeJoinRequest: Mock; saveMembership: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { membershipRepository = { @@ -16,7 +18,13 @@ describe('ApproveTeamJoinRequestUseCase', () => { removeJoinRequest: vi.fn(), saveMembership: vi.fn(), }; - useCase = new ApproveTeamJoinRequestUseCase(membershipRepository as unknown as ITeamMembershipRepository); + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: Mock; + }; + useCase = new ApproveTeamJoinRequestUseCase( + membershipRepository as unknown as ITeamMembershipRepository, + output, + ); }); it('should approve join request and save membership', async () => { @@ -37,6 +45,16 @@ describe('ApproveTeamJoinRequestUseCase', () => { status: 'active', joinedAt: expect.any(Date), }); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + membership: { + teamId, + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: expect.any(Date), + }, + }); }); it('should return error if request not found', async () => { @@ -45,6 +63,7 @@ describe('ApproveTeamJoinRequestUseCase', () => { const result = await useCase.execute({ teamId: 'team-1', requestId: 'req-1' }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('JOIN_REQUEST_NOT_FOUND'); + expect(result.unwrapErr().code).toBe('REQUEST_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts index 7b31e52a5..8e458b2dd 100644 --- a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts @@ -1,4 +1,3 @@ -import type { AsyncUseCase } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; @@ -6,34 +5,67 @@ import type { TeamJoinRequest, TeamMembership, } from '../../domain/types/TeamMembership'; -import type { ApproveTeamJoinRequestInputPort } from '../ports/input/ApproveTeamJoinRequestInputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export class ApproveTeamJoinRequestUseCase - implements AsyncUseCase { +export type ApproveTeamJoinRequestInput = { + teamId: string; + requestId: string; +}; + +export type ApproveTeamJoinRequestResult = { + membership: TeamMembership; +}; + +export type ApproveTeamJoinRequestErrorCode = + | 'TEAM_NOT_FOUND' + | 'REQUEST_NOT_FOUND' + | 'NOT_AUTHORIZED' + | 'REPOSITORY_ERROR'; + +export class ApproveTeamJoinRequestUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: ApproveTeamJoinRequestInputPort): Promise>> { + async execute(command: ApproveTeamJoinRequestInput): Promise< + Result> + > { const { teamId, requestId } = command; - const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId); - const request = allRequests.find((r) => r.id === requestId); + try { + const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId); + const request = allRequests.find((r) => r.id === requestId); - if (!request) { - return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' }); + if (!request) { + return Result.err({ code: 'REQUEST_NOT_FOUND' }); + } + + const membership: TeamMembership = { + teamId: request.teamId, + driverId: request.driverId, + role: 'driver', + status: 'active', + joinedAt: new Date(), + }; + + await this.membershipRepository.saveMembership(membership); + await this.membershipRepository.removeJoinRequest(requestId); + + const result: ApproveTeamJoinRequestResult = { + membership, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : String(error), + }, + }); } - - const membership: TeamMembership = { - teamId: request.teamId, - driverId: request.driverId, - role: 'driver', - status: 'active', - joinedAt: new Date(), - }; - - await this.membershipRepository.saveMembership(membership); - await this.membershipRepository.removeJoinRequest(requestId); - return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CancelRaceUseCase.test.ts b/core/racing/application/use-cases/CancelRaceUseCase.test.ts index c03081dc3..294114e66 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.test.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CancelRaceUseCase } from './CancelRaceUseCase'; +import { CancelRaceUseCase, type CancelRaceResult } from './CancelRaceUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { Logger } from '@core/shared/application'; import { Race } from '../../domain/entities/Race'; import { SessionType } from '../../domain/value-objects/SessionType'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CancelRaceUseCase', () => { let useCase: CancelRaceUseCase; @@ -17,6 +18,7 @@ describe('CancelRaceUseCase', () => { info: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { @@ -29,7 +31,12 @@ describe('CancelRaceUseCase', () => { info: vi.fn(), error: vi.fn(), }; - useCase = new CancelRaceUseCase(raceRepository as unknown as IRaceRepository, logger as unknown as Logger); + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new CancelRaceUseCase( + raceRepository as unknown as IRaceRepository, + logger as unknown as Logger, + output, + ); }); it('should cancel race successfully', async () => { @@ -46,21 +53,25 @@ describe('CancelRaceUseCase', () => { raceRepository.findById.mockResolvedValue(race); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute({ raceId, cancelledById: 'admin-1' }); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(raceRepository.findById).toHaveBeenCalledWith(raceId); expect(raceRepository.update).toHaveBeenCalledWith(expect.objectContaining({ id: raceId, status: 'cancelled' })); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ race: expect.objectContaining({ id: raceId, status: 'cancelled' }) }); }); it('should return error if race not found', async () => { const raceId = 'race-1'; raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute({ raceId, cancelledById: 'admin-1' }); expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return domain error if race is already cancelled', async () => { @@ -77,10 +88,12 @@ describe('CancelRaceUseCase', () => { raceRepository.findById.mockResolvedValue(race); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute({ raceId, cancelledById: 'admin-1' }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('RACE_ALREADY_CANCELLED'); + expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED'); + expect(result.unwrapErr().details?.message).toContain('already cancelled'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return domain error if race is completed', async () => { @@ -97,9 +110,11 @@ describe('CancelRaceUseCase', () => { raceRepository.findById.mockResolvedValue(race); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute({ raceId, cancelledById: 'admin-1' }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('CANNOT_CANCEL_COMPLETED_RACE'); + expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED'); + expect(result.unwrapErr().details?.message).toContain('completed race'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CancelRaceUseCase.ts b/core/racing/application/use-cases/CancelRaceUseCase.ts index c6fd7d77f..e9851d755 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.ts @@ -1,8 +1,20 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Race } from '../../domain/entities/Race'; + +export type CancelRaceInput = { + raceId: string; + cancelledById: string; +}; + +export type CancelRaceErrorCode = 'RACE_NOT_FOUND' | 'NOT_AUTHORIZED' | 'REPOSITORY_ERROR'; + +export type CancelRaceResult = { + race: Race; +}; /** * Use Case: CancelRaceUseCase @@ -13,14 +25,16 @@ import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; * - delegates cancellation rules to the Race domain entity * - persists the updated race via the repository. */ -export class CancelRaceUseCase - implements AsyncUseCase { +export class CancelRaceUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: CancelRaceCommandDTO): Promise>> { + async execute(command: CancelRaceInput): Promise< + Result> + > { const { raceId } = command; this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`); @@ -34,18 +48,39 @@ export class CancelRaceUseCase const cancelledRace = race.cancel(); await this.raceRepository.update(cancelledRace); this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`); + + const result: CancelRaceResult = { + race: cancelledRace, + }; + + this.output.present(result); + return Result.ok(undefined); } catch (error) { if (error instanceof Error && error.message.includes('already cancelled')) { this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`); - return Result.err({ code: 'RACE_ALREADY_CANCELLED' }); + return Result.err({ + code: 'NOT_AUTHORIZED', + details: { message: error.message }, + }); } if (error instanceof Error && error.message.includes('completed race')) { this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`); - return Result.err({ code: 'CANNOT_CANCEL_COMPLETED_RACE' }); + return Result.err({ + code: 'NOT_AUTHORIZED', + details: { message: error.message }, + }); } - this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error))); - return Result.err({ code: 'UNEXPECTED_ERROR' }); + this.logger.error( + `[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, + error instanceof Error ? error : new Error(String(error)), + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : String(error), + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts index 904a970db..d8b18fe1f 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CloseRaceEventStewardingUseCase } from './CloseRaceEventStewardingUseCase'; +import { CloseRaceEventStewardingUseCase, type CloseRaceEventStewardingResult } from './CloseRaceEventStewardingUseCase'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; @@ -8,6 +8,7 @@ import type { Logger } from '@core/shared/application'; import { RaceEvent } from '../../domain/entities/RaceEvent'; import { Session } from '../../domain/entities/Session'; import { SessionType } from '../../domain/value-objects/SessionType'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CloseRaceEventStewardingUseCase', () => { let useCase: CloseRaceEventStewardingUseCase; @@ -27,6 +28,7 @@ describe('CloseRaceEventStewardingUseCase', () => { let logger: { error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceEventRepository = { @@ -45,12 +47,14 @@ describe('CloseRaceEventStewardingUseCase', () => { logger = { error: vi.fn(), }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new CloseRaceEventStewardingUseCase( logger as unknown as Logger, raceEventRepository as unknown as IRaceEventRepository, raceRegistrationRepository as unknown as IRaceRegistrationRepository, penaltyRepository as unknown as IPenaltyRepository, domainEventPublisher as unknown as DomainEventPublisher, + output, ); }); @@ -80,32 +84,41 @@ describe('CloseRaceEventStewardingUseCase', () => { penaltyRepository.findByRaceId.mockResolvedValue([]); domainEventPublisher.publish.mockResolvedValue(undefined); - const result = await useCase.execute({}); + const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' }); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(raceEventRepository.findAwaitingStewardingClose).toHaveBeenCalled(); expect(raceEventRepository.update).toHaveBeenCalledWith( expect.objectContaining({ id: 'event-1', status: 'closed' }) ); expect(domainEventPublisher.publish).toHaveBeenCalled(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + race: expect.objectContaining({ id: 'event-1', status: 'closed' }) + }); }); it('should handle no expired events', async () => { raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([]); - const result = await useCase.execute({}); + const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' }); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(raceEventRepository.update).not.toHaveBeenCalled(); expect(domainEventPublisher.publish).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { raceEventRepository.findAwaitingStewardingClose.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({}); + const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('FAILED_TO_CLOSE_STEWARDING'); + expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); + expect(result.unwrapErr().details?.message).toContain('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts index 631f2ff3b..ac07dd681 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts @@ -1,4 +1,4 @@ -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; @@ -6,8 +6,17 @@ 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 '../dto/CloseRaceEventStewardingCommand'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export type CloseRaceEventStewardingInput = { + raceId: string; + closedById: string; +}; + +export type CloseRaceEventStewardingResult = { + race: RaceEvent; +}; /** * Use Case: CloseRaceEventStewardingUseCase @@ -18,9 +27,7 @@ import type { RaceEvent } from '../../domain/entities/RaceEvent'; * This would typically be run by a scheduled job (e.g., every 5 minutes) * to automatically close stewarding windows based on league configuration. */ -export class CloseRaceEventStewardingUseCase - implements AsyncUseCase -{ +export class CloseRaceEventStewardingUseCase { constructor( private readonly logger: Logger, @@ -28,22 +35,41 @@ export class CloseRaceEventStewardingUseCase private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly penaltyRepository: IPenaltyRepository, private readonly domainEventPublisher: DomainEventPublisher, + private readonly output: UseCaseOutputPort, ) {} - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async execute(_command: CloseRaceEventStewardingCommand): Promise>> { + async execute(_: CloseRaceEventStewardingInput): Promise>> { try { // Find all race events awaiting stewarding that have expired windows const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose(); + const closedRaceEventIds: string[] = []; + for (const raceEvent of expiredEvents) { await this.closeStewardingForRaceEvent(raceEvent); + closedRaceEventIds.push(raceEvent.id); + } + + // When multiple race events are processed, we present the last closed event for simplicity + const lastClosedEventId = closedRaceEventIds[closedRaceEventIds.length - 1]; + if (lastClosedEventId) { + const lastClosedEvent = await this.raceEventRepository.findById(lastClosedEventId); + if (lastClosedEvent) { + this.output.present({ + race: lastClosedEvent, + }); + } } return Result.ok(undefined); } catch (error) { this.logger.error('Failed to close race event stewarding', error instanceof Error ? error : new Error(String(error))); - return Result.err({ code: 'FAILED_TO_CLOSE_STEWARDING' }); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : String(error), + }, + }); } } diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts index 70ad97ce8..5d9f47a23 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CompleteDriverOnboardingUseCase } from './CompleteDriverOnboardingUseCase'; +import { CompleteDriverOnboardingUseCase, type CompleteDriverOnboardingResult } from './CompleteDriverOnboardingUseCase'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { Driver } from '../../domain/entities/Driver'; -import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand'; +import type { CompleteDriverOnboardingInput } from './CompleteDriverOnboardingUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CompleteDriverOnboardingUseCase', () => { let useCase: CompleteDriverOnboardingUseCase; @@ -10,19 +11,22 @@ describe('CompleteDriverOnboardingUseCase', () => { findById: Mock; create: Mock; }; + let output: { present: Mock }; beforeEach(() => { driverRepository = { findById: vi.fn(), create: vi.fn(), }; + output = { present: vi.fn() }; useCase = new CompleteDriverOnboardingUseCase( driverRepository as unknown as IDriverRepository, + output as unknown as UseCaseOutputPort, ); }); it('should create driver successfully when driver does not exist', async () => { - const command: CompleteDriverOnboardingCommand = { + const command: CompleteDriverOnboardingInput = { userId: 'user-1', firstName: 'John', lastName: 'Doe', @@ -44,7 +48,9 @@ describe('CompleteDriverOnboardingUseCase', () => { const result = await useCase.execute(command); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ driverId: 'user-1' }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ driver: createdDriver }); expect(driverRepository.findById).toHaveBeenCalledWith('user-1'); expect(driverRepository.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -58,7 +64,7 @@ describe('CompleteDriverOnboardingUseCase', () => { }); it('should return error when driver already exists', async () => { - const command: CompleteDriverOnboardingCommand = { + const command: CompleteDriverOnboardingInput = { userId: 'user-1', firstName: 'John', lastName: 'Doe', @@ -79,10 +85,11 @@ describe('CompleteDriverOnboardingUseCase', () => { expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS'); expect(driverRepository.create).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository create throws', async () => { - const command: CompleteDriverOnboardingCommand = { + const command: CompleteDriverOnboardingInput = { userId: 'user-1', firstName: 'John', lastName: 'Doe', @@ -96,11 +103,14 @@ describe('CompleteDriverOnboardingUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); + const error = result.unwrapErr() as { code: 'REPOSITORY_ERROR'; details?: { message: string } }; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); it('should handle bio being undefined', async () => { - const command: CompleteDriverOnboardingCommand = { + const command: CompleteDriverOnboardingInput = { userId: 'user-1', firstName: 'John', lastName: 'Doe', @@ -120,6 +130,7 @@ describe('CompleteDriverOnboardingUseCase', () => { const result = await useCase.execute(command); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(driverRepository.create).toHaveBeenCalledWith( expect.objectContaining({ id: 'user-1', @@ -129,5 +140,7 @@ describe('CompleteDriverOnboardingUseCase', () => { bio: undefined, }) ); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ driver: createdDriver }); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts index 58d4546d3..5c6edf86a 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -1,31 +1,41 @@ -import type { AsyncUseCase } from '@core/shared/application'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; 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 '../dto/CompleteDriverOnboardingCommand'; -import type { CompleteDriverOnboardingOutputPort } from '../ports/output/CompleteDriverOnboardingOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export interface CompleteDriverOnboardingInput { + userId: string; + firstName: string; + lastName: string; + displayName: string; + country: string; + bio?: string; +} + +export type CompleteDriverOnboardingResult = { + driver: Driver; +}; /** * Use Case for completing driver onboarding. */ -export class CompleteDriverOnboardingUseCase - implements AsyncUseCase -{ - constructor(private readonly driverRepository: IDriverRepository) {} +export class CompleteDriverOnboardingUseCase { + constructor( + private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, + ) {} - async execute(command: CompleteDriverOnboardingCommand): Promise>> { + async execute(command: CompleteDriverOnboardingInput): Promise>> { try { - // Check if driver already exists const existing = await this.driverRepository.findById(command.userId); if (existing) { return Result.err({ code: 'DRIVER_ALREADY_EXISTS' }); } - // Create new driver const driver = Driver.create({ id: command.userId, - iracingId: command.userId, // Assuming userId is iracingId for now + iracingId: command.userId, name: command.displayName, country: command.country, ...(command.bio !== undefined ? { bio: command.bio } : {}), @@ -33,9 +43,16 @@ export class CompleteDriverOnboardingUseCase await this.driverRepository.create(driver); - return Result.ok({ driverId: driver.id }); - } catch { - return Result.err({ code: 'UNKNOWN_ERROR' }); + this.output.present({ driver }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteRaceUseCase.test.ts b/core/racing/application/use-cases/CompleteRaceUseCase.test.ts index b47686fa1..6d132f838 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCase.test.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCase.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CompleteRaceUseCase } from './CompleteRaceUseCase'; +import { CompleteRaceUseCase, type CompleteRaceInput, type CompleteRaceResult } from './CompleteRaceUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CompleteRaceUseCase', () => { let useCase: CompleteRaceUseCase; @@ -23,6 +23,7 @@ describe('CompleteRaceUseCase', () => { save: Mock; }; let getDriverRating: Mock; + let output: { present: Mock }; beforeEach(() => { raceRepository = { @@ -40,17 +41,19 @@ describe('CompleteRaceUseCase', () => { save: vi.fn(), }; getDriverRating = vi.fn(); + output = { present: vi.fn() }; useCase = new CompleteRaceUseCase( raceRepository as unknown as IRaceRepository, raceRegistrationRepository as unknown as IRaceRegistrationRepository, resultRepository as unknown as IResultRepository, standingRepository as unknown as IStandingRepository, getDriverRating, + output as unknown as UseCaseOutputPort, ); }); it('should complete race successfully when race exists and has registered drivers', async () => { - const command: CompleteRaceCommandDTO = { + const command: CompleteRaceInput = { raceId: 'race-1', }; @@ -75,7 +78,7 @@ describe('CompleteRaceUseCase', () => { const result = await useCase.execute(command); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({}); + expect(result.unwrap()).toBeUndefined(); expect(raceRepository.findById).toHaveBeenCalledWith('race-1'); expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1'); expect(getDriverRating).toHaveBeenCalledTimes(2); @@ -83,10 +86,12 @@ describe('CompleteRaceUseCase', () => { expect(standingRepository.save).toHaveBeenCalledTimes(2); expect(mockRace.complete).toHaveBeenCalled(); expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' }); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ raceId: 'race-1', registeredDriverIds: ['driver-1', 'driver-2'] }); }); it('should return error when race does not exist', async () => { - const command: CompleteRaceCommandDTO = { + const command: CompleteRaceInput = { raceId: 'race-1', }; @@ -96,10 +101,11 @@ describe('CompleteRaceUseCase', () => { expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when no registered drivers', async () => { - const command: CompleteRaceCommandDTO = { + const command: CompleteRaceInput = { raceId: 'race-1', }; @@ -116,10 +122,11 @@ describe('CompleteRaceUseCase', () => { expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { - const command: CompleteRaceCommandDTO = { + const command: CompleteRaceInput = { raceId: 'race-1', }; @@ -137,6 +144,9 @@ describe('CompleteRaceUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); + const error = result.unwrapErr(); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteRaceUseCase.ts b/core/racing/application/use-cases/CompleteRaceUseCase.ts index eb31bbdff..a821a2fc5 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCase.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCase.ts @@ -2,14 +2,22 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort'; -import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort'; -import { Result } from '../../domain/entities/Result'; +import { Result as RaceResult } from '../../domain/entities/result/Result'; import { Standing } from '../../domain/entities/Standing'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export interface CompleteRaceInput { + raceId: string; +} + +export type CompleteRaceResult = { + raceId: string; + registeredDriverIds: string[]; +}; + +export type CompleteRaceErrorCode = 'RACE_NOT_FOUND' | 'NO_REGISTERED_DRIVERS' | 'REPOSITORY_ERROR'; /** * Use Case: CompleteRaceUseCase @@ -22,41 +30,52 @@ import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; * - updates league standings * - persists all changes via repositories. */ -export class CompleteRaceUseCase - implements AsyncUseCase { +interface DriverRatingInput { + driverId: string; +} + +interface DriverRatingOutput { + rating: number | null; + ratingChange: number | null; +} + +export class CompleteRaceUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, private readonly standingRepository: IStandingRepository, - private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise, + private readonly getDriverRating: (input: DriverRatingInput) => Promise, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: CompleteRaceCommandDTO): Promise>> { + async execute(command: CompleteRaceInput): Promise< + Result> + > { try { const { raceId } = command; const race = await this.raceRepository.findById(raceId); if (!race) { - return SharedResult.err({ code: 'RACE_NOT_FOUND' }); + return Result.err({ code: 'RACE_NOT_FOUND' }); } // Get registered drivers for this race const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); if (registeredDriverIds.length === 0) { - return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' }); + return Result.err({ code: 'NO_REGISTERED_DRIVERS' }); } - // Get driver ratings using clean ports - const ratingPromises = registeredDriverIds.map(driverId => - this.getDriverRating({ driverId }) + // Get driver ratings using injected provider + const ratingPromises = registeredDriverIds.map((driverId) => + this.getDriverRating({ driverId }), ); - + const ratingResults = await Promise.all(ratingPromises); const driverRatings = new Map(); - + registeredDriverIds.forEach((driverId, index) => { - const rating = ratingResults[index].rating; + const rating = ratingResults[index]?.rating ?? null; if (rating !== null) { driverRatings.set(driverId, rating); } @@ -77,17 +96,24 @@ export class CompleteRaceUseCase const completedRace = race.complete(); await this.raceRepository.update(completedRace); - return SharedResult.ok({}); - } catch { - return SharedResult.err({ code: 'UNKNOWN_ERROR' }); + this.output.present({ raceId, registeredDriverIds }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } private generateRaceResults( raceId: string, driverIds: string[], - driverRatings: Map - ): Result[] { + driverRatings: Map, + ): RaceResult[] { // Create driver performance data const driverPerformances = driverIds.map(driverId => ({ driverId, @@ -114,7 +140,7 @@ export class CompleteRaceUseCase }); // Generate results - const results: Result[] = []; + const results: RaceResult[] = []; for (let i = 0; i < driverPerformances.length; i++) { const { driverId } = driverPerformances[i]!; const position = i + 1; @@ -130,7 +156,7 @@ export class CompleteRaceUseCase const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0; results.push( - Result.create({ + RaceResult.create({ id: `${raceId}-${driverId}`, raceId, driverId, @@ -138,16 +164,16 @@ export class CompleteRaceUseCase startPosition, fastestLap, incidents, - }) + }), ); } return results; } - private async updateStandings(leagueId: string, results: Result[]): Promise { + private async updateStandings(leagueId: string, results: RaceResult[]): Promise { // Group results by driver - const resultsByDriver = new Map(); + const resultsByDriver = new Map(); for (const result of results) { const existing = resultsByDriver.get(result.driverId) || []; existing.push(result); diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts index 6c524d6ef..a5f7fb62c 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CompleteRaceUseCaseWithRatings } from './CompleteRaceUseCaseWithRatings'; +import { + CompleteRaceUseCaseWithRatings, + type CompleteRaceWithRatingsInput, + type CompleteRaceWithRatingsResult, +} from './CompleteRaceUseCaseWithRatings'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; -import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CompleteRaceUseCaseWithRatings', () => { let useCase: CompleteRaceUseCaseWithRatings; @@ -30,6 +33,7 @@ describe('CompleteRaceUseCaseWithRatings', () => { let ratingUpdateService: { updateDriverRatingsAfterRace: Mock; }; + let output: { present: Mock }; beforeEach(() => { raceRepository = { @@ -52,18 +56,21 @@ describe('CompleteRaceUseCaseWithRatings', () => { ratingUpdateService = { updateDriverRatingsAfterRace: vi.fn(), }; + output = { present: vi.fn() }; + useCase = new CompleteRaceUseCaseWithRatings( raceRepository as unknown as IRaceRepository, raceRegistrationRepository as unknown as IRaceRegistrationRepository, resultRepository as unknown as IResultRepository, standingRepository as unknown as IStandingRepository, - driverRatingProvider as unknown as DriverRatingProvider, + driverRatingProvider, ratingUpdateService as unknown as RatingUpdateService, + output as unknown as UseCaseOutputPort, ); }); - it('should complete race successfully when race exists and has registered drivers', async () => { - const command: CompleteRaceCommandDTO = { + it('completes race with ratings when race exists and has registered drivers', async () => { + const command: CompleteRaceWithRatingsInput = { raceId: 'race-1', }; @@ -75,7 +82,12 @@ describe('CompleteRaceUseCaseWithRatings', () => { }; raceRepository.findById.mockResolvedValue(mockRace); raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); - driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600], ['driver-2', 1500]])); + driverRatingProvider.getRatings.mockReturnValue( + new Map([ + ['driver-1', 1600], + ['driver-2', 1500], + ]), + ); resultRepository.create.mockResolvedValue(undefined); standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null); standingRepository.save.mockResolvedValue(undefined); @@ -94,10 +106,15 @@ describe('CompleteRaceUseCaseWithRatings', () => { expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled(); expect(mockRace.complete).toHaveBeenCalled(); expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' }); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + raceId: 'race-1', + ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'], + }); }); - it('should return error when race does not exist', async () => { - const command: CompleteRaceCommandDTO = { + it('returns error when race does not exist', async () => { + const command: CompleteRaceWithRatingsInput = { raceId: 'race-1', }; @@ -107,10 +124,32 @@ describe('CompleteRaceUseCaseWithRatings', () => { expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return error when no registered drivers', async () => { - const command: CompleteRaceCommandDTO = { + it('returns error when race is already completed', async () => { + const command: CompleteRaceWithRatingsInput = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'completed', + complete: vi.fn(), + }; + raceRepository.findById.mockResolvedValue(mockRace); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().code).toBe('ALREADY_COMPLETED'); + expect(output.present).not.toHaveBeenCalled(); + expect(raceRegistrationRepository.getRegisteredDrivers).not.toHaveBeenCalled(); + }); + + it('returns error when no registered drivers', async () => { + const command: CompleteRaceWithRatingsInput = { raceId: 'race-1', }; @@ -127,10 +166,39 @@ describe('CompleteRaceUseCaseWithRatings', () => { expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return error when repository throws', async () => { - const command: CompleteRaceCommandDTO = { + it('returns rating update error when rating service throws', async () => { + const command: CompleteRaceWithRatingsInput = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']); + driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]])); + resultRepository.create.mockResolvedValue(undefined); + standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null); + standingRepository.save.mockResolvedValue(undefined); + ratingUpdateService.updateDriverRatingsAfterRace.mockRejectedValue(new Error('Rating error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('RATING_UPDATE_FAILED'); + expect(error.details?.message).toBe('Rating error'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns repository error when persistence fails', async () => { + const command: CompleteRaceWithRatingsInput = { raceId: 'race-1', }; @@ -148,6 +216,9 @@ describe('CompleteRaceUseCaseWithRatings', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); + const error = result.unwrapErr(); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts index 861bc2140..407d31d6b 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts @@ -2,21 +2,38 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; -import { Result } from '../../domain/entities/Result'; +import { Result as RaceResult } from '../../domain/entities/result/Result'; import { Standing } from '../../domain/entities/Standing'; import { RaceResultGenerator } from '../utils/RaceResultGenerator'; import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export interface CompleteRaceWithRatingsInput { + raceId: string; +} + +export type CompleteRaceWithRatingsResult = { + raceId: string; + ratingsUpdatedForDriverIds: string[]; +}; + +export type CompleteRaceWithRatingsErrorCode = + | 'RACE_NOT_FOUND' + | 'NO_REGISTERED_DRIVERS' + | 'ALREADY_COMPLETED' + | 'RATING_UPDATE_FAILED' + | 'REPOSITORY_ERROR'; + +interface DriverRatingProvider { + getRatings(driverIds: string[]): Map; +} /** - * Enhanced CompleteRaceUseCase that includes rating updates + * Enhanced CompleteRaceUseCase that includes rating updates. */ -export class CompleteRaceUseCaseWithRatings - implements AsyncUseCase { +export class CompleteRaceUseCaseWithRatings { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, @@ -24,60 +41,77 @@ export class CompleteRaceUseCaseWithRatings private readonly standingRepository: IStandingRepository, private readonly driverRatingProvider: DriverRatingProvider, private readonly ratingUpdateService: RatingUpdateService, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: CompleteRaceCommandDTO): Promise>> { + async execute(command: CompleteRaceWithRatingsInput): Promise< + Result> + > { try { const { raceId } = command; const race = await this.raceRepository.findById(raceId); if (!race) { - return SharedResult.err({ code: 'RACE_NOT_FOUND' }); + return Result.err({ code: 'RACE_NOT_FOUND' }); + } + + if (race.status === 'completed') { + return Result.err({ code: 'ALREADY_COMPLETED' }); } - // Get registered drivers for this race const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); if (registeredDriverIds.length === 0) { - return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' }); + return Result.err({ code: 'NO_REGISTERED_DRIVERS' }); } - // Get driver ratings const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); - // Generate realistic race results const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings); - // Save results for (const result of results) { await this.resultRepository.create(result); } - // Update standings await this.updateStandings(race.leagueId, results); - // Update driver ratings based on performance - await this.updateDriverRatings(results, registeredDriverIds.length); + try { + await this.updateDriverRatings(results, registeredDriverIds.length); + } catch (error) { + return Result.err({ + code: 'RATING_UPDATE_FAILED', + details: { + message: error instanceof Error ? error.message : 'Failed to update driver ratings', + }, + }); + } - // Complete the race const completedRace = race.complete(); await this.raceRepository.update(completedRace); - return SharedResult.ok(undefined); - } catch { - return SharedResult.err({ code: 'UNKNOWN_ERROR' }); + this.output.present({ + raceId, + ratingsUpdatedForDriverIds: registeredDriverIds, + }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } - private async updateStandings(leagueId: string, results: Result[]): Promise { - // Group results by driver - const resultsByDriver = new Map(); + private async updateStandings(leagueId: string, results: RaceResult[]): Promise { + const resultsByDriver = new Map(); for (const result of results) { const existing = resultsByDriver.get(result.driverId) || []; existing.push(result); resultsByDriver.set(result.driverId, existing); } - // Update or create standings for each driver for (const [driverId, driverResults] of resultsByDriver) { let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId); @@ -88,10 +122,18 @@ export class CompleteRaceUseCaseWithRatings }); } - // Add all results for this driver (should be just one for this race) for (const result of driverResults) { standing = standing.addRaceResult(result.position, { - 1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1 + 1: 25, + 2: 18, + 3: 15, + 4: 12, + 5: 10, + 6: 8, + 7: 6, + 8: 4, + 9: 2, + 10: 1, }); } @@ -99,8 +141,8 @@ export class CompleteRaceUseCaseWithRatings } } - private async updateDriverRatings(results: Result[], totalDrivers: number): Promise { - const driverResults = results.map(result => ({ + private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise { + const driverResults = results.map((result) => ({ driverId: result.driverId, position: result.position, totalDrivers, diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts index c5e30910f..b38896a8f 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts @@ -1,10 +1,13 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase'; -import type { CreateLeagueWithSeasonAndScoringCommand } from '../dto/CreateLeagueWithSeasonAndScoringCommand'; +import { + CreateLeagueWithSeasonAndScoringUseCase, + type CreateLeagueWithSeasonAndScoringCommand, + type CreateLeagueWithSeasonAndScoringResult, +} from './CreateLeagueWithSeasonAndScoringUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; describe('CreateLeagueWithSeasonAndScoringUseCase', () => { let useCase: CreateLeagueWithSeasonAndScoringUseCase; @@ -24,6 +27,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { warn: Mock; error: Mock; }; + let output: { present: Mock } & UseCaseOutputPort; beforeEach(() => { leagueRepository = { @@ -42,12 +46,14 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { present: vi.fn() } as unknown as typeof output; useCase = new CreateLeagueWithSeasonAndScoringUseCase( leagueRepository as unknown as ILeagueRepository, seasonRepository as unknown as ISeasonRepository, leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, getLeagueScoringPresetById, logger as unknown as Logger, + output, ); }); @@ -80,11 +86,13 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.leagueId).toBeDefined(); - expect(data.seasonId).toBeDefined(); - expect(data.scoringPresetId).toBe('club-default'); - expect(data.scoringPresetName).toBe('Club Default'); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as CreateLeagueWithSeasonAndScoringResult; + expect(presented.league.id.toString()).toBeDefined(); + expect(presented.season.id).toBeDefined(); + expect(presented.scoringConfig.seasonId.toString()).toBe(presented.season.id); expect(leagueRepository.create).toHaveBeenCalledTimes(1); expect(seasonRepository.create).toHaveBeenCalledTimes(1); expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1); @@ -106,7 +114,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('League name is required'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe('League name is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when ownerId is empty', async () => { @@ -125,7 +135,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('League ownerId is required'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe('League ownerId is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when gameId is empty', async () => { @@ -144,7 +156,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('gameId is required'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe('gameId is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when visibility is missing', async () => { @@ -161,7 +175,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('visibility is required'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe('visibility is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when maxDrivers is invalid', async () => { @@ -181,7 +197,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('maxDrivers must be greater than 0 when provided'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe('maxDrivers must be greater than 0 when provided'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when ranked league has insufficient drivers', async () => { @@ -201,7 +219,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toContain('Ranked leagues require at least 10 drivers'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toContain('Ranked leagues require at least 10 drivers'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when scoring preset is unknown', async () => { @@ -223,7 +243,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('Unknown scoring preset: unknown-preset'); + expect(result.unwrapErr().code).toBe('UNKNOWN_PRESET'); + expect(result.unwrapErr().details?.message).toBe('Unknown scoring preset: unknown-preset'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { @@ -250,6 +272,8 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('DB error'); + expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); + expect(result.unwrapErr().details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts index 76b0587b2..5e5ceccd2 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -1,22 +1,19 @@ import { v4 as uuidv4 } from 'uuid'; import { League } from '../../domain/entities/League'; -import { Season } from '../../domain/entities/Season'; +import { Season } from '../../domain/entities/season/Season'; +import { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; -import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort'; -import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { LeagueVisibility, MIN_RANKED_LEAGUE_DRIVERS, } from '../../domain/value-objects/LeagueVisibility'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { LeagueVisibilityInput } from '../dto/LeagueVisibilityInput'; -import type { CreateLeagueWithSeasonAndScoringOutputPort } from '../ports/output/CreateLeagueWithSeasonAndScoringOutputPort'; -export interface CreateLeagueWithSeasonAndScoringCommand { +export type CreateLeagueWithSeasonAndScoringCommand = { name: string; description?: string; /** @@ -24,7 +21,7 @@ export interface CreateLeagueWithSeasonAndScoringCommand { * - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers. * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. */ - visibility: LeagueVisibilityInput; + visibility: string; ownerId: string; gameId: string; maxDrivers?: number; @@ -34,21 +31,37 @@ export interface CreateLeagueWithSeasonAndScoringCommand { enableNationsChampionship: boolean; enableTrophyChampionship: boolean; scoringPresetId?: string; -} +}; -export class CreateLeagueWithSeasonAndScoringUseCase - implements AsyncUseCase { +export type CreateLeagueWithSeasonAndScoringResult = { + league: League; + season: Season; + scoringConfig: LeagueScoringConfig; +}; + +type CreateLeagueWithSeasonAndScoringErrorCode = + | 'VALIDATION_ERROR' + | 'UNKNOWN_PRESET' + | 'REPOSITORY_ERROR'; + +type ScoringPreset = { + id: string; + name: string; +}; + +export class CreateLeagueWithSeasonAndScoringUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, - private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise, + private readonly getLeagueScoringPresetById: (input: { presetId: string }) => Promise, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} async execute( command: CreateLeagueWithSeasonAndScoringCommand, - ): Promise>> { + ): Promise>> { this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command }); const validation = this.validate(command); if (validation.isErr()) { @@ -93,8 +106,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase const presetId = command.scoringPresetId ?? 'club-default'; this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`); - const preset: LeagueScoringPresetOutputPort | undefined = - await this.getLeagueScoringPresetById({ presetId }); + const preset = await this.getLeagueScoringPresetById({ presetId }); if (!preset) { this.logger.error(`Unknown scoring preset: ${presetId}`); @@ -102,10 +114,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase } this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); - // Note: createScoringConfigFromPreset business logic should be moved to domain layer - // For now, we'll create a basic config structure - const finalConfig = { - id: uuidv4(), + const scoringConfig = LeagueScoringConfig.create({ seasonId, scoringPresetId: preset.id, championships: { @@ -114,26 +123,33 @@ export class CreateLeagueWithSeasonAndScoringUseCase nations: command.enableNationsChampionship, trophy: command.enableTrophyChampionship, }, - }; + }); this.logger.debug(`Scoring configuration created from preset ${preset.id}.`); - await this.leagueScoringConfigRepository.save(finalConfig); + await this.leagueScoringConfigRepository.save(scoringConfig); this.logger.info(`Scoring configuration saved for season ${seasonId}.`); - const result: CreateLeagueWithSeasonAndScoringOutputPort = { - leagueId: league.id.toString(), - seasonId, - scoringPresetId: preset.id, - scoringPresetName: preset.name, + const result: CreateLeagueWithSeasonAndScoringResult = { + league, + season, + scoringConfig, }; this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result }); - return Result.ok(result); + this.output.present(result); + return Result.ok(undefined); } catch (error) { - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } - private validate(command: CreateLeagueWithSeasonAndScoringCommand): Result> { + private validate( + command: CreateLeagueWithSeasonAndScoringCommand, + ): Result> { this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command }); if (!command.name || command.name.trim().length === 0) { this.logger.warn('Validation failed: League name is required', { command }); diff --git a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts index bbdde5f46..aa86844b8 100644 --- a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts +++ b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts @@ -1,13 +1,17 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, Mock } from 'vitest'; import { Season } from '@core/racing/domain/entities/season/Season'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import { CreateSeasonForLeagueUseCase, - type CreateSeasonForLeagueCommand, + type CreateSeasonForLeagueInput, + type CreateSeasonForLeagueResult, } from '@core/racing/application/use-cases/CreateSeasonForLeagueUseCase'; import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; function createLeagueConfigFormModel(overrides?: Partial): LeagueConfigFormModel { return { @@ -66,6 +70,8 @@ function createLeagueConfigFormModel(overrides?: Partial) }; } +type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>; + describe('CreateSeasonForLeagueUseCase', () => { const mockLeagueFindById = vi.fn(); const mockLeagueRepo: ILeagueRepository = { @@ -91,15 +97,18 @@ describe('CreateSeasonForLeagueUseCase', () => { listActiveByLeague: vi.fn(), }; + let output: { present: Mock } & UseCaseOutputPort; + beforeEach(() => { vi.clearAllMocks(); + output = { present: vi.fn() } as unknown as typeof output; }); 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 useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output); const config = createLeagueConfigFormModel({ basics: { @@ -124,17 +133,22 @@ describe('CreateSeasonForLeagueUseCase', () => { }, }); - const command: CreateSeasonForLeagueCommand = { + const command: CreateSeasonForLeagueInput = { leagueId: 'league-1', name: 'Season from Config', gameId: 'iracing', config, }; - const result = await useCase.execute(command); + const result: Result = await useCase.execute(command); expect(result.isOk()).toBe(true); - expect(result.value!.seasonId).toBeDefined(); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult; + expect(presented.season).toBeInstanceOf(Season); + expect(presented.league.id).toBe('league-1'); }); it('clones configuration from a source season when sourceSeasonId is provided', async () => { @@ -150,18 +164,64 @@ describe('CreateSeasonForLeagueUseCase', () => { mockSeasonFindById.mockResolvedValue(sourceSeason); mockSeasonAdd.mockResolvedValue(undefined); - const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo); + const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output); - const command: CreateSeasonForLeagueCommand = { + const command: CreateSeasonForLeagueInput = { leagueId: 'league-1', name: 'Cloned Season', gameId: 'iracing', sourceSeasonId: 'source-season', }; - const result = await useCase.execute(command); + const result: Result = await useCase.execute(command); expect(result.isOk()).toBe(true); - expect(result.value!.seasonId).toBeDefined(); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult; + expect(presented.season.maxDrivers).toBe(40); }); -}); \ No newline at end of file + + it('returns error when league not found and does not call output', async () => { + mockLeagueFindById.mockResolvedValue(null); + + const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output); + + const command: CreateSeasonForLeagueInput = { + leagueId: 'missing-league', + name: 'Any', + gameId: 'iracing', + }; + + const result: Result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toBe('League not found: missing-league'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns validation error when source season is missing and does not call output', async () => { + mockLeagueFindById.mockResolvedValue({ id: 'league-1' }); + mockSeasonFindById.mockResolvedValue(undefined); + + const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output); + + const command: CreateSeasonForLeagueInput = { + leagueId: 'league-1', + name: 'Cloned Season', + gameId: 'iracing', + sourceSeasonId: 'missing-source', + }; + + const result: Result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.details?.message).toBe('Source Season not found: missing-source'); + expect(output.present).not.toHaveBeenCalled(); + }); +}) diff --git a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts index c2bd5116c..a552278df 100644 --- a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts +++ b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts @@ -1,4 +1,5 @@ import { Season } from '../../domain/entities/Season'; +import { League } from '../../domain/entities/League'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; @@ -13,10 +14,11 @@ import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern'; import type { Weekday } from '../../domain/types/Weekday'; import { v4 as uuidv4 } from 'uuid'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface CreateSeasonForLeagueCommand { +export type CreateSeasonForLeagueInput = { leagueId: string; name: string; gameId: string; @@ -26,13 +28,14 @@ export interface CreateSeasonForLeagueCommand { * When omitted, the Season will be created with minimal metadata only. */ config?: LeagueConfigFormModel; -} +}; -export interface CreateSeasonForLeagueResultDTO { - seasonId: string; -} +export type CreateSeasonForLeagueResult = { + league: League; + season: Season; +}; -type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'SOURCE_SEASON_NOT_FOUND'; +type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'; /** * CreateSeasonForLeagueUseCase @@ -44,79 +47,77 @@ export class CreateSeasonForLeagueUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - command: CreateSeasonForLeagueCommand, - ): Promise>> { - const league = await this.leagueRepository.findById(command.leagueId); - if (!league) { - return Result.err({ - code: 'LEAGUE_NOT_FOUND', - details: { message: `League not found: ${command.leagueId}` }, - }); - } - - let baseSeasonProps: { - schedule?: SeasonSchedule; - scoringConfig?: SeasonScoringConfig; - dropPolicy?: SeasonDropPolicy; - stewardingConfig?: SeasonStewardingConfig; - maxDrivers?: number; - } = {}; - - if (command.sourceSeasonId) { - const source = await this.seasonRepository.findById(command.sourceSeasonId); - if (!source) { + input: CreateSeasonForLeagueInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { return Result.err({ - code: 'SOURCE_SEASON_NOT_FOUND', - details: { message: `Source Season not found: ${command.sourceSeasonId}` }, + code: 'LEAGUE_NOT_FOUND', + details: { message: `League not found: ${input.leagueId}` }, }); } - baseSeasonProps = { - ...(source.schedule !== undefined ? { schedule: source.schedule } : {}), - ...(source.scoringConfig !== undefined - ? { scoringConfig: source.scoringConfig } - : {}), - ...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}), - ...(source.stewardingConfig !== undefined - ? { stewardingConfig: source.stewardingConfig } - : {}), - ...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}), - }; - } else if (command.config) { - baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config); + + let baseSeasonProps: { + schedule?: SeasonSchedule; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + } = {}; + + if (input.sourceSeasonId) { + const source = await this.seasonRepository.findById(input.sourceSeasonId); + if (!source) { + return Result.err({ + code: 'VALIDATION_ERROR', + details: { message: `Source Season not found: ${input.sourceSeasonId}` }, + }); + } + baseSeasonProps = { + ...(source.schedule !== undefined ? { schedule: source.schedule } : {}), + ...(source.scoringConfig !== undefined ? { scoringConfig: source.scoringConfig } : {}), + ...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}), + ...(source.stewardingConfig !== undefined ? { stewardingConfig: source.stewardingConfig } : {}), + ...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}), + }; + } else if (input.config) { + baseSeasonProps = this.deriveSeasonPropsFromConfig(input.config); + } + + const seasonId = uuidv4(); + + const season = Season.create({ + id: seasonId, + leagueId: league.id, + gameId: input.gameId, + name: input.name, + year: new Date().getFullYear(), + status: 'planned', + ...(baseSeasonProps?.schedule ? { schedule: baseSeasonProps.schedule } : {}), + ...(baseSeasonProps?.scoringConfig ? { scoringConfig: baseSeasonProps.scoringConfig } : {}), + ...(baseSeasonProps?.dropPolicy ? { dropPolicy: baseSeasonProps.dropPolicy } : {}), + ...(baseSeasonProps?.stewardingConfig ? { stewardingConfig: baseSeasonProps.stewardingConfig } : {}), + ...(baseSeasonProps?.maxDrivers !== undefined ? { maxDrivers: baseSeasonProps.maxDrivers } : {}), + }); + + await this.seasonRepository.add(season); + + this.output.present({ league, season }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } - - const seasonId = uuidv4(); - - const season = Season.create({ - id: seasonId, - leagueId: league.id, - gameId: command.gameId, - name: command.name, - year: new Date().getFullYear(), - status: 'planned', - ...(baseSeasonProps?.schedule - ? { schedule: baseSeasonProps.schedule } - : {}), - ...(baseSeasonProps?.scoringConfig - ? { scoringConfig: baseSeasonProps.scoringConfig } - : {}), - ...(baseSeasonProps?.dropPolicy - ? { dropPolicy: baseSeasonProps.dropPolicy } - : {}), - ...(baseSeasonProps?.stewardingConfig - ? { stewardingConfig: baseSeasonProps.stewardingConfig } - : {}), - ...(baseSeasonProps?.maxDrivers !== undefined - ? { maxDrivers: baseSeasonProps.maxDrivers } - : {}), - }); - - await this.seasonRepository.add(season); - - return Result.ok({ seasonId }); } private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): { @@ -216,4 +217,4 @@ export class CreateSeasonForLeagueUseCase { plannedRounds, }); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/CreateSponsorUseCase.test.ts b/core/racing/application/use-cases/CreateSponsorUseCase.test.ts index 487820828..d8f8a8bd0 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.test.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CreateSponsorUseCase } from './CreateSponsorUseCase'; -import type { CreateSponsorCommand } from '../dto/CreateSponsorCommand'; +import { CreateSponsorUseCase, type CreateSponsorInput } from './CreateSponsorUseCase'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CreateSponsorUseCase', () => { let useCase: CreateSponsorUseCase; @@ -15,6 +15,9 @@ describe('CreateSponsorUseCase', () => { warn: Mock; error: Mock; }; + let output: { + present: Mock; + }; beforeEach(() => { sponsorRepository = { @@ -26,14 +29,18 @@ describe('CreateSponsorUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + }; useCase = new CreateSponsorUseCase( sponsorRepository as unknown as ISponsorRepository, logger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); }); it('should create sponsor successfully', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: 'test@example.com', websiteUrl: 'https://example.com', @@ -42,95 +49,104 @@ describe('CreateSponsorUseCase', () => { sponsorRepository.create.mockResolvedValue(undefined); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.sponsor.id).toBeDefined(); - expect(data.sponsor.name).toBe('Test Sponsor'); - expect(data.sponsor.contactEmail).toBe('test@example.com'); - expect(data.sponsor.websiteUrl).toBe('https://example.com'); - expect(data.sponsor.logoUrl).toBe('https://example.com/logo.png'); - expect(data.sponsor.createdAt).toBeInstanceOf(Date); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0]; + expect(presented.sponsor.id).toBeDefined(); + expect(presented.sponsor.name).toBe('Test Sponsor'); + expect(presented.sponsor.contactEmail).toBe('test@example.com'); + expect(presented.sponsor.websiteUrl).toBe('https://example.com'); + expect(presented.sponsor.logoUrl).toBe('https://example.com/logo.png'); + expect(presented.sponsor.createdAt).toBeInstanceOf(Date); expect(sponsorRepository.create).toHaveBeenCalledTimes(1); }); it('should create sponsor without optional fields', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: 'test@example.com', }; sponsorRepository.create.mockResolvedValue(undefined); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.sponsor.websiteUrl).toBeUndefined(); - expect(data.sponsor.logoUrl).toBeUndefined(); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0]; + expect(presented.sponsor.websiteUrl).toBeUndefined(); + expect(presented.sponsor.logoUrl).toBeUndefined(); }); it('should return error when name is empty', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: '', contactEmail: 'test@example.com', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().details.message).toBe('Sponsor name is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when contactEmail is empty', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: '', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().details.message).toBe('Sponsor contact email is required'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when contactEmail is invalid', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: 'invalid-email', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().details.message).toBe('Invalid sponsor contact email format'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when websiteUrl is invalid', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: 'test@example.com', websiteUrl: 'invalid-url', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().details.message).toBe('Invalid sponsor website URL'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { - const command: CreateSponsorCommand = { + const input: CreateSponsorInput = { name: 'Test Sponsor', contactEmail: 'test@example.com', }; sponsorRepository.create.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateSponsorUseCase.ts b/core/racing/application/use-cases/CreateSponsorUseCase.ts index 56d6a9fea..65084a9c4 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.ts @@ -4,64 +4,58 @@ * Creates a new sponsor. */ import { v4 as uuidv4 } from 'uuid'; -import { Sponsor } from '../../domain/entities/Sponsor'; +import { Sponsor } from '../../domain/entities/sponsor/Sponsor'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { CreateSponsorOutputPort } from '../ports/output/CreateSponsorOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export interface CreateSponsorCommand { +export interface CreateSponsorInput { name: string; contactEmail: string; websiteUrl?: string; logoUrl?: string; } -export class CreateSponsorUseCase - implements AsyncUseCase -{ +type CreateSponsorResult = { + sponsor: Sponsor; +}; + +export class CreateSponsorUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} async execute( - command: CreateSponsorCommand, - ): Promise>> { - this.logger.debug('Executing CreateSponsorUseCase', { command }); - const validation = this.validate(command); + input: CreateSponsorInput, + ): Promise>> { + this.logger.debug('Executing CreateSponsorUseCase', { input }); + const validation = this.validate(input); if (validation.isErr()) { return Result.err(validation.unwrapErr()); } - this.logger.info('Command validated successfully.'); + this.logger.info('Input validated successfully.'); try { const sponsorId = uuidv4(); this.logger.debug(`Generated sponsorId: ${sponsorId}`); const sponsor = Sponsor.create({ id: sponsorId, - name: command.name, - contactEmail: command.contactEmail, - ...(command.websiteUrl !== undefined ? { websiteUrl: command.websiteUrl } : {}), - ...(command.logoUrl !== undefined ? { logoUrl: command.logoUrl } : {}), + name: input.name, + contactEmail: input.contactEmail, + ...(input.websiteUrl !== undefined ? { websiteUrl: input.websiteUrl } : {}), + ...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl } : {}), }); await this.sponsorRepository.create(sponsor); this.logger.info(`Sponsor ${sponsor.name} (${sponsor.id}) created successfully.`); - const result: CreateSponsorOutputPort = { - sponsor: { - id: sponsor.id, - name: sponsor.name, - contactEmail: sponsor.contactEmail, - createdAt: sponsor.createdAt, - ...(sponsor.websiteUrl !== undefined ? { websiteUrl: sponsor.websiteUrl } : {}), - ...(sponsor.logoUrl !== undefined ? { logoUrl: sponsor.logoUrl } : {}), - }, - }; - this.logger.debug('CreateSponsorUseCase completed successfully.', { result }); - return Result.ok(result); + this.output.present({ sponsor }); + this.logger.debug('CreateSponsorUseCase completed successfully.'); + return Result.ok(undefined); } catch (error) { return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); } diff --git a/core/racing/application/use-cases/CreateTeamUseCase.test.ts b/core/racing/application/use-cases/CreateTeamUseCase.test.ts index 9f4cec89f..f7c801115 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.test.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CreateTeamUseCase, type CreateTeamCommandDTO } from './CreateTeamUseCase'; +import { + CreateTeamUseCase, + type CreateTeamInput, + type CreateTeamResult, +} from './CreateTeamUseCase'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('CreateTeamUseCase', () => { let useCase: CreateTeamUseCase; @@ -19,6 +24,7 @@ describe('CreateTeamUseCase', () => { warn: Mock; error: Mock; }; + let output: { present: Mock }; beforeEach(() => { teamRepository = { @@ -34,15 +40,17 @@ describe('CreateTeamUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { present: vi.fn() }; useCase = new CreateTeamUseCase( teamRepository as unknown as ITeamRepository, membershipRepository as unknown as ITeamMembershipRepository, logger as unknown as Logger, + output as unknown as UseCaseOutputPort, ); }); it('should create team successfully', async () => { - const command: CreateTeamCommandDTO = { + const command: CreateTeamInput = { name: 'Test Team', tag: 'TT', description: 'A test team', @@ -66,19 +74,15 @@ describe('CreateTeamUseCase', () => { const result = await useCase.execute(command); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.team.id).toBeDefined(); - expect(data.team.name).toBe('Test Team'); - expect(data.team.tag).toBe('TT'); - expect(data.team.description).toBe('A test team'); - expect(data.team.ownerId).toBe('owner-123'); - expect(data.team.leagues).toEqual(['league-1']); + expect(result.unwrap()).toBeUndefined(); expect(teamRepository.create).toHaveBeenCalledTimes(1); expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ team: mockTeam }); }); it('should return error when driver already belongs to a team', async () => { - const command: CreateTeamCommandDTO = { + const command: CreateTeamInput = { name: 'Test Team', tag: 'TT', description: 'A test team', @@ -97,13 +101,17 @@ describe('CreateTeamUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('Driver already belongs to a team'); + expect(result.unwrapErr().code).toBe('VALIDATION_ERROR'); + expect(result.unwrapErr().details?.message).toBe( + 'Driver already belongs to a team', + ); expect(teamRepository.create).not.toHaveBeenCalled(); expect(membershipRepository.saveMembership).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { - const command: CreateTeamCommandDTO = { + const command: CreateTeamInput = { name: 'Test Team', tag: 'TT', description: 'A test team', @@ -117,6 +125,8 @@ describe('CreateTeamUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().details.message).toBe('DB error'); + expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); + expect(result.unwrapErr().details?.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateTeamUseCase.ts b/core/racing/application/use-cases/CreateTeamUseCase.ts index fd0bbee80..c83befd1e 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.ts @@ -12,12 +12,12 @@ import type { TeamMembershipStatus, TeamRole, } from '../../domain/types/TeamMembership'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { CreateTeamOutputPort } from '../ports/output/CreateTeamOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export interface CreateTeamCommandDTO { +export interface CreateTeamInput { name: string; tag: string; description: string; @@ -25,27 +25,42 @@ export interface CreateTeamCommandDTO { leagues: string[]; } -export class CreateTeamUseCase - implements AsyncUseCase -{ +export interface CreateTeamResult { + team: Team; +} + +export type CreateTeamErrorCode = + | 'VALIDATION_ERROR' + | 'LEAGUE_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export class CreateTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} async execute( - command: CreateTeamCommandDTO, - ): Promise>> { - this.logger.debug('Executing CreateTeamUseCase', { command }); - const { name, tag, description, ownerId, leagues } = command; + input: CreateTeamInput, + ): Promise< + Result> + > { + this.logger.debug('Executing CreateTeamUseCase', { input }); + const { name, tag, description, ownerId, leagues } = input; - const existingMembership = await this.membershipRepository.getActiveMembershipForDriver( - ownerId, - ); + const existingMembership = + await this.membershipRepository.getActiveMembershipForDriver(ownerId); if (existingMembership) { - this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId }); - return Result.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } }); + this.logger.warn( + 'Validation failed: Driver already belongs to a team', + { ownerId }, + ); + return Result.err({ + code: 'VALIDATION_ERROR', + details: { message: 'Driver already belongs to a team' }, + }); } this.logger.info('Command validated successfully.'); @@ -63,7 +78,9 @@ export class CreateTeamUseCase }); const createdTeam = await this.teamRepository.create(team); - this.logger.info(`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`); + this.logger.info( + `Team ${createdTeam.name} (${createdTeam.id}) created successfully.`, + ); const membership: TeamMembership = { teamId: createdTeam.id, @@ -76,11 +93,17 @@ export class CreateTeamUseCase await this.membershipRepository.saveMembership(membership); this.logger.debug('Team membership created successfully.'); - const result: CreateTeamOutputPort = { team: createdTeam }; + const result: CreateTeamResult = { team: createdTeam }; this.logger.debug('CreateTeamUseCase completed successfully.', { result }); - return Result.ok(result); + this.output.present(result); + return Result.ok(undefined); } catch (error) { - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts index 1888eb954..b45ae1bed 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts @@ -1,34 +1,28 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; -import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; +import { + DashboardOverviewUseCase, + type DashboardOverviewInput, + type DashboardOverviewResult, +} from '@core/racing/application/use-cases/DashboardOverviewUseCase'; import { Driver } from '@core/racing/domain/entities/Driver'; import { Race } from '@core/racing/domain/entities/Race'; -import { Result } from '@core/racing/domain/entities/Result'; import { League } from '@core/racing/domain/entities/League'; import { Standing } from '@core/racing/domain/entities/Standing'; -import { LeagueMembership, JoinRequest } from '@core/racing/domain/entities/LeagueMembership'; +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import { Result as RaceResult } from '@core/racing/domain/entities/Result'; import type { FeedItem } from '@core/social/domain/types/FeedItem'; - -interface TestImageService { - getDriverAvatar(driverId: string): string; - getTeamLogo(teamId: string): string; - getLeagueCover(leagueId: string): string; - getLeagueLogo(leagueId: string): string; -} - -function createTestImageService(): TestImageService { - return { - getDriverAvatar: (driverId: string) => `avatar-${driverId}`, - getTeamLogo: (teamId: string) => `team-logo-${teamId}`, - getLeagueCover: (leagueId: string) => `league-cover-${leagueId}`, - getLeagueLogo: (leagueId: string) => `league-logo-${leagueId}`, - }; -} +import { Result as UseCaseResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('DashboardOverviewUseCase', () => { it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => { - // Given a driver with memberships in two leagues and future races with mixed registration + const output: UseCaseOutputPort = { + present: vi.fn(), + }; + const driverId = 'driver-1'; const driver = Driver.create({ id: driverId, iracingId: '12345', name: 'Alice Racer', country: 'US' }); @@ -75,7 +69,7 @@ describe('DashboardOverviewUseCase', () => { }), ]; - const results: Result[] = []; + const results: RaceResult[] = []; const memberships = [ LeagueMembership.create({ @@ -96,18 +90,24 @@ describe('DashboardOverviewUseCase', () => { const feedItems: FeedItem[] = []; const friends: Driver[] = []; - + const driverRepository = { findById: async (id: string): Promise => (id === driver.id ? driver : null), findByIRacingId: async (): Promise => null, findAll: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByIRacingId: async (): Promise => false, }; - + const raceRepository = { findById: async (): Promise => null, findAll: async (): Promise => races, @@ -116,66 +116,104 @@ describe('DashboardOverviewUseCase', () => { findCompletedByLeagueId: async (): Promise => [], findByStatus: async (): Promise => [], findByDateRange: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, }; - + const resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => results, - findByRaceId: async (): Promise => [], - findByDriverId: async (): Promise => [], - findByDriverIdAndLeagueId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - createMany: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByRaceId: async (): Promise => { throw new Error('Not implemented'); }, + findById: async (): Promise => null, + findAll: async (): Promise => results, + findByRaceId: async (): Promise => [], + findByDriverId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + createMany: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByRaceId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByRaceId: async (): Promise => false, }; - + const leagueRepository = { findById: async (): Promise => null, findAll: async (): Promise => leagues, findByOwnerId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, searchByName: async (): Promise => [], }; - + const standingRepository = { findByLeagueId: async (): Promise => [], findByDriverIdAndLeagueId: async (): Promise => null, findAll: async (): Promise => [], - save: async (): Promise => { throw new Error('Not implemented'); }, - saveMany: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByLeagueId: async (): Promise => { throw new Error('Not implemented'); }, + save: async (): Promise => { + throw new Error('Not implemented'); + }, + saveMany: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByLeagueId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, recalculate: async (): Promise => [], }; - + const leagueMembershipRepository = { getMembership: async (leagueId: string, driverIdParam: string): Promise => { return ( memberships.find( - (m) => m.leagueId === leagueId && m.driverId === driverIdParam, + m => m.leagueId === leagueId && m.driverId === driverIdParam, ) ?? null ); }, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], - saveMembership: async (): Promise => { throw new Error('Not implemented'); }, - removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, - removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + getJoinRequests: async (): Promise => [], + saveMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + removeMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + saveJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + removeJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const raceRegistrationRepository = { isRegistered: async (raceId: string, driverIdParam: string): Promise => { if (driverIdParam !== driverId) return false; @@ -183,24 +221,30 @@ describe('DashboardOverviewUseCase', () => { }, getRegisteredDrivers: async (): Promise => [], getRegistrationCount: async (): Promise => 0, - register: async (): Promise => { throw new Error('Not implemented'); }, - withdraw: async (): Promise => { throw new Error('Not implemented'); }, + register: async (): Promise => { + throw new Error('Not implemented'); + }, + withdraw: async (): Promise => { + throw new Error('Not implemented'); + }, getDriverRegistrations: async (): Promise => [], - clearRaceRegistrations: async (): Promise => { throw new Error('Not implemented'); }, + clearRaceRegistrations: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const feedRepository = { getFeedForDriver: async (): Promise => feedItems, getGlobalFeed: async (): Promise => [], }; - + const socialRepository = { getFriends: async (): Promise => friends, getFriendIds: async (): Promise => [], getSuggestedFriends: async (): Promise => [], }; - const imageService = createTestImageService(); + const getDriverAvatar = async (id: string): Promise => `avatar-${id}`; const getDriverStats = (id: string) => id === driverId @@ -224,29 +268,37 @@ describe('DashboardOverviewUseCase', () => { raceRegistrationRepository, feedRepository, socialRepository, - imageService, + getDriverAvatar, getDriverStats, + output, ); - // When - const result = await useCase.execute({ driverId }); + const input: DashboardOverviewInput = { driverId }; + + const result: UseCaseResult< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > = await useCase.execute(input); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); - const vm = result.unwrap(); + expect(output.present).toHaveBeenCalledTimes(1); + const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult; - // Then myUpcomingRaces only contains registered races from the driver's leagues - expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']); + expect(vm.myUpcomingRaces.map(r => r.race.id)).toEqual(['race-1', 'race-3']); - // And otherUpcomingRaces contains the other upcoming races in those leagues - expect(vm.otherUpcomingRaces.map((r) => r.id)).toEqual(['race-2', 'race-4']); + expect(vm.otherUpcomingRaces.map(r => r.race.id)).toEqual(['race-2', 'race-4']); - // And nextRace is the earliest upcoming race from myUpcomingRaces expect(vm.nextRace).not.toBeNull(); - expect(vm.nextRace!.id).toBe('race-1'); + expect(vm.nextRace!.race.id).toBe('race-1'); }); it('builds recentResults sorted by date descending and leagueStandingsSummaries from standings', async () => { - // Given completed races with results and standings + const output: UseCaseOutputPort = { + present: vi.fn(), + }; + const driverId = 'driver-2'; const driver = Driver.create({ id: driverId, iracingId: '67890', name: 'Result Driver', country: 'DE' }); @@ -276,8 +328,8 @@ describe('DashboardOverviewUseCase', () => { const races = [raceOld, raceNew]; - const results = [ - Result.create({ + const results: RaceResult[] = [ + RaceResult.create({ id: 'result-old', raceId: raceOld.id, driverId, @@ -286,7 +338,7 @@ describe('DashboardOverviewUseCase', () => { incidents: 3, startPosition: 5, }), - Result.create({ + RaceResult.create({ id: 'result-new', raceId: raceNew.id, driverId, @@ -312,10 +364,7 @@ describe('DashboardOverviewUseCase', () => { }), ]; - const standingsByLeague = new Map< - string, - Standing[] - >(); + const standingsByLeague = new Map(); standingsByLeague.set('league-A', [ Standing.create({ leagueId: 'league-A', driverId, position: 3, points: 50 }), Standing.create({ leagueId: 'league-A', driverId: 'other-1', position: 1, points: 80 }), @@ -329,13 +378,19 @@ describe('DashboardOverviewUseCase', () => { findById: async (id: string): Promise => (id === driver.id ? driver : null), findByIRacingId: async (): Promise => null, findAll: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByIRacingId: async (): Promise => false, }; - + const raceRepository = { findById: async (): Promise => null, findAll: async (): Promise => races, @@ -344,89 +399,133 @@ describe('DashboardOverviewUseCase', () => { findCompletedByLeagueId: async (): Promise => [], findByStatus: async (): Promise => [], findByDateRange: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, }; - + const resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => results, - findByRaceId: async (): Promise => [], - findByDriverId: async (): Promise => [], - findByDriverIdAndLeagueId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - createMany: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByRaceId: async (): Promise => { throw new Error('Not implemented'); }, + findById: async (): Promise => null, + findAll: async (): Promise => results, + findByRaceId: async (): Promise => [], + findByDriverId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + createMany: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByRaceId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByRaceId: async (): Promise => false, }; - + const leagueRepository = { findById: async (): Promise => null, findAll: async (): Promise => leagues, findByOwnerId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, searchByName: async (): Promise => [], }; - + const standingRepository = { findByLeagueId: async (leagueId: string): Promise => standingsByLeague.get(leagueId) ?? [], findByDriverIdAndLeagueId: async (): Promise => null, findAll: async (): Promise => [], - save: async (): Promise => { throw new Error('Not implemented'); }, - saveMany: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByLeagueId: async (): Promise => { throw new Error('Not implemented'); }, + save: async (): Promise => { + throw new Error('Not implemented'); + }, + saveMany: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByLeagueId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, recalculate: async (): Promise => [], }; - + const leagueMembershipRepository = { getMembership: async (leagueId: string, driverIdParam: string): Promise => { return ( memberships.find( - (m) => m.leagueId === leagueId && m.driverId === driverIdParam, + m => m.leagueId === leagueId && m.driverId === driverIdParam, ) ?? null ); }, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], - saveMembership: async (): Promise => { throw new Error('Not implemented'); }, - removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, - removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + getJoinRequests: async (): Promise => [], + saveMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + removeMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + saveJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + removeJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const raceRegistrationRepository = { isRegistered: async (): Promise => false, getRegisteredDrivers: async (): Promise => [], getRegistrationCount: async (): Promise => 0, - register: async (): Promise => { throw new Error('Not implemented'); }, - withdraw: async (): Promise => { throw new Error('Not implemented'); }, + register: async (): Promise => { + throw new Error('Not implemented'); + }, + withdraw: async (): Promise => { + throw new Error('Not implemented'); + }, getDriverRegistrations: async (): Promise => [], - clearRaceRegistrations: async (): Promise => { throw new Error('Not implemented'); }, + clearRaceRegistrations: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const feedRepository = { getFeedForDriver: async (): Promise => [], getGlobalFeed: async (): Promise => [], }; - + const socialRepository = { getFriends: async (): Promise => [], getFriendIds: async (): Promise => [], getSuggestedFriends: async (): Promise => [], }; - const imageService = createTestImageService(); + const getDriverAvatar = async (id: string): Promise => `avatar-${id}`; const getDriverStats = (id: string) => id === driverId @@ -450,42 +549,51 @@ describe('DashboardOverviewUseCase', () => { raceRegistrationRepository, feedRepository, socialRepository, - imageService, + getDriverAvatar, getDriverStats, + output, ); - // When - const result = await useCase.execute({ driverId }); + const input: DashboardOverviewInput = { driverId }; + + const result: UseCaseResult< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > = await useCase.execute(input); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); - const vm = result.unwrap(); + expect(output.present).toHaveBeenCalledTimes(1); + const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult; - // Then recentResults are sorted by finishedAt descending (newest first) expect(vm.recentResults.length).toBe(2); - expect(vm.recentResults[0]!.raceId).toBe('race-new'); - expect(vm.recentResults[1]!.raceId).toBe('race-old'); + expect(vm.recentResults[0]!.race.id).toBe('race-new'); + expect(vm.recentResults[1]!.race.id).toBe('race-old'); - // And leagueStandingsSummaries reflect the driver's position and points per league const summariesByLeague = new Map( - vm.leagueStandingsSummaries.map((s) => [s.leagueId, s]), + vm.leagueStandingsSummaries.map(s => [s.league.id, s]), ); const summaryA = summariesByLeague.get('league-A'); const summaryB = summariesByLeague.get('league-B'); expect(summaryA).toBeDefined(); - expect(summaryA!.position).toBe(3); - expect(summaryA!.points).toBe(50); + expect(summaryA!.standing?.position).toBe(3); + expect(summaryA!.standing?.points).toBe(50); expect(summaryA!.totalDrivers).toBe(2); expect(summaryB).toBeDefined(); - expect(summaryB!.position).toBe(1); - expect(summaryB!.points).toBe(100); + expect(summaryB!.standing?.position).toBe(1); + expect(summaryB!.standing?.points).toBe(100); expect(summaryB!.totalDrivers).toBe(2); }); it('returns empty collections and safe defaults when driver has no races or standings', async () => { - // Given a driver with no related data + const output: UseCaseOutputPort = { + present: vi.fn(), + }; + const driverId = 'driver-empty'; const driver = Driver.create({ id: driverId, iracingId: '11111', name: 'New Racer', country: 'FR' }); @@ -494,13 +602,19 @@ describe('DashboardOverviewUseCase', () => { findById: async (id: string): Promise => (id === driver.id ? driver : null), findByIRacingId: async (): Promise => null, findAll: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByIRacingId: async (): Promise => false, }; - + const raceRepository = { findById: async (): Promise => null, findAll: async (): Promise => [], @@ -509,82 +623,126 @@ describe('DashboardOverviewUseCase', () => { findCompletedByLeagueId: async (): Promise => [], findByStatus: async (): Promise => [], findByDateRange: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, }; - + const resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => [], - findByRaceId: async (): Promise => [], - findByDriverId: async (): Promise => [], - findByDriverIdAndLeagueId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - createMany: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByRaceId: async (): Promise => { throw new Error('Not implemented'); }, + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByRaceId: async (): Promise => [], + findByDriverId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + createMany: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByRaceId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, existsByRaceId: async (): Promise => false, }; - + const leagueRepository = { findById: async (): Promise => null, findAll: async (): Promise => [], findByOwnerId: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, searchByName: async (): Promise => [], }; - + const standingRepository = { findByLeagueId: async (): Promise => [], findByDriverIdAndLeagueId: async (): Promise => null, findAll: async (): Promise => [], - save: async (): Promise => { throw new Error('Not implemented'); }, - saveMany: async (): Promise => { throw new Error('Not implemented'); }, - delete: async (): Promise => { throw new Error('Not implemented'); }, - deleteByLeagueId: async (): Promise => { throw new Error('Not implemented'); }, + save: async (): Promise => { + throw new Error('Not implemented'); + }, + saveMany: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByLeagueId: async (): Promise => { + throw new Error('Not implemented'); + }, exists: async (): Promise => false, recalculate: async (): Promise => [], }; - + const leagueMembershipRepository = { getMembership: async (): Promise => null, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], - saveMembership: async (): Promise => { throw new Error('Not implemented'); }, - removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, - removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + getJoinRequests: async (): Promise => [], + saveMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + removeMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + saveJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + removeJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const raceRegistrationRepository = { isRegistered: async (): Promise => false, getRegisteredDrivers: async (): Promise => [], getRegistrationCount: async (): Promise => 0, - register: async (): Promise => { throw new Error('Not implemented'); }, - withdraw: async (): Promise => { throw new Error('Not implemented'); }, + register: async (): Promise => { + throw new Error('Not implemented'); + }, + withdraw: async (): Promise => { + throw new Error('Not implemented'); + }, getDriverRegistrations: async (): Promise => [], - clearRaceRegistrations: async (): Promise => { throw new Error('Not implemented'); }, + clearRaceRegistrations: async (): Promise => { + throw new Error('Not implemented'); + }, }; - + const feedRepository = { getFeedForDriver: async (): Promise => [], getGlobalFeed: async (): Promise => [], }; - + const socialRepository = { getFriends: async (): Promise => [], getFriendIds: async (): Promise => [], getSuggestedFriends: async (): Promise => [], }; - const imageService = createTestImageService(); + const getDriverAvatar = async (id: string): Promise => `avatar-${id}`; const getDriverStats = () => null; @@ -598,17 +756,24 @@ describe('DashboardOverviewUseCase', () => { raceRegistrationRepository, feedRepository, socialRepository, - imageService, + getDriverAvatar, getDriverStats, + output, ); - // When - const result = await useCase.execute({ driverId }); + const input: DashboardOverviewInput = { driverId }; + + const result: UseCaseResult< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > = await useCase.execute(input); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); - const vm = result.unwrap(); + expect(output.present).toHaveBeenCalledTimes(1); + const vm = (output.present as any).mock.calls[0][0] as DashboardOverviewResult; - // Then collections are empty and no errors are thrown expect(vm.myUpcomingRaces).toEqual([]); expect(vm.otherUpcomingRaces).toEqual([]); expect(vm.nextRace).toBeNull(); @@ -618,4 +783,378 @@ describe('DashboardOverviewUseCase', () => { expect(vm.feedSummary.notificationCount).toBe(0); expect(vm.feedSummary.items).toEqual([]); }); -}); \ No newline at end of file + + it('returns DRIVER_NOT_FOUND error and does not present when driver is missing', async () => { + const output: UseCaseOutputPort = { + present: vi.fn(), + }; + + const driverId = 'missing-driver'; + + const driverRepository = { + findById: async (): Promise => null, + findByIRacingId: async (): Promise => null, + findAll: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + existsByIRacingId: async (): Promise => false, + }; + + const raceRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByLeagueId: async (): Promise => [], + findUpcomingByLeagueId: async (): Promise => [], + findCompletedByLeagueId: async (): Promise => [], + findByStatus: async (): Promise => [], + findByDateRange: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + }; + + const resultRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByRaceId: async (): Promise => [], + findByDriverId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + createMany: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByRaceId: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + existsByRaceId: async (): Promise => false, + }; + + const leagueRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByOwnerId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + searchByName: async (): Promise => [], + }; + + const standingRepository = { + findByLeagueId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => null, + findAll: async (): Promise => [], + save: async (): Promise => { + throw new Error('Not implemented'); + }, + saveMany: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByLeagueId: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + recalculate: async (): Promise => [], + }; + + const leagueMembershipRepository = { + getMembership: async (): Promise => null, + getLeagueMembers: async (): Promise => [], + getJoinRequests: async (): Promise => [], + saveMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + removeMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + saveJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + removeJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + }; + + const raceRegistrationRepository = { + isRegistered: async (): Promise => false, + getRegisteredDrivers: async (): Promise => [], + getRegistrationCount: async (): Promise => 0, + register: async (): Promise => { + throw new Error('Not implemented'); + }, + withdraw: async (): Promise => { + throw new Error('Not implemented'); + }, + getDriverRegistrations: async (): Promise => [], + clearRaceRegistrations: async (): Promise => { + throw new Error('Not implemented'); + }, + }; + + const feedRepository = { + getFeedForDriver: async (): Promise => [], + getGlobalFeed: async (): Promise => [], + }; + + const socialRepository = { + getFriends: async (): Promise => [], + getFriendIds: async (): Promise => [], + getSuggestedFriends: async (): Promise => [], + }; + + const getDriverAvatar = async (id: string): Promise => `avatar-${id}`; + + const getDriverStats = () => null; + + const useCase = new DashboardOverviewUseCase( + driverRepository, + raceRepository, + resultRepository, + leagueRepository, + standingRepository, + leagueMembershipRepository, + raceRegistrationRepository, + feedRepository, + socialRepository, + getDriverAvatar, + getDriverStats, + output, + ); + + const input: DashboardOverviewInput = { driverId }; + + const result: UseCaseResult< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('DRIVER_NOT_FOUND'); + expect(err.details?.message).toBe('Driver not found'); + + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when an unexpected error occurs and does not present', async () => { + const output: UseCaseOutputPort = { + present: vi.fn(), + }; + + const driverId = 'driver-error'; + + const driver = Driver.create({ id: driverId, iracingId: '99999', name: 'Error Driver', country: 'GB' }); + + const driverRepository = { + findById: async (): Promise => driver, + findByIRacingId: async (): Promise => null, + findAll: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + existsByIRacingId: async (): Promise => false, + }; + + const raceRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => { + throw new Error('DB failure'); + }, + findByLeagueId: async (): Promise => [], + findUpcomingByLeagueId: async (): Promise => [], + findCompletedByLeagueId: async (): Promise => [], + findByStatus: async (): Promise => [], + findByDateRange: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + }; + + const resultRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByRaceId: async (): Promise => [], + findByDriverId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + createMany: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByRaceId: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + existsByRaceId: async (): Promise => false, + }; + + const leagueRepository = { + findById: async (): Promise => null, + findAll: async (): Promise => [], + findByOwnerId: async (): Promise => [], + create: async (): Promise => { + throw new Error('Not implemented'); + }, + update: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + searchByName: async (): Promise => [], + }; + + const standingRepository = { + findByLeagueId: async (): Promise => [], + findByDriverIdAndLeagueId: async (): Promise => null, + findAll: async (): Promise => [], + save: async (): Promise => { + throw new Error('Not implemented'); + }, + saveMany: async (): Promise => { + throw new Error('Not implemented'); + }, + delete: async (): Promise => { + throw new Error('Not implemented'); + }, + deleteByLeagueId: async (): Promise => { + throw new Error('Not implemented'); + }, + exists: async (): Promise => false, + recalculate: async (): Promise => [], + }; + + const leagueMembershipRepository = { + getMembership: async (): Promise => null, + getLeagueMembers: async (): Promise => [], + getJoinRequests: async (): Promise => [], + saveMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + removeMembership: async (): Promise => { + throw new Error('Not implemented'); + }, + saveJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + removeJoinRequest: async (): Promise => { + throw new Error('Not implemented'); + }, + }; + + const raceRegistrationRepository = { + isRegistered: async (): Promise => false, + getRegisteredDrivers: async (): Promise => [], + getRegistrationCount: async (): Promise => 0, + register: async (): Promise => { + throw new Error('Not implemented'); + }, + withdraw: async (): Promise => { + throw new Error('Not implemented'); + }, + getDriverRegistrations: async (): Promise => [], + clearRaceRegistrations: async (): Promise => { + throw new Error('Not implemented'); + }, + }; + + const feedRepository = { + getFeedForDriver: async (): Promise => [], + getGlobalFeed: async (): Promise => [], + }; + + const socialRepository = { + getFriends: async (): Promise => [], + getFriendIds: async (): Promise => [], + getSuggestedFriends: async (): Promise => [], + }; + + const getDriverAvatar = async (id: string): Promise => `avatar-${id}`; + + const getDriverStats = () => null; + + const useCase = new DashboardOverviewUseCase( + driverRepository, + raceRepository, + resultRepository, + leagueRepository, + standingRepository, + leagueMembershipRepository, + raceRegistrationRepository, + feedRepository, + socialRepository, + getDriverAvatar, + getDriverStats, + output, + ); + + const input: DashboardOverviewInput = { driverId }; + + const result: UseCaseResult< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB failure'); + + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/DashboardOverviewUseCase.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.ts index 2167a01b3..a04009bf2 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.ts @@ -5,29 +5,19 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; -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 { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { League } from '../../domain/entities/League'; import { Race } from '../../domain/entities/Race'; import { Result as RaceResult } from '../../domain/entities/Result'; import { Driver } from '../../domain/entities/Driver'; import { Standing } from '../../domain/entities/Standing'; import type { FeedItem } from '@core/social/domain/types/FeedItem'; -import type { - DashboardOverviewOutputPort, - DashboardDriverSummaryOutputPort, - DashboardRaceSummaryOutputPort, - DashboardRecentResultOutputPort, - DashboardLeagueStandingSummaryOutputPort, - DashboardFeedItemSummaryOutputPort, - DashboardFeedSummaryOutputPort, - DashboardFriendSummaryOutputPort, -} from '../ports/output/DashboardOverviewOutputPort'; -interface DashboardOverviewParams { +export interface DashboardOverviewInput { driverId: string; } @@ -40,6 +30,58 @@ interface DashboardDriverStatsAdapter { consistency: number | null; } +export interface DashboardDriverSummary { + driver: Driver; + avatarUrl: string | null; + rating: number | null; + globalRank: number | null; + totalRaces: number; + wins: number; + podiums: number; + consistency: number | null; +} + +export interface DashboardRaceSummary { + race: Race; + league: League | null; + isMyLeague: boolean; +} + +export interface DashboardRecentRaceResultSummary { + race: Race; + league: League | null; + result: RaceResult; +} + +export interface DashboardLeagueStandingSummary { + league: League; + standing: Standing | null; + totalDrivers: number; +} + +export interface DashboardFeedSummary { + notificationCount: number; + items: FeedItem[]; +} + +export interface DashboardFriendSummary { + driver: Driver; + avatarUrl: string | null; +} + +export interface DashboardOverviewResult { + currentDriver: DashboardDriverSummary | null; + myUpcomingRaces: DashboardRaceSummary[]; + otherUpcomingRaces: DashboardRaceSummary[]; + upcomingRaces: DashboardRaceSummary[]; + activeLeaguesCount: number; + nextRace: DashboardRaceSummary | null; + recentResults: DashboardRecentRaceResultSummary[]; + leagueStandingsSummaries: DashboardLeagueStandingSummary[]; + feedSummary: DashboardFeedSummary; + friends: DashboardFriendSummary[]; +} + export class DashboardOverviewUseCase { constructor( private readonly driverRepository: IDriverRepository, @@ -51,104 +93,146 @@ export class DashboardOverviewUseCase { private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly feedRepository: IFeedRepository, private readonly socialRepository: ISocialGraphRepository, - private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise, - private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null, + private readonly getDriverAvatar: (driverId: string) => Promise, + private readonly getDriverStats: ( + driverId: string, + ) => DashboardDriverStatsAdapter | null, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: DashboardOverviewParams): Promise> { - const { driverId } = params; + async execute( + input: DashboardOverviewInput, + ): Promise< + Result< + void, + ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }> + > + > { + const { driverId } = input; - const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([ - this.driverRepository.findById(driverId), - this.leagueRepository.findAll(), - this.raceRepository.findAll(), - this.resultRepository.findAll(), - this.feedRepository.getFeedForDriver(driverId), - this.socialRepository.getFriends(driverId), - ]); + try { + const [driver, allLeagues, allRaces, allResults, feedItems, friends] = + await Promise.all([ + this.driverRepository.findById(driverId), + this.leagueRepository.findAll(), + this.raceRepository.findAll(), + this.resultRepository.findAll(), + this.feedRepository.getFeedForDriver(driverId), + this.socialRepository.getFriends(driverId), + ]); - const leagueMap = new Map(allLeagues.map(league => [league.id, league.name])); + if (!driver) { + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: 'Driver not found' }, + }); + } - const driverStats = this.getDriverStats(driverId); + const leagueMap = new Map(allLeagues.map(league => [league.id, league])); - const currentDriver: DashboardDriverSummaryOutputPort | null = driver - ? { - id: driver.id, - name: driver.name, - country: driver.country, - avatarUrl: (await this.getDriverAvatar({ driverId: driver.id })).avatarUrl, - rating: driverStats?.rating ?? null, - globalRank: driverStats?.overallRank ?? null, - totalRaces: driverStats?.totalRaces ?? 0, - wins: driverStats?.wins ?? 0, - podiums: driverStats?.podiums ?? 0, - consistency: driverStats?.consistency ?? null, - } - : null; + const driverStats = this.getDriverStats(driverId); - const driverLeagues = await this.getDriverLeagues(allLeagues, driverId); - const driverLeagueIds = new Set(driverLeagues.map(league => league.id)); + const currentDriver: DashboardDriverSummary = { + driver, + avatarUrl: await this.getDriverAvatar(driver.id), + rating: driverStats?.rating ?? null, + globalRank: driverStats?.overallRank ?? null, + totalRaces: driverStats?.totalRaces ?? 0, + wins: driverStats?.wins ?? 0, + podiums: driverStats?.podiums ?? 0, + consistency: driverStats?.consistency ?? null, + }; - const now = new Date(); - const upcomingRaces = allRaces - .filter(race => race.status === 'scheduled' && race.scheduledAt > now) - .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); + const driverLeagues = await this.getDriverLeagues(allLeagues, driverId); + const driverLeagueIds = new Set(driverLeagues.map(league => league.id)); - const upcomingRacesInDriverLeagues = upcomingRaces.filter(race => - driverLeagueIds.has(race.leagueId), - ); + const now = new Date(); + const upcomingRaces = allRaces + .filter(race => race.status === 'scheduled' && race.scheduledAt > now) + .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); - const { myUpcomingRaces, otherUpcomingRaces } = - await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap); + const upcomingRacesInDriverLeagues = upcomingRaces.filter(race => + driverLeagueIds.has(race.leagueId), + ); - const nextRace: DashboardRaceSummaryOutputPort | null = - myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null; + const { myUpcomingRaces, otherUpcomingRaces } = + await this.partitionUpcomingRacesByRegistration( + upcomingRacesInDriverLeagues, + driverId, + leagueMap, + ); - const upcomingRacesSummaries: DashboardRaceSummaryOutputPort[] = [ - ...myUpcomingRaces, - ...otherUpcomingRaces, - ].slice().sort( - (a, b) => - new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(), - ); + const nextRace: DashboardRaceSummary | null = + myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null; - const recentResults = this.buildRecentResults(allResults, allRaces, allLeagues, driverId); + const upcomingRacesSummaries: DashboardRaceSummary[] = [ + ...myUpcomingRaces, + ...otherUpcomingRaces, + ] + .slice() + .sort( + (a, b) => + a.race.scheduledAt.getTime() - b.race.scheduledAt.getTime(), + ); - const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries( - driverLeagues, - driverId, - ); + const recentResults = this.buildRecentResults( + allResults, + allRaces, + allLeagues, + driverId, + ); - const activeLeaguesCount = this.computeActiveLeaguesCount( - upcomingRacesSummaries, - leagueStandingsSummaries, - ); + const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries( + driverLeagues, + driverId, + ); - const feedSummary = this.buildFeedSummary(feedItems); + const activeLeaguesCount = this.computeActiveLeaguesCount( + upcomingRacesSummaries, + leagueStandingsSummaries, + ); - const friendsSummary = await this.buildFriendsSummary(friends); + const feedSummary = this.buildFeedSummary(feedItems); - const viewModel: DashboardOverviewOutputPort = { - currentDriver, - myUpcomingRaces, - otherUpcomingRaces, - upcomingRaces: upcomingRacesSummaries, - activeLeaguesCount, - nextRace, - recentResults, - leagueStandingsSummaries, - feedSummary, - friends: friendsSummary, - }; + const friendsSummary = await this.buildFriendsSummary(friends); - return Result.ok(viewModel); + const result: DashboardOverviewResult = { + currentDriver, + myUpcomingRaces, + otherUpcomingRaces, + upcomingRaces: upcomingRacesSummaries, + activeLeaguesCount, + nextRace, + recentResults, + leagueStandingsSummaries, + feedSummary, + friends: friendsSummary, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); + } } - private async getDriverLeagues(allLeagues: League[], driverId: string): Promise { + private async getDriverLeagues( + allLeagues: League[], + driverId: string, + ): Promise { const driverLeagues: League[] = []; for (const league of allLeagues) { - const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId); + const membership = await this.leagueMembershipRepository.getMembership( + league.id, + driverId, + ); if (membership && membership.status === 'active') { driverLeagues.push(league); } @@ -160,16 +244,19 @@ export class DashboardOverviewUseCase { private async partitionUpcomingRacesByRegistration( upcomingRaces: Race[], driverId: string, - leagueMap: Map, + leagueMap: Map, ): Promise<{ - myUpcomingRaces: DashboardRaceSummaryOutputPort[]; - otherUpcomingRaces: DashboardRaceSummaryOutputPort[]; + myUpcomingRaces: DashboardRaceSummary[]; + otherUpcomingRaces: DashboardRaceSummary[]; }> { - const myUpcomingRaces: DashboardRaceSummaryOutputPort[] = []; - const otherUpcomingRaces: DashboardRaceSummaryOutputPort[] = []; + const myUpcomingRaces: DashboardRaceSummary[] = []; + const otherUpcomingRaces: DashboardRaceSummary[] = []; for (const race of upcomingRaces) { - const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId); + const isRegistered = await this.raceRegistrationRepository.isRegistered( + race.id, + driverId, + ); const summary = this.mapRaceToSummary(race, leagueMap, true); if (isRegistered) { @@ -184,17 +271,12 @@ export class DashboardOverviewUseCase { private mapRaceToSummary( race: Race, - leagueMap: Map, + leagueMap: Map, isMyLeague: boolean, - ): DashboardRaceSummaryOutputPort { + ): DashboardRaceSummary { return { - id: race.id, - leagueId: race.leagueId, - leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League', - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt.toISOString(), - status: race.status, + race, + league: leagueMap.get(race.leagueId) ?? null, isMyLeague, }; } @@ -204,7 +286,7 @@ export class DashboardOverviewUseCase { allRaces: Race[], allLeagues: League[], driverId: string, - ): DashboardRecentResultOutputPort[] { + ): DashboardRecentRaceResultSummary[] { const raceById = new Map(allRaces.map(race => [race.id, race])); const leagueById = new Map(allLeagues.map(league => [league.id, league])); @@ -215,26 +297,20 @@ export class DashboardOverviewUseCase { const race = raceById.get(result.raceId); if (!race) return null; - const league = leagueById.get(race.leagueId); + const league = leagueById.get(race.leagueId) ?? null; - const finishedAt = race.scheduledAt.toISOString(); - - const item: DashboardRecentResultOutputPort = { - raceId: race.id, - raceName: race.track, - leagueId: race.leagueId, - leagueName: league?.name ?? 'Unknown League', - finishedAt, - position: result.position, - incidents: result.incidents, + const item: DashboardRecentRaceResultSummary = { + race, + league, + result, }; return item; }) - .filter((item): item is DashboardRecentResultOutputPort => !!item) + .filter((item): item is DashboardRecentRaceResultSummary => !!item) .sort( (a, b) => - new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(), + b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime(), ); const RECENT_RESULTS_LIMIT = 5; @@ -245,8 +321,8 @@ export class DashboardOverviewUseCase { private async buildLeagueStandingsSummaries( driverLeagues: League[], driverId: string, - ): Promise { - const summaries: DashboardLeagueStandingSummaryOutputPort[] = []; + ): Promise { + const summaries: DashboardLeagueStandingSummary[] = []; for (const league of driverLeagues.slice(0, 3)) { const standings = await this.standingRepository.findByLeagueId(league.id); @@ -255,10 +331,8 @@ export class DashboardOverviewUseCase { ); summaries.push({ - leagueId: league.id, - leagueName: league.name, - position: driverStanding?.position ?? 0, - points: driverStanding?.points ?? 0, + league, + standing: driverStanding ?? null, totalDrivers: standings.length, }); } @@ -267,55 +341,42 @@ export class DashboardOverviewUseCase { } private computeActiveLeaguesCount( - upcomingRaces: DashboardRaceSummaryOutputPort[], - leagueStandingsSummaries: DashboardLeagueStandingSummaryOutputPort[], + upcomingRaces: DashboardRaceSummary[], + leagueStandingsSummaries: DashboardLeagueStandingSummary[], ): number { const activeLeagueIds = new Set(); for (const race of upcomingRaces) { - activeLeagueIds.add(race.leagueId); + activeLeagueIds.add(race.race.leagueId); } for (const standing of leagueStandingsSummaries) { - activeLeagueIds.add(standing.leagueId); + activeLeagueIds.add(standing.league.id); } return activeLeagueIds.size; } - private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryOutputPort { - const items: DashboardFeedItemSummaryOutputPort[] = feedItems.map(item => ({ - id: item.id, - type: item.type, - headline: item.headline, - timestamp: - item.timestamp instanceof Date - ? item.timestamp.toISOString() - : new Date(item.timestamp).toISOString(), - ...(item.body !== undefined ? { body: item.body } : {}), - ...(item.ctaLabel !== undefined ? { ctaLabel: item.ctaLabel } : {}), - ...(item.ctaHref !== undefined ? { ctaHref: item.ctaHref } : {}), - })); - + private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummary { return { - notificationCount: items.length, - items, + notificationCount: feedItems.length, + items: feedItems, }; } - private async buildFriendsSummary(friends: Driver[]): Promise { - const friendSummaries: DashboardFriendSummaryOutputPort[] = []; + private async buildFriendsSummary( + friends: Driver[], + ): Promise { + const friendSummaries: DashboardFriendSummary[] = []; for (const friend of friends) { - const avatarResult = await this.getDriverAvatar({ driverId: friend.id }); + const avatarUrl = await this.getDriverAvatar(friend.id); friendSummaries.push({ - id: friend.id, - name: friend.name, - country: friend.country, - avatarUrl: avatarResult.avatarUrl, + driver: friend, + avatarUrl, }); } return friendSummaries; } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/FileProtestUseCase.test.ts b/core/racing/application/use-cases/FileProtestUseCase.test.ts index 59caa2303..f44f14290 100644 --- a/core/racing/application/use-cases/FileProtestUseCase.test.ts +++ b/core/racing/application/use-cases/FileProtestUseCase.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { FileProtestUseCase } from './FileProtestUseCase'; +import { FileProtestUseCase, type FileProtestInput, type FileProtestResult, type FileProtestErrorCode } from './FileProtestUseCase'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Result } from '@core/shared/application/Result'; describe('FileProtestUseCase', () => { let mockProtestRepo: { @@ -14,6 +17,7 @@ describe('FileProtestUseCase', () => { let mockLeagueMembershipRepo: { getLeagueMembers: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { mockProtestRepo = { @@ -25,6 +29,9 @@ describe('FileProtestUseCase', () => { mockLeagueMembershipRepo = { getLeagueMembers: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; }); it('should return error when race does not exist', async () => { @@ -32,6 +39,7 @@ describe('FileProtestUseCase', () => { mockProtestRepo as unknown as IProtestRepository, mockRaceRepo as unknown as IRaceRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); mockRaceRepo.findById.mockResolvedValue(null); @@ -41,10 +49,13 @@ describe('FileProtestUseCase', () => { protestingDriverId: 'driver1', accusedDriverId: 'driver2', incident: { lap: 5, description: 'Collision' }, - }); + } as FileProtestInput); - expect(result.isOk()).toBe(false); - expect(result.error!.details.message).toBe('Race not found'); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details?.message).toBe('Race not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when protesting against self', async () => { @@ -52,6 +63,7 @@ describe('FileProtestUseCase', () => { mockProtestRepo as unknown as IProtestRepository, mockRaceRepo as unknown as IRaceRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); @@ -61,10 +73,13 @@ describe('FileProtestUseCase', () => { protestingDriverId: 'driver1', accusedDriverId: 'driver1', incident: { lap: 5, description: 'Collision' }, - }); + } as FileProtestInput); - expect(result.isOk()).toBe(false); - expect(result.error!.details.message).toBe('Cannot file a protest against yourself'); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('SELF_PROTEST'); + expect(err.details?.message).toBe('Cannot file a protest against yourself'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when protesting driver is not an active member', async () => { @@ -72,6 +87,7 @@ describe('FileProtestUseCase', () => { mockProtestRepo as unknown as IProtestRepository, mockRaceRepo as unknown as IRaceRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); @@ -84,10 +100,13 @@ describe('FileProtestUseCase', () => { protestingDriverId: 'driver1', accusedDriverId: 'driver2', incident: { lap: 5, description: 'Collision' }, - }); + } as FileProtestInput); - expect(result.isOk()).toBe(false); - expect(result.error!.details.message).toBe('Protesting driver is not an active member of this league'); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('NOT_MEMBER'); + expect(err.details?.message).toBe('Protesting driver is not an active member of this league'); + expect(output.present).not.toHaveBeenCalled(); }); it('should create protest and return protestId on success', async () => { @@ -95,6 +114,7 @@ describe('FileProtestUseCase', () => { mockProtestRepo as unknown as IProtestRepository, mockRaceRepo as unknown as IRaceRepository, mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); @@ -110,12 +130,10 @@ describe('FileProtestUseCase', () => { incident: { lap: 5, description: 'Collision' }, comment: 'Test comment', proofVideoUrl: 'http://example.com/video', - }); + } as FileProtestInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - protestId: expect.any(String), - }); + expect(result.unwrap()).toBeUndefined(); expect(mockProtestRepo.create).toHaveBeenCalledWith( expect.objectContaining({ raceId: 'race1', @@ -127,5 +145,14 @@ describe('FileProtestUseCase', () => { status: 'pending', }) ); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as unknown as Mock).mock.calls[0][0] as FileProtestResult; + expect(presented.protest.raceId).toBe('race1'); + expect(presented.protest.protestingDriverId).toBe('driver1'); + expect(presented.protest.accusedDriverId).toBe('driver2'); + expect(presented.protest.incident).toEqual({ lap: 5, description: 'Collision', timeInRace: undefined }); + expect(presented.protest.comment).toBe('Test comment'); + expect(presented.protest.proofVideoUrl).toBe('http://example.com/video'); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/FileProtestUseCase.ts b/core/racing/application/use-cases/FileProtestUseCase.ts index 977aabf3d..5b9a5f921 100644 --- a/core/racing/application/use-cases/FileProtestUseCase.ts +++ b/core/racing/application/use-cases/FileProtestUseCase.ts @@ -10,62 +10,82 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { ProtestIncident } from '../../domain/entities/Protest'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { randomUUID } from 'crypto'; -export interface FileProtestCommand { +export type FileProtestErrorCode = 'RACE_NOT_FOUND' | 'SELF_PROTEST' | 'NOT_MEMBER' | 'REPOSITORY_ERROR'; + +export interface FileProtestInput { raceId: string; protestingDriverId: string; accusedDriverId: string; - incident: ProtestIncident; + incident: { + lap: number; + description: string; + timeInRace?: number; + }; comment?: string; proofVideoUrl?: string; } +export interface FileProtestResult { + protest: Protest; +} + export class FileProtestUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly raceRepository: IRaceRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: FileProtestCommand): Promise>> { - // Validate race exists - const race = await this.raceRepository.findById(command.raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); + async execute(command: FileProtestInput): Promise>> { + try { + // Validate race exists + const race = await this.raceRepository.findById(command.raceId); + if (!race) { + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); + } + + // Validate drivers are not the same + if (command.protestingDriverId === command.accusedDriverId) { + return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } }); + } + + // Validate protesting driver is a member of the league + const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); + const protestingDriverMembership = memberships.find(m => { + const driverId = (m as any).driverId; + const status = (m as any).status; + return driverId === command.protestingDriverId && status === 'active'; + }); + + if (!protestingDriverMembership) { + return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } }); + } + + // Create the protest + const protest = Protest.create({ + id: randomUUID(), + raceId: command.raceId, + protestingDriverId: command.protestingDriverId, + accusedDriverId: command.accusedDriverId, + incident: command.incident, + ...(command.comment !== undefined ? { comment: command.comment } : {}), + ...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}), + status: 'pending', + filedAt: new Date(), + }); + + await this.protestRepository.create(protest); + + this.output.present({ protest }); + + return Result.ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to file protest'; + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } - - // Validate drivers are not the same - if (command.protestingDriverId === command.accusedDriverId) { - return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } }); - } - - // Validate protesting driver is a member of the league - const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); - const protestingDriverMembership = memberships.find( - m => m.driverId === command.protestingDriverId && m.status === 'active' - ); - - if (!protestingDriverMembership) { - return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } }); - } - - // Create the protest - const protest = Protest.create({ - id: randomUUID(), - raceId: command.raceId, - protestingDriverId: command.protestingDriverId, - accusedDriverId: command.accusedDriverId, - incident: command.incident, - ...(command.comment !== undefined ? { comment: command.comment } : {}), - ...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}), - status: 'pending', - filedAt: new Date(), - }); - - await this.protestRepository.create(protest); - - return Result.ok({ protestId: protest.id }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts index be6a2a3f8..39d76b9a4 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetAllLeaguesWithCapacityAndScoringUseCase } from './GetAllLeaguesWithCapacityAndScoringUseCase'; +import { + GetAllLeaguesWithCapacityAndScoringUseCase, + type GetAllLeaguesWithCapacityAndScoringInput, + type GetAllLeaguesWithCapacityAndScoringResult, + type LeagueCapacityAndScoringSummary, +} from './GetAllLeaguesWithCapacityAndScoringUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { let mockLeagueRepo: { findAll: Mock }; @@ -13,7 +18,7 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { let mockSeasonRepo: { findByLeagueId: Mock }; let mockScoringConfigRepo: { findBySeasonId: Mock }; let mockGameRepo: { findById: Mock }; - let mockPresetProvider: { getPresetById: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { mockLeagueRepo = { findAll: vi.fn() }; @@ -21,7 +26,7 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { mockSeasonRepo = { findByLeagueId: vi.fn() }; mockScoringConfigRepo = { findBySeasonId: vi.fn() }; mockGameRepo = { findById: vi.fn() }; - mockPresetProvider = { getPresetById: vi.fn() }; + output = { present: vi.fn() } as unknown as typeof output; }); it('should return enriched leagues with capacity and scoring', async () => { @@ -31,10 +36,11 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { mockSeasonRepo as unknown as ISeasonRepository, mockScoringConfigRepo as unknown as ILeagueScoringConfigRepository, mockGameRepo as unknown as IGameRepository, - mockPresetProvider as unknown as LeagueScoringPresetProvider, + { getPresetById: vi.fn().mockReturnValue({ id: 'preset1', name: 'Default' }) }, + output, ); - const league = { id: 'league1', name: 'Test League' }; + const league = { id: 'league1', name: 'Test League', settings: { maxDrivers: 30 } }; const members = [ { status: 'active', role: 'member' }, { status: 'active', role: 'owner' }, @@ -42,29 +48,33 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { const season = { id: 'season1', status: 'active', gameId: 'game1' }; const scoringConfig = { scoringPresetId: 'preset1' }; const game = { id: 'game1', name: 'iRacing' }; - const preset = { id: 'preset1', name: 'Default' }; mockLeagueRepo.findAll.mockResolvedValue([league]); mockMembershipRepo.getLeagueMembers.mockResolvedValue(members); mockSeasonRepo.findByLeagueId.mockResolvedValue([season]); mockScoringConfigRepo.findBySeasonId.mockResolvedValue(scoringConfig); mockGameRepo.findById.mockResolvedValue(game); - mockPresetProvider.getPresetById.mockReturnValue(preset); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllLeaguesWithCapacityAndScoringInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - leagues: [ - { - league, - usedDriverSlots: 2, - season, - scoringConfig, - game, - preset, - }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = + output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityAndScoringResult; + + expect(presented.leagues).toHaveLength(1); + + const [summary] = presented.leagues as LeagueCapacityAndScoringSummary[]; + + expect(summary.league).toEqual(league); + expect(summary.currentDrivers).toBe(2); + expect(summary.maxDrivers).toBe(30); + expect(summary.season).toEqual(season); + expect(summary.scoringConfig).toEqual(scoringConfig); + expect(summary.game).toEqual(game); + expect(summary.preset).toEqual({ id: 'preset1', name: 'Default' }); }); }); diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts index e6c573148..adb11b965 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts @@ -3,76 +3,126 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; -import type { LeagueEnrichedData, AllLeaguesWithCapacityAndScoringOutputPort } from '../ports/output/AllLeaguesWithCapacityAndScoringOutputPort'; +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/season/Season'; +import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; +import type { Game } from '../../domain/entities/Game'; +import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets'; import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export type GetAllLeaguesWithCapacityAndScoringInput = {}; + +export type LeagueCapacityAndScoringSummary = { + league: League; + currentDrivers: number; + maxDrivers: number; + season?: Season; + scoringConfig?: LeagueScoringConfig; + game?: Game; + preset?: LeagueScoringPreset; +}; + +export type GetAllLeaguesWithCapacityAndScoringResult = { + leagues: LeagueCapacityAndScoringSummary[]; +}; + +export type GetAllLeaguesWithCapacityAndScoringErrorCode = 'REPOSITORY_ERROR'; /** * Use Case for retrieving all leagues with capacity and scoring information. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and delegates presentation to an output port. */ -export class GetAllLeaguesWithCapacityAndScoringUseCase -{ +export class GetAllLeaguesWithCapacityAndScoringUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, - private readonly presetProvider: LeagueScoringPresetProvider, + private readonly presetProvider: { getPresetById(presetId: string): LeagueScoringPreset | undefined }, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise> { - const leagues = await this.leagueRepository.findAll(); + async execute( + _input: GetAllLeaguesWithCapacityAndScoringInput = {}, + ): Promise< + Result< + void, + ApplicationErrorCode< + GetAllLeaguesWithCapacityAndScoringErrorCode, + { message: string } + > + > + > { + try { + const leagues = await this.leagueRepository.findAll(); - const enrichedLeagues: LeagueEnrichedData[] = []; + const enrichedLeagues: LeagueCapacityAndScoringSummary[] = []; - for (const league of leagues) { - const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); + for (const league of leagues) { + const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); - const usedDriverSlots = members.filter( - (m) => - m.status === 'active' && - (m.role === 'owner' || - m.role === 'admin' || - m.role === 'steward' || - m.role === 'member'), - ).length; + const currentDrivers = members.filter( + (m) => + m.status === 'active' && + (m.role === 'owner' || + m.role === 'admin' || + m.role === 'steward' || + m.role === 'member'), + ).length; - const seasons = await this.seasonRepository.findByLeagueId(league.id); - const activeSeason = - seasons && seasons.length > 0 - ? seasons.find((s) => s.status === 'active') ?? seasons[0] - : undefined; + const seasons = await this.seasonRepository.findByLeagueId(league.id); + const activeSeason = + seasons && seasons.length > 0 + ? seasons.find((s) => s.status === 'active') ?? seasons[0] + : undefined; - let scoringConfig: LeagueEnrichedData['scoringConfig']; - let game: LeagueEnrichedData['game']; - let preset: LeagueEnrichedData['preset']; + let scoringConfig: LeagueScoringConfig | undefined; + let game: Game | undefined; + let preset: LeagueScoringPreset | undefined; - if (activeSeason) { - const scoringConfigResult = - await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); - scoringConfig = scoringConfigResult ?? undefined; - if (scoringConfig) { - const gameResult = await this.gameRepository.findById(activeSeason.gameId); - game = gameResult ?? undefined; - const presetId = scoringConfig.scoringPresetId; - if (presetId) { - preset = this.presetProvider.getPresetById(presetId); + if (activeSeason) { + const scoringConfigResult = + await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); + scoringConfig = scoringConfigResult ?? undefined; + if (scoringConfig) { + const gameResult = await this.gameRepository.findById(activeSeason.gameId); + game = gameResult ?? undefined; + const presetId = scoringConfig.scoringPresetId; + if (presetId) { + preset = this.presetProvider.getPresetById(presetId); + } } } + + const maxDrivers = league.settings.maxDrivers ?? 0; + + enrichedLeagues.push({ + league, + currentDrivers, + maxDrivers, + ...(activeSeason ? { season: activeSeason } : {}), + ...(scoringConfig ? { scoringConfig } : {}), + ...(game ? { game } : {}), + ...(preset ? { preset } : {}), + }); } - enrichedLeagues.push({ - league, - usedDriverSlots, - ...(activeSeason ? { season: activeSeason } : {}), - ...(scoringConfig ? { scoringConfig } : {}), - ...(game ? { game } : {}), - ...(preset ? { preset } : {}), + this.output.present({ leagues: enrichedLeagues }); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load leagues with capacity and scoring'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, }); } - return Result.ok({ leagues: enrichedLeagues }); - } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts index 2377441e7..07fabe377 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts @@ -1,25 +1,34 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetAllLeaguesWithCapacityUseCase } from './GetAllLeaguesWithCapacityUseCase'; +import { + GetAllLeaguesWithCapacityUseCase, + type GetAllLeaguesWithCapacityInput, + type GetAllLeaguesWithCapacityResult, + type LeagueCapacitySummary, +} from './GetAllLeaguesWithCapacityUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetAllLeaguesWithCapacityUseCase', () => { let mockLeagueRepo: { findAll: Mock }; let mockMembershipRepo: { getLeagueMembers: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { mockLeagueRepo = { findAll: vi.fn() }; mockMembershipRepo = { getLeagueMembers: vi.fn() }; + output = { present: vi.fn() } as unknown as typeof output; }); it('should return leagues with capacity information', async () => { const useCase = new GetAllLeaguesWithCapacityUseCase( mockLeagueRepo as unknown as ILeagueRepository, mockMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); - const league1 = { id: 'league1', name: 'Test League 1' }; - const league2 = { id: 'league2', name: 'Test League 2' }; + const league1 = { id: 'league1', name: 'Test League 1', settings: { maxDrivers: 10 } }; + const league2 = { id: 'league2', name: 'Test League 2', settings: { maxDrivers: 20 } }; const members1 = [ { status: 'active', role: 'member' }, { status: 'active', role: 'owner' }, @@ -34,33 +43,43 @@ describe('GetAllLeaguesWithCapacityUseCase', () => { .mockResolvedValueOnce(members1) .mockResolvedValueOnce(members2); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllLeaguesWithCapacityInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - leagues: [league1, league2], - memberCounts: new Map([ - ['league1', 2], - ['league2', 1], - ]), - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityResult; + expect(presented.leagues).toHaveLength(2); + + const [first, second] = presented.leagues as LeagueCapacitySummary[]; + + expect(first.league).toEqual(league1); + expect(first.currentDrivers).toBe(2); + expect(first.maxDrivers).toBe(10); + + expect(second.league).toEqual(league2); + expect(second.currentDrivers).toBe(1); + expect(second.maxDrivers).toBe(20); }); it('should return empty result when no leagues', async () => { const useCase = new GetAllLeaguesWithCapacityUseCase( mockLeagueRepo as unknown as ILeagueRepository, mockMembershipRepo as unknown as ILeagueMembershipRepository, + output, ); mockLeagueRepo.findAll.mockResolvedValue([]); - mockMembershipRepo.getLeagueMembers.mockResolvedValue([]); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllLeaguesWithCapacityInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - leagues: [], - memberCounts: new Map(), - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityResult; + expect(presented.leagues).toEqual([]); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts index e7ff30a28..8cad39553 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts @@ -1,47 +1,78 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { AllLeaguesWithCapacityOutputPort } from '../ports/output/AllLeaguesWithCapacityOutputPort'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@/shared/application/Result'; +import type { League } from '../../domain/entities/League'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export type GetAllLeaguesWithCapacityInput = {}; + +export type LeagueCapacitySummary = { + league: League; + currentDrivers: number; + maxDrivers: number; +}; + +export type GetAllLeaguesWithCapacityResult = { + leagues: LeagueCapacitySummary[]; +}; + +export type GetAllLeaguesWithCapacityErrorCode = 'REPOSITORY_ERROR'; /** * Use Case for retrieving all leagues with capacity information. - * Orchestrates domain logic and returns result. + * Orchestrates domain logic and delegates presentation to an output port. */ -export class GetAllLeaguesWithCapacityUseCase - implements AsyncUseCase -{ +export class GetAllLeaguesWithCapacityUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { - const leagues = await this.leagueRepository.findAll(); + async execute( + _input: GetAllLeaguesWithCapacityInput = {}, + ): Promise< + Result< + void, + ApplicationErrorCode + > + > { + try { + const leagues = await this.leagueRepository.findAll(); - const memberCounts: Record = {}; + const summaries: LeagueCapacitySummary[] = []; - for (const league of leagues) { - const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); + for (const league of leagues) { + const members = await this.leagueMembershipRepository.getLeagueMembers(league.id); - const usedSlots = members.filter( - (m) => - m.status === 'active' && - (m.role === 'owner' || - m.role === 'admin' || - m.role === 'steward' || - m.role === 'member'), - ).length; + const currentDrivers = members.filter( + (m) => + m.status === 'active' && + (m.role === 'owner' || + m.role === 'admin' || + m.role === 'steward' || + m.role === 'member'), + ).length; - memberCounts[league.id] = usedSlots; + const maxDrivers = league.settings.maxDrivers ?? 0; + + summaries.push({ league, currentDrivers, maxDrivers }); + } + + this.output.present({ leagues: summaries }); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load leagues with capacity'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - const output: AllLeaguesWithCapacityOutputPort = { - leagues, - memberCounts, - }; - - return Result.ok(output); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts index 09e941cbd..b63b0a2e1 100644 --- a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GetAllRacesPageDataUseCase } from './GetAllRacesPageDataUseCase'; +import { + GetAllRacesPageDataUseCase, + type GetAllRacesPageDataResult, + type GetAllRacesPageDataInput, +} from './GetAllRacesPageDataUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetAllRacesPageDataUseCase', () => { const mockRaceFindAll = vi.fn(); @@ -39,15 +44,21 @@ describe('GetAllRacesPageDataUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: ReturnType }; + beforeEach(() => { vi.clearAllMocks(); + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: ReturnType }; }); - it('should return races and filters data', async () => { + it('should present races and filters data', async () => { const useCase = new GetAllRacesPageDataUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); const race1 = { @@ -58,7 +69,7 @@ describe('GetAllRacesPageDataUseCase', () => { status: 'scheduled' as const, leagueId: 'league1', strengthOfField: 5, - }; + } as any; const race2 = { id: 'race2', track: 'Track B', @@ -67,97 +78,110 @@ describe('GetAllRacesPageDataUseCase', () => { status: 'completed' as const, leagueId: 'league2', strengthOfField: null, - }; - const league1 = { id: 'league1', name: 'League One' }; - const league2 = { id: 'league2', name: 'League Two' }; + } as any; + const league1 = { id: 'league1', name: 'League One' } as any; + const league2 = { id: 'league2', name: 'League Two' } as any; mockRaceFindAll.mockResolvedValue([race1, race2]); mockLeagueFindAll.mockResolvedValue([league1, league2]); - const result = await useCase.execute(); + const input: GetAllRacesPageDataInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - races: [ - { - id: 'race2', - track: 'Track B', - car: 'Car B', - scheduledAt: '2023-01-02T10:00:00.000Z', - status: 'completed', - leagueId: 'league2', - leagueName: 'League Two', - strengthOfField: null, - }, - { - id: 'race1', - track: 'Track A', - car: 'Car A', - scheduledAt: '2023-01-01T10:00:00.000Z', - status: 'scheduled', - leagueId: 'league1', - leagueName: 'League One', - strengthOfField: 5, - }, - ], - filters: { - statuses: [ - { value: 'all', label: 'All Statuses' }, - { value: 'scheduled', label: 'Scheduled' }, - { value: 'running', label: 'Live' }, - { value: 'completed', label: 'Completed' }, - { value: 'cancelled', label: 'Cancelled' }, - ], - leagues: [ - { id: 'league1', name: 'League One' }, - { id: 'league2', name: 'League Two' }, - ], + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetAllRacesPageDataResult; + + expect(presented.races).toEqual([ + { + id: 'race2', + track: 'Track B', + car: 'Car B', + scheduledAt: '2023-01-02T10:00:00.000Z', + status: 'completed', + leagueId: 'league2', + leagueName: 'League Two', + strengthOfField: null, }, + { + id: 'race1', + track: 'Track A', + car: 'Car A', + scheduledAt: '2023-01-01T10:00:00.000Z', + status: 'scheduled', + leagueId: 'league1', + leagueName: 'League One', + strengthOfField: 5, + }, + ]); + + expect(presented.filters).toEqual({ + statuses: [ + { value: 'all', label: 'All Statuses' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'running', label: 'Live' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + ], + leagues: [ + { id: 'league1', name: 'League One' }, + { id: 'league2', name: 'League Two' }, + ], }); }); - it('should return empty result when no races or leagues', async () => { + it('should present empty result when no races or leagues', async () => { const useCase = new GetAllRacesPageDataUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); mockRaceFindAll.mockResolvedValue([]); mockLeagueFindAll.mockResolvedValue([]); - const result = await useCase.execute(); + const result = await useCase.execute({}); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - races: [], - filters: { - statuses: [ - { value: 'all', label: 'All Statuses' }, - { value: 'scheduled', label: 'Scheduled' }, - { value: 'running', label: 'Live' }, - { value: 'completed', label: 'Completed' }, - { value: 'cancelled', label: 'Cancelled' }, - ], - leagues: [], - }, + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetAllRacesPageDataResult; + + expect(presented.races).toEqual([]); + expect(presented.filters).toEqual({ + statuses: [ + { value: 'all', label: 'All Statuses' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'running', label: 'Live' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + ], + leagues: [], }); }); - it('should return error when repository throws', async () => { + it('should return error when repository throws and not present data', async () => { const useCase = new GetAllRacesPageDataUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); const error = new Error('Repository error'); mockRaceFindAll.mockRejectedValue(error); - const result = await useCase.execute(); + const result = await useCase.execute({}); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Repository error'); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts index 3c16019dd..44226f043 100644 --- a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts @@ -1,19 +1,47 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { Logger , AsyncUseCase } from '@core/shared/application'; -import type { AllRacesPageOutputPort, AllRacesListItem, AllRacesFilterOptions } from '../ports/output/AllRacesPageOutputPort'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { RaceStatus } from '../../domain/entities/Race'; -export class GetAllRacesPageDataUseCase - implements AsyncUseCase { +export type GetAllRacesPageDataInput = {}; + +export interface GetAllRacesPageRaceItem { + id: string; + track: string; + car: string; + scheduledAt: string; + status: RaceStatus; + leagueId: string; + leagueName: string; + strengthOfField: number | null; +} + +export interface GetAllRacesPageDataFilters { + statuses: { value: 'all' | RaceStatus; label: string }[]; + leagues: { id: string; name: string }[]; +} + +export interface GetAllRacesPageDataResult { + races: GetAllRacesPageRaceItem[]; + filters: GetAllRacesPageDataFilters; +} + +export type GetAllRacesPageDataErrorCode = 'REPOSITORY_ERROR'; + +export class GetAllRacesPageDataUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetAllRacesPageDataInput, + ): Promise>> { this.logger.debug('Executing GetAllRacesPageDataUseCase'); try { const [allRaces, allLeagues] = await Promise.all([ @@ -22,12 +50,12 @@ export class GetAllRacesPageDataUseCase ]); this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`); - const leagueMap = new Map(allLeagues.map((league) => [league.id.toString(), league.name.toString()])); + const leagueMap = new Map(allLeagues.map(league => [league.id.toString(), league.name.toString()])); - const races: AllRacesListItem[] = allRaces + const races: GetAllRacesPageRaceItem[] = allRaces .slice() .sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime()) - .map((race) => ({ + .map(race => ({ id: race.id, track: race.track, car: race.car, @@ -43,7 +71,7 @@ export class GetAllRacesPageDataUseCase uniqueLeagues.set(league.id.toString(), { id: league.id.toString(), name: league.name.toString() }); } - const filters: AllRacesFilterOptions = { + const filters: GetAllRacesPageDataFilters = { statuses: [ { value: 'all', label: 'All Statuses' }, { value: 'scheduled', label: 'Scheduled' }, @@ -54,19 +82,24 @@ export class GetAllRacesPageDataUseCase leagues: Array.from(uniqueLeagues.values()), }; - const viewModel: AllRacesPageOutputPort = { + const result: GetAllRacesPageDataResult = { races, filters, }; this.logger.debug('Successfully retrieved all races page data.'); - return Result.ok(viewModel); + this.output.present(result); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error))); + this.logger.error( + 'Error executing GetAllRacesPageDataUseCase', + error instanceof Error ? error : new Error(String(error)), + ); return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.test.ts b/core/racing/application/use-cases/GetAllRacesUseCase.test.ts index c0aab4bbd..0cb43677e 100644 --- a/core/racing/application/use-cases/GetAllRacesUseCase.test.ts +++ b/core/racing/application/use-cases/GetAllRacesUseCase.test.ts @@ -1,8 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GetAllRacesUseCase } from './GetAllRacesUseCase'; +import { + GetAllRacesUseCase, + type GetAllRacesResult, + type GetAllRacesInput, +} from './GetAllRacesUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetAllRacesUseCase', () => { const mockRaceFindAll = vi.fn(); @@ -39,15 +44,21 @@ describe('GetAllRacesUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: ReturnType }; + beforeEach(() => { vi.clearAllMocks(); + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: ReturnType }; }); - it('should return races data', async () => { + it('should present domain races and leagues data', async () => { const useCase = new GetAllRacesUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); const race1 = { @@ -56,75 +67,74 @@ describe('GetAllRacesUseCase', () => { car: 'Car A', scheduledAt: new Date('2023-01-01T10:00:00Z'), leagueId: 'league1', - }; + } as any; const race2 = { id: 'race2', track: 'Track B', car: 'Car B', scheduledAt: new Date('2023-01-02T10:00:00Z'), leagueId: 'league2', - }; - const league1 = { id: 'league1', name: 'League One' }; - const league2 = { id: 'league2', name: 'League Two' }; + } as any; + const league1 = { id: 'league1' } as any; + const league2 = { id: 'league2' } as any; mockRaceFindAll.mockResolvedValue([race1, race2]); mockLeagueFindAll.mockResolvedValue([league1, league2]); - const result = await useCase.execute(); + const input: GetAllRacesInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - races: [ - { - id: 'race1', - name: 'Track A - Car A', - date: '2023-01-01T10:00:00.000Z', - leagueName: 'League One', - }, - { - id: 'race2', - name: 'Track B - Car B', - date: '2023-01-02T10:00:00.000Z', - leagueName: 'League Two', - }, - ], - totalCount: 2, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetAllRacesResult; + expect(presented.totalCount).toBe(2); + expect(presented.races).toEqual([race1, race2]); + expect(presented.leagues).toEqual([league1, league2]); }); - it('should return empty result when no races or leagues', async () => { + it('should present empty result when no races or leagues', async () => { const useCase = new GetAllRacesUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); mockRaceFindAll.mockResolvedValue([]); mockLeagueFindAll.mockResolvedValue([]); - const result = await useCase.execute(); + const result = await useCase.execute({}); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - races: [], - totalCount: 0, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetAllRacesResult; + expect(presented.totalCount).toBe(0); + expect(presented.races).toEqual([]); + expect(presented.leagues).toEqual([]); }); - it('should return error when repository throws', async () => { + it('should return error when repository throws and not present data', async () => { const useCase = new GetAllRacesUseCase( mockRaceRepo, mockLeagueRepo, mockLogger, + output, ); const error = new Error('Repository error'); mockRaceFindAll.mockRejectedValue(error); - const result = await useCase.execute(); + const result = await useCase.execute({}); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Repository error'); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.ts b/core/racing/application/use-cases/GetAllRacesUseCase.ts index a353471cd..29b0c1900 100644 --- a/core/racing/application/use-cases/GetAllRacesUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesUseCase.ts @@ -1,46 +1,56 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { GetAllRacesOutputPort } from '../ports/output/GetAllRacesOutputPort'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Race } from '../../domain/entities/Race'; +import type { League } from '../../domain/entities/League'; -export class GetAllRacesUseCase implements AsyncUseCase { +export type GetAllRacesInput = {}; + +export interface GetAllRacesResult { + races: Race[]; + leagues: League[]; + totalCount: number; +} + +export type GetAllRacesErrorCode = 'REPOSITORY_ERROR'; + +export class GetAllRacesUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetAllRacesInput, + ): Promise>> { this.logger.debug('Executing GetAllRacesUseCase'); try { const races = await this.raceRepository.findAll(); const leagues = await this.leagueRepository.findAll(); - const leagueMap = new Map(leagues.map(league => [league.id, league.name])); - const output: GetAllRacesOutputPort = { - races: races.map(race => ({ - id: race.id, - leagueId: race.leagueId, - track: race.track, - car: race.car, - status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled', - scheduledAt: race.scheduledAt.toISOString(), - strengthOfField: race.strengthOfField || null, - leagueName: (leagueMap.get(race.leagueId) || 'Unknown League').toString(), - })), + const result: GetAllRacesResult = { + races, + leagues, totalCount: races.length, }; this.logger.debug('Successfully retrieved all races.'); - return Result.ok(output); + this.output.present(result); + return Result.ok(undefined); } catch (error) { - this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error))); + this.logger.error( + 'Error executing GetAllRacesUseCase', + error instanceof Error ? error : new Error(String(error)), + ); return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts b/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts index ba6444c5c..3578cb5bb 100644 --- a/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts +++ b/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GetAllTeamsUseCase } from './GetAllTeamsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { GetAllTeamsUseCase, type GetAllTeamsInput, type GetAllTeamsResult } from './GetAllTeamsUseCase'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetAllTeamsUseCase', () => { const mockTeamFindAll = vi.fn(); @@ -36,8 +37,13 @@ describe('GetAllTeamsUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: Mock }; + beforeEach(() => { vi.clearAllMocks(); + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; }); it('should return teams data', async () => { @@ -45,6 +51,7 @@ describe('GetAllTeamsUseCase', () => { mockTeamRepo, mockTeamMembershipRepo, mockLogger, + output, ); const team1 = { @@ -69,10 +76,15 @@ describe('GetAllTeamsUseCase', () => { mockTeamFindAll.mockResolvedValue([team1, team2]); mockTeamMembershipCountByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3)); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllTeamsInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetAllTeamsResult; + + expect(presented).toEqual({ teams: [ { id: 'team1', @@ -95,6 +107,7 @@ describe('GetAllTeamsUseCase', () => { memberCount: 3, }, ], + totalCount: 2, }); }); @@ -103,15 +116,22 @@ describe('GetAllTeamsUseCase', () => { mockTeamRepo, mockTeamMembershipRepo, mockLogger, + output, ); mockTeamFindAll.mockResolvedValue([]); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllTeamsInput); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetAllTeamsResult; + + expect(presented).toEqual({ teams: [], + totalCount: 0, }); }); @@ -120,15 +140,20 @@ describe('GetAllTeamsUseCase', () => { mockTeamRepo, mockTeamMembershipRepo, mockLogger, + output, ); const error = new Error('Repository error'); mockTeamFindAll.mockRejectedValue(error); - const result = await useCase.execute(); + const result = await useCase.execute({} as GetAllTeamsInput); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Repository error'); + const err = result.unwrapErr(); + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetAllTeamsUseCase.ts b/core/racing/application/use-cases/GetAllTeamsUseCase.ts index cc723ee49..a9f977f24 100644 --- a/core/racing/application/use-cases/GetAllTeamsUseCase.ts +++ b/core/racing/application/use-cases/GetAllTeamsUseCase.ts @@ -1,27 +1,50 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { GetAllTeamsOutputPort } from '../ports/output/GetAllTeamsOutputPort'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export type GetAllTeamsInput = {}; + +export type GetAllTeamsErrorCode = 'REPOSITORY_ERROR'; + +export interface TeamSummary { + id: string; + name: string; + tag: string; + description: string; + ownerId: string; + leagues: string[]; + createdAt: Date; + memberCount: number; +} + +export interface GetAllTeamsResult { + teams: TeamSummary[]; + totalCount: number; +} /** * Use Case for retrieving all teams. */ -export class GetAllTeamsUseCase implements AsyncUseCase { +export class GetAllTeamsUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly teamMembershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetAllTeamsInput = {}, + ): Promise>> { this.logger.debug('Executing GetAllTeamsUseCase'); try { const teams = await this.teamRepository.findAll(); - const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all( + const enrichedTeams: TeamSummary[] = await Promise.all( teams.map(async (team) => { const memberCount = await this.teamMembershipRepository.countByTeamId(team.id); return { @@ -37,19 +60,21 @@ export class GetAllTeamsUseCase implements AsyncUseCase { const mockFindById = vi.fn(); @@ -36,8 +37,11 @@ describe('GetDriverTeamUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: Mock }; + beforeEach(() => { vi.clearAllMocks(); + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; }); it('should return driver team data when membership and team exist', async () => { @@ -45,6 +49,7 @@ describe('GetDriverTeamUseCase', () => { mockTeamRepo, mockMembershipRepo, mockLogger, + output as unknown as UseCaseOutputPort, ); const driverId = 'driver1'; @@ -54,14 +59,16 @@ describe('GetDriverTeamUseCase', () => { mockGetActiveMembershipForDriver.mockResolvedValue(membership); mockFindById.mockResolvedValue(team); - const result = await useCase.execute({ driverId }); + const input: GetDriverTeamInput = { driverId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - team, - membership, - driverId, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = (output.present as Mock).mock.calls as [[GetDriverTeamResult]]; + expect(presented.driverId).toBe(driverId); + expect(presented.team).toBe(team); + expect(presented.membership).toBe(membership); }); it('should return error when no active membership found', async () => { @@ -69,17 +76,20 @@ describe('GetDriverTeamUseCase', () => { mockTeamRepo, mockMembershipRepo, mockLogger, + output as unknown as UseCaseOutputPort, ); const driverId = 'driver1'; mockGetActiveMembershipForDriver.mockResolvedValue(null); - const result = await useCase.execute({ driverId }); + const input: GetDriverTeamInput = { driverId }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('MEMBERSHIP_NOT_FOUND'); expect(result.unwrapErr().details.message).toBe('No active membership found for driver driver1'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when team not found', async () => { @@ -87,6 +97,7 @@ describe('GetDriverTeamUseCase', () => { mockTeamRepo, mockMembershipRepo, mockLogger, + output as unknown as UseCaseOutputPort, ); const driverId = 'driver1'; @@ -95,11 +106,13 @@ describe('GetDriverTeamUseCase', () => { mockGetActiveMembershipForDriver.mockResolvedValue(membership); mockFindById.mockResolvedValue(null); - const result = await useCase.execute({ driverId }); + const input: GetDriverTeamInput = { driverId }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('TEAM_NOT_FOUND'); expect(result.unwrapErr().details.message).toBe('Team not found for teamId team1'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository throws', async () => { @@ -107,6 +120,7 @@ describe('GetDriverTeamUseCase', () => { mockTeamRepo, mockMembershipRepo, mockLogger, + output as unknown as UseCaseOutputPort, ); const driverId = 'driver1'; @@ -114,10 +128,12 @@ describe('GetDriverTeamUseCase', () => { mockGetActiveMembershipForDriver.mockRejectedValue(error); - const result = await useCase.execute({ driverId }); + const input: GetDriverTeamInput = { driverId }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); expect(result.unwrapErr().details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetDriverTeamUseCase.ts b/core/racing/application/use-cases/GetDriverTeamUseCase.ts index ae8f95d93..7d32ff81e 100644 --- a/core/racing/application/use-cases/GetDriverTeamUseCase.ts +++ b/core/racing/application/use-cases/GetDriverTeamUseCase.ts @@ -1,24 +1,37 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { DriverTeamOutputPort } from '../ports/output/DriverTeamOutputPort'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Team } from '../../domain/entities/Team'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; + +export type GetDriverTeamInput = { + driverId: string; +}; + +export type GetDriverTeamResult = { + driverId: string; + team: Team; + membership: TeamMembership; +}; + +export type GetDriverTeamErrorCode = 'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; /** * Use Case for retrieving a driver's team. * Orchestrates domain logic and returns result. */ -export class GetDriverTeamUseCase - implements AsyncUseCase<{ driverId: string }, Result>> -{ +export class GetDriverTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: { driverId: string }): Promise>> { + async execute(input: GetDriverTeamInput): Promise>> { this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`); try { const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId); @@ -33,24 +46,18 @@ export class GetDriverTeamUseCase this.logger.error(`Team not found for teamId: ${membership.teamId}`); return Result.err({ code: 'TEAM_NOT_FOUND', details: { message: `Team not found for teamId ${membership.teamId}` } }); } - this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`); + this.logger.debug(`Found team for teamId: ${team.id}`); - const output: DriverTeamOutputPort = { + const result: GetDriverTeamResult = { driverId: input.driverId, - team: { - id: team.id, - name: team.name.value, - tag: team.tag.value, - description: team.description.value, - ownerId: team.ownerId.value, - leagues: team.leagues.map(l => l.value), - createdAt: team.createdAt.value, - }, + team, membership, }; this.logger.info(`Successfully retrieved driver team for driverId: ${input.driverId}`); - return Result.ok(output); + this.output.present(result); + + return Result.ok(undefined); } catch (error) { this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error))); return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } }); diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts index 25bca3124..19d877f90 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GetDriversLeaderboardUseCase } from './GetDriversLeaderboardUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetDriversLeaderboardUseCase, + type GetDriversLeaderboardResult, + type GetDriversLeaderboardInput, + type GetDriversLeaderboardErrorCode, +} from './GetDriversLeaderboardUseCase'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IRankingService } from '../../domain/services/IRankingService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetDriversLeaderboardUseCase', () => { const mockDriverFindAll = vi.fn(); @@ -34,8 +41,13 @@ describe('GetDriversLeaderboardUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: Mock }; + beforeEach(() => { vi.clearAllMocks(); + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; }); it('should return drivers leaderboard data', async () => { @@ -45,6 +57,7 @@ describe('GetDriversLeaderboardUseCase', () => { mockDriverStatsService, mockGetDriverAvatar, mockLogger, + output, ); const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } }; @@ -63,49 +76,52 @@ describe('GetDriversLeaderboardUseCase', () => { if (id === 'driver2') return stats2; return null; }); - 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' }); + mockGetDriverAvatar.mockImplementation((driverId: string) => { + if (driverId === 'driver1') return Promise.resolve('avatar-driver1'); + if (driverId === 'driver2') return Promise.resolve('avatar-driver2'); + return Promise.resolve('avatar-default'); }); - const result = await useCase.execute(); + const input: GetDriversLeaderboardInput = { leagueId: 'league-1' }; - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - drivers: [ - { - id: 'driver1', - name: 'Driver One', - rating: 2500, - skillLevel: 'advanced', - nationality: 'US', - racesCompleted: 10, - wins: 5, - podiums: 7, - isActive: true, - rank: 1, - avatarUrl: 'avatar-driver1', - }, - { - id: 'driver2', - name: 'Driver Two', - rating: 2400, - skillLevel: 'intermediate', - nationality: 'US', - racesCompleted: 8, - wins: 3, - podiums: 4, - isActive: true, - rank: 2, - avatarUrl: 'avatar-driver2', - }, - ], - totalRaces: 18, - totalWins: 8, - activeCount: 2, - }); - }); + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult; + + expect(presented).toEqual({ + items: [ + expect.objectContaining({ + driver: driver1, + rating: 2500, + skillLevel: 'advanced', + racesCompleted: 10, + wins: 5, + podiums: 7, + isActive: true, + rank: 1, + avatarUrl: 'avatar-driver1', + }), + expect.objectContaining({ + driver: driver2, + rating: 2400, + skillLevel: 'intermediate', + racesCompleted: 8, + wins: 3, + podiums: 4, + isActive: true, + rank: 2, + avatarUrl: 'avatar-driver2', + }), + ], + totalRaces: 18, + totalWins: 8, + activeCount: 2, + }); + }); it('should return empty result when no drivers', async () => { const useCase = new GetDriversLeaderboardUseCase( @@ -114,16 +130,24 @@ describe('GetDriversLeaderboardUseCase', () => { mockDriverStatsService, mockGetDriverAvatar, mockLogger, + output, ); mockDriverFindAll.mockResolvedValue([]); mockRankingGetAllDriverRankings.mockReturnValue([]); - const result = await useCase.execute(); + const input: GetDriversLeaderboardInput = { leagueId: 'league-1' }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - drivers: [], + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult; + + expect(presented).toEqual({ + items: [], totalRaces: 0, totalWins: 0, activeCount: 0, @@ -137,6 +161,7 @@ describe('GetDriversLeaderboardUseCase', () => { mockDriverStatsService, mockGetDriverAvatar, mockLogger, + output, ); const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } }; @@ -145,32 +170,37 @@ describe('GetDriversLeaderboardUseCase', () => { mockDriverFindAll.mockResolvedValue([driver1]); mockRankingGetAllDriverRankings.mockReturnValue(rankings); mockDriverStatsGetDriverStats.mockReturnValue(null); - mockGetDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver1' }); + mockGetDriverAvatar.mockResolvedValue('avatar-driver1'); - const result = await useCase.execute(); + const input: GetDriversLeaderboardInput = { leagueId: 'league-1' }; - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - drivers: [ - { - id: 'driver1', - name: 'Driver One', - rating: 2500, - skillLevel: 'advanced', - nationality: 'US', - racesCompleted: 0, - wins: 0, - podiums: 0, - isActive: false, - rank: 1, - avatarUrl: 'avatar-driver1', - }, - ], - totalRaces: 0, - totalWins: 0, - activeCount: 0, - }); - }); + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult; + + expect(presented).toEqual({ + items: [ + expect.objectContaining({ + driver: driver1, + rating: 2500, + skillLevel: 'advanced', + racesCompleted: 0, + wins: 0, + podiums: 0, + isActive: false, + rank: 1, + avatarUrl: 'avatar-driver1', + }), + ], + totalRaces: 0, + totalWins: 0, + activeCount: 0, + }); + }); it('should return error when repository throws', async () => { const useCase = new GetDriversLeaderboardUseCase( @@ -179,15 +209,20 @@ describe('GetDriversLeaderboardUseCase', () => { mockDriverStatsService, mockGetDriverAvatar, mockLogger, + output, ); const error = new Error('Repository error'); mockDriverFindAll.mockRejectedValue(error); - const result = await useCase.execute(); + const input: GetDriversLeaderboardInput = { leagueId: 'league-1' }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Repository error'); + const err = result.unwrapErr(); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect((err as any).details?.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts index be429b30d..bbab57830 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -1,55 +1,80 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IRankingService } from '../../domain/services/IRankingService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; -import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort'; -import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort'; -import type { DriversLeaderboardOutputPort, DriverLeaderboardItemOutputPort } from '../ports/output/DriversLeaderboardOutputPort'; import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Driver } from '../../domain/entities/Driver'; +import type { Team } from '../../domain/entities/Team'; + +export type GetDriversLeaderboardInput = { + leagueId: string; + seasonId?: string; +}; + +export interface DriverLeaderboardItem { + driver: Driver; + team?: Team; + rating: number; + skillLevel: SkillLevel; + racesCompleted: number; + wins: number; + podiums: number; + isActive: boolean; + rank: number; + avatarUrl?: string; +} + +export interface GetDriversLeaderboardResult { + items: DriverLeaderboardItem[]; + totalRaces: number; + totalWins: number; + activeCount: number; +} + +export type GetDriversLeaderboardErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'REPOSITORY_ERROR'; /** * Use Case for retrieving driver leaderboard data. * Orchestrates domain logic and returns result. */ -export class GetDriversLeaderboardUseCase - implements AsyncUseCase -{ +export class GetDriversLeaderboardUseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly rankingService: IRankingService, private readonly driverStatsService: IDriverStatsService, - private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise, + private readonly getDriverAvatar: (driverId: string) => Promise, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetDriversLeaderboardInput, + ): Promise>> { this.logger.debug('Executing GetDriversLeaderboardUseCase'); try { const drivers = await this.driverRepository.findAll(); const rankings = this.rankingService.getAllDriverRankings(); - const avatarUrls: Record = {}; + const avatarUrls: Record = {}; for (const driver of drivers) { - const avatarResult = await this.getDriverAvatar({ driverId: driver.id }); - avatarUrls[driver.id] = avatarResult.avatarUrl; + avatarUrls[driver.id] = await this.getDriverAvatar(driver.id); } - const driverItems: DriverLeaderboardItemOutputPort[] = drivers.map(driver => { - const ranking = rankings.find(r => r.driverId === driver.id); + const items: DriverLeaderboardItem[] = drivers.map((driver) => { + const ranking = rankings.find((r) => r.driverId === driver.id); const stats = this.driverStatsService.getDriverStats(driver.id); const rating = ranking?.rating ?? 0; const racesCompleted = stats?.totalRaces ?? 0; const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating); return { - id: driver.id, - name: driver.name.value, + driver, rating, skillLevel, - nationality: driver.country.value, racesCompleted, wins: stats?.wins ?? 0, podiums: stats?.podiums ?? 0, @@ -59,26 +84,29 @@ export class GetDriversLeaderboardUseCase }; }); - // Calculate totals - const totalRaces = driverItems.reduce((sum, d) => sum + d.racesCompleted, 0); - const totalWins = driverItems.reduce((sum, d) => sum + d.wins, 0); - const activeCount = driverItems.filter(d => d.isActive).length; + const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0); + const totalWins = items.reduce((sum, d) => sum + d.wins, 0); + const activeCount = items.filter((d) => d.isActive).length; - const result: DriversLeaderboardOutputPort = { - drivers: driverItems.sort((a, b) => b.rating - a.rating), + this.logger.debug('Successfully retrieved drivers leaderboard.'); + + this.output.present({ + items: items.sort((a, b) => b.rating - a.rating), totalRaces, totalWins, activeCount, - }; + }); - this.logger.debug('Successfully retrieved drivers leaderboard.'); - return Result.ok(result); + return Result.ok(undefined); } catch (error) { - this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error))); + this.logger.error( + 'Error executing GetDriversLeaderboardUseCase', + error instanceof Error ? error : new Error(String(error)), + ); return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' }, }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts index af5510d06..cadbd108c 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts @@ -1,9 +1,15 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetEntitySponsorshipPricingUseCase } from './GetEntitySponsorshipPricingUseCase'; +import { + GetEntitySponsorshipPricingUseCase, + type GetEntitySponsorshipPricingInput, + type GetEntitySponsorshipPricingResult, +} from './GetEntitySponsorshipPricingUseCase'; import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetEntitySponsorshipPricingUseCase', () => { let mockSponsorshipPricingRepo: ISponsorshipPricingRepository; @@ -13,6 +19,9 @@ describe('GetEntitySponsorshipPricingUseCase', () => { let mockFindByEntity: Mock; let mockFindPendingByEntity: Mock; let mockFindBySeasonId: Mock; + let output: UseCaseOutputPort & { + present: Mock; + }; beforeEach(() => { mockFindByEntity = vi.fn(); @@ -65,24 +74,35 @@ describe('GetEntitySponsorshipPricingUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { present: vi.fn() } as unknown as typeof output; }); - it('should return null when no pricing found', async () => { + it('should return PRICING_NOT_CONFIGURED when no pricing found', async () => { const useCase = new GetEntitySponsorshipPricingUseCase( mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockLogger, + output, ); - const dto = { entityType: 'season' as const, entityId: 'season1' }; + const dto: GetEntitySponsorshipPricingInput = { + entityType: 'season', + entityId: 'season1', + }; mockFindByEntity.mockResolvedValue(null); const result = await useCase.execute(dto); - expect(result.isOk()).toBe(true); - expect(result.value).toBe(null); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + 'PRICING_NOT_CONFIGURED', + { message: string } + >; + expect(err.code).toBe('PRICING_NOT_CONFIGURED'); + expect(err.details.message).toContain('No sponsorship pricing configured'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return pricing data when found', async () => { @@ -91,9 +111,13 @@ describe('GetEntitySponsorshipPricingUseCase', () => { mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockLogger, + output, ); - const dto = { entityType: 'season' as const, entityId: 'season1' }; + const dto: GetEntitySponsorshipPricingInput = { + entityType: 'season', + entityId: 'season1', + }; const pricing = { acceptingApplications: true, customRequirements: 'Some requirements', @@ -118,34 +142,29 @@ describe('GetEntitySponsorshipPricingUseCase', () => { const result = await useCase.execute(dto); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - entityType: 'season', - entityId: 'season1', - acceptingApplications: true, - customRequirements: 'Some requirements', - mainSlot: { - tier: 'main', - price: 100, - currency: 'USD', - formattedPrice: '$100', - benefits: ['Benefit 1'], - available: true, - maxSlots: 5, - filledSlots: 0, - pendingRequests: 0, - }, - secondarySlot: { - tier: 'secondary', - price: 50, - currency: 'USD', - formattedPrice: '$50', - benefits: ['Benefit 2'], - available: true, - maxSlots: 10, - filledSlots: 0, - pendingRequests: 0, - }, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = (output.present as Mock).mock.calls[0][0] as GetEntitySponsorshipPricingResult; + + expect(presented.entityType).toBe('season'); + expect(presented.entityId).toBe('season1'); + expect(presented.acceptingApplications).toBe(true); + expect(presented.customRequirements).toBe('Some requirements'); + expect(presented.tiers).toHaveLength(2); + + const mainTier = presented.tiers.find(tier => tier.name === 'main'); + const secondaryTier = presented.tiers.find(tier => tier.name === 'secondary'); + + expect(mainTier).toBeDefined(); + expect(mainTier?.price.amount).toBe(100); + expect(mainTier?.price.currency).toBe('USD'); + expect(mainTier?.benefits).toEqual(['Benefit 1']); + + expect(secondaryTier).toBeDefined(); + expect(secondaryTier?.price.amount).toBe(50); + expect(secondaryTier?.price.currency).toBe('USD'); + expect(secondaryTier?.benefits).toEqual(['Benefit 2']); }); it('should return error when repository throws', async () => { @@ -154,9 +173,13 @@ describe('GetEntitySponsorshipPricingUseCase', () => { mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, mockLogger, + output, ); - const dto = { entityType: 'season' as const, entityId: 'season1' }; + const dto: GetEntitySponsorshipPricingInput = { + entityType: 'season', + entityId: 'season1', + }; const error = new Error('Repository error'); mockFindByEntity.mockRejectedValue(error); @@ -164,7 +187,12 @@ describe('GetEntitySponsorshipPricingUseCase', () => { const result = await useCase.execute(dto); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Repository error'); + const err = result.unwrapErr() as ApplicationErrorCode< + 'REPOSITORY_ERROR', + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts index cbef29020..9edec67d9 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts @@ -8,96 +8,128 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { SponsorshipPricing, SponsorshipSlotConfig } from '../../domain/value-objects/SponsorshipPricing'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetEntitySponsorshipPricingInputPort } from '../ports/input/GetEntitySponsorshipPricingInputPort'; -import type { GetEntitySponsorshipPricingOutputPort } from '../ports/output/GetEntitySponsorshipPricingOutputPort'; -export class GetEntitySponsorshipPricingUseCase - implements AsyncUseCase -{ +export type SponsorshipEntityType = 'season' | 'league' | 'team'; + +export type GetEntitySponsorshipPricingInput = { + entityType: SponsorshipEntityType; + entityId: string; +}; + +export type SponsorshipPricingTier = { + name: string; + price: SponsorshipPricing['mainSlot'] extends SponsorshipSlotConfig + ? SponsorshipSlotConfig['price'] + : SponsorshipPricing['secondarySlots'] extends SponsorshipSlotConfig + ? SponsorshipSlotConfig['price'] + : never; + benefits: string[]; +}; + +export type GetEntitySponsorshipPricingResult = { + entityType: SponsorshipEntityType; + entityId: string; + acceptingApplications: boolean; + customRequirements?: string; + tiers: SponsorshipPricingTier[]; +}; + +export type GetEntitySponsorshipPricingErrorCode = + | 'ENTITY_NOT_FOUND' + | 'PRICING_NOT_CONFIGURED' + | 'REPOSITORY_ERROR'; + +export class GetEntitySponsorshipPricingUseCase { constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(dto: GetEntitySponsorshipPricingInputPort): Promise>> { - this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); + async execute( + input: GetEntitySponsorshipPricingInput, + ): Promise< + Result> + > { + this.logger.debug( + `Executing GetEntitySponsorshipPricingUseCase for entityType: ${input.entityType}, entityId: ${input.entityId}`, + ); + try { - const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); + const pricing = await this.sponsorshipPricingRepo.findByEntity( + input.entityType, + input.entityId, + ); if (!pricing) { - this.logger.info(`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); - return Result.ok(null); + this.logger.info( + `No pricing configured for entityType: ${input.entityType}, entityId: ${input.entityId}`, + ); + + return Result.err({ + code: 'PRICING_NOT_CONFIGURED', + details: { + message: `No sponsorship pricing configured for entityType: ${input.entityType}, entityId: ${input.entityId}`, + }, + }); } - // Count pending requests by tier - const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity( - dto.entityType, - dto.entityId, - ); - const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length; - const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length; + const tiers: SponsorshipPricingTier[] = []; - // Count filled slots (for seasons, check SeasonSponsorship table) - let filledMainSlots = 0; - let filledSecondarySlots = 0; - - if (dto.entityType === 'season') { - const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId); - const activeSponsorships = sponsorships.filter(s => s.isActive()); - filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length; - filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length; + if (pricing.mainSlot) { + tiers.push({ + name: 'main', + price: pricing.mainSlot.price, + benefits: pricing.mainSlot.benefits, + }); } - const result: GetEntitySponsorshipPricingOutputPort = { - entityType: dto.entityType, - entityId: dto.entityId, + if (pricing.secondarySlots) { + tiers.push({ + name: 'secondary', + price: pricing.secondarySlots.price, + benefits: pricing.secondarySlots.benefits, + }); + } + + const result: GetEntitySponsorshipPricingResult = { + entityType: input.entityType, + entityId: input.entityId, acceptingApplications: pricing.acceptingApplications, ...(pricing.customRequirements !== undefined ? { customRequirements: pricing.customRequirements } : {}), + tiers, }; - if (pricing.mainSlot) { - const mainMaxSlots = pricing.mainSlot.maxSlots; - result.mainSlot = { - tier: 'main', - price: pricing.mainSlot.price.amount, - currency: pricing.mainSlot.price.currency, - formattedPrice: pricing.mainSlot.price.format(), - benefits: pricing.mainSlot.benefits, - available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots, - maxSlots: mainMaxSlots, - filledSlots: filledMainSlots, - pendingRequests: pendingMainCount, - }; - } + this.logger.info( + `Successfully retrieved sponsorship pricing for entityType: ${input.entityType}, entityId: ${input.entityId}`, + ); + this.output.present(result); - if (pricing.secondarySlots) { - const secondaryMaxSlots = pricing.secondarySlots.maxSlots; - result.secondarySlot = { - tier: 'secondary', - price: pricing.secondarySlots.price.amount, - currency: pricing.secondarySlots.price.currency, - formattedPrice: pricing.secondarySlots.price.format(), - benefits: pricing.secondarySlots.benefits, - available: - pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots, - maxSlots: secondaryMaxSlots, - filledSlots: filledSecondarySlots, - pendingRequests: pendingSecondaryCount, - }; - } - - this.logger.info(`Successfully retrieved sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); - return Result.ok(result); + return Result.ok(undefined); } catch (error) { - this.logger.error('Error executing GetEntitySponsorshipPricingUseCase', error instanceof Error ? error : new Error(String(error))); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } }); + this.logger.error( + 'Error executing GetEntitySponsorshipPricingUseCase', + error instanceof Error ? error : new Error(String(error)), + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load sponsorship pricing', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts index 16d2c7565..3f8d5ac20 100644 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts @@ -1,13 +1,27 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueAdminPermissionsUseCase } from './GetLeagueAdminPermissionsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueAdminPermissionsUseCase, + type GetLeagueAdminPermissionsInput, + type GetLeagueAdminPermissionsResult, + type GetLeagueAdminPermissionsErrorCode, +} from './GetLeagueAdminPermissionsUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueAdminPermissionsUseCase', () => { let mockLeagueRepo: ILeagueRepository; let mockMembershipRepo: ILeagueMembershipRepository; let mockFindById: Mock; let mockGetMembership: Mock; + let output: UseCaseOutputPort & { present: Mock }; + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as any; beforeEach(() => { mockFindById = vi.fn(); @@ -22,6 +36,7 @@ describe('GetLeagueAdminPermissionsUseCase', () => { findByOwnerId: vi.fn(), searchByName: vi.fn(), } as ILeagueRepository; + mockMembershipRepo = { getMembership: mockGetMembership, getMembershipsForDriver: vi.fn(), @@ -33,80 +48,134 @@ describe('GetLeagueAdminPermissionsUseCase', () => { countByLeagueId: vi.fn(), getLeagueMembers: vi.fn(), } as ILeagueMembershipRepository; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; }); const createUseCase = () => new GetLeagueAdminPermissionsUseCase( mockLeagueRepo, mockMembershipRepo, + logger, + output, ); - const params = { + const input: GetLeagueAdminPermissionsInput = { leagueId: 'league1', performerDriverId: 'driver1', }; - it('should return no permissions when league not found', async () => { + it('returns LEAGUE_NOT_FOUND when league does not exist and does not call output', async () => { mockFindById.mockResolvedValue(null); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return no permissions when membership not found', async () => { + it('returns USER_NOT_MEMBER when membership is missing and does not call output', async () => { mockFindById.mockResolvedValue({ id: 'league1' }); mockGetMembership.mockResolvedValue(null); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('USER_NOT_MEMBER'); + expect(err.details.message).toBe('User is not a member of this league'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return no permissions when membership not active', async () => { + it('returns USER_NOT_MEMBER when membership is not active and does not call output', async () => { mockFindById.mockResolvedValue({ id: 'league1' }); mockGetMembership.mockResolvedValue({ status: 'inactive', role: 'admin' }); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('USER_NOT_MEMBER'); + expect(err.details.message).toBe('User is not a member of this league'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return no permissions when role is member', async () => { + it('returns USER_NOT_MEMBER when role is member and does not call output', async () => { mockFindById.mockResolvedValue({ id: 'league1' }); mockGetMembership.mockResolvedValue({ status: 'active', role: 'member' }); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('USER_NOT_MEMBER'); + expect(err.details.message).toBe('User is not a member of this league'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return permissions when role is admin', async () => { - mockFindById.mockResolvedValue({ id: 'league1' }); + it('returns admin permissions for admin role and calls output once', async () => { + const league = { id: 'league1' } as any; + mockFindById.mockResolvedValue(league); mockGetMembership.mockResolvedValue({ status: 'active', role: 'admin' }); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueAdminPermissionsResult; + expect(presented.league).toBe(league); + expect(presented.permissions).toEqual({ + canManageSchedule: true, + canManageMembers: true, + canManageSponsorships: true, + canManageScoring: true, + }); }); - it('should return permissions when role is owner', async () => { - mockFindById.mockResolvedValue({ id: 'league1' }); + it('returns admin permissions for owner role and calls output once', async () => { + const league = { id: 'league1' } as any; + mockFindById.mockResolvedValue(league); mockGetMembership.mockResolvedValue({ status: 'active', role: 'owner' }); const useCase = createUseCase(); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueAdminPermissionsResult; + expect(presented.league).toBe(league); + expect(presented.permissions).toEqual({ + canManageSchedule: true, + canManageMembers: true, + canManageSponsorships: true, + canManageScoring: true, + }); }); -}); \ No newline at end of file + + it('wraps repository errors in REPOSITORY_ERROR and does not call output', async () => { + const error = new Error('repo failed'); + mockFindById.mockRejectedValue(error); + + const useCase = createUseCase(); + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('repo failed'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts index ef824ef15..31006f237 100644 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts @@ -1,30 +1,90 @@ +import type { Logger } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { League } from '../../domain/entities/League'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/application/Result'; -import type { GetLeagueAdminPermissionsOutputPort } from '../ports/output/GetLeagueAdminPermissionsOutputPort'; -export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<{ leagueId: string; performerDriverId: string }, GetLeagueAdminPermissionsOutputPort, 'NO_ERROR'> { +export type GetLeagueAdminPermissionsInput = { + leagueId: string; + performerDriverId: string; +}; + +export type LeagueAdminPermissions = { + canManageSchedule: boolean; + canManageMembers: boolean; + canManageSponsorships: boolean; + canManageScoring: boolean; +}; + +export type GetLeagueAdminPermissionsResult = { + league: League; + permissions: LeagueAdminPermissions; +}; + +export type GetLeagueAdminPermissionsErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'USER_NOT_MEMBER' + | 'REPOSITORY_ERROR'; + +export class GetLeagueAdminPermissionsUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { leagueId: string; performerDriverId: string }): Promise> { - const league = await this.leagueRepository.findById(params.leagueId); - if (!league) { - return Result.ok({ canRemoveMember: false, canUpdateRoles: false }); + async execute( + input: GetLeagueAdminPermissionsInput, + ): Promise>> { + const { leagueId, performerDriverId } = input; + + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + this.logger.warn('League not found when checking admin permissions', { leagueId, performerDriverId }); + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const membership = await this.leagueMembershipRepository.getMembership(leagueId, performerDriverId); + if (!membership || membership.status !== 'active' || (membership.role !== 'owner' && membership.role !== 'admin')) { + this.logger.warn('User is not a member or not authorized for league admin permissions', { + leagueId, + performerDriverId, + }); + return Result.err({ + code: 'USER_NOT_MEMBER', + details: { message: 'User is not a member of this league' }, + }); + } + + const permissions: LeagueAdminPermissions = { + canManageSchedule: true, + canManageMembers: true, + canManageSponsorships: true, + canManageScoring: true, + }; + + const result: GetLeagueAdminPermissionsResult = { + league, + permissions, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + this.logger.error('Failed to load league admin permissions', err); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to load league admin permissions' }, + }); } - - const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId); - if (!membership || membership.status !== 'active') { - return Result.ok({ canRemoveMember: false, canUpdateRoles: false }); - } - - // Business logic: owners and admins can remove members and update roles - const canRemoveMember = membership.role === 'owner' || membership.role === 'admin'; - const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin'; - - return Result.ok({ canRemoveMember, canUpdateRoles }); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts b/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts index 4c373c6c5..68ea2fe9b 100644 --- a/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts @@ -1,10 +1,18 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueAdminUseCase } from './GetLeagueAdminUseCase'; +import { + GetLeagueAdminUseCase, + type GetLeagueAdminInput, + type GetLeagueAdminResult, + type GetLeagueAdminErrorCode, +} from './GetLeagueAdminUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueAdminUseCase', () => { let mockLeagueRepo: ILeagueRepository; let mockFindById: Mock; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { mockFindById = vi.fn(); @@ -18,13 +26,18 @@ describe('GetLeagueAdminUseCase', () => { findByOwnerId: vi.fn(), searchByName: vi.fn(), } as ILeagueRepository; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; }); const createUseCase = () => new GetLeagueAdminUseCase( mockLeagueRepo, + output, ); - const params = { + const params: GetLeagueAdminInput = { leagueId: 'league1', }; @@ -35,8 +48,10 @@ describe('GetLeagueAdminUseCase', () => { const result = await useCase.execute(params); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND'); - expect(result.unwrapErr().details.message).toBe('League not found'); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return league data when league found', async () => { @@ -47,11 +62,24 @@ describe('GetLeagueAdminUseCase', () => { const result = await useCase.execute(params); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ - league: { - id: 'league1', - ownerId: 'owner1', - }, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueAdminResult; + expect(presented.league.id).toBe('league1'); + expect(presented.league.ownerId).toBe('owner1'); + }); + + it('should return repository error when repository throws', async () => { + const repoError = new Error('Repository failure'); + mockFindById.mockRejectedValue(repoError); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts index bd527dc68..8d7443548 100644 --- a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts @@ -1,26 +1,43 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { AsyncUseCase } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueAdminOutputPort } from '../ports/output/GetLeagueAdminOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { League } from '../../domain/entities/League'; -export class GetLeagueAdminUseCase implements AsyncUseCase<{ leagueId: string }, GetLeagueAdminOutputPort, 'LEAGUE_NOT_FOUND'> { +export type GetLeagueAdminInput = { + leagueId: string; +}; + +export type GetLeagueAdminResult = { + league: League; +}; + +export type GetLeagueAdminErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export class GetLeagueAdminUseCase { constructor( private readonly leagueRepository: ILeagueRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { leagueId: string }): Promise>> { - const league = await this.leagueRepository.findById(params.leagueId); - if (!league) { - return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); - } + async execute( + input: GetLeagueAdminInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); + } - const dto: GetLeagueAdminOutputPort = { - league: { - id: league.id, - ownerId: league.ownerId, - }, - }; - return Result.ok(dto); + this.output.present({ league }); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to load league admin data' }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts index 9857df206..93832d20a 100644 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { GetLeagueDriverSeasonStatsUseCase } from './GetLeagueDriverSeasonStatsUseCase'; +import { + GetLeagueDriverSeasonStatsUseCase, + type GetLeagueDriverSeasonStatsResult, + type GetLeagueDriverSeasonStatsInput, + type GetLeagueDriverSeasonStatsErrorCode, +} from './GetLeagueDriverSeasonStatsUseCase'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; @@ -7,6 +12,8 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { DriverRatingPort } from '../ports/DriverRatingPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueDriverSeasonStatsUseCase', () => { const mockStandingFindByLeagueId = vi.fn(); @@ -25,8 +32,17 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { let driverRepository: IDriverRepository; let teamRepository: ITeamRepository; let driverRatingPort: DriverRatingPort; + let output: UseCaseOutputPort & { present: ReturnType }; beforeEach(() => { + mockStandingFindByLeagueId.mockReset(); + mockResultFindByDriverIdAndLeagueId.mockReset(); + mockPenaltyFindByRaceId.mockReset(); + mockRaceFindByLeagueId.mockReset(); + mockDriverRatingGetRating.mockReset(); + mockDriverFindById.mockReset(); + mockTeamFindById.mockReset(); + standingRepository = { findByLeagueId: mockStandingFindByLeagueId, findByDriverIdAndLeagueId: vi.fn(), @@ -67,6 +83,12 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { getRating: mockDriverRatingGetRating, }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { + present: ReturnType; + }; + useCase = new GetLeagueDriverSeasonStatsUseCase( standingRepository, resultRepository, @@ -75,20 +97,18 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { driverRepository, teamRepository, driverRatingPort, + output, ); }); it('should return league driver season stats for given league id', async () => { - const params = { leagueId: 'league-1' }; + const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'league-1' }; const mockStandings = [ { driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }, { driverId: 'driver-2', position: 2, points: 80, racesCompleted: 5 }, ]; - const mockRaces = [ - { id: 'race-1' }, - { id: 'race-2' }, - ]; + const mockRaces = [{ id: 'race-1' }, { id: 'race-2' }]; const mockPenalties = [ { driverId: 'driver-1', status: 'applied', type: 'points_deduction', value: 10 }, ]; @@ -97,28 +117,31 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { const mockDriver = { id: 'driver-1', name: 'Driver One', teamId: 'team-1' }; const mockTeam = { id: 'team-1', name: 'Team One' }; - standingRepository.findByLeagueId.mockResolvedValue(mockStandings); - raceRepository.findByLeagueId.mockResolvedValue(mockRaces); - penaltyRepository.findByRaceId.mockImplementation((raceId) => { + mockStandingFindByLeagueId.mockResolvedValue(mockStandings); + mockRaceFindByLeagueId.mockResolvedValue(mockRaces); + mockPenaltyFindByRaceId.mockImplementation((raceId: string) => { if (raceId === 'race-1') return Promise.resolve(mockPenalties); return Promise.resolve([]); }); - driverRatingPort.getRating.mockReturnValue(mockRating); - resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults); - driverRepository.findById.mockImplementation((id) => { + mockDriverRatingGetRating.mockReturnValue(mockRating); + mockResultFindByDriverIdAndLeagueId.mockResolvedValue(mockResults); + mockDriverFindById.mockImplementation((id: string) => { if (id === 'driver-1') return Promise.resolve(mockDriver); if (id === 'driver-2') return Promise.resolve({ id: 'driver-2', name: 'Driver Two' }); return Promise.resolve(null); }); - teamRepository.findById.mockResolvedValue(mockTeam); + mockTeamFindById.mockResolvedValue(mockTeam); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const output = result.value!; - expect(output.leagueId).toBe('league-1'); - expect(output.stats).toHaveLength(2); - expect(output.stats[0]).toEqual({ + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetLeagueDriverSeasonStatsResult; + expect(presented.leagueId).toBe('league-1'); + expect(presented.stats).toHaveLength(2); + expect(presented.stats[0]).toEqual({ leagueId: 'league-1', driverId: 'driver-1', position: 1, @@ -141,26 +164,68 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { }); it('should handle no penalties', async () => { - const params = { leagueId: 'league-1' }; + const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'league-1' }; - const mockStandings = [{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }]; + const mockStandings = [ + { driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }, + ]; const mockRaces = [{ id: 'race-1' }]; const mockResults = [{ position: 1 }]; const mockRating = { rating: null, ratingChange: null }; const mockDriver = { id: 'driver-1', name: 'Driver One' }; - standingRepository.findByLeagueId.mockResolvedValue(mockStandings); - raceRepository.findByLeagueId.mockResolvedValue(mockRaces); - penaltyRepository.findByRaceId.mockResolvedValue([]); - driverRatingPort.getRating.mockReturnValue(mockRating); - resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults); - driverRepository.findById.mockResolvedValue(mockDriver); - teamRepository.findById.mockResolvedValue(null); + mockStandingFindByLeagueId.mockResolvedValue(mockStandings); + mockRaceFindByLeagueId.mockResolvedValue(mockRaces); + mockPenaltyFindByRaceId.mockResolvedValue([]); + mockDriverRatingGetRating.mockReturnValue(mockRating); + mockResultFindByDriverIdAndLeagueId.mockResolvedValue(mockResults); + mockDriverFindById.mockResolvedValue(mockDriver); + mockTeamFindById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const output = result.value!; - expect(output.stats[0].penaltyPoints).toBe(0); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetLeagueDriverSeasonStatsResult; + expect(presented.stats[0].penaltyPoints).toBe(0); }); -}); \ No newline at end of file + + it('should return LEAGUE_NOT_FOUND when no standings are found', async () => { + const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'missing-league' }; + + mockStandingFindByLeagueId.mockResolvedValue([]); + mockRaceFindByLeagueId.mockResolvedValue([]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueDriverSeasonStatsErrorCode, + { message: string } + >; + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when an unexpected error occurs', async () => { + const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'league-1' }; + const thrown = new Error('repository failure'); + + mockStandingFindByLeagueId.mockRejectedValue(thrown); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueDriverSeasonStatsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts index 350119039..84eec5018 100644 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts @@ -4,16 +4,52 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; -import type { LeagueDriverSeasonStatsOutputPort } from '../ports/output/LeagueDriverSeasonStatsOutputPort'; -import type { AsyncUseCase } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { DriverRatingPort } from '../ports/DriverRatingPort'; +export type DriverSeasonStats = { + leagueId: string; + driverId: string; + position: number; + driverName: string; + teamId?: string; + teamName?: string; + totalPoints: number; + basePoints: number; + penaltyPoints: number; + bonusPoints: number; + pointsPerRace: number; + racesStarted: number; + racesFinished: number; + dnfs: number; + noShows: number; + avgFinish: number | null; + rating: number | null; + ratingChange: number | null; +}; + +export type GetLeagueDriverSeasonStatsInput = { + leagueId: string; +}; + +export type GetLeagueDriverSeasonStatsResult = { + leagueId: string; + stats: DriverSeasonStats[]; +}; + +export type GetLeagueDriverSeasonStatsErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'DRIVER_NOT_FOUND' + | 'REPOSITORY_ERROR'; + /** * Use Case for retrieving league driver season statistics. * Orchestrates domain logic and returns the result. */ -export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsOutputPort, 'NO_ERROR'> { +export class GetLeagueDriverSeasonStatsUseCase { constructor( private readonly standingRepository: IStandingRepository, private readonly resultRepository: IResultRepository, @@ -22,105 +58,137 @@ export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueI private readonly driverRepository: IDriverRepository, private readonly teamRepository: ITeamRepository, private readonly driverRatingPort: DriverRatingPort, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { leagueId: string }): Promise> { - const { leagueId } = params; + async execute( + input: GetLeagueDriverSeasonStatsInput, + ): Promise< + Result> + > { + try { + const { leagueId } = input; - // Get standings and races for the league - const [standings, races] = await Promise.all([ - this.standingRepository.findByLeagueId(leagueId), - this.raceRepository.findByLeagueId(leagueId), - ]); + const [standings, races] = await Promise.all([ + this.standingRepository.findByLeagueId(leagueId), + this.raceRepository.findByLeagueId(leagueId), + ]); - // Fetch all penalties for all races in the league - const penaltiesArrays = await Promise.all( - races.map(race => this.penaltyRepository.findByRaceId(race.id)) - ); - const penaltiesForLeague = penaltiesArrays.flat(); - - // Group penalties by driver for quick lookup - const penaltiesByDriver = new Map(); - for (const p of penaltiesForLeague) { - // Only count applied penalties - if (p.status !== 'applied') continue; - - const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; - - // Convert penalty to points delta based on type - if (p.type === 'points_deduction' && p.value) { - // Points deductions are negative - current.baseDelta -= p.value; + if (!standings || standings.length === 0) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); } - penaltiesByDriver.set(p.driverId, current); - } - - // Collect driver ratings - const driverRatings = new Map(); - for (const standing of standings) { - const ratingInfo = this.driverRatingPort.getRating(standing.driverId); - driverRatings.set(standing.driverId, ratingInfo); - } - - // Collect driver results - const driverResults = new Map>(); - for (const standing of standings) { - const results = await this.resultRepository.findByDriverIdAndLeagueId( - standing.driverId, - leagueId, + const penaltiesArrays = await Promise.all( + races.map(race => this.penaltyRepository.findByRaceId(race.id)), ); - driverResults.set(standing.driverId, results); - } + const penaltiesForLeague = penaltiesArrays.flat(); - // Fetch drivers and teams - const driverIds = standings.map(s => s.driverId); - const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); - const driversMap = new Map(drivers.filter(d => d).map(d => [d!.id, d!])); - const teamIds = Array.from(new Set(drivers.filter(d => d?.teamId).map(d => d!.teamId!))); - const teams = await Promise.all(teamIds.map(id => this.teamRepository.findById(id))); - const teamsMap = new Map(teams.filter(t => t).map(t => [t!.id, t!])); + const penaltiesByDriver = new Map(); + for (const p of penaltiesForLeague) { + if (p.status !== 'applied') continue; - // Compute stats - const stats = standings.map(standing => { - const driver = driversMap.get(standing.driverId); - const team = driver?.teamId ? teamsMap.get(driver.teamId) : undefined; - const penalties = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; - const results = driverResults.get(standing.driverId) ?? []; - const rating = driverRatings.get(standing.driverId); + const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; - const racesStarted = results.length; - const racesFinished = results.filter(r => r.position > 0).length; - const dnfs = results.filter(r => r.position === 0).length; - const noShows = races.length - racesStarted; - const avgFinish = results.length > 0 ? results.reduce((sum, r) => sum + r.position, 0) / results.length : null; - const pointsPerRace = racesStarted > 0 ? standing.points / racesStarted : 0; + if (p.type === 'points_deduction' && p.value) { + current.baseDelta -= p.value; + } - return { + penaltiesByDriver.set(p.driverId, current); + } + + const driverRatings = new Map(); + for (const standing of standings) { + const driverId = String(standing.driverId); + const ratingInfo = this.driverRatingPort.getRating(driverId); + driverRatings.set(driverId, ratingInfo); + } + + const driverResults = new Map>(); + for (const standing of standings) { + const driverId = String(standing.driverId); + const results = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId); + driverResults.set( + driverId, + results.map(result => ({ position: Number((result as any).position) })), + ); + } + + const driverIds = standings.map(s => String(s.driverId)); + const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); + const driversMap = new Map(drivers.filter(d => d).map(d => [String(d!.id), d!])); + const teamIds = Array.from( + new Set( + drivers + .filter(d => (d as any)?.teamId) + .map(d => (d as any).teamId as string), + ), + ); + const teams = await Promise.all(teamIds.map(id => this.teamRepository.findById(id))); + const teamsMap = new Map(teams.filter(t => t).map(t => [String(t!.id), t!])); + + const stats: DriverSeasonStats[] = standings.map(standing => { + const driverId = String(standing.driverId); + const driver = driversMap.get(driverId) as any; + const teamId = driver?.teamId as string | undefined; + const team = teamId ? teamsMap.get(String(teamId)) : undefined; + const penalties = penaltiesByDriver.get(driverId) ?? { baseDelta: 0, bonusDelta: 0 }; + const results = driverResults.get(driverId) ?? []; + const rating = driverRatings.get(driverId); + + const racesStarted = results.length; + const racesFinished = results.filter(r => r.position > 0).length; + const dnfs = results.filter(r => r.position === 0).length; + const noShows = races.length - racesStarted; + const avgFinish = + results.length > 0 + ? results.reduce((sum, r) => sum + r.position, 0) / results.length + : null; + const totalPoints = Number(standing.points); + const pointsPerRace = racesStarted > 0 ? totalPoints / racesStarted : 0; + + return { + leagueId, + driverId, + position: Number(standing.position), + driverName: String(driver?.name ?? ''), + teamId, + teamName: (team as any)?.name as string | undefined, + totalPoints, + basePoints: totalPoints - penalties.baseDelta, + penaltyPoints: penalties.baseDelta, + bonusPoints: penalties.bonusDelta, + pointsPerRace, + racesStarted, + racesFinished, + dnfs, + noShows, + avgFinish, + rating: rating?.rating ?? null, + ratingChange: rating?.ratingChange ?? null, + }; + }); + + const result: GetLeagueDriverSeasonStatsResult = { leagueId, - driverId: standing.driverId, - position: standing.position, - driverName: driver?.name ?? '', - teamId: driver?.teamId ?? undefined, - teamName: team?.name ?? undefined, - totalPoints: standing.points, - basePoints: standing.points - penalties.baseDelta, - penaltyPoints: penalties.baseDelta, - bonusPoints: penalties.bonusDelta, - pointsPerRace, - racesStarted, - racesFinished, - dnfs, - noShows, - avgFinish, - rating: rating?.rating ?? null, - ratingChange: rating?.ratingChange ?? null, + stats, }; - }); - return Result.ok({ - leagueId, - stats, - }); + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to fetch league driver season stats'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts index 1483b2bef..593a5077f 100644 --- a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts @@ -1,17 +1,26 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { GetLeagueFullConfigUseCase } from './GetLeagueFullConfigUseCase'; +import { + GetLeagueFullConfigUseCase, + type GetLeagueFullConfigInput, + type GetLeagueFullConfigResult, + type GetLeagueFullConfigErrorCode, +} from './GetLeagueFullConfigUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueFullConfigUseCase', () => { let useCase: GetLeagueFullConfigUseCase; - let leagueRepository: ILeagueRepository; - let seasonRepository: ISeasonRepository; - let leagueScoringConfigRepository: ILeagueScoringConfigRepository; - let gameRepository: IGameRepository; + let leagueRepository: ILeagueRepository & { findById: ReturnType }; + let seasonRepository: ISeasonRepository & { findByLeagueId: ReturnType }; + let leagueScoringConfigRepository: ILeagueScoringConfigRepository & { + findBySeasonId: ReturnType; + }; + let gameRepository: IGameRepository & { findById: ReturnType }; + let output: UseCaseOutputPort & { present: ReturnType }; beforeEach(() => { leagueRepository = { @@ -31,16 +40,21 @@ describe('GetLeagueFullConfigUseCase', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: ReturnType; + }; + useCase = new GetLeagueFullConfigUseCase( leagueRepository, seasonRepository, leagueScoringConfigRepository, gameRepository, + output, ); }); it('should return league config when league exists', async () => { - const params = { leagueId: 'league-1' }; + const input: GetLeagueFullConfigInput = { leagueId: 'league-1' }; const mockLeague = { id: 'league-1', @@ -69,33 +83,41 @@ describe('GetLeagueFullConfigUseCase', () => { leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(mockScoringConfig); gameRepository.findById.mockResolvedValue(mockGame); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const output = result.value!; - expect(output).toEqual({ - league: mockLeague, - activeSeason: mockSeasons[0], - scoringConfig: mockScoringConfig, - game: mockGame, - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const firstCall = output.present.mock.calls[0]!; + const presented = firstCall[0] as GetLeagueFullConfigResult; + + expect(presented.config.league).toEqual(mockLeague); + expect(presented.config.activeSeason).toEqual(mockSeasons[0]); + expect(presented.config.scoringConfig).toEqual(mockScoringConfig); + expect(presented.config.game).toEqual(mockGame); }); - it('should return error when league not found', async () => { - const params = { leagueId: 'league-1' }; + it('should return error when league not found and not call presenter', async () => { + const input: GetLeagueFullConfigInput = { leagueId: 'league-1' }; leagueRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - const error = result.unwrapErr(); + const error = result.unwrapErr() as ApplicationErrorCode< + GetLeagueFullConfigErrorCode, + { message: string } + >; + 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 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should handle no active season', async () => { - const params = { leagueId: 'league-1' }; + const input: GetLeagueFullConfigInput = { leagueId: 'league-1' }; const mockLeague = { id: 'league-1', @@ -107,12 +129,37 @@ describe('GetLeagueFullConfigUseCase', () => { leagueRepository.findById.mockResolvedValue(mockLeague); seasonRepository.findByLeagueId.mockResolvedValue([]); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const output = result.value!; - expect(output).toEqual({ - league: mockLeague, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const firstCall = output.present.mock.calls[0]!; + const presented = firstCall[0] as GetLeagueFullConfigResult; + + expect(presented.config.league).toEqual(mockLeague); + expect(presented.config.activeSeason).toBeUndefined(); + expect(presented.config.scoringConfig).toBeUndefined(); + expect(presented.config.game).toBeUndefined(); }); -}); \ No newline at end of file + + it('should return repository error when repository throws and not call presenter', async () => { + const input: GetLeagueFullConfigInput = { leagueId: 'league-1' }; + + const thrownError = new Error('Repository failure'); + leagueRepository.findById.mockRejectedValue(thrownError); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + GetLeagueFullConfigErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts index 2e054c99e..aa9bb204f 100644 --- a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts @@ -2,53 +2,93 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +export type GetLeagueFullConfigInput = { + leagueId: string; +}; + +export type LeagueFullConfig = { + league: unknown; + activeSeason?: unknown; + scoringConfig?: unknown | null; + game?: unknown | null; +}; + +export type GetLeagueFullConfigResult = { + config: LeagueFullConfig; +}; + +export type GetLeagueFullConfigErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + /** * Use Case for retrieving a league's full configuration. * Orchestrates domain logic and returns the configuration data. */ -export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueFullConfigOutputPort, 'LEAGUE_NOT_FOUND'> { +export class GetLeagueFullConfigUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { leagueId: string }): Promise>> { - const { leagueId } = params; + async execute( + input: GetLeagueFullConfigInput, + ): Promise>> { + const { leagueId } = input; - const league = await this.leagueRepository.findById(leagueId); - if (!league) { - return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League with id ${leagueId} not found` } }); + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const seasons = await this.seasonRepository.findByLeagueId(leagueId); + const activeSeason = + seasons && seasons.length > 0 + ? seasons.find((s) => s.status === 'active') ?? seasons[0] + : undefined; + + const scoringConfig = await (async () => { + if (!activeSeason) return null; + return (await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)) ?? null; + })(); + + const game = await (async () => { + if (!activeSeason || !activeSeason.gameId) return null; + return (await this.gameRepository.findById(activeSeason.gameId)) ?? null; + })(); + + const config: LeagueFullConfig = { + league, + ...(activeSeason ? { activeSeason } : {}), + ...(scoringConfig !== null ? { scoringConfig } : {}), + ...(game !== null ? { game } : {}), + }; + + const result: GetLeagueFullConfigResult = { + config, + }; + + this.output.present(result); + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load league full configuration'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - const seasons = await this.seasonRepository.findByLeagueId(leagueId); - const activeSeason = - seasons && seasons.length > 0 - ? seasons.find((s) => s.status === 'active') ?? seasons[0] - : undefined; - - let scoringConfig = await (async () => { - if (!activeSeason) return undefined; - return this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); - })(); - let game = await (async () => { - if (!activeSeason || !activeSeason.gameId) return undefined; - return this.gameRepository.findById(activeSeason.gameId); - })(); - - const output: LeagueFullConfigOutputPort = { - league, - ...(activeSeason ? { activeSeason } : {}), - ...(scoringConfig ? { scoringConfig } : {}), - ...(game ? { game } : {}), - }; - - return Result.ok(output); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts index 9ec01ac08..439d2132c 100644 --- a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts @@ -1,8 +1,16 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueJoinRequestsUseCase } from './GetLeagueJoinRequestsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueJoinRequestsUseCase, + type GetLeagueJoinRequestsInput, + type GetLeagueJoinRequestsResult, + type GetLeagueJoinRequestsErrorCode, +} from './GetLeagueJoinRequestsUseCase'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Driver } from '../../domain/entities/Driver'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueJoinRequestsUseCase', () => { let useCase: GetLeagueJoinRequestsUseCase; @@ -12,6 +20,10 @@ describe('GetLeagueJoinRequestsUseCase', () => { let driverRepository: { findById: Mock; }; + let leagueRepository: { + exists: Mock; + }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueMembershipRepository = { @@ -20,17 +32,29 @@ describe('GetLeagueJoinRequestsUseCase', () => { driverRepository = { findById: vi.fn(), }; + leagueRepository = { + exists: vi.fn(), + }; + output = { + present: vi.fn(), + }; + useCase = new GetLeagueJoinRequestsUseCase( leagueMembershipRepository as unknown as ILeagueMembershipRepository, driverRepository as unknown as IDriverRepository, + leagueRepository as unknown as ILeagueRepository, + output, ); }); - it('should return join requests with drivers', async () => { + it('should return join requests with drivers when league exists', async () => { const leagueId = 'league-1'; + const input: GetLeagueJoinRequestsInput = { leagueId }; + const joinRequests = [ { id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }, ]; + const driver = Driver.create({ id: 'driver-1', iracingId: '123', @@ -38,23 +62,66 @@ describe('GetLeagueJoinRequestsUseCase', () => { country: 'US', }); + leagueRepository.exists.mockResolvedValue(true); leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); driverRepository.findById.mockResolvedValue(driver); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - joinRequests: [ - { - id: 'req-1', - leagueId, - driverId: 'driver-1', - requestedAt: expect.any(Date), - message: 'msg', - driver: { id: 'driver-1', name: 'Driver 1' }, - }, - ], + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueJoinRequestsResult; + + expect(presented.joinRequests).toHaveLength(1); + expect(presented.joinRequests[0]).toMatchObject({ + id: 'req-1', + leagueId, + driverId: 'driver-1', + message: 'msg', }); + expect(presented.joinRequests[0].driver).toBe(driver); }); -}); \ No newline at end of file + + it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => { + const leagueId = 'missing-league'; + const input: GetLeagueJoinRequestsInput = { leagueId }; + + leagueRepository.exists.mockResolvedValue(false); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueJoinRequestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const leagueId = 'league-1'; + const input: GetLeagueJoinRequestsInput = { leagueId }; + const error = new Error('Repository failure'); + + leagueRepository.exists.mockRejectedValue(error); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueJoinRequestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts index d8bc820bb..7e68592d6 100644 --- a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts @@ -1,33 +1,83 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { Driver } from '../../domain/entities/Driver'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueJoinRequestsOutputPort } from '../ports/output/GetLeagueJoinRequestsOutputPort'; -export interface GetLeagueJoinRequestsUseCaseParams { +export interface GetLeagueJoinRequestsInput { leagueId: string; } -export class GetLeagueJoinRequestsUseCase implements AsyncUseCase { +export type GetLeagueJoinRequestsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export interface LeagueJoinRequestWithDriver { + id: string; + leagueId: string; + driverId: string; + requestedAt: Date; + message?: string; + driver: Driver; +} + +export interface GetLeagueJoinRequestsResult { + joinRequests: LeagueJoinRequestWithDriver[]; +} + +export class GetLeagueJoinRequestsUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise>> { - const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); - const driverIds = [...new Set(joinRequests.map(r => r.driverId))]; - const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); - const driverMap = new Map(drivers.filter(d => d !== null).map(d => [d!.id, { id: d!.id, name: d!.name }])); - const enrichedJoinRequests = joinRequests - .filter(request => driverMap.has(request.driverId)) - .map(request => ({ - ...request, - driver: driverMap.get(request.driverId)!, - })); - return Result.ok({ - joinRequests: enrichedJoinRequests, - }); + async execute( + input: GetLeagueJoinRequestsInput, + ): Promise>> { + try { + const leagueExists = await this.leagueRepository.exists(input.leagueId); + + if (!leagueExists) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const joinRequests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId); + const driverIds = [...new Set(joinRequests.map(request => request.driverId))]; + const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); + + const driverMap = new Map( + drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]), + ); + + const enrichedJoinRequests: LeagueJoinRequestWithDriver[] = joinRequests + .filter(request => driverMap.has(request.driverId)) + .map(request => ({ + ...request, + driver: driverMap.get(request.driverId)!, + })); + + const result: GetLeagueJoinRequestsResult = { + joinRequests: enrichedJoinRequests, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message + : 'Failed to load league join requests'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts index e74bf1b34..f4f49d4f4 100644 --- a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts @@ -1,9 +1,18 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueMembershipsUseCase } from './GetLeagueMembershipsUseCase'; +import { + GetLeagueMembershipsUseCase, + type GetLeagueMembershipsInput, + type GetLeagueMembershipsResult, + type GetLeagueMembershipsErrorCode, +} from './GetLeagueMembershipsUseCase'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { LeagueMembership } from '../../domain/entities/LeagueMembership'; import { Driver } from '../../domain/entities/Driver'; +import { League } from '../../domain/entities/League'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueMembershipsUseCase', () => { let useCase: GetLeagueMembershipsUseCase; @@ -13,6 +22,10 @@ describe('GetLeagueMembershipsUseCase', () => { let driverRepository: { findById: Mock; }; + let leagueRepository: { + findById: Mock; + }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueMembershipRepository = { @@ -21,14 +34,28 @@ describe('GetLeagueMembershipsUseCase', () => { driverRepository = { findById: vi.fn(), }; + leagueRepository = { + findById: vi.fn(), + }; + output = { + present: vi.fn(), + }; useCase = new GetLeagueMembershipsUseCase( leagueMembershipRepository as unknown as ILeagueMembershipRepository, driverRepository as unknown as IDriverRepository, + leagueRepository as unknown as ILeagueRepository, + output, ); }); it('should return league memberships with drivers', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'Test Description', + ownerId: 'owner-1', + }); const memberships = [ LeagueMembership.create({ id: 'membership-1', @@ -58,6 +85,7 @@ describe('GetLeagueMembershipsUseCase', () => { country: 'UK', }); + leagueRepository.findById.mockResolvedValue(league); leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); driverRepository.findById.mockImplementation((id: string) => { if (id === 'driver-1') return Promise.resolve(driver1); @@ -65,20 +93,30 @@ describe('GetLeagueMembershipsUseCase', () => { return Promise.resolve(null); }); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - memberships, - drivers: [ - { id: 'driver-1', name: 'Driver 1' }, - { id: 'driver-2', name: 'Driver 2' }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueMembershipsResult; + + expect(presented.league).toEqual(league); + expect(presented.memberships).toHaveLength(2); + expect(presented.memberships[0].membership).toEqual(memberships[0]); + expect(presented.memberships[0].driver).toEqual(driver1); + expect(presented.memberships[1].membership).toEqual(memberships[1]); + expect(presented.memberships[1].driver).toEqual(driver2); }); it('should handle drivers not found', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'Test Description', + ownerId: 'owner-1', + }); const memberships = [ LeagueMembership.create({ id: 'membership-1', @@ -89,15 +127,61 @@ describe('GetLeagueMembershipsUseCase', () => { }), ]; + leagueRepository.findById.mockResolvedValue(league); leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); driverRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - memberships, - drivers: [], + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueMembershipsResult; + + expect(presented.league).toEqual(league); + expect(presented.memberships).toHaveLength(1); + expect(presented.memberships[0].membership).toEqual(memberships[0]); + expect(presented.memberships[0].driver).toBeNull(); + }); + + it('should return error when league not found', async () => { + const leagueId = 'non-existent-league'; + + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueMembershipsErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details?.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return repository error on unexpected failure', async () => { + const leagueId = 'league-1'; + + leagueRepository.findById.mockImplementation(() => { + throw new Error('Database connection failed'); }); + + const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueMembershipsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('Database connection failed'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts index 3b1f27e0e..bc9aab7d5 100644 --- a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts @@ -1,36 +1,81 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { LeagueMembership } from '../../domain/entities/LeagueMembership'; +import type { Driver } from '../../domain/entities/Driver'; +import type { League } from '../../domain/entities/League'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueMembershipsOutputPort } from '../ports/output/GetLeagueMembershipsOutputPort'; -export interface GetLeagueMembershipsUseCaseParams { +export interface GetLeagueMembershipsInput { leagueId: string; } -export class GetLeagueMembershipsUseCase implements AsyncUseCase { +export type GetLeagueMembershipsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export interface LeagueMembershipWithDriver { + membership: LeagueMembership; + driver: Driver | null; +} + +export interface GetLeagueMembershipsResult { + league: League; + memberships: LeagueMembershipWithDriver[]; +} + +export class GetLeagueMembershipsUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetLeagueMembershipsUseCaseParams): Promise>> { - const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); - const drivers: { id: string; name: string }[] = []; + async execute( + input: GetLeagueMembershipsInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); - // Get driver details for each membership - for (const membership of memberships) { - const driver = await this.driverRepository.findById(membership.driverId); - if (driver) { - drivers.push({ id: driver.id, name: driver.name }); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); } - } - const dto: GetLeagueMembershipsOutputPort = { - memberships, - drivers, - }; - return Result.ok(dto); + const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId); + const driverIds = [...new Set(memberships.map(membership => membership.driverId.toString()))]; + + const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); + const driverMap = new Map( + drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]), + ); + + const membershipsWithDrivers: LeagueMembershipWithDriver[] = memberships.map(membership => ({ + membership, + driver: driverMap.get(membership.driverId.toString()) ?? null, + })); + + const result: GetLeagueMembershipsResult = { + league, + memberships: membershipsWithDrivers, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message + : 'Failed to load league memberships'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts index c3873caef..0f5649550 100644 --- a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts @@ -1,25 +1,55 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueOwnerSummaryUseCase } from './GetLeagueOwnerSummaryUseCase'; +import { + GetLeagueOwnerSummaryUseCase, + type GetLeagueOwnerSummaryInput, + type GetLeagueOwnerSummaryResult, + type GetLeagueOwnerSummaryErrorCode, +} from './GetLeagueOwnerSummaryUseCase'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Driver } from '../../domain/entities/Driver'; +import { League } from '../../domain/entities/League'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; describe('GetLeagueOwnerSummaryUseCase', () => { let useCase: GetLeagueOwnerSummaryUseCase; + let leagueRepository: { + findById: Mock; + }; let driverRepository: { findById: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + }; driverRepository = { findById: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetLeagueOwnerSummaryUseCase( + leagueRepository as unknown as ILeagueRepository, driverRepository as unknown as IDriverRepository, + output, ); }); - it('should return owner summary when driver exists', async () => { + it('should return owner summary when league and owner exist', async () => { + const leagueId = 'league-1'; const ownerId = 'owner-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'Desc', + ownerId, + settings: {}, + }); const driver = Driver.create({ id: ownerId, iracingId: '123', @@ -27,30 +57,81 @@ describe('GetLeagueOwnerSummaryUseCase', () => { country: 'US', }); + leagueRepository.findById.mockResolvedValue(league); driverRepository.findById.mockResolvedValue(driver); - const result = await useCase.execute({ ownerId }); + const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - summary: { - driver: { id: ownerId, name: 'Owner Name' }, - rating: 0, - rank: 0, - }, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetLeagueOwnerSummaryResult; + + expect(presented.league).toBe(league); + expect(presented.owner).toBe(driver); + expect(presented.rating).toBe(0); + expect(presented.rank).toBe(0); }); - it('should return null summary when driver does not exist', async () => { - const ownerId = 'owner-1'; + it('should return error when league does not exist', async () => { + const leagueId = 'league-1'; + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput); + + expect(result.isErr()).toBe(true); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetLeagueOwnerSummaryErrorCode, + { message: string } + >; + expect(errorResult.code).toBe('LEAGUE_NOT_FOUND'); + expect(errorResult.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return error when owner does not exist', async () => { + const leagueId = 'league-1'; + const ownerId = 'owner-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'Desc', + ownerId, + settings: {}, + }); + + leagueRepository.findById.mockResolvedValue(league); driverRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ ownerId }); + const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput); - expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - summary: null, - }); + expect(result.isErr()).toBe(true); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetLeagueOwnerSummaryErrorCode, + { message: string } + >; + expect(errorResult.code).toBe('OWNER_NOT_FOUND'); + expect(errorResult.details.message).toBe('League owner not found'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('should return repository error when repository throws', async () => { + const leagueId = 'league-1'; + + leagueRepository.findById.mockRejectedValue(new Error('DB failure')); + + const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput); + + expect(result.isErr()).toBe(true); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetLeagueOwnerSummaryErrorCode, + { message: string } + >; + + expect(errorResult.code).toBe('REPOSITORY_ERROR'); + expect(errorResult.details.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts index d1077ccad..f3b2ab870 100644 --- a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts @@ -1,19 +1,65 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueOwnerSummaryOutputPort } from '../ports/output/GetLeagueOwnerSummaryOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { Driver } from '../../domain/entities/Driver'; +import type { League } from '../../domain/entities/League'; -export interface GetLeagueOwnerSummaryUseCaseParams { - ownerId: string; -} +export type GetLeagueOwnerSummaryInput = { + leagueId: string; +}; -export class GetLeagueOwnerSummaryUseCase implements AsyncUseCase { - constructor(private readonly driverRepository: IDriverRepository) {} +export type GetLeagueOwnerSummaryResult = { + league: League; + owner: Driver; + rating: number; + rank: number; +}; - async execute(params: GetLeagueOwnerSummaryUseCaseParams): Promise>> { - const driver = await this.driverRepository.findById(params.ownerId); - const summary = driver ? { driver: { id: driver.id, iracingId: driver.iracingId.toString(), name: driver.name.toString(), country: driver.country.toString(), bio: driver.bio?.toString(), joinedAt: driver.joinedAt.toDate().toISOString() }, rating: 0, rank: 0 } : null; - return Result.ok({ summary }); +export type GetLeagueOwnerSummaryErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'OWNER_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export class GetLeagueOwnerSummaryUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + input: GetLeagueOwnerSummaryInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); + } + + const ownerId = league.ownerId.toString(); + const owner = await this.driverRepository.findById(ownerId); + + if (!owner) { + return Result.err({ code: 'OWNER_NOT_FOUND', details: { message: 'League owner not found' } }); + } + + this.output.present({ + league, + owner, + rating: 0, + rank: 0, + }); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to fetch league owner summary'; + + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts index 0fe8e8b1c..ea6339ca8 100644 --- a/core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts @@ -1,11 +1,20 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueProtestsUseCase } from './GetLeagueProtestsUseCase'; +import { + GetLeagueProtestsUseCase, + GetLeagueProtestsResult, + GetLeagueProtestsInput, + GetLeagueProtestsErrorCode, +} from './GetLeagueProtestsUseCase'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Race } from '../../domain/entities/Race'; import { Protest } from '../../domain/entities/Protest'; import { Driver } from '../../domain/entities/Driver'; +import { League } from '../../domain/entities/League'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueProtestsUseCase', () => { let useCase: GetLeagueProtestsUseCase; @@ -18,6 +27,10 @@ describe('GetLeagueProtestsUseCase', () => { let driverRepository: { findById: Mock; }; + let leagueRepository: { + findById: Mock; + }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { @@ -29,15 +42,30 @@ describe('GetLeagueProtestsUseCase', () => { driverRepository = { findById: vi.fn(), }; + leagueRepository = { + findById: vi.fn(), + }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetLeagueProtestsUseCase( raceRepository as unknown as IRaceRepository, protestRepository as unknown as IProtestRepository, driverRepository as unknown as IDriverRepository, + leagueRepository as unknown as ILeagueRepository, + output, ); }); it('should return protests with races and drivers', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + ownerId: 'owner-1', + description: 'A test league', + }); const race = Race.create({ id: 'race-1', leagueId, @@ -67,6 +95,7 @@ describe('GetLeagueProtestsUseCase', () => { country: 'UK', }); + leagueRepository.findById.mockResolvedValue(league); raceRepository.findByLeagueId.mockResolvedValue([race]); protestRepository.findByRaceId.mockResolvedValue([protest]); driverRepository.findById.mockImplementation((id: string) => { @@ -75,48 +104,93 @@ describe('GetLeagueProtestsUseCase', () => { return Promise.resolve(null); }); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueProtestsInput = { leagueId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - protests: [ - { - id: 'protest-1', - raceId: 'race-1', - protestingDriverId: 'driver-1', - accusedDriverId: 'driver-2', - submittedAt: expect.any(Date), - description: '', - status: 'pending', - }, - ], - races: [ - { - id: 'race-1', - name: 'Track 1', - date: expect.any(String), - }, - ], - drivers: [ - { id: 'driver-1', name: 'Driver 1' }, - { id: 'driver-2', name: 'Driver 2' }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueProtestsResult; + + expect(presented.league).toEqual(league); + expect(presented.protests).toHaveLength(1); + const presentedProtest = presented.protests[0]; + expect(presentedProtest.protest).toEqual(protest); + expect(presentedProtest.race).toEqual(race); + expect(presentedProtest.protestingDriver).toEqual(driver1); + expect(presentedProtest.accusedDriver).toEqual(driver2); }); - it('should return empty when no races', async () => { + it('should return empty protests when no races', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + ownerId: 'owner-1', + description: 'A test league', + }); + leagueRepository.findById.mockResolvedValue(league); raceRepository.findByLeagueId.mockResolvedValue([]); - protestRepository.findByRaceId.mockResolvedValue([]); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueProtestsInput = { leagueId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - protests: [], - races: [], - drivers: [], + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueProtestsResult; + + expect(presented.league).toEqual(league); + expect(presented.protests).toEqual([]); + }); + + it('should return LEAGUE_NOT_FOUND when league does not exist', async () => { + const leagueId = 'missing-league'; + + leagueRepository.findById.mockResolvedValue(null); + + const input: GetLeagueProtestsInput = { leagueId }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueProtestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + ownerId: 'owner-1', + description: 'A test league', }); + + leagueRepository.findById.mockResolvedValue(league); + raceRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); + + const input: GetLeagueProtestsInput = { leagueId }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueProtestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts b/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts index 3a832b91b..3dc831317 100644 --- a/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts @@ -1,93 +1,103 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/application/Result'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueProtestsOutputPort, ProtestOutputPort, RaceOutputPort, DriverOutputPort } from '../ports/output/GetLeagueProtestsOutputPort'; +import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { Race } from '../../domain/entities/Race'; +import type { Protest } from '../../domain/entities/Protest'; +import type { Driver } from '../../domain/entities/Driver'; +import type { League } from '../../domain/entities/League'; -export interface GetLeagueProtestsUseCaseParams { +export interface GetLeagueProtestsInput { leagueId: string; } -export class GetLeagueProtestsUseCase implements AsyncUseCase { +export type GetLeagueProtestsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export interface LeagueProtestWithEntities { + protest: Protest; + race: Race | null; + protestingDriver: Driver | null; + accusedDriver: Driver | null; +} + +export interface GetLeagueProtestsResult { + league: League; + protests: LeagueProtestWithEntities[]; +} + +export class GetLeagueProtestsUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly protestRepository: IProtestRepository, private readonly driverRepository: IDriverRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetLeagueProtestsUseCaseParams): Promise>> { - const races = await this.raceRepository.findByLeagueId(params.leagueId); - const protests: ProtestOutputPort[] = []; - const racesById: Record = {}; - const driversById: Record = {}; - const driverIds = new Set(); + async execute( + input: GetLeagueProtestsInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); - for (const race of races) { - racesById[race.id] = { - id: race.id, - leagueId: race.leagueId, - scheduledAt: race.scheduledAt.toISOString(), - track: race.track, - trackId: race.trackId, - car: race.car, - carId: race.carId, - sessionType: race.sessionType.toString(), - status: race.status, - strengthOfField: race.strengthOfField, - registeredCount: race.registeredCount, - maxParticipants: race.maxParticipants, - }; - const raceProtests = await this.protestRepository.findByRaceId(race.id); - for (const protest of raceProtests) { - protests.push({ - id: protest.id, - raceId: protest.raceId, - protestingDriverId: protest.protestingDriverId, - accusedDriverId: protest.accusedDriverId, - incident: { - lap: protest.incident.lap, - description: protest.incident.description, - timeInRace: protest.incident.timeInRace, - }, - comment: protest.comment, - proofVideoUrl: protest.proofVideoUrl, - status: protest.status.toString(), - reviewedBy: protest.reviewedBy, - decisionNotes: protest.decisionNotes, - filedAt: protest.filedAt.toISOString(), - reviewedAt: protest.reviewedAt?.toISOString(), - defense: protest.defense ? { - statement: protest.defense.statement.toString(), - videoUrl: protest.defense.videoUrl?.toString(), - submittedAt: protest.defense.submittedAt.toDate().toISOString(), - } : undefined, - defenseRequestedAt: protest.defenseRequestedAt?.toISOString(), - defenseRequestedBy: protest.defenseRequestedBy, + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, }); - driverIds.add(protest.protestingDriverId); - driverIds.add(protest.accusedDriverId); } - } - for (const driverId of driverIds) { - const driver = await this.driverRepository.findById(driverId); - if (driver) { - driversById[driver.id] = { - id: driver.id, - iracingId: driver.iracingId.toString(), - name: driver.name.toString(), - country: driver.country.toString(), - bio: driver.bio?.toString(), - joinedAt: driver.joinedAt.toDate().toISOString(), - }; + const races = await this.raceRepository.findByLeagueId(input.leagueId); + const protests: Protest[] = []; + const racesById: Record = {}; + const driversById: Record = {}; + const driverIds = new Set(); + + for (const race of races) { + racesById[race.id] = race; + const raceProtests = await this.protestRepository.findByRaceId(race.id); + for (const protest of raceProtests) { + protests.push(protest); + driverIds.add(protest.protestingDriverId); + driverIds.add(protest.accusedDriverId); + } } + + for (const driverId of driverIds) { + const driver = await this.driverRepository.findById(driverId); + if (driver) { + driversById[driver.id] = driver; + } + } + + const protestsWithEntities: LeagueProtestWithEntities[] = protests.map(protest => ({ + protest, + race: racesById[protest.raceId] ?? null, + protestingDriver: driversById[protest.protestingDriverId] ?? null, + accusedDriver: driversById[protest.accusedDriverId] ?? null, + })); + + const result: GetLeagueProtestsResult = { + league, + protests: protestsWithEntities, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message + : 'Failed to load league protests'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - return Result.ok({ - protests, - racesById, - driversById, - }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts b/core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts index 63ce998a3..6fa815f7b 100644 --- a/core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts @@ -1,25 +1,56 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueScheduleUseCase } from './GetLeagueScheduleUseCase'; -import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueScheduleUseCase, + type GetLeagueScheduleInput, + type GetLeagueScheduleResult, + type GetLeagueScheduleErrorCode, +} from './GetLeagueScheduleUseCase'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { League } from '../../domain/entities/League'; import { Race } from '../../domain/entities/Race'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; describe('GetLeagueScheduleUseCase', () => { let useCase: GetLeagueScheduleUseCase; + let leagueRepository: { + findById: Mock; + }; let raceRepository: { findByLeagueId: Mock; }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + }; raceRepository = { findByLeagueId: vi.fn(), }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetLeagueScheduleUseCase( + leagueRepository as unknown as ILeagueRepository, raceRepository as unknown as IRaceRepository, + logger, + output, ); }); - it('should return league schedule', async () => { + it('should present league schedule when races exist', async () => { const leagueId = 'league-1'; + const league = { id: leagueId } as unknown as League; const race = Race.create({ id: 'race-1', leagueId, @@ -28,32 +59,83 @@ describe('GetLeagueScheduleUseCase', () => { car: 'Car 1', }); + leagueRepository.findById.mockResolvedValue(league); raceRepository.findByLeagueId.mockResolvedValue([race]); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueScheduleInput = { leagueId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - races: [ - { - id: 'race-1', - name: 'Track 1 - Car 1', - scheduledAt: new Date('2023-01-01T10:00:00Z'), - }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0]![0] as GetLeagueScheduleResult; + + expect(presented.league).toBe(league); + expect(presented.races).toHaveLength(1); + expect(presented.races[0].race).toBe(race); }); - it('should return empty schedule when no races', async () => { + it('should present empty schedule when no races exist', async () => { const leagueId = 'league-1'; + const league = { id: leagueId } as unknown as League; + leagueRepository.findById.mockResolvedValue(league); raceRepository.findByLeagueId.mockResolvedValue([]); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueScheduleInput = { leagueId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - races: [], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0]![0] as GetLeagueScheduleResult; + + expect(presented.league).toBe(league); + expect(presented.races).toHaveLength(0); + }); + + it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => { + const leagueId = 'missing-league'; + + leagueRepository.findById.mockResolvedValue(null); + + const input: GetLeagueScheduleInput = { leagueId }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScheduleErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const leagueId = 'league-1'; + const league = { id: leagueId } as League; + const repositoryError = new Error('DB down'); + + leagueRepository.findById.mockResolvedValue(league); + raceRepository.findByLeagueId.mockRejectedValue(repositoryError); + + const input: GetLeagueScheduleInput = { leagueId }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScheduleErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB down'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts b/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts index 01c70813a..35ec9a37d 100644 --- a/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts @@ -1,24 +1,83 @@ -import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { GetLeagueScheduleOutputPort } from '../ports/output/GetLeagueScheduleOutputPort'; +import type { League } from '../../domain/entities/League'; +import type { Race } from '../../domain/entities/Race'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -export interface GetLeagueScheduleUseCaseParams { +export type GetLeagueScheduleErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export interface GetLeagueScheduleInput { leagueId: string; } -export class GetLeagueScheduleUseCase implements AsyncUseCase { - constructor(private readonly raceRepository: IRaceRepository) {} +export interface LeagueScheduledRace { + race: Race; +} - async execute(params: GetLeagueScheduleUseCaseParams): Promise>> { - const races = await this.raceRepository.findByLeagueId(params.leagueId); - return Result.ok({ - races: races.map(race => ({ - id: race.id, - name: `${race.track} - ${race.car}`, - scheduledAt: race.scheduledAt, - })), - }); +export interface GetLeagueScheduleResult { + league: League; + races: LeagueScheduledRace[]; +} + +export class GetLeagueScheduleUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly raceRepository: IRaceRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + input: GetLeagueScheduleInput, + ): Promise< + Result> + > { + this.logger.debug('Fetching league schedule', { input }); + const { leagueId } = input; + + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + this.logger.warn('League not found when fetching schedule', { leagueId }); + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const races = await this.raceRepository.findByLeagueId(leagueId); + + const scheduledRaces: LeagueScheduledRace[] = races.map(race => ({ + race, + })); + + const result: GetLeagueScheduleResult = { + league, + races: scheduledRaces, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + this.logger.error( + 'Failed to load league schedule due to an unexpected error', + error instanceof Error ? error : new Error('Unknown error'), + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load league schedule', + }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts index f2ce5734a..5d273c776 100644 --- a/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts @@ -1,9 +1,16 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueScoringConfigUseCase } from './GetLeagueScoringConfigUseCase'; +import { + GetLeagueScoringConfigUseCase, + type GetLeagueScoringConfigResult, + type GetLeagueScoringConfigInput, + type GetLeagueScoringConfigErrorCode, +} from './GetLeagueScoringConfigUseCase'; import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import { IGameRepository } from '../../domain/repositories/IGameRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueScoringConfigUseCase', () => { let useCase: GetLeagueScoringConfigUseCase; @@ -11,26 +18,31 @@ describe('GetLeagueScoringConfigUseCase', () => { let seasonRepository: { findByLeagueId: Mock }; let leagueScoringConfigRepository: { findBySeasonId: Mock }; let gameRepository: { findById: Mock }; - let getLeagueScoringPresetById: Mock; + let presetProvider: { getPresetById: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueRepository = { findById: vi.fn() }; seasonRepository = { findByLeagueId: vi.fn() }; leagueScoringConfigRepository = { findBySeasonId: vi.fn() }; gameRepository = { findById: vi.fn() }; - getLeagueScoringPresetById = vi.fn(); + presetProvider = { getPresetById: vi.fn() }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: Mock; + }; useCase = new GetLeagueScoringConfigUseCase( leagueRepository as unknown as ILeagueRepository, seasonRepository as unknown as ISeasonRepository, leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, gameRepository as unknown as IGameRepository, - getLeagueScoringPresetById, + presetProvider, + output, ); }); it('should return scoring config for active season', async () => { - const leagueId = 'league-1'; - const league = { id: leagueId }; + const input: GetLeagueScoringConfigInput = { leagueId: 'league-1' }; + const league = { id: input.leagueId }; const season = { id: 'season-1', status: 'active', gameId: 'game-1' }; const scoringConfig = { scoringPresetId: 'preset-1', championships: [] }; const game = { id: 'game-1', name: 'Game 1' }; @@ -40,25 +52,25 @@ describe('GetLeagueScoringConfigUseCase', () => { seasonRepository.findByLeagueId.mockResolvedValue([season]); leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig); gameRepository.findById.mockResolvedValue(game); - getLeagueScoringPresetById.mockResolvedValue(preset); + presetProvider.getPresetById.mockReturnValue(preset); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - leagueId, - seasonId: 'season-1', - gameId: 'game-1', - gameName: 'Game 1', - scoringPresetId: 'preset-1', - preset, - championships: [], - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0][0] as GetLeagueScoringConfigResult; + expect(presented.league).toEqual(league); + expect(presented.season).toEqual(season); + expect(presented.scoringConfig).toEqual(scoringConfig); + expect(presented.game).toEqual(game); + expect(presented.preset).toEqual(preset); }); it('should return scoring config for first season if no active', async () => { - const leagueId = 'league-1'; - const league = { id: leagueId }; + const input: GetLeagueScoringConfigInput = { leagueId: 'league-1' }; + const league = { id: input.leagueId }; const season = { id: 'season-1', status: 'inactive', gameId: 'game-1' }; const scoringConfig = { scoringPresetId: undefined, championships: [] }; const game = { id: 'game-1', name: 'Game 1' }; @@ -68,16 +80,18 @@ describe('GetLeagueScoringConfigUseCase', () => { leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig); gameRepository.findById.mockResolvedValue(game); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - leagueId, - seasonId: 'season-1', - gameId: 'game-1', - gameName: 'Game 1', - championships: [], - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0][0] as GetLeagueScoringConfigResult; + expect(presented.league).toEqual(league); + expect(presented.season).toEqual(season); + expect(presented.scoringConfig).toEqual(scoringConfig); + expect(presented.game).toEqual(game); + expect(presented.preset).toBeUndefined(); }); it('should return error if league not found', async () => { @@ -86,7 +100,13 @@ describe('GetLeagueScoringConfigUseCase', () => { const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'LEAGUE_NOT_FOUND' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error if no seasons', async () => { @@ -96,7 +116,13 @@ describe('GetLeagueScoringConfigUseCase', () => { const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'NO_SEASONS' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('NO_SEASONS'); + expect(err.details.message).toBe('No seasons found for league'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error if no seasons (null)', async () => { @@ -106,29 +132,69 @@ describe('GetLeagueScoringConfigUseCase', () => { const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'NO_SEASONS' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('NO_SEASONS'); + expect(err.details.message).toBe('No seasons found for league'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error if no scoring config', async () => { leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); - seasonRepository.findByLeagueId.mockResolvedValue([{ id: 'season-1', status: 'active', gameId: 'game-1' }]); + seasonRepository.findByLeagueId.mockResolvedValue([ + { id: 'season-1', status: 'active', gameId: 'game-1' }, + ]); leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(null); const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'NO_SCORING_CONFIG' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('NO_SCORING_CONFIG'); + expect(err.details.message).toBe('Scoring configuration not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error if game not found', async () => { leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); - seasonRepository.findByLeagueId.mockResolvedValue([{ id: 'season-1', status: 'active', gameId: 'game-1' }]); - leagueScoringConfigRepository.findBySeasonId.mockResolvedValue({ scoringPresetId: undefined, championships: [] }); + seasonRepository.findByLeagueId.mockResolvedValue([ + { id: 'season-1', status: 'active', gameId: 'game-1' }, + ]); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue({ + scoringPresetId: undefined, + championships: [], + }); gameRepository.findById.mockResolvedValue(null); const result = await useCase.execute({ leagueId: 'league-1' }); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'GAME_NOT_FOUND' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('GAME_NOT_FOUND'); + expect(err.details.message).toBe('Game not found for season'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should wrap repository errors', async () => { + leagueRepository.findById.mockRejectedValue(new Error('db down')); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueScoringConfigErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('db down'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts index 595ce5f72..8e79286e0 100644 --- a/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts @@ -2,78 +2,129 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; -import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort'; -import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort'; -import type { LeagueScoringConfigOutputPort } from '../ports/output/LeagueScoringConfigOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/season/Season'; +import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig'; +import type { Game } from '../../domain/entities/Game'; -type GetLeagueScoringConfigErrorCode = +export type GetLeagueScoringConfigInput = { + leagueId: string; +}; + +export type GetLeagueScoringConfigResult = { + league: League; + season: Season; + scoringConfig: LeagueScoringConfig; + game: Game; + preset?: LeagueScoringPreset; +}; + +export type GetLeagueScoringConfigErrorCode = | 'LEAGUE_NOT_FOUND' | 'NO_SEASONS' | 'NO_ACTIVE_SEASON' | 'NO_SCORING_CONFIG' - | 'GAME_NOT_FOUND'; + | 'GAME_NOT_FOUND' + | 'REPOSITORY_ERROR'; /** * Use Case for retrieving a league's scoring configuration for its active season. */ -export class GetLeagueScoringConfigUseCase - implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigOutputPort, GetLeagueScoringConfigErrorCode> -{ +export class GetLeagueScoringConfigUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, - private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise, + private readonly presetProvider: { + getPresetById(presetId: string): LeagueScoringPreset | undefined; + }, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { leagueId: string }): Promise>> { + async execute( + params: GetLeagueScoringConfigInput, + ): Promise< + Result> + > { const { leagueId } = params; - const league = await this.leagueRepository.findById(leagueId); - if (!league) { - return Result.err({ code: 'LEAGUE_NOT_FOUND' }); + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const seasons = await this.seasonRepository.findByLeagueId(leagueId); + if (!seasons || seasons.length === 0) { + return Result.err({ + code: 'NO_SEASONS', + details: { message: 'No seasons found for league' }, + }); + } + + const activeSeason = + seasons.find((s) => s.status === 'active') ?? seasons[0]; + + if (!activeSeason) { + return Result.err({ + code: 'NO_ACTIVE_SEASON', + details: { message: 'No active season found for league' }, + }); + } + + const scoringConfig = + await this.leagueScoringConfigRepository.findBySeasonId( + activeSeason.id, + ); + if (!scoringConfig) { + return Result.err({ + code: 'NO_SCORING_CONFIG', + details: { message: 'Scoring configuration not found' }, + }); + } + + const game = await this.gameRepository.findById(activeSeason.gameId); + if (!game) { + return Result.err({ + code: 'GAME_NOT_FOUND', + details: { message: 'Game not found for season' }, + }); + } + + const presetId = scoringConfig.scoringPresetId; + const preset = presetId + ? this.presetProvider.getPresetById(presetId) + : undefined; + + const result: GetLeagueScoringConfigResult = { + league, + season: activeSeason, + scoringConfig, + game, + ...(preset !== undefined ? { preset } : {}), + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load league scoring config', + }, + }); } - - const seasons = await this.seasonRepository.findByLeagueId(leagueId); - if (!seasons || seasons.length === 0) { - return Result.err({ code: 'NO_SEASONS' }); - } - - const activeSeason = - seasons.find((s) => s.status === 'active') ?? seasons[0]; - - if (!activeSeason) { - return Result.err({ code: 'NO_ACTIVE_SEASON' }); - } - - const scoringConfig = - await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); - if (!scoringConfig) { - return Result.err({ code: 'NO_SCORING_CONFIG' }); - } - - const game = await this.gameRepository.findById(activeSeason.gameId); - if (!game) { - return Result.err({ code: 'GAME_NOT_FOUND' }); - } - - const presetId = scoringConfig.scoringPresetId; - const preset = presetId ? await this.getLeagueScoringPresetById({ presetId }) : undefined; - - const output: LeagueScoringConfigOutputPort = { - leagueId: league.id, - seasonId: activeSeason.id, - gameId: game.id, - gameName: game.name, - ...(presetId !== undefined ? { scoringPresetId: presetId } : {}), - ...(preset !== undefined ? { preset } : {}), - championships: scoringConfig.championships, - }; - - return Result.ok(output); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts index cf50a8f03..f91c07f40 100644 --- a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts @@ -1,25 +1,60 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueSeasonsUseCase } from './GetLeagueSeasonsUseCase'; -import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueSeasonsUseCase, + type GetLeagueSeasonsInput, + type GetLeagueSeasonsResult, + type GetLeagueSeasonsErrorCode, +} from './GetLeagueSeasonsUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Season } from '../../domain/entities/Season'; +import { League } from '../../domain/entities/League'; describe('GetLeagueSeasonsUseCase', () => { let useCase: GetLeagueSeasonsUseCase; let seasonRepository: { findByLeagueId: Mock; }; + let leagueRepository: { + findById: Mock; + }; + let output: UseCaseOutputPort & { + present: Mock; + }; beforeEach(() => { seasonRepository = { findByLeagueId: vi.fn(), + } as unknown as ISeasonRepository as any; + + leagueRepository = { + findById: vi.fn(), + } as unknown as ILeagueRepository as any; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { + present: Mock; }; + useCase = new GetLeagueSeasonsUseCase( seasonRepository as unknown as ISeasonRepository, + leagueRepository as unknown as ILeagueRepository, + output, ); }); - it('should return seasons mapped to view model', async () => { + it('should present seasons with correct isParallelActive flags on success', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'A test league', + ownerId: 'owner-1', + }); + const seasons = [ Season.create({ id: 'season-1', @@ -39,37 +74,38 @@ describe('GetLeagueSeasonsUseCase', () => { }), ]; + leagueRepository.findById.mockResolvedValue(league); seasonRepository.findByLeagueId.mockResolvedValue(seasons); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - seasons: [ - { - seasonId: 'season-1', - name: 'Season 1', - status: 'active', - startDate: new Date('2023-01-01'), - endDate: new Date('2023-12-31'), - isPrimary: false, - isParallelActive: false, // only one active - }, - { - seasonId: 'season-2', - name: 'Season 2', - status: 'planned', - startDate: undefined, - endDate: undefined, - isPrimary: false, - isParallelActive: false, - }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueSeasonsResult; + + expect(presented.league).toBe(league); + expect(presented.seasons).toHaveLength(2); + + expect(presented.seasons[0]!.season).toBe(seasons[0]); + expect(presented.seasons[0]!.isPrimary).toBe(false); + expect(presented.seasons[0]!.isParallelActive).toBe(false); + + expect(presented.seasons[1]!.season).toBe(seasons[1]); + expect(presented.seasons[1]!.isPrimary).toBe(false); + expect(presented.seasons[1]!.isParallelActive).toBe(false); }); it('should set isParallelActive true for active seasons when multiple active', async () => { const leagueId = 'league-1'; + const league = League.create({ + id: leagueId, + name: 'Test League', + description: 'A test league', + ownerId: 'owner-1', + }); + const seasons = [ Season.create({ id: 'season-1', @@ -87,27 +123,56 @@ describe('GetLeagueSeasonsUseCase', () => { }), ]; + leagueRepository.findById.mockResolvedValue(league); seasonRepository.findByLeagueId.mockResolvedValue(seasons); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.seasons).toHaveLength(2); - expect(viewModel.seasons[0]!.isParallelActive).toBe(true); - expect(viewModel.seasons[1]!.isParallelActive).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetLeagueSeasonsResult; + + expect(presented.seasons).toHaveLength(2); + expect(presented.seasons[0]!.isParallelActive).toBe(true); + expect(presented.seasons[1]!.isParallelActive).toBe(true); }); - it('should return error when repository fails', async () => { - const leagueId = 'league-1'; - seasonRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); + it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => { + const leagueId = 'missing-league'; - const result = await useCase.execute({ leagueId }); + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch seasons', - }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueSeasonsErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const leagueId = 'league-1'; + const errorMessage = 'DB error'; + + leagueRepository.findById.mockRejectedValue(new Error(errorMessage)); + + const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueSeasonsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe(errorMessage); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts index feaf328e0..59138c29b 100644 --- a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts @@ -1,33 +1,77 @@ -import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; -import type { GetLeagueSeasonsOutputPort } from '../ports/output/GetLeagueSeasonsOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/Season'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; -export interface GetLeagueSeasonsUseCaseParams { +export type GetLeagueSeasonsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export interface GetLeagueSeasonsInput { leagueId: string; } -export class GetLeagueSeasonsUseCase { - constructor(private readonly seasonRepository: ISeasonRepository) {} +export interface LeagueSeasonSummary { + season: Season; + isPrimary: boolean; + isParallelActive: boolean; +} - async execute(params: GetLeagueSeasonsUseCaseParams): Promise>> { +export interface GetLeagueSeasonsResult { + league: League; + seasons: LeagueSeasonSummary[]; +} + +export class GetLeagueSeasonsUseCase { + constructor( + private readonly seasonRepository: ISeasonRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + input: GetLeagueSeasonsInput, + ): Promise< + Result> + > { try { - const seasons = await this.seasonRepository.findByLeagueId(params.leagueId); - const activeCount = seasons.filter(s => s.status === 'active').length; - const output: GetLeagueSeasonsOutputPort = { - seasons: seasons.map(s => ({ - seasonId: s.id, - name: s.name, - status: s.status, - startDate: s.startDate ?? new Date(), - endDate: s.endDate ?? new Date(), + const { leagueId } = input; + const league = await this.leagueRepository.findById(leagueId); + + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const seasons = await this.seasonRepository.findByLeagueId(leagueId); + const activeCount = seasons.filter(season => season.status === 'active').length; + + const result: GetLeagueSeasonsResult = { + league, + seasons: seasons.map(season => ({ + season, isPrimary: false, - isParallelActive: s.status === 'active' && activeCount > 1 - })) + isParallelActive: + season.status === 'active' && activeCount > 1, + })), }; - return Result.ok(output); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to fetch seasons' } }); + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load league seasons', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts index be75e272b..ca9580146 100644 --- a/core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueStandingsUseCase } from './GetLeagueStandingsUseCase'; -import { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + GetLeagueStandingsUseCase, + type GetLeagueStandingsInput, + type GetLeagueStandingsResult, + type GetLeagueStandingsErrorCode, +} from './GetLeagueStandingsUseCase'; +import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { Standing } from '../../domain/entities/Standing'; import { Driver } from '../../domain/entities/Driver'; @@ -13,6 +20,7 @@ describe('GetLeagueStandingsUseCase', () => { let driverRepository: { findById: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { standingRepository = { @@ -21,13 +29,18 @@ describe('GetLeagueStandingsUseCase', () => { driverRepository = { findById: vi.fn(), }; + output = { + present: vi.fn(), + }; + useCase = new GetLeagueStandingsUseCase( standingRepository as unknown as IStandingRepository, driverRepository as unknown as IDriverRepository, + output, ); }); - it('should return standings with drivers mapped', async () => { + it('should present standings with drivers mapped and return ok result', async () => { const leagueId = 'league-1'; const standings = [ Standing.create({ @@ -65,37 +78,43 @@ describe('GetLeagueStandingsUseCase', () => { return Promise.resolve(null); }); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } satisfies GetLeagueStandingsInput); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - standings: [ - { - driverId: 'driver-1', - driver: { id: 'driver-1', name: 'Driver One' }, - points: 100, - rank: 1, - }, - { - driverId: 'driver-2', - driver: { id: 'driver-2', name: 'Driver Two' }, - points: 80, - rank: 2, - }, - ], + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetLeagueStandingsResult; + + expect(presented.standings).toHaveLength(2); + expect(presented.standings[0]).toEqual({ + driverId: 'driver-1', + driver: driver1, + points: 100, + rank: 1, + }); + expect(presented.standings[1]).toEqual({ + driverId: 'driver-2', + driver: driver2, + points: 80, + rank: 2, }); }); - it('should return error when repository fails', async () => { + it('should return repository error and not call output when repository fails', async () => { const leagueId = 'league-1'; standingRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute({ leagueId } satisfies GetLeagueStandingsInput); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch league standings', - }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetLeagueStandingsErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts b/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts index 5764ed8b9..8564c2067 100644 --- a/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts @@ -1,12 +1,26 @@ -import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { LeagueStandingsOutputPort } from '../ports/output/LeagueStandingsOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Driver } from '../../domain/entities/Driver'; +import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -export interface GetLeagueStandingsUseCaseParams { +export type GetLeagueStandingsErrorCode = 'REPOSITORY_ERROR'; + +export type GetLeagueStandingsInput = { leagueId: string; -} +}; + +export type LeagueStandingItem = { + driverId: string; + driver: Driver; + points: number; + rank: number; +}; + +export type GetLeagueStandingsResult = { + standings: LeagueStandingItem[]; +}; /** * Use Case for retrieving league standings. @@ -15,29 +29,44 @@ export class GetLeagueStandingsUseCase { constructor( private readonly standingRepository: IStandingRepository, private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: GetLeagueStandingsUseCaseParams, - ): Promise>> { + input: GetLeagueStandingsInput, + ): Promise< + Result> + > { try { - const standings = await this.standingRepository.findByLeagueId(params.leagueId); - const driverIds = [...new Set(standings.map(s => s.driverId))]; + const standings = await this.standingRepository.findByLeagueId(input.leagueId); + const driverIds = [...new Set(standings.map(s => s.driverId.toString()))]; const driverPromises = driverIds.map(id => this.driverRepository.findById(id)); const driverResults = await Promise.all(driverPromises); - const drivers = driverResults.filter((d): d is NonNullable => d !== null); - const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); - const viewModel: LeagueStandingsOutputPort = { - standings: standings.map(s => ({ - driverId: s.driverId, - driver: driverMap.get(s.driverId)!, - points: s.points, - rank: s.position, + const drivers = driverResults.filter( + (driver): driver is NonNullable<(typeof driverResults)[number]> => driver !== null, + ); + const driverMap = new Map(drivers.map(driver => [driver.id, driver])); + + const result: GetLeagueStandingsResult = { + standings: standings.map(standing => ({ + driverId: standing.driverId.toString(), + driver: driverMap.get(standing.driverId.toString())!, + points: standing.points.toNumber(), + rank: standing.position.toNumber(), })), }; - return Result.ok(viewModel); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league standings' }); + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error ? error.message : 'Failed to fetch league standings', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts index ddfe1e915..4292891d0 100644 --- a/core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts @@ -1,7 +1,14 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueStatsUseCase, + type GetLeagueStatsInput, + type GetLeagueStatsResult, + type GetLeagueStatsErrorCode, +} from './GetLeagueStatsUseCase'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueStatsUseCase', () => { let useCase: GetLeagueStatsUseCase; @@ -12,6 +19,7 @@ describe('GetLeagueStatsUseCase', () => { findByLeagueId: Mock; }; let getDriverRating: Mock; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueMembershipRepository = { @@ -21,15 +29,20 @@ describe('GetLeagueStatsUseCase', () => { findByLeagueId: vi.fn(), }; getDriverRating = vi.fn(); + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetLeagueStatsUseCase( leagueMembershipRepository as unknown as ILeagueMembershipRepository, raceRepository as unknown as IRaceRepository, getDriverRating, + output, ); }); it('should return league stats with average rating', async () => { - const leagueId = 'league-1'; + const input: GetLeagueStatsInput = { leagueId: 'league-1' }; const memberships = [ { driverId: 'driver-1' }, { driverId: 'driver-2' }, @@ -39,25 +52,29 @@ describe('GetLeagueStatsUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); raceRepository.findByLeagueId.mockResolvedValue(races); - 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 }); + getDriverRating.mockImplementation((driverInput: { driverId: string }) => { + if (driverInput.driverId === 'driver-1') return Promise.resolve({ rating: 1500, ratingChange: null }); + if (driverInput.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null }); + if (driverInput.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(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - totalMembers: 3, - totalRaces: 2, - averageRating: 1550, // (1500 + 1600) / 2 - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = (output.present as Mock).mock.calls[0]?.[0] as GetLeagueStatsResult; + + expect(presented.leagueId).toBe(input.leagueId); + expect(presented.driverCount).toBe(3); + expect(presented.raceCount).toBe(2); + expect(presented.averageRating).toBe(1550); // (1500 + 1600) / 2 }); it('should return 0 average rating when no valid ratings', async () => { - const leagueId = 'league-1'; + const input: GetLeagueStatsInput = { leagueId: 'league-1' }; const memberships = [{ driverId: 'driver-1' }]; const races = [{ id: 'race-1' }]; @@ -65,26 +82,50 @@ describe('GetLeagueStatsUseCase', () => { raceRepository.findByLeagueId.mockResolvedValue(races); getDriverRating.mockResolvedValue({ rating: null, ratingChange: null }); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - totalMembers: 1, - totalRaces: 1, - averageRating: 0, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = (output.present as Mock).mock.calls[0]?.[0] as GetLeagueStatsResult; + + expect(presented.leagueId).toBe(input.leagueId); + expect(presented.driverCount).toBe(1); + expect(presented.raceCount).toBe(1); + expect(presented.averageRating).toBe(0); + }); + + it('should return error when league has no members', async () => { + const input: GetLeagueStatsInput = { leagueId: 'league-1' }; + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + GetLeagueStatsErrorCode, + { message: string } + >; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when repository fails', async () => { - const leagueId = 'league-1'; + const input: GetLeagueStatsInput = { leagueId: 'league-1' }; leagueMembershipRepository.getLeagueMembers.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ leagueId }); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch league stats', - }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetLeagueStatsErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetLeagueStatsUseCase.ts b/core/racing/application/use-cases/GetLeagueStatsUseCase.ts index daeafda2a..f9fa07921 100644 --- a/core/racing/application/use-cases/GetLeagueStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStatsUseCase.ts @@ -1,48 +1,78 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { LeagueStatsOutputPort } from '../ports/output/LeagueStatsOutputPort'; -import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort'; -import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export interface GetLeagueStatsUseCaseParams { +export interface GetLeagueStatsInput { leagueId: string; } +export interface GetLeagueStatsResult { + leagueId: string; + driverCount: number; + raceCount: number; + averageRating: number; +} + +export type GetLeagueStatsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; + export class GetLeagueStatsUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, - private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise, + private readonly getDriverRating: (input: { + driverId: string; + }) => Promise<{ rating: number | null; ratingChange: number | null }>, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetLeagueStatsUseCaseParams): Promise>> { + async execute( + input: GetLeagueStatsInput, + ): Promise>> { try { - const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); - const races = await this.raceRepository.findByLeagueId(params.leagueId); - const driverIds = memberships.map(m => m.driverId); - - // Get ratings for all drivers using clean ports - const ratingPromises = driverIds.map(driverId => - this.getDriverRating({ driverId }) - ); - + const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId); + + if (memberships.length === 0) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const races = await this.raceRepository.findByLeagueId(input.leagueId); + const driverIds = memberships.map(membership => String(membership.driverId)); + + 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 viewModel: LeagueStatsOutputPort = { - totalMembers: memberships.length, - totalRaces: races.length, + + const averageRating = + validRatings.length > 0 + ? Math.round(validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length) + : 0; + + const result: GetLeagueStatsResult = { + leagueId: input.leagueId, + driverCount: memberships.length, + raceCount: races.length, averageRating, }; - return Result.ok(viewModel); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league stats' }); + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Failed to fetch league stats'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts b/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts index ea682646b..881c7a6ea 100644 --- a/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueWalletUseCase.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetLeagueWalletUseCase } from './GetLeagueWalletUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetLeagueWalletUseCase, + type GetLeagueWalletResult, + type GetLeagueWalletInput, + type GetLeagueWalletErrorCode, +} from './GetLeagueWalletUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet'; @@ -7,17 +13,27 @@ import { Money } from '../../domain/value-objects/Money'; import { Transaction } from '../../domain/entities/league-wallet/Transaction'; import { TransactionId } from '../../domain/entities/league-wallet/TransactionId'; import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetLeagueWalletUseCase', () => { + let leagueRepository: { + exists: Mock; + }; let leagueWalletRepository: { findByLeagueId: Mock; }; let transactionRepository: { findByWalletId: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: GetLeagueWalletUseCase; beforeEach(() => { + leagueRepository = { + exists: vi.fn(), + }; + leagueWalletRepository = { findByLeagueId: vi.fn(), }; @@ -26,9 +42,15 @@ describe('GetLeagueWalletUseCase', () => { findByWalletId: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetLeagueWalletUseCase( + leagueRepository as unknown as ILeagueRepository, leagueWalletRepository as unknown as ILeagueWalletRepository, transactionRepository as unknown as ITransactionRepository, + output, ); }); @@ -42,6 +64,7 @@ describe('GetLeagueWalletUseCase', () => { balance, }); + leagueRepository.exists.mockResolvedValue(true); leagueWalletRepository.findByLeagueId.mockResolvedValue(wallet); const sponsorshipTx = Transaction.create({ @@ -99,65 +122,103 @@ describe('GetLeagueWalletUseCase', () => { transactionRepository.findByWalletId.mockResolvedValue(transactions); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueWalletInput = { leagueId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); - expect(viewModel.balance).toBe(balance.amount); - expect(viewModel.currency).toBe(balance.currency); + const presented = (output.present as Mock).mock.calls[0]![0] as GetLeagueWalletResult; + + expect(presented.wallet).toBe(wallet); + expect(presented.transactions).toHaveLength(transactions.length); + expect(presented.transactions[0]!.id).toEqual( + transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]! + .id, + ); + + const { aggregates } = presented; const expectedTotalRevenue = - sponsorshipTx.amount.amount + - membershipTx.amount.amount + - pendingPrizeTx.amount.amount; - + sponsorshipTx.amount.add(membershipTx.amount).add(pendingPrizeTx.amount); + const expectedTotalFees = - sponsorshipTx.platformFee.amount + - membershipTx.platformFee.amount + - pendingPrizeTx.platformFee.amount; + sponsorshipTx.platformFee + .add(membershipTx.platformFee) + .add(pendingPrizeTx.platformFee); - const expectedTotalWithdrawals = withdrawalTx.netAmount.amount; - const expectedPendingPayouts = pendingPrizeTx.netAmount.amount; + const expectedTotalWithdrawals = withdrawalTx.netAmount; + const expectedPendingPayouts = pendingPrizeTx.netAmount; - expect(viewModel.totalRevenue).toBe(expectedTotalRevenue); - expect(viewModel.totalFees).toBe(expectedTotalFees); - expect(viewModel.totalWithdrawals).toBe(expectedTotalWithdrawals); - expect(viewModel.pendingPayouts).toBe(expectedPendingPayouts); - - expect(viewModel.transactions).toHaveLength(transactions.length); - expect(viewModel.transactions[0]!.id).toBe(transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!.id.toString()); - expect(viewModel.transactions.find(t => t.type === 'sponsorship')).toBeTruthy(); - expect(viewModel.transactions.find(t => t.type === 'membership')).toBeTruthy(); - expect(viewModel.transactions.find(t => t.type === 'withdrawal')).toBeTruthy(); - expect(viewModel.transactions.find(t => t.type === 'prize')).toBeTruthy(); + expect(aggregates.balance).toBe(balance); + expect(aggregates.totalRevenue.amount).toBe(expectedTotalRevenue.amount); + expect(aggregates.totalFees.amount).toBe(expectedTotalFees.amount); + expect(aggregates.totalWithdrawals.amount).toBe( + expectedTotalWithdrawals.amount, + ); + expect(aggregates.pendingPayouts.amount).toBe(expectedPendingPayouts.amount); }); it('returns error result when wallet is missing', async () => { const leagueId = 'league-missing'; + leagueRepository.exists.mockResolvedValue(true); leagueWalletRepository.findByLeagueId.mockResolvedValue(null); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueWalletInput = { leagueId }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Wallet not found', - }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('WALLET_NOT_FOUND'); + expect(err.details.message).toBe('League wallet not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns league not found when league does not exist', async () => { + const leagueId = 'league-missing'; + + leagueRepository.exists.mockResolvedValue(false); + + const input: GetLeagueWalletInput = { leagueId }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('returns repository error when repository throws', async () => { const leagueId = 'league-1'; - leagueWalletRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); + leagueRepository.exists.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ leagueId }); + const input: GetLeagueWalletInput = { leagueId }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch league wallet', - }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetLeagueWalletErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); diff --git a/core/racing/application/use-cases/GetLeagueWalletUseCase.ts b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts index d54c462cb..beacb1bd2 100644 --- a/core/racing/application/use-cases/GetLeagueWalletUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueWalletUseCase.ts @@ -1,105 +1,136 @@ +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; -import type { GetLeagueWalletOutputPort, WalletTransactionOutputPort } from '../ports/output/GetLeagueWalletOutputPort'; -import type { TransactionType } from '../../domain/entities/league-wallet/Transaction'; +import type { Transaction } from '../../domain/entities/league-wallet/Transaction'; +import type { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; - -export interface GetLeagueWalletUseCaseParams { +import { Money } from '../../domain/value-objects/Money'; + +export type GetLeagueWalletErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'WALLET_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export interface GetLeagueWalletInput { leagueId: string; } - + +export interface GetLeagueWalletAggregates { + balance: Money; + totalRevenue: Money; + totalFees: Money; + totalWithdrawals: Money; + pendingPayouts: Money; +} + +export interface GetLeagueWalletResult { + wallet: LeagueWallet; + transactions: Transaction[]; + aggregates: GetLeagueWalletAggregates; +} + /** * Use Case for retrieving league wallet information. */ export class GetLeagueWalletUseCase { constructor( + private readonly leagueRepository: ILeagueRepository, private readonly leagueWalletRepository: ILeagueWalletRepository, private readonly transactionRepository: ITransactionRepository, + private readonly output: UseCaseOutputPort, ) {} - + async execute( - params: GetLeagueWalletUseCaseParams, - ): Promise>> { + input: GetLeagueWalletInput, + ): Promise< + Result> + > { try { - const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId); - - if (!wallet) { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' }); - } - - const transactions = await this.transactionRepository.findByWalletId(wallet.id.toString()); - - let totalRevenue = 0; - let totalFees = 0; - let totalWithdrawals = 0; - let pendingPayouts = 0; - - const transactionViewModels: WalletTransactionOutputPort[] = transactions - .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) - .map(transaction => { - const amount = transaction.amount.amount; - const fee = transaction.platformFee.amount; - const netAmount = transaction.netAmount.amount; - - if ( - transaction.type === 'sponsorship_payment' || - transaction.type === 'membership_payment' || - transaction.type === 'prize_payout' - ) { - totalRevenue += amount; - totalFees += fee; - } - - if (transaction.type === 'withdrawal' && transaction.status === 'completed') { - totalWithdrawals += netAmount; - } - - if (transaction.type === 'prize_payout' && transaction.status === 'pending') { - pendingPayouts += netAmount; - } - - return { - id: transaction.id.toString(), - type: this.mapTransactionType(transaction.type), - description: transaction.description ?? '', - amount, - fee, - netAmount, - date: transaction.createdAt.toISOString(), - status: transaction.status === 'cancelled' ? 'failed' : transaction.status, - }; + const leagueExists = await this.leagueRepository.exists(input.leagueId); + if (!leagueExists) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, }); - - const output: GetLeagueWalletOutputPort = { - balance: wallet.balance.amount, - currency: wallet.balance.currency, + } + + const wallet = await this.leagueWalletRepository.findByLeagueId(input.leagueId); + + if (!wallet) { + return Result.err({ + code: 'WALLET_NOT_FOUND', + details: { message: 'League wallet not found' }, + }); + } + + const transactions = await this.transactionRepository.findByWalletId( + wallet.id.toString(), + ); + + const { aggregates } = this.computeAggregates(wallet.balance, transactions); + + const result: GetLeagueWalletResult = { + wallet, + transactions: transactions + .slice() + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()), + aggregates, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to fetch league wallet', + }, + }); + } + } + + private computeAggregates( + balance: Money, + transactions: Transaction[], + ): { aggregates: GetLeagueWalletAggregates } { + let totalRevenue = Money.create(0, balance.currency); + let totalFees = Money.create(0, balance.currency); + let totalWithdrawals = Money.create(0, balance.currency); + let pendingPayouts = Money.create(0, balance.currency); + + for (const transaction of transactions) { + if ( + transaction.type === 'sponsorship_payment' || + transaction.type === 'membership_payment' || + transaction.type === 'prize_payout' + ) { + totalRevenue = totalRevenue.add(transaction.amount); + totalFees = totalFees.add(transaction.platformFee); + } + + if (transaction.type === 'withdrawal' && transaction.status === 'completed') { + totalWithdrawals = totalWithdrawals.add(transaction.netAmount); + } + + if (transaction.type === 'prize_payout' && transaction.status === 'pending') { + pendingPayouts = pendingPayouts.add(transaction.netAmount); + } + } + + return { + aggregates: { + balance, totalRevenue, totalFees, totalWithdrawals, pendingPayouts, - canWithdraw: true, - transactions: transactionViewModels, - }; - - return Result.ok(output); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league wallet' }); - } - } - - private mapTransactionType(type: TransactionType): WalletTransactionOutputPort['type'] { - switch (type) { - case 'sponsorship_payment': - return 'sponsorship'; - case 'membership_payment': - return 'membership'; - case 'prize_payout': - return 'prize'; - case 'withdrawal': - return 'withdrawal'; - case 'refund': - return 'sponsorship'; - } + }, + }; } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts index 1a8d896d0..42ee6d1e1 100644 --- a/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetPendingSponsorshipRequestsUseCase } from './GetPendingSponsorshipRequestsUseCase'; +import { + GetPendingSponsorshipRequestsUseCase, + type GetPendingSponsorshipRequestsResult, + type GetPendingSponsorshipRequestsInput, + type GetPendingSponsorshipRequestsErrorCode, +} from './GetPendingSponsorshipRequestsUseCase'; import { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest'; import { Sponsor } from '../../domain/entities/Sponsor'; import { Money } from '../../domain/value-objects/Money'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetPendingSponsorshipRequestsUseCase', () => { let useCase: GetPendingSponsorshipRequestsUseCase; @@ -14,6 +21,9 @@ describe('GetPendingSponsorshipRequestsUseCase', () => { let sponsorRepo: { findById: Mock; }; + let output: UseCaseOutputPort & { + present: Mock; + }; beforeEach(() => { sponsorshipRequestRepo = { @@ -22,14 +32,21 @@ describe('GetPendingSponsorshipRequestsUseCase', () => { sponsorRepo = { findById: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as typeof output; useCase = new GetPendingSponsorshipRequestsUseCase( sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, sponsorRepo as unknown as ISponsorRepository, + output, ); }); - it('should return pending sponsorship requests', async () => { - const dto = { entityType: 'season' as const, entityId: 'entity-1' }; + it('should present pending sponsorship requests', async () => { + const input: GetPendingSponsorshipRequestsInput = { + entityType: 'season', + entityId: 'entity-1', + }; const request = SponsorshipRequest.create({ id: 'req-1', sponsorId: 'sponsor-1', @@ -49,42 +66,41 @@ describe('GetPendingSponsorshipRequestsUseCase', () => { sponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([request]); sponsorRepo.findById.mockResolvedValue(sponsor); - const result = await useCase.execute(dto); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - entityType: 'season', - entityId: 'entity-1', - requests: [ - { - id: 'req-1', - sponsorId: 'sponsor-1', - sponsorName: 'Test Sponsor', - sponsorLogo: 'logo.png', - tier: 'main', - offeredAmount: 10000, - currency: 'USD', - formattedAmount: '$100.00', - message: 'Test message', - createdAt: expect.any(Date), - platformFee: 1000, - netAmount: 9000, - }, - ], - totalCount: 1, - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0][0] as GetPendingSponsorshipRequestsResult; + + expect(presented.entityType).toBe('season'); + expect(presented.entityId).toBe('entity-1'); + expect(presented.totalCount).toBe(1); + expect(presented.requests).toHaveLength(1); + const summary = presented.requests[0]; + expect(summary.sponsor?.name).toBe('Test Sponsor'); + expect(summary.financials.offeredAmount.amount).toBe(10000); + expect(summary.financials.offeredAmount.currency).toBe('USD'); }); it('should return error when repository fails', async () => { - const dto = { entityType: 'season' as const, entityId: 'entity-1' }; - sponsorshipRequestRepo.findPendingByEntity.mockRejectedValue(new Error('DB error')); + const input: GetPendingSponsorshipRequestsInput = { + entityType: 'season', + entityId: 'entity-1', + }; + const error = new Error('DB error'); + sponsorshipRequestRepo.findPendingByEntity.mockRejectedValue(error); - const result = await useCase.execute(dto); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch pending sponsorship requests', - }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetPendingSponsorshipRequestsErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts index c89ada239..0b10effa9 100644 --- a/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts @@ -7,86 +7,103 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; -import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; -import type { PendingSponsorshipRequestsOutputPort } from '../ports/output/PendingSponsorshipRequestsOutputPort'; -import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest'; +import type { Sponsor } from '../../domain/entities/Sponsor'; +import { Money } from '../../domain/value-objects/Money'; -export interface GetPendingSponsorshipRequestsDTO { +export interface GetPendingSponsorshipRequestsInput { entityType: SponsorableEntityType; entityId: string; } -export interface PendingSponsorshipRequestDTO { - id: string; - sponsorId: string; - sponsorName: string; - sponsorLogo?: string; - tier: SponsorshipTier; - offeredAmount: number; - currency: string; - formattedAmount: string; - message?: string; - createdAt: Date; - platformFee: number; - netAmount: number; +export interface PendingSponsorshipRequestFinancials { + offeredAmount: Money; + platformFee: Money; + netAmount: Money; } -export interface GetPendingSponsorshipRequestsResultDTO { +export interface PendingSponsorshipRequestSummary { + request: SponsorshipRequest; + sponsor: Sponsor | null; + financials: PendingSponsorshipRequestFinancials; +} + +export interface GetPendingSponsorshipRequestsResult { entityType: SponsorableEntityType; entityId: string; - requests: PendingSponsorshipRequestDTO[]; + requests: PendingSponsorshipRequestSummary[]; totalCount: number; } +export type GetPendingSponsorshipRequestsErrorCode = 'REPOSITORY_ERROR'; + export class GetPendingSponsorshipRequestsUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorRepo: ISponsorRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - dto: GetPendingSponsorshipRequestsDTO, - ): Promise>> { + input: GetPendingSponsorshipRequestsInput, + ): Promise< + Result> + > { try { const requests = await this.sponsorshipRequestRepo.findPendingByEntity( - dto.entityType, - dto.entityId + input.entityType, + input.entityId, ); - const requestDTOs: PendingSponsorshipRequestDTO[] = []; + const summaries: PendingSponsorshipRequestSummary[] = []; for (const request of requests) { const sponsor = await this.sponsorRepo.findById(request.sponsorId); - requestDTOs.push({ - id: request.id, - sponsorId: request.sponsorId, - sponsorName: sponsor?.name ?? 'Unknown Sponsor', - ...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}), - tier: request.tier, - offeredAmount: request.offeredAmount.amount, - currency: request.offeredAmount.currency, - formattedAmount: request.offeredAmount.format(), - ...(request.message !== undefined ? { message: request.message } : {}), - createdAt: request.createdAt, - platformFee: request.getPlatformFee().amount, - netAmount: request.getNetAmount().amount, + const offeredAmount = Money.create( + request.offeredAmount.amount, + request.offeredAmount.currency, + ); + const platformFee = request.getPlatformFee(); + const netAmount = request.getNetAmount(); + + summaries.push({ + request, + sponsor: sponsor ?? null, + financials: { + offeredAmount, + platformFee, + netAmount, + }, }); } // Sort by creation date (newest first) - requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + summaries.sort((a, b) => b.request.createdAt.getTime() - a.request.createdAt.getTime()); - const outputPort: PendingSponsorshipRequestsOutputPort = { - entityType: dto.entityType, - entityId: dto.entityId, - requests: requestDTOs, - totalCount: requestDTOs.length, + const result: GetPendingSponsorshipRequestsResult = { + entityType: input.entityType, + entityId: input.entityId, + requests: summaries, + totalCount: summaries.length, }; - return Result.ok(outputPort); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch pending sponsorship requests' }); + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to fetch pending sponsorship requests', + }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts b/core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts index 8e271996b..b57b1c117 100644 --- a/core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts +++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts @@ -1,12 +1,19 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetProfileOverviewUseCase } from './GetProfileOverviewUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetProfileOverviewUseCase, + type GetProfileOverviewInput, + type GetProfileOverviewResult, + type GetProfileOverviewErrorCode, +} from './GetProfileOverviewUseCase'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; -import { IImageServicePort } from '../ports/IImageServicePort'; +import type { IImageServicePort } from '../ports/IImageServicePort'; import { Driver } from '../../domain/entities/Driver'; import { Team } from '../../domain/entities/Team'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetProfileOverviewUseCase', () => { let useCase: GetProfileOverviewUseCase; @@ -27,6 +34,10 @@ describe('GetProfileOverviewUseCase', () => { }; let getDriverStats: Mock; let getAllDriverRankings: Mock; + let driverExtendedProfileProvider: { + getExtendedProfile: Mock; + }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { driverRepository = { @@ -46,14 +57,23 @@ describe('GetProfileOverviewUseCase', () => { }; getDriverStats = vi.fn(); getAllDriverRankings = vi.fn(); + driverExtendedProfileProvider = { + getExtendedProfile: vi.fn(), + }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetProfileOverviewUseCase( driverRepository as unknown as IDriverRepository, teamRepository as unknown as ITeamRepository, teamMembershipRepository as unknown as ITeamMembershipRepository, socialRepository as unknown as ISocialGraphRepository, imageService as unknown as IImageServicePort, + driverExtendedProfileProvider, getDriverStats, getAllDriverRankings, + output, ); }); @@ -65,15 +85,33 @@ describe('GetProfileOverviewUseCase', () => { name: 'Test Driver', country: 'US', }); - const teams = [Team.create({ id: 'team-1', name: 'Test Team', tag: 'TT', description: 'Test', ownerId: 'owner-1', leagues: [] })]; - const friends = [Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' })]; + const teams = [ + Team.create({ + id: 'team-1', + name: 'Test Team', + tag: 'TT', + description: 'Test', + ownerId: 'owner-1', + leagues: [], + }), + ]; + const friends = [ + Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' }), + ]; const statsAdapter = { rating: 1500, wins: 5, + podiums: 2, + dnfs: 1, totalRaces: 10, avgFinish: 3.5, + bestFinish: 1, + worstFinish: 10, + overallRank: 10, + consistency: 90, + percentile: 75, }; - const rankings = [{ driverId, rating: 1500, overallRank: 1 }]; + const rankings = [{ driverId, rating: 1500, overallRank: 10 }]; driverRepository.findById.mockResolvedValue(driver); teamRepository.findAll.mockResolvedValue(teams); @@ -82,38 +120,48 @@ describe('GetProfileOverviewUseCase', () => { imageService.getDriverAvatar.mockReturnValue('avatar-url'); getDriverStats.mockReturnValue(statsAdapter); getAllDriverRankings.mockReturnValue(rankings); + driverExtendedProfileProvider.getExtendedProfile.mockReturnValue(null); - const result = await useCase.execute({ driverId }); + const result = await useCase.execute({ driverId } as GetProfileOverviewInput); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.currentDriver?.id).toBe(driverId); - expect(viewModel.extendedProfile).toBe(null); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as unknown as Mock).mock + .calls[0][0] as GetProfileOverviewResult; + expect(presented.driverInfo.driver.id).toBe(driverId); + expect(presented.extendedProfile).toBeNull(); }); it('should return error for non-existing driver', async () => { const driverId = 'driver-1'; driverRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ driverId }); - + const result = await useCase.execute({ driverId } as GetProfileOverviewInput); + expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'DRIVER_NOT_FOUND', - message: 'Driver not found', - }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetProfileOverviewErrorCode, + { message: string } + >; + expect(error.code).toBe('DRIVER_NOT_FOUND'); + expect(error.details.message).toBe('Driver not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { const driverId = 'driver-1'; driverRepository.findById.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ driverId }); - + const result = await useCase.execute({ driverId } as GetProfileOverviewInput); + expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch profile overview', - }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetProfileOverviewErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts index 2d5258d2c..6d0e28208 100644 --- a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts +++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts @@ -6,9 +6,10 @@ import type { ISocialGraphRepository } from '@core/social/domain/repositories/IS import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider'; import type { Driver } from '../../domain/entities/Driver'; import type { Team } from '../../domain/entities/Team'; -import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; interface ProfileDriverStatsAdapter { rating: number | null; @@ -30,10 +31,67 @@ interface DriverRankingEntry { overallRank: number | null; } -export interface GetProfileOverviewParams { +export type GetProfileOverviewInput = { driverId: string; +}; + +export interface ProfileOverviewStats { + totalRaces: number; + wins: number; + podiums: number; + dnfs: number; + avgFinish: number | null; + bestFinish: number | null; + worstFinish: number | null; + finishRate: number | null; + winRate: number | null; + podiumRate: number | null; + percentile: number | null; + rating: number | null; + consistency: number | null; + overallRank: number | null; } +export interface ProfileOverviewFinishDistribution { + totalRaces: number; + wins: number; + podiums: number; + topTen: number; + dnfs: number; + other: number; +} + +export interface ProfileOverviewTeamMembership { + team: Team; + membership: TeamMembership; +} + +export interface ProfileOverviewSocialSummary { + friendsCount: number; + friends: Driver[]; +} + +export interface ProfileOverviewDriverInfo { + driver: Driver; + totalDrivers: number; + globalRank: number | null; + consistency: number | null; + rating: number | null; +} + +export type GetProfileOverviewResult = { + driverInfo: ProfileOverviewDriverInfo; + stats: ProfileOverviewStats | null; + finishDistribution: ProfileOverviewFinishDistribution | null; + teamMemberships: ProfileOverviewTeamMembership[]; + socialSummary: ProfileOverviewSocialSummary; + extendedProfile: unknown; +}; + +export type GetProfileOverviewErrorCode = + | 'DRIVER_NOT_FOUND' + | 'REPOSITORY_ERROR'; + export class GetProfileOverviewUseCase { constructor( private readonly driverRepository: IDriverRepository, @@ -44,16 +102,24 @@ export class GetProfileOverviewUseCase { private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider, private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null, private readonly getAllDriverRankings: () => DriverRankingEntry[], + private readonly output: UseCaseOutputPort, ) {} - - async execute(params: GetProfileOverviewParams): Promise>> { + + async execute( + input: GetProfileOverviewInput, + ): Promise< + Result> + > { try { - const { driverId } = params; + const { driverId } = input; const driver = await this.driverRepository.findById(driverId); if (!driver) { - return Result.err({ code: 'DRIVER_NOT_FOUND', message: 'Driver not found' }); + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: 'Driver not found' }, + }); } const [statsAdapter, teams, friends] = await Promise.all([ @@ -62,15 +128,15 @@ export class GetProfileOverviewUseCase { this.socialRepository.getFriends(driverId), ]); - const driverSummary = this.buildDriverSummary(driver, statsAdapter); + const driverInfo = this.buildDriverInfo(driver, statsAdapter); const stats = this.buildStats(statsAdapter); const finishDistribution = this.buildFinishDistribution(statsAdapter); const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]); const socialSummary = this.buildSocialSummary(friends as Driver[]); const extendedProfile = this.driverExtendedProfileProvider.getExtendedProfile(driverId); - - const outputPort: ProfileOverviewOutputPort = { - driver: driverSummary, + + const result: GetProfileOverviewResult = { + driverInfo, stats, finishDistribution, teamMemberships, @@ -78,35 +144,36 @@ export class GetProfileOverviewUseCase { extendedProfile, }; - return Result.ok(outputPort); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch profile overview' }); + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load profile overview', + }, + }); } } - private buildDriverSummary( + private buildDriverInfo( driver: Driver, stats: ProfileDriverStatsAdapter | null, - ): ProfileOverviewOutputPort['driver'] { + ): ProfileOverviewDriverInfo { const rankings = this.getAllDriverRankings(); const fallbackRank = this.computeFallbackRank(driver.id, rankings); const totalDrivers = rankings.length; return { - id: driver.id, - name: driver.name.value, - country: driver.country.value, - avatarUrl: this.imageService.getDriverAvatar(driver.id), - iracingId: driver.iracingId?.value ?? null, - joinedAt: - driver.joinedAt instanceof Date - ? driver.joinedAt - : new Date(driver.joinedAt.value), - rating: stats?.rating ?? null, + driver, + totalDrivers, globalRank: stats?.overallRank ?? fallbackRank, consistency: stats?.consistency ?? null, - bio: driver.bio?.value ?? null, - totalDrivers, + rating: stats?.rating ?? null, }; } @@ -123,7 +190,7 @@ export class GetProfileOverviewUseCase { private buildStats( stats: ProfileDriverStatsAdapter | null, - ): ProfileOverviewStatsViewModel | null { + ): ProfileOverviewStats | null { if (!stats) { return null; } @@ -132,12 +199,9 @@ export class GetProfileOverviewUseCase { const dnfs = stats.dnfs; const finishedRaces = Math.max(totalRaces - dnfs, 0); - const finishRate = - totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null; - const winRate = - totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null; - const podiumRate = - totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null; + const finishRate = totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null; + const winRate = totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null; + const podiumRate = totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null; return { totalRaces, @@ -159,7 +223,7 @@ export class GetProfileOverviewUseCase { private buildFinishDistribution( stats: ProfileDriverStatsAdapter | null, - ): ProfileOverviewFinishDistributionViewModel | null { + ): ProfileOverviewFinishDistribution | null { if (!stats || stats.totalRaces <= 0) { return null; } @@ -189,8 +253,8 @@ export class GetProfileOverviewUseCase { private async buildTeamMemberships( driverId: string, teams: Team[], - ): Promise { - const memberships: ProfileOverviewOutputPort['teamMemberships'] = []; + ): Promise { + const memberships: ProfileOverviewTeamMembership[] = []; for (const team of teams) { const membership = await this.teamMembershipRepository.getMembership( @@ -200,33 +264,22 @@ export class GetProfileOverviewUseCase { if (!membership) continue; memberships.push({ - teamId: team.id, - teamName: team.name.value, - teamTag: team.tag?.value ?? null, - role: membership.role, - joinedAt: - membership.joinedAt instanceof Date - ? membership.joinedAt - : new Date(membership.joinedAt), - isCurrent: membership.status === 'active', + team, + membership, }); } - memberships.sort((a, b) => a.joinedAt.getTime() - b.joinedAt.getTime()); + memberships.sort( + (a, b) => a.membership.joinedAt.getTime() - b.membership.joinedAt.getTime(), + ); return memberships; } - private buildSocialSummary(friends: Driver[]): ProfileOverviewOutputPort['socialSummary'] { + private buildSocialSummary(friends: Driver[]): ProfileOverviewSocialSummary { return { friendsCount: friends.length, - friends: friends.map(friend => ({ - id: friend.id, - name: friend.name.value, - country: friend.country.value, - avatarUrl: this.imageService.getDriverAvatar(friend.id), - })), + friends, }; } - -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetRaceDetailUseCase.test.ts b/core/racing/application/use-cases/GetRaceDetailUseCase.test.ts index 8f7047978..7ee60eead 100644 --- a/core/racing/application/use-cases/GetRaceDetailUseCase.test.ts +++ b/core/racing/application/use-cases/GetRaceDetailUseCase.test.ts @@ -1,34 +1,38 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRaceDetailUseCase } from './GetRaceDetailUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetRaceDetailUseCase, + type GetRaceDetailInput, + type GetRaceDetailResult, + type GetRaceDetailErrorCode, +} from './GetRaceDetailUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; -import type { IImageServicePort } from '../ports/IImageServicePort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetRaceDetailUseCase', () => { let useCase: GetRaceDetailUseCase; let raceRepository: { findById: Mock }; let leagueRepository: { findById: Mock }; let driverRepository: { findById: Mock }; - let raceRegistrationRepository: { getRegisteredDrivers: Mock }; + let raceRegistrationRepository: { findByRaceId: Mock }; let resultRepository: { findByRaceId: Mock }; let leagueMembershipRepository: { getMembership: Mock }; - let driverRatingProvider: { getRating: Mock; getRatings: Mock }; - let imageService: { getDriverAvatar: Mock; getTeamLogo: Mock; getLeagueCover: Mock; getLeagueLogo: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { findById: vi.fn() }; leagueRepository = { findById: vi.fn() }; driverRepository = { findById: vi.fn() }; - raceRegistrationRepository = { getRegisteredDrivers: vi.fn() }; + raceRegistrationRepository = { findByRaceId: vi.fn() }; resultRepository = { findByRaceId: vi.fn() }; leagueMembershipRepository = { getMembership: vi.fn() }; - driverRatingProvider = { getRating: vi.fn(), getRatings: vi.fn() }; - imageService = { getDriverAvatar: vi.fn(), getTeamLogo: vi.fn(), getLeagueCover: vi.fn(), getLeagueLogo: vi.fn() }; + output = { present: vi.fn() } as UseCaseOutputPort & { present: Mock }; + useCase = new GetRaceDetailUseCase( raceRepository as unknown as IRaceRepository, leagueRepository as unknown as ILeagueRepository, @@ -36,12 +40,11 @@ describe('GetRaceDetailUseCase', () => { raceRegistrationRepository as unknown as IRaceRegistrationRepository, resultRepository as unknown as IResultRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, - driverRatingProvider as DriverRatingProvider, - imageService as IImageServicePort, + output, ); }); - it('should return race detail when race exists', async () => { + it('should present race detail when race exists', async () => { const raceId = 'race-1'; const driverId = 'driver-1'; const race = { @@ -62,9 +65,11 @@ describe('GetRaceDetailUseCase', () => { description: 'Description', settings: { maxDrivers: 20, qualifyingFormat: 'ladder' }, }; - const registeredDriverIds = ['driver-1', 'driver-2']; + const registrations = [ + { driverId: { toString: () => 'driver-1' } }, + { driverId: { toString: () => 'driver-2' } }, + ]; const membership = { status: 'active' as const }; - const ratings = new Map([['driver-1', 1600], ['driver-2', 1400]]); const drivers = [ { id: 'driver-1', name: 'Driver 1', country: 'US' }, { id: 'driver-2', name: 'Driver 2', country: 'UK' }, @@ -72,46 +77,41 @@ describe('GetRaceDetailUseCase', () => { raceRepository.findById.mockResolvedValue(race); leagueRepository.findById.mockResolvedValue(league); - raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(registeredDriverIds); + raceRegistrationRepository.findByRaceId.mockResolvedValue(registrations); leagueMembershipRepository.getMembership.mockResolvedValue(membership); - driverRatingProvider.getRatings.mockReturnValue(ratings); - driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id) || null)); - imageService.getDriverAvatar.mockImplementation((id) => `avatar-${id}`); + driverRepository.findById.mockImplementation((id: string) => + Promise.resolve(drivers.find(d => d.id === id) || null), + ); + resultRepository.findByRaceId.mockResolvedValue([]); - const result = await useCase.execute({ raceId, driverId }); + const input: GetRaceDetailInput = { raceId, driverId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.race).toEqual({ - id: raceId, - leagueId: 'league-1', - track: 'Track 1', - car: 'Car 1', - scheduledAt: '2023-01-01T10:00:00.000Z', - sessionType: 'race', - status: 'scheduled', - strengthOfField: 1500, - registeredCount: 10, - maxParticipants: 20, - }); - expect(viewModel.league).toEqual({ - id: 'league-1', - name: 'League 1', - description: 'Description', - settings: { maxDrivers: 20, qualifyingFormat: 'ladder' }, - }); - expect(viewModel.entryList).toHaveLength(2); - expect(viewModel.registration).toEqual({ isUserRegistered: true, canRegister: false }); - expect(viewModel.userResult).toBeNull(); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetRaceDetailResult; + expect(presented.race).toEqual(race); + expect(presented.league).toEqual(league); + expect(presented.registrations).toEqual(registrations); + expect(presented.drivers).toHaveLength(2); + expect(presented.isUserRegistered).toBe(true); + expect(presented.canRegister).toBe(true); + expect(presented.userResult).toBeNull(); }); it('should return error when race not found', async () => { raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1', driverId: 'driver-1' }); + const input: GetRaceDetailInput = { raceId: 'race-1', driverId: 'driver-1' }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' }); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details?.message).toBe('Race not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should include user result when race is completed', async () => { @@ -126,37 +126,43 @@ describe('GetRaceDetailUseCase', () => { sessionType: 'race' as const, status: 'completed' as const, }; - const results = [{ - driverId: 'driver-1', - position: 2, - startPosition: 1, - incidents: 0, - fastestLap: 120, - getPositionChange: () => -1, - isPodium: () => true, - isClean: () => true, - }]; + const registrations: Array<{ driverId: { toString: () => string } }> = []; + const userDomainResult = { + driverId: { toString: () => driverId }, + } as unknown as { driverId: { toString: () => string } }; raceRepository.findById.mockResolvedValue(race); leagueRepository.findById.mockResolvedValue(null); - raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]); + raceRegistrationRepository.findByRaceId.mockResolvedValue(registrations); leagueMembershipRepository.getMembership.mockResolvedValue(null); - driverRatingProvider.getRatings.mockReturnValue(new Map()); - resultRepository.findByRaceId.mockResolvedValue(results); + driverRepository.findById.mockResolvedValue(null); + resultRepository.findByRaceId.mockResolvedValue([userDomainResult]); - const result = await useCase.execute({ raceId, driverId }); + const input: GetRaceDetailInput = { raceId, driverId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.userResult).toEqual({ - position: 2, - startPosition: 1, - incidents: 0, - fastestLap: 120, - positionChange: -1, - isPodium: true, - isClean: true, - ratingChange: 61, // based on calculateRatingChange - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as GetRaceDetailResult; + expect(presented.userResult).toBe(userDomainResult); + expect(presented.race).toEqual(race); + expect(presented.league).toBeNull(); + expect(presented.registrations).toEqual(registrations); + }); + + it('should wrap repository errors', async () => { + const error = new Error('db down'); + raceRepository.findById.mockRejectedValue(error); + + const input: GetRaceDetailInput = { raceId: 'race-1', driverId: 'driver-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('db down'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceDetailUseCase.ts b/core/racing/application/use-cases/GetRaceDetailUseCase.ts index 2727e84b6..59454786b 100644 --- a/core/racing/application/use-cases/GetRaceDetailUseCase.ts +++ b/core/racing/application/use-cases/GetRaceDetailUseCase.ts @@ -4,31 +4,35 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { RaceDetailOutputPort } from '../ports/output/RaceDetailOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { League } from '../../domain/entities/League'; +import type { Race } from '../../domain/entities/Race'; +import type { RaceRegistration } from '../../domain/entities/RaceRegistration'; +import type { Result as DomainResult } from '../../domain/entities/Result'; -/** - * Use Case: GetRaceDetailUseCase - * - * Given a race id and current driver id: - * - When the race exists, it builds a view model with race, league, entry list, registration flags and user result. - * - When the race does not exist, it presents a view model with an error and no race data. - * - * Given a completed race with a result for the driver: - * - When computing rating change, it applies the same position-based formula used in the legacy UI. - */ -export interface GetRaceDetailQueryParams { +export type GetRaceDetailInput = { raceId: string; driverId: string; -} +}; -type GetRaceDetailErrorCode = 'RACE_NOT_FOUND'; +// Backwards-compatible alias for older callers +export type GetRaceDetailQueryParams = GetRaceDetailInput; -export class GetRaceDetailUseCase - implements AsyncUseCase -{ +export type GetRaceDetailErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export type GetRaceDetailResult = { + race: Race; + league: League | null; + registrations: RaceRegistration[]; + drivers: NonNullable>>[]; + userResult: DomainResult | null; + isUserRegistered: boolean; + canRegister: boolean; +}; + +export class GetRaceDetailUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, @@ -36,50 +40,69 @@ export class GetRaceDetailUseCase private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetRaceDetailQueryParams): Promise>> { - const { raceId, driverId } = params; + async execute( + input: GetRaceDetailInput, + ): Promise>> { + const { raceId, driverId } = input; - const race = await this.raceRepository.findById(raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND' }); + try { + const race = await this.raceRepository.findById(raceId); + if (!race) { + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found' }, + }); + } + + const [league, registrations, membership] = await Promise.all([ + this.leagueRepository.findById(race.leagueId), + this.raceRegistrationRepository.findByRaceId(race.id), + this.leagueMembershipRepository.getMembership(race.leagueId, driverId), + ]); + + const drivers = await Promise.all( + registrations.map(registration => this.driverRepository.findById(registration.driverId.toString())), + ); + + const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); + + const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId); + const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date(); + const canRegister = !!membership && membership.status === 'active' && isUpcoming; + + let userResult: DomainResult | null = null; + + if (race.status === 'completed') { + const results = await this.resultRepository.findByRaceId(race.id); + userResult = results.find(r => r.driverId.toString() === driverId) ?? null; + } + + const result: GetRaceDetailResult = { + race, + league: league ?? null, + registrations, + drivers: validDrivers, + userResult, + isUserRegistered, + canRegister, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load race detail'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - const [league, registrations, membership] = await Promise.all([ - this.leagueRepository.findById(race.leagueId), - this.raceRegistrationRepository.findByRaceId(race.id), - this.leagueMembershipRepository.getMembership(race.leagueId, driverId), - ]); - - const drivers = await Promise.all( - registrations.map(registration => this.driverRepository.findById(registration.driverId.toString())), - ); - - const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); - - const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId); - const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date(); - const canRegister = !!membership && membership.status === 'active' && isUpcoming; - - let userResult: Result | null = null; - - if (race.status === 'completed') { - const results = await this.resultRepository.findByRaceId(race.id); - userResult = results.find(r => r.driverId.toString() === driverId) ?? null; - } - - const outputPort: RaceDetailOutputPort = { - race, - league, - registrations, - drivers: validDrivers, - userResult, - isUserRegistered, - canRegister, - }; - - return Result.ok(outputPort); } - } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts b/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts index b29ab7109..bf81302d6 100644 --- a/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts +++ b/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts @@ -1,28 +1,38 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRacePenaltiesUseCase } from './GetRacePenaltiesUseCase'; +import { + GetRacePenaltiesUseCase, + type GetRacePenaltiesInput, + type GetRacePenaltiesResult, + type GetRacePenaltiesErrorCode, +} from './GetRacePenaltiesUseCase'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetRacePenaltiesUseCase', () => { let useCase: GetRacePenaltiesUseCase; let penaltyRepository: { findByRaceId: Mock }; let driverRepository: { findById: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { penaltyRepository = { findByRaceId: vi.fn() }; driverRepository = { findById: vi.fn() }; + output = { present: vi.fn() } as UseCaseOutputPort & { present: Mock }; useCase = new GetRacePenaltiesUseCase( penaltyRepository as unknown as IPenaltyRepository, driverRepository as unknown as IDriverRepository, + output, ); }); - it('should return penalties with driver map', async () => { - const raceId = 'race-1'; + it('should return penalties with drivers', async () => { + const input: GetRacePenaltiesInput = { raceId: 'race-1' }; const penalties = [ { id: 'penalty-1', - raceId, + raceId: input.raceId, driverId: 'driver-1', issuedBy: 'driver-2', type: 'time' as const, @@ -38,26 +48,47 @@ describe('GetRacePenaltiesUseCase', () => { ]; penaltyRepository.findByRaceId.mockResolvedValue(penalties); - driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id))); + driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find((d) => d.id === id))); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.penalties).toEqual(penalties); - expect(dto.driverMap.get('driver-1')).toBe('Driver 1'); - expect(dto.driverMap.get('driver-2')).toBe('Driver 2'); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetRacePenaltiesResult; + expect(presented.penalties).toEqual(penalties); + expect(presented.drivers).toEqual(drivers); }); it('should return empty when no penalties', async () => { penaltyRepository.findByRaceId.mockResolvedValue([]); driverRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1' }); + const input: GetRacePenaltiesInput = { raceId: 'race-1' }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.penalties).toEqual([]); - expect(dto.driverMap.size).toBe(0); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetRacePenaltiesResult; + expect(presented.penalties).toEqual([]); + expect(presented.drivers).toEqual([]); + }); + + it('should return repository error when repository throws', async () => { + const input: GetRacePenaltiesInput = { raceId: 'race-1' }; + const repositoryError = new Error('Repository failure'); + penaltyRepository.findByRaceId.mockRejectedValue(repositoryError); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetRacePenaltiesErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts b/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts index 24effdec0..e0bf7fc43 100644 --- a/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts +++ b/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts @@ -7,40 +7,60 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { RacePenaltiesOutputPort } from '../ports/output/RacePenaltiesOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { Driver } from '../../domain/entities/Driver'; -export interface GetRacePenaltiesInput { +export type GetRacePenaltiesInput = { raceId: string; -} +}; -export class GetRacePenaltiesUseCase implements AsyncUseCase { +export type GetRacePenaltiesResult = { + penalties: unknown[]; + drivers: Driver[]; +}; + +export type GetRacePenaltiesErrorCode = 'REPOSITORY_ERROR'; + +export class GetRacePenaltiesUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: GetRacePenaltiesInput): Promise>> { - const penalties = await this.penaltyRepository.findByRaceId(input.raceId); + async execute( + input: GetRacePenaltiesInput, + ): Promise>> { + try { + const penalties = await this.penaltyRepository.findByRaceId(input.raceId); - const driverIds = new Set(); - penalties.forEach((penalty) => { - driverIds.add(penalty.driverId); - driverIds.add(penalty.issuedBy); - }); + const driverIds = new Set(); + penalties.forEach((penalty: any) => { + driverIds.add(penalty.driverId); + driverIds.add(penalty.issuedBy); + }); - const drivers = await Promise.all( - Array.from(driverIds).map((id) => this.driverRepository.findById(id)), - ); + const drivers = await Promise.all( + Array.from(driverIds).map((id) => this.driverRepository.findById(id)), + ); - const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); + const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); - const outputPort: RacePenaltiesOutputPort = { - penalties, - drivers: validDrivers, - }; - return Result.ok(outputPort); + this.output.present({ penalties, drivers: validDrivers }); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message ? error.message : 'Failed to load race penalties'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message, + }, + } as ApplicationErrorCode); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts b/core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts index 6187fc547..ca5f437ee 100644 --- a/core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts @@ -1,64 +1,122 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRaceProtestsUseCase } from './GetRaceProtestsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetRaceProtestsUseCase, + type GetRaceProtestsInput, + type GetRaceProtestsResult, + type GetRaceProtestsErrorCode, +} from './GetRaceProtestsUseCase'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { Protest } from '../../domain/entities/Protest'; +import { Driver } from '../../domain/entities/Driver'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetRaceProtestsUseCase', () => { let useCase: GetRaceProtestsUseCase; let protestRepository: { findByRaceId: Mock }; let driverRepository: { findById: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { protestRepository = { findByRaceId: vi.fn() }; driverRepository = { findById: vi.fn() }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetRaceProtestsUseCase( protestRepository as unknown as IProtestRepository, driverRepository as unknown as IDriverRepository, + output, ); }); - it('should return protests with driver map', async () => { + it('should return protests with drivers', async () => { const raceId = 'race-1'; - const protests = [ - { - id: 'protest-1', - raceId, - protestingDriverId: 'driver-1', - accusedDriverId: 'driver-2', - reviewedBy: 'driver-3', - filedAt: new Date(), - comment: 'Comment', - status: 'pending' as const, - }, - ]; - const drivers = [ - { id: 'driver-1', name: 'Driver 1' }, - { id: 'driver-2', name: 'Driver 2' }, - { id: 'driver-3', name: 'Driver 3' }, - ]; + const protest = Protest.create({ + id: 'protest-1', + raceId, + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 1, description: 'Incident' }, + status: 'pending', + filedAt: new Date(), + reviewedBy: 'driver-3', + }); - protestRepository.findByRaceId.mockResolvedValue(protests); - driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id))); + const driver1 = Driver.create({ + id: 'driver-1', + iracingId: 'ir-1', + name: 'Driver 1', + country: 'US', + }); + const driver2 = Driver.create({ + id: 'driver-2', + iracingId: 'ir-2', + name: 'Driver 2', + country: 'UK', + }); + const driver3 = Driver.create({ + id: 'driver-3', + iracingId: 'ir-3', + name: 'Driver 3', + country: 'DE', + }); - const result = await useCase.execute({ raceId }); + protestRepository.findByRaceId.mockResolvedValue([protest]); + driverRepository.findById.mockImplementation((id: string) => { + if (id === 'driver-1') return Promise.resolve(driver1); + if (id === 'driver-2') return Promise.resolve(driver2); + if (id === 'driver-3') return Promise.resolve(driver3); + return Promise.resolve(null); + }); + + const input: GetRaceProtestsInput = { raceId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.protests).toEqual(protests); - expect(dto.driverMap.get('driver-1')).toBe('Driver 1'); - expect(dto.driverMap.get('driver-2')).toBe('Driver 2'); - expect(dto.driverMap.get('driver-3')).toBe('Driver 3'); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetRaceProtestsResult; + + expect(presented.protests).toHaveLength(1); + expect(presented.protests[0]).toEqual(protest); + expect(presented.drivers).toHaveLength(3); + expect(presented.drivers).toEqual(expect.arrayContaining([driver1, driver2, driver3])); }); it('should return empty when no protests', async () => { protestRepository.findByRaceId.mockResolvedValue([]); driverRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1' }); + const input: GetRaceProtestsInput = { raceId: 'race-1' }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.protests).toEqual([]); - expect(dto.driverMap.size).toBe(0); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetRaceProtestsResult; + + expect(presented.protests).toEqual([]); + expect(presented.drivers).toEqual([]); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + protestRepository.findByRaceId.mockRejectedValue(new Error('DB error')); + + const input: GetRaceProtestsInput = { raceId: 'race-1' }; + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetRaceProtestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceProtestsUseCase.ts b/core/racing/application/use-cases/GetRaceProtestsUseCase.ts index 88940e79a..f5607b284 100644 --- a/core/racing/application/use-cases/GetRaceProtestsUseCase.ts +++ b/core/racing/application/use-cases/GetRaceProtestsUseCase.ts @@ -7,43 +7,69 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { RaceProtestsOutputPort } from '../ports/output/RaceProtestsOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { Protest } from '../../domain/entities/Protest'; +import type { Driver } from '../../domain/entities/Driver'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; export interface GetRaceProtestsInput { raceId: string; } -export class GetRaceProtestsUseCase implements AsyncUseCase { +export type GetRaceProtestsErrorCode = 'REPOSITORY_ERROR'; + +export interface GetRaceProtestsResult { + protests: Protest[]; + drivers: Driver[]; +} + +export class GetRaceProtestsUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: GetRaceProtestsInput): Promise>> { - const protests = await this.protestRepository.findByRaceId(input.raceId); + async execute( + input: GetRaceProtestsInput, + ): Promise>> { + try { + const protests = await this.protestRepository.findByRaceId(input.raceId); - const driverIds = new Set(); - protests.forEach((protest) => { - driverIds.add(protest.protestingDriverId); - driverIds.add(protest.accusedDriverId); - if (protest.reviewedBy) { - driverIds.add(protest.reviewedBy); - } - }); + const driverIds = new Set(); + protests.forEach((protest) => { + driverIds.add(protest.protestingDriverId); + driverIds.add(protest.accusedDriverId); + if (protest.reviewedBy) { + driverIds.add(protest.reviewedBy); + } + }); - const drivers = await Promise.all( - Array.from(driverIds).map((id) => this.driverRepository.findById(id)), - ); + const drivers = await Promise.all( + Array.from(driverIds).map((id) => this.driverRepository.findById(id)), + ); - const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); + const validDrivers = drivers.filter((driver): driver is NonNullable => driver !== null); - const outputPort: RaceProtestsOutputPort = { - protests, - drivers: validDrivers, - }; - return Result.ok(outputPort); + const result: GetRaceProtestsResult = { + protests, + drivers: validDrivers, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message + : 'Failed to load race protests'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts index 05eead61e..60e4eade7 100644 --- a/core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts +++ b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts @@ -1,42 +1,105 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRaceRegistrationsUseCase } from './GetRaceRegistrationsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetRaceRegistrationsUseCase, + type GetRaceRegistrationsInput, + type GetRaceRegistrationsResult, + type GetRaceRegistrationsErrorCode, +} from './GetRaceRegistrationsUseCase'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; +import { Race } from '@core/racing/domain/entities/Race'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('GetRaceRegistrationsUseCase', () => { let useCase: GetRaceRegistrationsUseCase; + let raceRepository: { findById: Mock }; let registrationRepository: { findByRaceId: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { + raceRepository = { findById: vi.fn() }; registrationRepository = { findByRaceId: vi.fn() }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetRaceRegistrationsUseCase( + raceRepository as unknown as IRaceRepository, registrationRepository as unknown as IRaceRegistrationRepository, + output, ); }); - it('should return registrations', async () => { - const raceId = 'race-1'; + it('should present race and registrations on success', async () => { + const input: GetRaceRegistrationsInput = { raceId: 'race-1' }; + + const race = Race.create({ + id: input.raceId, + leagueId: 'league-1', + scheduledAt: new Date(), + track: 'Track', + car: 'Car', + }); + const registrations = [ - RaceRegistration.create({ raceId, driverId: 'driver-1' }), - RaceRegistration.create({ raceId, driverId: 'driver-2' }), + RaceRegistration.create({ raceId: input.raceId, driverId: 'driver-1' }), + RaceRegistration.create({ raceId: input.raceId, driverId: 'driver-2' }), ]; + raceRepository.findById.mockResolvedValue(race); registrationRepository.findByRaceId.mockResolvedValue(registrations); - const result = await useCase.execute({ raceId }); + const result: Result> = + await useCase.execute(input); expect(result.isOk()).toBe(true); - const outputPort = result.unwrap(); - expect(outputPort.registrations).toEqual(registrations); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetRaceRegistrationsResult; + + expect(presented.race).toEqual(race); + expect(presented.registrations).toHaveLength(2); + expect(presented.registrations[0].registration).toEqual(registrations[0]); + expect(presented.registrations[1].registration).toEqual(registrations[1]); }); - it('should return empty array when no registrations', async () => { - registrationRepository.findByRaceId.mockResolvedValue([]); + it('should return RACE_NOT_FOUND error when race does not exist', async () => { + const input: GetRaceRegistrationsInput = { raceId: 'non-existent-race' }; - const result = await useCase.execute({ raceId: 'race-1' }); + raceRepository.findById.mockResolvedValue(null); - expect(result.isOk()).toBe(true); - const outputPort = result.unwrap(); - expect(outputPort.registrations).toEqual([]); + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetRaceRegistrationsErrorCode, + { message: string } + >; + + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details?.message).toBe('Race not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const input: GetRaceRegistrationsInput = { raceId: 'race-1' }; + + raceRepository.findById.mockRejectedValue(new Error('DB failure')); + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetRaceRegistrationsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts index 0d4b6add5..85fe2eb19 100644 --- a/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts +++ b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts @@ -1,29 +1,72 @@ import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; -import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; -import type { RaceRegistrationsOutputPort } from '../ports/output/RaceRegistrationsOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; +import type { Race } from '@core/racing/domain/entities/Race'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -/** - * Use Case: GetRaceRegistrationsUseCase - * - * Returns registered driver IDs for a race. - * Orchestrates domain logic and delegates presentation to the presenter. - */ -export class GetRaceRegistrationsUseCase implements AsyncUseCase { +export type GetRaceRegistrationsInput = { + raceId: string; +}; + +export type RaceRegistrationWithContext = { + registration: RaceRegistration; +}; + +export type GetRaceRegistrationsResult = { + race: Race; + registrations: RaceRegistrationWithContext[]; +}; + +export type GetRaceRegistrationsErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export class GetRaceRegistrationsUseCase { constructor( + private readonly raceRepository: IRaceRepository, private readonly registrationRepository: IRaceRegistrationRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise>> { - const { raceId } = params; - const registrations = await this.registrationRepository.findByRaceId(raceId); + async execute( + input: GetRaceRegistrationsInput, + ): Promise>> { + const { raceId } = input; - const outputPort: RaceRegistrationsOutputPort = { - registrations, - }; + try { + const race = await this.raceRepository.findById(raceId); - return Result.ok(outputPort); + if (!race) { + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found' }, + }); + } + + const registrations = await this.registrationRepository.findByRaceId(raceId); + + const registrationsWithContext: RaceRegistrationWithContext[] = registrations.map(registration => ({ + registration, + })); + + const result: GetRaceRegistrationsResult = { + race, + registrations: registrationsWithContext, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load race registrations'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts index 4c91d1509..96f534824 100644 --- a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts +++ b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts @@ -1,10 +1,17 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRaceResultsDetailUseCase } from './GetRaceResultsDetailUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetRaceResultsDetailUseCase, + type GetRaceResultsDetailInput, + type GetRaceResultsDetailResult, + type GetRaceResultsDetailErrorCode, +} from './GetRaceResultsDetailUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetRaceResultsDetailUseCase', () => { let useCase: GetRaceResultsDetailUseCase; @@ -13,6 +20,7 @@ describe('GetRaceResultsDetailUseCase', () => { let resultRepository: { findByRaceId: Mock }; let driverRepository: { findAll: Mock }; let penaltyRepository: { findByRaceId: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { findById: vi.fn() }; @@ -20,39 +28,79 @@ describe('GetRaceResultsDetailUseCase', () => { resultRepository = { findByRaceId: vi.fn() }; driverRepository = { findAll: vi.fn() }; penaltyRepository = { findByRaceId: vi.fn() }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: Mock; + }; + useCase = new GetRaceResultsDetailUseCase( raceRepository as unknown as IRaceRepository, leagueRepository as unknown as ILeagueRepository, resultRepository as unknown as IResultRepository, driverRepository as unknown as IDriverRepository, penaltyRepository as unknown as IPenaltyRepository, + output, ); }); - it('should return race results detail when race exists', async () => { - const raceId = 'race-1'; + it('presents race results detail when race exists', async () => { + const input: GetRaceResultsDetailInput = { raceId: 'race-1' }; + const race = { - id: raceId, + id: 'race-1', leagueId: 'league-1', track: 'Track 1', scheduledAt: new Date('2023-01-01T10:00:00Z'), status: 'completed' as const, }; + const league = { id: 'league-1', name: 'League 1', settings: { pointsSystem: 'f1-2024' }, }; + const results = [ - { driverId: 'driver-1', position: 1, fastestLap: 120 }, - { driverId: 'driver-2', position: 2, fastestLap: 125 }, + { + id: 'res-1', + raceId: 'race-1', + driverId: 'driver-1', + position: { toNumber: () => 1 }, + fastestLap: { toNumber: () => 120 }, + incidents: { toNumber: () => 0 }, + startPosition: { toNumber: () => 1 }, + }, + { + id: 'res-2', + raceId: 'race-1', + driverId: 'driver-2', + position: { toNumber: () => 2 }, + fastestLap: { toNumber: () => 125 }, + incidents: { toNumber: () => 1 }, + startPosition: { toNumber: () => 2 }, + }, ]; + const drivers = [ { id: 'driver-1', name: 'Driver 1' }, { id: 'driver-2', name: 'Driver 2' }, ]; + const penalties = [ - { driverId: 'driver-1', type: 'time' as const, value: 10 }, + { + id: 'pen-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'cut track', + protestId: undefined, + issuedBy: 'steward-1', + status: 'pending', + issuedAt: new Date(), + appliedAt: undefined, + notes: undefined, + }, ]; raceRepository.findById.mockResolvedValue(race); @@ -61,32 +109,57 @@ describe('GetRaceResultsDetailUseCase', () => { driverRepository.findAll.mockResolvedValue(drivers); penaltyRepository.findByRaceId.mockResolvedValue(penalties); - const result = await useCase.execute({ raceId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.race).toEqual({ - id: raceId, - leagueId: 'league-1', - track: 'Track 1', - scheduledAt: new Date('2023-01-01T10:00:00Z'), - status: 'completed', - }); - expect(viewModel.league).toEqual({ id: 'league-1', name: 'League 1' }); - expect(viewModel.results).toEqual(results); - expect(viewModel.drivers).toEqual(drivers); - expect(viewModel.penalties).toEqual([{ driverId: 'driver-1', type: 'time', value: 10 }]); - expect(viewModel.pointsSystem).toBeDefined(); - expect(viewModel.fastestLapTime).toBe(120); - expect(viewModel.currentDriverId).toBe('driver-1'); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]![0] as GetRaceResultsDetailResult; + + expect(presented.race).toEqual(race); + expect(presented.league).toEqual(league); + expect(presented.results).toEqual(results); + expect(presented.drivers).toEqual(drivers); + expect(presented.penalties).toEqual(penalties); + expect(presented.pointsSystem).toBeDefined(); + expect(presented.fastestLapTime).toBe(120); + expect(presented.currentDriverId).toBe('driver-1'); }); - it('should return error when race not found', async () => { + it('returns error when race not found and does not present data', async () => { + const input: GetRaceResultsDetailInput = { raceId: 'race-1' }; + raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1' }); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetRaceResultsDetailErrorCode, + { message: string } + >; + + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(error.details.message).toBe('Race not found'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('returns repository error when an unexpected error occurs', async () => { + const input: GetRaceResultsDetailInput = { raceId: 'race-1' }; + + raceRepository.findById.mockRejectedValue(new Error('Database failure')); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + GetRaceResultsDetailErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Database failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts index ae2012a25..dcf9e4c6e 100644 --- a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts +++ b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts @@ -1,67 +1,102 @@ -import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; -import type { RaceResultsDetailOutputPort } from '../ports/output/RaceResultsDetailOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { Driver } from '../../domain/entities/Driver'; import type { League } from '../../domain/entities/League'; -import type { Result as DomainResult } from '../../domain/entities/Result'; -import type { Penalty } from '../../domain/entities/Penalty'; +import type { Race } from '../../domain/entities/Race'; +import type { Penalty } from '../../domain/entities/penalty/Penalty'; +import type { Result as DomainResult } from '../../domain/entities/result/Result'; -export interface GetRaceResultsDetailParams { +export type GetRaceResultsDetailInput = { raceId: string; driverId?: string; -} +}; +export type GetRaceResultsDetailErrorCode = + | 'RACE_NOT_FOUND' + | 'REPOSITORY_ERROR'; -type GetRaceResultsDetailErrorCode = 'RACE_NOT_FOUND'; +export type GetRaceResultsDetailResult = { + race: Race; + league: League | null; + results: DomainResult[]; + drivers: Driver[]; + penalties: Penalty[]; + pointsSystem?: Record; + fastestLapTime?: number; + currentDriverId?: string; +}; -export class GetRaceResultsDetailUseCase implements AsyncUseCase { +export class GetRaceResultsDetailUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly resultRepository: IResultRepository, private readonly driverRepository: IDriverRepository, private readonly penaltyRepository: IPenaltyRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetRaceResultsDetailParams): Promise>> { - const { raceId, driverId } = params; + async execute( + params: GetRaceResultsDetailInput, + ): Promise< + Result> + > { + try { + const { raceId, driverId } = params; - const race = await this.raceRepository.findById(raceId); + const race = await this.raceRepository.findById(raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND' }); + if (!race) { + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found' }, + }); + } + + const [league, results, drivers, penalties] = await Promise.all([ + this.leagueRepository.findById(race.leagueId), + this.resultRepository.findByRaceId(raceId), + this.driverRepository.findAll(), + this.penaltyRepository.findByRaceId(raceId), + ]); + + const effectiveCurrentDriverId = + driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined); + + const pointsSystem = this.buildPointsSystem(league); + const fastestLapTime = this.getFastestLapTime(results); + + const result: GetRaceResultsDetailResult = { + race, + league, + results, + drivers, + penalties, + ...(pointsSystem ? { pointsSystem } : {}), + ...(fastestLapTime !== undefined ? { fastestLapTime } : {}), + ...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}), + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && typeof error.message === 'string' + ? error.message + : 'Failed to load race results detail'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - const [league, results, drivers, penalties] = await Promise.all([ - this.leagueRepository.findById(race.leagueId), - this.resultRepository.findByRaceId(raceId), - this.driverRepository.findAll(), - this.penaltyRepository.findByRaceId(raceId), - ]); - - const effectiveCurrentDriverId = - driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined); - - const pointsSystem = this.buildPointsSystem(league); - const fastestLapTime = this.getFastestLapTime(results); - - const outputPort: RaceResultsDetailOutputPort = { - race, - league, - results, - drivers, - penalties, - ...(pointsSystem ? { pointsSystem } : {}), - ...(fastestLapTime !== undefined ? { fastestLapTime } : {}), - ...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}), - }; - - return Result.ok(outputPort); } private buildPointsSystem(league: League | null): Record | undefined { @@ -114,7 +149,6 @@ export class GetRaceResultsDetailUseCase implements AsyncUseCase r.fastestLap)); + return Math.min(...results.map(r => r.fastestLap.toNumber())); } - -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetRaceWithSOFUseCase.test.ts b/core/racing/application/use-cases/GetRaceWithSOFUseCase.test.ts index 5fee247f5..99d0e64c2 100644 --- a/core/racing/application/use-cases/GetRaceWithSOFUseCase.test.ts +++ b/core/racing/application/use-cases/GetRaceWithSOFUseCase.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRaceWithSOFUseCase } from './GetRaceWithSOFUseCase'; +import { + GetRaceWithSOFUseCase, + type GetRaceWithSOFInput, + type GetRaceWithSOFResult, + type GetRaceWithSOFErrorCode, +} from './GetRaceWithSOFUseCase'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import { IResultRepository } from '../../domain/repositories/IResultRepository'; import { Race } from '../../domain/entities/Race'; import { SessionType } from '../../domain/value-objects/SessionType'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetRaceWithSOFUseCase', () => { let useCase: GetRaceWithSOFUseCase; @@ -18,6 +25,7 @@ describe('GetRaceWithSOFUseCase', () => { findByRaceId: Mock; }; let getDriverRating: Mock; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { @@ -30,21 +38,31 @@ describe('GetRaceWithSOFUseCase', () => { findByRaceId: vi.fn(), }; getDriverRating = vi.fn(); + output = { + present: vi.fn(), + }; useCase = new GetRaceWithSOFUseCase( raceRepository as unknown as IRaceRepository, registrationRepository as unknown as IRaceRegistrationRepository, resultRepository as unknown as IResultRepository, getDriverRating, + output, ); }); it('should return error when race not found', async () => { raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1' }); + const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetRaceWithSOFErrorCode, + { message: string } + >; + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details?.message).toBe('Race with id race-1 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return race with stored SOF when available', async () => { @@ -62,20 +80,31 @@ describe('GetRaceWithSOFUseCase', () => { }); 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']); + 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' }); + const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput); 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'); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]]; + expect(presented.race.id).toBe('race-1'); + expect(presented.race.leagueId).toBe('league-1'); + expect(presented.strengthOfField).toBe(1500); + expect(presented.registeredCount).toBe(10); + expect(presented.maxParticipants).toBe(20); + expect(presented.participantCount).toBe(10); }); it('should calculate SOF for upcoming race using registrations', async () => { @@ -92,17 +121,23 @@ describe('GetRaceWithSOFUseCase', () => { raceRepository.findById.mockResolvedValue(race); registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); getDriverRating.mockImplementation((input) => { - if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null }); - if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null }); + if (input.driverId === 'driver-1') { + return Promise.resolve({ rating: 1400, ratingChange: null }); + } + 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' } as GetRaceWithSOFInput); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.strengthOfField).toBe(1500); // average - expect(dto.participantCount).toBe(2); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]]; + expect(presented.strengthOfField).toBe(1500); // average + expect(presented.participantCount).toBe(2); expect(registrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1'); expect(resultRepository.findByRaceId).not.toHaveBeenCalled(); }); @@ -124,17 +159,23 @@ describe('GetRaceWithSOFUseCase', () => { { driverId: 'driver-2' }, ]); getDriverRating.mockImplementation((input) => { - if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null }); - if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null }); + if (input.driverId === 'driver-1') { + return Promise.resolve({ rating: 1400, ratingChange: null }); + } + 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' } as GetRaceWithSOFInput); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.strengthOfField).toBe(1500); - expect(dto.participantCount).toBe(2); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]]; + expect(presented.strengthOfField).toBe(1500); + expect(presented.participantCount).toBe(2); expect(resultRepository.findByRaceId).toHaveBeenCalledWith('race-1'); expect(registrationRepository.getRegisteredDrivers).not.toHaveBeenCalled(); }); @@ -153,17 +194,21 @@ describe('GetRaceWithSOFUseCase', () => { raceRepository.findById.mockResolvedValue(race); registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); getDriverRating.mockImplementation((input) => { - if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null }); + if (input.driverId === 'driver-1') { + return Promise.resolve({ rating: 1400, ratingChange: null }); + } // 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' } as GetRaceWithSOFInput); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.strengthOfField).toBe(1400); // only one rating - expect(dto.participantCount).toBe(2); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]]; + expect(presented.strengthOfField).toBe(1400); // only one rating + expect(presented.participantCount).toBe(2); }); it('should return null SOF when no participants', async () => { @@ -180,11 +225,28 @@ describe('GetRaceWithSOFUseCase', () => { raceRepository.findById.mockResolvedValue(race); registrationRepository.getRegisteredDrivers.mockResolvedValue([]); - const result = await useCase.execute({ raceId: 'race-1' }); + const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.strengthOfField).toBe(null); - expect(dto.participantCount).toBe(0); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]]; + expect(presented.strengthOfField).toBe(null); + expect(presented.participantCount).toBe(0); }); -}); \ No newline at end of file + + it('should wrap repository errors in REPOSITORY_ERROR and not call output', async () => { + raceRepository.findById.mockRejectedValue(new Error('boom')); + + const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetRaceWithSOFErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('boom'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts b/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts index b0e8f4e2f..6df70adba 100644 --- a/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts +++ b/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts @@ -7,90 +7,114 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort'; -import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort'; -import type { RaceWithSOFOutputPort } from '../ports/output/RaceWithSOFOutputPort'; import { AverageStrengthOfFieldCalculator, type StrengthOfFieldCalculator, } from '../../domain/services/StrengthOfFieldCalculator'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Race } from '../../domain/entities/Race'; -export interface GetRaceWithSOFQueryParams { +export interface GetRaceWithSOFInput { raceId: string; } -type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND'; +export type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR'; -export class GetRaceWithSOFUseCase implements AsyncUseCase { +export type GetRaceWithSOFResult = { + race: Race; + strengthOfField: number | null; + participantCount: number; + registeredCount: number; + maxParticipants: number; +}; + +type GetDriverRating = (input: { driverId: string }) => Promise<{ rating: number | null }>; + +export class GetRaceWithSOFUseCase { private readonly sofCalculator: StrengthOfFieldCalculator; constructor( private readonly raceRepository: IRaceRepository, private readonly registrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, - private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise, + private readonly getDriverRating: GetDriverRating, + private readonly output: UseCaseOutputPort, sofCalculator?: StrengthOfFieldCalculator, ) { this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); } - async execute(params: GetRaceWithSOFQueryParams): Promise>> { + async execute( + params: GetRaceWithSOFInput, + ): Promise>> { const { raceId } = params; - const race = await this.raceRepository.findById(raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND' }); + try { + const race = await this.raceRepository.findById(raceId); + if (!race) { + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: `Race with id ${raceId} not found` }, + }); + } + + // Get participant IDs based on race status + let participantIds: string[] = []; + + if (race.status === 'completed') { + // For completed races, use results + const results = await this.resultRepository.findByRaceId(raceId); + participantIds = results.map(r => r.driverId.toString()); + } else { + // For upcoming/running races, use registrations + participantIds = await this.registrationRepository.getRegisteredDrivers(raceId); + } + + // Use stored SOF if available, otherwise calculate + let strengthOfField = race.strengthOfField ?? null; + + if (strengthOfField === null && participantIds.length > 0) { + // 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.reduce<{ driverId: string; rating: number }[]>( + (acc, driverId, index) => { + const ratingResult = ratingResults[index]; + if (ratingResult && ratingResult.rating !== null) { + acc.push({ driverId, rating: ratingResult.rating }); + } + return acc; + }, + [], + ); + + strengthOfField = this.sofCalculator.calculate(driverRatings); + } + + const result: GetRaceWithSOFResult = { + race, + strengthOfField, + registeredCount: race.registeredCount ?? participantIds.length, + maxParticipants: race.maxParticipants ?? participantIds.length, + participantCount: participantIds.length, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + (error as Error)?.message ?? 'Failed to load race with SOF'; + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - // Get participant IDs based on race status - let participantIds: string[] = []; - - if (race.status === 'completed') { - // For completed races, use results - const results = await this.resultRepository.findByRaceId(raceId); - participantIds = results.map(r => r.driverId); - } else { - // For upcoming/running races, use registrations - participantIds = await this.registrationRepository.getRegisteredDrivers(raceId); - } - - // Use stored SOF if available, otherwise calculate - let strengthOfField = race.strengthOfField ?? null; - - if (strengthOfField === null && participantIds.length > 0) { - // 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 - .filter((_, index) => ratingResults[index].rating !== null) - .map((driverId, index) => ({ - driverId, - rating: ratingResults[index].rating! - })); - - strengthOfField = this.sofCalculator.calculate(driverRatings); - } - - const outputPort: RaceWithSOFOutputPort = { - id: race.id, - leagueId: race.leagueId, - scheduledAt: race.scheduledAt, - track: race.track ?? '', - car: race.car ?? '', - status: race.status, - strengthOfField, - registeredCount: race.registeredCount ?? participantIds.length, - maxParticipants: race.maxParticipants ?? participantIds.length, - participantCount: participantIds.length, - }; - - return Result.ok(outputPort); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts b/core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts index 4ead21dee..a90e13e12 100644 --- a/core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts +++ b/core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts @@ -1,23 +1,67 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetRacesPageDataUseCase } from './GetRacesPageDataUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetRacesPageDataUseCase, + type GetRacesPageDataResult, + type GetRacesPageDataInput, + type GetRacesPageDataErrorCode, +} from './GetRacesPageDataUseCase'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Result } from '@core/shared/application/Result'; describe('GetRacesPageDataUseCase', () => { let useCase: GetRacesPageDataUseCase; - let raceRepository: { findAll: Mock }; - let leagueRepository: { findAll: Mock }; + let raceRepository: IRaceRepository; + let leagueRepository: ILeagueRepository; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { - raceRepository = { findAll: vi.fn() }; - leagueRepository = { findAll: vi.fn() }; - useCase = new GetRacesPageDataUseCase( - raceRepository as unknown as IRaceRepository, - leagueRepository as unknown as ILeagueRepository, - ); + const raceFindAll = vi.fn(); + const leagueFindAll = vi.fn(); + + raceRepository = { + findById: vi.fn(), + findAll: raceFindAll, + 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(), + } as unknown as IRaceRepository; + + leagueRepository = { + findById: vi.fn(), + findAll: leagueFindAll, + findByOwnerId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + searchByName: vi.fn(), + } as unknown as ILeagueRepository; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new GetRacesPageDataUseCase(raceRepository, leagueRepository, logger, output); }); - it('should return races page data', async () => { + it('should present races page data for a league', async () => { const races = [ { id: 'race-1', @@ -43,32 +87,48 @@ describe('GetRacesPageDataUseCase', () => { isLive: () => false, isPast: () => true, }, - ]; - const leagues = [ - { id: 'league-1', name: 'League 1' }, - ]; + ] as any[]; - raceRepository.findAll.mockResolvedValue(races); - leagueRepository.findAll.mockResolvedValue(leagues); + const leagues = [{ id: 'league-1', name: 'League 1' }] as any[]; - const result = await useCase.execute(); + (raceRepository.findAll as Mock).mockResolvedValue(races); + (leagueRepository.findAll as Mock).mockResolvedValue(leagues); + + const input: GetRacesPageDataInput = { leagueId: 'league-1' }; + + const result: Result> = + await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.races).toHaveLength(2); - expect(dto.races[0]).toEqual({ - id: 'race-1', - track: 'Track 1', - car: 'Car 1', - scheduledAt: '2023-01-01T10:00:00.000Z', - status: 'scheduled', - leagueId: 'league-1', - leagueName: 'League 1', - strengthOfField: 1500, - isUpcoming: true, - isLive: false, - isPast: false, - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0]! as GetRacesPageDataResult; + + expect(presented.leagueId).toBe('league-1'); + expect(presented.races).toHaveLength(2); + + expect(presented.races[0].race.id).toBe('race-1'); + expect(presented.races[0].leagueName).toBe('League 1'); + expect(presented.races[1].race.id).toBe('race-2'); }); -}); \ No newline at end of file + it('should return repository error when repositories throw and not present data', async () => { + const error = new Error('Repository error'); + + (raceRepository.findAll as Mock).mockRejectedValue(error); + + const input: GetRacesPageDataInput = { leagueId: 'league-1' }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetRacesPageDataUseCase.ts index d7b257b15..5e5a1c2d8 100644 --- a/core/racing/application/use-cases/GetRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetRacesPageDataUseCase.ts @@ -1,43 +1,80 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { RacesPageOutputPort } from '../ports/output/RacesPageOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Race } from '../../domain/entities/Race'; -export class GetRacesPageDataUseCase implements AsyncUseCase { +export type GetRacesPageDataInput = { + leagueId: string; +}; + +export type GetRacesPageRaceItem = { + race: Race; + leagueName: string; +}; + +export type GetRacesPageDataResult = { + leagueId: string; + races: GetRacesPageRaceItem[]; +}; + +export type GetRacesPageDataErrorCode = 'REPOSITORY_ERROR'; + +export class GetRacesPageDataUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { - const [allRaces, allLeagues] = await Promise.all([ - this.raceRepository.findAll(), - this.leagueRepository.findAll(), - ]); + async execute( + input: GetRacesPageDataInput, + ): Promise>> { + this.logger.debug('GetRacesPageDataUseCase:execute', { input }); - const leagueMap = new Map(allLeagues.map(l => [l.id, l.name])); + try { + const [allRaces, allLeagues] = await Promise.all([ + this.raceRepository.findAll(), + this.leagueRepository.findAll(), + ]); - const races = allRaces - .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()) - .map(race => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - status: race.status, - leagueId: race.leagueId, - strengthOfField: race.strengthOfField, + const leagueMap = new Map(allLeagues.map(league => [league.id, league.name])); + + const filteredRaces = allRaces + .filter(race => race.leagueId === input.leagueId) + .sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime()); + + const races: GetRacesPageRaceItem[] = filteredRaces.map(race => ({ + race, + leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League', })); - const outputPort: RacesPageOutputPort = { - page: 1, - pageSize: races.length, - totalCount: races.length, - races, - }; + const result: GetRacesPageDataResult = { + leagueId: input.leagueId, + races, + }; - return Result.ok(outputPort); + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to load races page data'; + + this.logger.error( + 'GetRacesPageDataUseCase:execution error', + error instanceof Error ? error : new Error(message), + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); + } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts b/core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts index 676bd8596..1dc014a99 100644 --- a/core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts +++ b/core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetSeasonDetailsUseCase } from './GetSeasonDetailsUseCase'; +import { + GetSeasonDetailsUseCase, + type GetSeasonDetailsInput, + type GetSeasonDetailsResult, + type GetSeasonDetailsErrorCode, +} from './GetSeasonDetailsUseCase'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Season } from '../../domain/entities/Season'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetSeasonDetailsUseCase', () => { let useCase: GetSeasonDetailsUseCase; @@ -12,6 +19,9 @@ describe('GetSeasonDetailsUseCase', () => { let seasonRepository: { findById: Mock; }; + let output: UseCaseOutputPort & { + present: Mock; + }; beforeEach(() => { leagueRepository = { @@ -20,9 +30,14 @@ describe('GetSeasonDetailsUseCase', () => { seasonRepository = { findById: vi.fn(), }; + output = { + present: vi.fn(), + }; + useCase = new GetSeasonDetailsUseCase( leagueRepository as unknown as ILeagueRepository, seasonRepository as unknown as ISeasonRepository, + output, ); }); @@ -39,34 +54,51 @@ describe('GetSeasonDetailsUseCase', () => { leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(season); - const result = await useCase.execute({ + const input: GetSeasonDetailsInput = { leagueId: 'league-1', seasonId: 'season-1', - }); + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.seasonId).toBe('season-1'); - expect(dto.leagueId).toBe('league-1'); - expect(dto.gameId).toBe('iracing'); - expect(dto.name).toBe('Detailed Season'); - expect(dto.status).toBe('planned'); - expect(dto.maxDrivers).toBe(24); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + (output.present.mock.calls[0]?.[0] as GetSeasonDetailsResult | undefined) ?? + undefined; + + expect(presented).toBeDefined(); + expect(presented?.leagueId).toBe('league-1'); + expect(presented?.season.id).toBe('season-1'); + expect(presented?.season.leagueId).toBe('league-1'); + expect(presented?.season.gameId).toBe('iracing'); + expect(presented?.season.name).toBe('Detailed Season'); + expect(presented?.season.status).toBe('planned'); + expect(presented?.season.maxDrivers).toBe(24); }); it('returns error when league not found', async () => { leagueRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ + const input: GetSeasonDetailsInput = { leagueId: 'league-1', seasonId: 'season-1', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'LEAGUE_NOT_FOUND', - details: { message: 'League not found: league-1' }, - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSeasonDetailsErrorCode, + { message: string } + >; + + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toBe('League not found: league-1'); + expect(output.present).not.toHaveBeenCalled(); }); it('returns error when season not found', async () => { @@ -74,16 +106,25 @@ describe('GetSeasonDetailsUseCase', () => { leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ + const input: GetSeasonDetailsInput = { leagueId: 'league-1', seasonId: 'season-1', - }); + }; + + const result = await useCase.execute(input); 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' }, - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSeasonDetailsErrorCode, + { message: string } + >; + + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(error.details.message).toBe( + 'Season season-1 does not belong to league league-1', + ); + expect(output.present).not.toHaveBeenCalled(); }); it('returns error when season belongs to different league', async () => { @@ -99,15 +140,48 @@ describe('GetSeasonDetailsUseCase', () => { leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(season); - const result = await useCase.execute({ + const input: GetSeasonDetailsInput = { leagueId: 'league-1', seasonId: 'season-1', - }); + }; + + const result = await useCase.execute(input); 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' }, - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSeasonDetailsErrorCode, + { message: string } + >; + + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(error.details.message).toBe( + 'Season season-1 does not belong to league league-1', + ); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns repository error when an unexpected exception occurs', async () => { + leagueRepository.findById.mockRejectedValue( + new Error('Unexpected repository failure'), + ); + + const input: GetSeasonDetailsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSeasonDetailsErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Unexpected repository failure'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts b/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts index 42c8a4d28..94cd258f1 100644 --- a/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts +++ b/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts @@ -2,47 +2,23 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { Season } from '../../domain/entities/Season'; -export interface GetSeasonDetailsQuery { +export type GetSeasonDetailsInput = { leagueId: string; seasonId: string; -} +}; -export interface SeasonDetailsDTO { - seasonId: string; - leagueId: string; - gameId: string; - name: string; - status: import('../../domain/entities/Season').SeasonStatus; - startDate?: Date; - endDate?: Date; - maxDrivers?: number; - schedule?: { - startDate: Date; - plannedRounds: number; - }; - scoring?: { - scoringPresetId: string; - customScoringEnabled: boolean; - }; - dropPolicy?: { - strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy; - n?: number; - }; - stewarding?: { - decisionMode: import('../../domain/entities/League').StewardingDecisionMode; - requiredVotes?: number; - requireDefense: boolean; - defenseTimeLimit: number; - voteTimeLimit: number; - protestDeadlineHours: number; - stewardingClosesHours: number; - notifyAccusedOnProtest: boolean; - notifyOnVoteRequired: boolean; - }; -} +export type GetSeasonDetailsResult = { + leagueId: Season['leagueId']; + season: Season; +}; -type GetSeasonDetailsErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND'; +export type GetSeasonDetailsErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'REPOSITORY_ERROR'; /** * GetSeasonDetailsUseCase @@ -51,82 +27,51 @@ export class GetSeasonDetailsUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(query: GetSeasonDetailsQuery): Promise>> { - const league = await this.leagueRepository.findById(query.leagueId); - if (!league) { + async execute( + input: GetSeasonDetailsInput, + ): Promise< + Result> + > { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League not found: ${input.leagueId}` }, + }); + } + + const season = await this.seasonRepository.findById(input.seasonId); + if (!season || season.leagueId !== league.id) { + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { + message: `Season ${input.seasonId} does not belong to league ${league.id}`, + }, + }); + } + + const result: GetSeasonDetailsResult = { + leagueId: league.id, + season, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof (error as Error).message === 'string' + ? (error as Error).message + : 'Failed to load season details'; + return Result.err({ - code: 'LEAGUE_NOT_FOUND', - details: { message: `League not found: ${query.leagueId}` }, + code: 'REPOSITORY_ERROR', + details: { message }, }); } - - const season = await this.seasonRepository.findById(query.seasonId); - if (!season || season.leagueId !== league.id) { - return Result.err({ - code: 'SEASON_NOT_FOUND', - details: { message: `Season ${query.seasonId} does not belong to league ${league.id}` }, - }); - } - - return Result.ok({ - seasonId: season.id, - leagueId: season.leagueId, - gameId: season.gameId, - name: season.name, - status: season.status, - ...(season.startDate !== undefined ? { startDate: season.startDate } : {}), - ...(season.endDate !== undefined ? { endDate: season.endDate } : {}), - ...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}), - ...(season.schedule - ? { - schedule: { - startDate: season.schedule.startDate, - plannedRounds: season.schedule.plannedRounds, - }, - } - : {}), - ...(season.scoringConfig - ? { - scoring: { - scoringPresetId: season.scoringConfig.scoringPresetId, - customScoringEnabled: - season.scoringConfig.customScoringEnabled ?? false, - }, - } - : {}), - ...(season.dropPolicy - ? { - dropPolicy: { - strategy: season.dropPolicy.strategy, - ...(season.dropPolicy.n !== undefined - ? { n: season.dropPolicy.n } - : {}), - }, - } - : {}), - ...(season.stewardingConfig - ? { - stewarding: { - decisionMode: season.stewardingConfig.decisionMode, - ...(season.stewardingConfig.requiredVotes !== undefined - ? { requiredVotes: season.stewardingConfig.requiredVotes } - : {}), - requireDefense: season.stewardingConfig.requireDefense, - defenseTimeLimit: season.stewardingConfig.defenseTimeLimit, - voteTimeLimit: season.stewardingConfig.voteTimeLimit, - protestDeadlineHours: - season.stewardingConfig.protestDeadlineHours, - stewardingClosesHours: - season.stewardingConfig.stewardingClosesHours, - notifyAccusedOnProtest: - season.stewardingConfig.notifyAccusedOnProtest, - notifyOnVoteRequired: - season.stewardingConfig.notifyOnVoteRequired, - }, - } - : {}), - }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.ts b/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.ts index 4ba5b8018..4b7f5ef5d 100644 --- a/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.ts +++ b/core/racing/application/use-cases/GetSeasonSponsorshipsUseCase.ts @@ -3,45 +3,91 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { SponsorshipDetailOutput } from '../ports/output/SponsorSponsorshipsOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -export interface GetSeasonSponsorshipsParams { +export type GetSeasonSponsorshipsInput = { seasonId: string; -} +}; -export interface GetSeasonSponsorshipsOutputPort { +export type SeasonSponsorshipMetrics = { + drivers: number; + races: number; + completedRaces: number; + impressions: number; +}; + +export type SeasonSponsorshipFinancials = { + amount: number; + currency: string; +}; + +import type { LeagueId } from '../../domain/entities/LeagueId'; +import type { LeagueName } from '../../domain/entities/LeagueName'; + +export type SeasonSponsorshipDetail = { + id: string; + leagueId: LeagueId; + leagueName: LeagueName; seasonId: string; - sponsorships: SponsorshipDetailOutput[]; -} + seasonName: string; + seasonStartDate?: Date; + seasonEndDate?: Date; + tier: string; + status: string; + pricing: SeasonSponsorshipFinancials; + platformFee: SeasonSponsorshipFinancials; + netAmount: SeasonSponsorshipFinancials; + metrics: SeasonSponsorshipMetrics; + createdAt: Date; + activatedAt?: Date; +}; -export class GetSeasonSponsorshipsUseCase - implements AsyncUseCase -{ +export type GetSeasonSponsorshipsResult = { + seasonId: string; + sponsorships: SeasonSponsorshipDetail[]; +}; + +export type GetSeasonSponsorshipsErrorCode = + | 'SEASON_NOT_FOUND' + | 'LEAGUE_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export class GetSeasonSponsorshipsUseCase { constructor( private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: GetSeasonSponsorshipsParams, - ): Promise>> { + input: GetSeasonSponsorshipsInput, + ): Promise>> { try { - const { seasonId } = params; + const { seasonId } = input; const season = await this.seasonRepository.findById(seasonId); if (!season) { - return Result.ok(null); + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { + message: 'Season not found', + }, + }); } const league = await this.leagueRepository.findById(season.leagueId); if (!league) { - return Result.ok(null); + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { + message: 'League not found for season', + }, + }); } const sponsorships = await this.seasonSponsorshipRepository.findBySeasonId(seasonId); @@ -55,7 +101,7 @@ export class GetSeasonSponsorshipsUseCase const completedRaces = races.filter(r => r.status === 'completed').length; const impressions = completedRaces * driverCount * 100; - const sponsorshipDetails: SponsorshipDetailOutput[] = sponsorships.map(sponsorship => { + const sponsorshipDetails: SeasonSponsorshipDetail[] = sponsorships.map(sponsorship => { const platformFee = sponsorship.getPlatformFee(); const netAmount = sponsorship.getNetAmount(); @@ -65,8 +111,8 @@ export class GetSeasonSponsorshipsUseCase leagueName: league.name, seasonId: season.id, seasonName: season.name, - ...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}), - ...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}), + seasonStartDate: season.startDate, + seasonEndDate: season.endDate, tier: sponsorship.tier, status: sponsorship.status, pricing: { @@ -88,16 +134,25 @@ export class GetSeasonSponsorshipsUseCase impressions, }, createdAt: sponsorship.createdAt, - ...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}), + activatedAt: sponsorship.activatedAt, }; }); - return Result.ok({ + this.output.present({ seasonId, sponsorships: sponsorshipDetails, }); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch season sponsorships' }); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to fetch season sponsorships', + }, + }); } } } diff --git a/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts b/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts index 8377f440c..3e89b9925 100644 --- a/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts +++ b/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetSponsorDashboardUseCase } from './GetSponsorDashboardUseCase'; +import { + GetSponsorDashboardUseCase, + type GetSponsorDashboardInput, + type GetSponsorDashboardResult, + type GetSponsorDashboardErrorCode, +} from './GetSponsorDashboardUseCase'; import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; @@ -11,6 +16,8 @@ import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; import { Season } from '../../domain/entities/Season'; import { League } from '../../domain/entities/League'; import { Money } from '../../domain/value-objects/Money'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetSponsorDashboardUseCase', () => { let useCase: GetSponsorDashboardUseCase; @@ -32,6 +39,7 @@ describe('GetSponsorDashboardUseCase', () => { let raceRepository: { findByLeagueId: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { sponsorRepository = { @@ -52,6 +60,10 @@ describe('GetSponsorDashboardUseCase', () => { raceRepository = { findByLeagueId: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetSponsorDashboardUseCase( sponsorRepository as unknown as ISponsorRepository, seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, @@ -59,10 +71,11 @@ describe('GetSponsorDashboardUseCase', () => { leagueRepository as unknown as ILeagueRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, raceRepository as unknown as IRaceRepository, + output, ); }); - it('should return sponsor dashboard for existing sponsor', async () => { + it('should present sponsor dashboard for existing sponsor', async () => { const sponsorId = 'sponsor-1'; const sponsor = Sponsor.create({ id: sponsorId, @@ -99,34 +112,56 @@ describe('GetSponsorDashboardUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); raceRepository.findByLeagueId.mockResolvedValue(races); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorDashboardInput = { sponsorId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dashboard = result.unwrap(); - expect(dashboard?.sponsorId).toBe(sponsorId); - expect(dashboard?.metrics.impressions).toBe(100); // 1 completed race * 1 driver * 100 + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const dashboard = (output.present as Mock).mock.calls[0][0] as GetSponsorDashboardResult; + + expect(dashboard.sponsorId).toBe(sponsorId); + expect(dashboard.metrics.impressions).toBe(100); // 1 completed race * 1 driver * 100 + expect(dashboard.investment.totalInvestment.amount).toBe(10000); + expect(dashboard.investment.totalInvestment.currency).toBe('USD'); }); - it('should return null for non-existing sponsor', async () => { + it('should return error when sponsor does not exist', async () => { const sponsorId = 'sponsor-1'; sponsorRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorDashboardInput = { sponsorId }; + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.unwrap()).toBe(null); + expect(result.isErr()).toBe(true); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSponsorDashboardErrorCode, + { message: string } + >; + + expect(error.code).toBe('SPONSOR_NOT_FOUND'); + expect(error.details.message).toBe('Sponsor not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { const sponsorId = 'sponsor-1'; sponsorRepository.findById.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorDashboardInput = { sponsorId }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch sponsor dashboard', - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetSponsorDashboardErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts b/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts index bcdde904c..fc8f79fdf 100644 --- a/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts @@ -10,45 +10,58 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { SponsorDashboardOutputPort } from '../ports/output/SponsorDashboardOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import { Money } from '../../domain/value-objects/Money'; -export interface GetSponsorDashboardQueryParams { +export interface GetSponsorDashboardInput { sponsorId: string; } -export interface SponsoredLeagueDTO { - id: string; - name: string; - tier: 'main' | 'secondary'; +export interface SponsoredLeagueMetrics { drivers: number; races: number; impressions: number; - status: 'active' | 'upcoming' | 'completed'; } -export interface SponsorDashboardDTO { +export type SponsoredLeagueStatus = 'active' | 'upcoming' | 'completed'; + +export interface SponsoredLeagueSummary { + leagueId: string; + leagueName: string; + tier: 'main' | 'secondary'; + metrics: SponsoredLeagueMetrics; + status: SponsoredLeagueStatus; +} + +export interface SponsorDashboardMetrics { + impressions: number; + impressionsChange: number; + uniqueViewers: number; + viewersChange: number; + races: number; + drivers: number; + exposure: number; + exposureChange: number; +} + +export interface SponsorInvestmentSummary { + activeSponsorships: number; + totalInvestment: Money; + costPerThousandViews: number; +} + +export interface GetSponsorDashboardResult { sponsorId: string; sponsorName: string; - metrics: { - impressions: number; - impressionsChange: number; - uniqueViewers: number; - viewersChange: number; - races: number; - drivers: number; - exposure: number; - exposureChange: number; - }; - sponsoredLeagues: SponsoredLeagueDTO[]; - investment: { - activeSponsorships: number; - totalInvestment: number; - costPerThousandViews: number; - }; + metrics: SponsorDashboardMetrics; + sponsoredLeagues: SponsoredLeagueSummary[]; + investment: SponsorInvestmentSummary; } +export type GetSponsorDashboardErrorCode = 'SPONSOR_NOT_FOUND' | 'REPOSITORY_ERROR'; + export class GetSponsorDashboardUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, @@ -57,17 +70,23 @@ export class GetSponsorDashboardUseCase { private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: GetSponsorDashboardQueryParams, - ): Promise>> { + params: GetSponsorDashboardInput, + ): Promise>> { try { const { sponsorId } = params; const sponsor = await this.sponsorRepository.findById(sponsorId); if (!sponsor) { - return Result.ok(null); + return Result.err({ + code: 'SPONSOR_NOT_FOUND', + details: { + message: 'Sponsor not found', + }, + }); } // Get all sponsorships for this sponsor @@ -77,8 +96,8 @@ export class GetSponsorDashboardUseCase { let totalImpressions = 0; let totalDrivers = 0; let totalRaces = 0; - let totalInvestment = 0; - const sponsoredLeagues: SponsoredLeagueDTO[] = []; + let totalInvestmentMoney = Money.create(0, 'USD'); + const sponsoredLeagues: SponsoredLeagueSummary[] = []; const seenLeagues = new Set(); for (const sponsorship of sponsorships) { @@ -104,14 +123,13 @@ export class GetSponsorDashboardUseCase { totalRaces += raceCount; // Calculate impressions based on completed races and drivers - // This is a simplified calculation - in production would come from analytics const completedRaces = races.filter(r => r.status === 'completed').length; const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race totalImpressions += leagueImpressions; // Determine status based on season dates const now = new Date(); - let status: 'active' | 'upcoming' | 'completed' = 'active'; + let status: SponsoredLeagueStatus = 'active'; if (season.endDate && season.endDate < now) { status = 'completed'; } else if (season.startDate && season.startDate > now) { @@ -119,22 +137,26 @@ export class GetSponsorDashboardUseCase { } // Add investment - totalInvestment += sponsorship.pricing.amount; + totalInvestmentMoney = totalInvestmentMoney.add( + Money.create(sponsorship.pricing.amount, sponsorship.pricing.currency), + ); sponsoredLeagues.push({ - id: league.id, - name: league.name, + leagueId: league.id, + leagueName: league.name, tier: sponsorship.tier, - drivers: driverCount, - races: raceCount, - impressions: leagueImpressions, + metrics: { + drivers: driverCount, + races: raceCount, + impressions: leagueImpressions, + }, status, }); } const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; const costPerThousandViews = totalImpressions > 0 - ? (totalInvestment / (totalImpressions / 1000)) + ? totalInvestmentMoney.amount / (totalImpressions / 1000) : 0; // Calculate unique viewers (simplified: assume 70% of impressions are unique) @@ -146,7 +168,7 @@ export class GetSponsorDashboardUseCase { ? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10)) : 0; - const outputPort: SponsorDashboardOutputPort = { + const result: GetSponsorDashboardResult = { sponsorId, sponsorName: sponsor.name, metrics: { @@ -162,14 +184,23 @@ export class GetSponsorDashboardUseCase { sponsoredLeagues, investment: { activeSponsorships, - totalInvestment, + totalInvestment: totalInvestmentMoney, costPerThousandViews: Math.round(costPerThousandViews * 100) / 100, }, }; - return Result.ok(outputPort); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor dashboard' }); + this.output.present(result); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to fetch sponsor dashboard', + }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts index ef270ebee..ea014f8a0 100644 --- a/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts +++ b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts @@ -1,16 +1,23 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetSponsorSponsorshipsUseCase } from './GetSponsorSponsorshipsUseCase'; +import { + GetSponsorSponsorshipsUseCase, + type GetSponsorSponsorshipsInput, + type GetSponsorSponsorshipsResult, + type GetSponsorSponsorshipsErrorCode, +} from './GetSponsorSponsorshipsUseCase'; import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import { Sponsor } from '../../domain/entities/Sponsor'; -import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; -import { Season } from '../../domain/entities/Season'; +import { Sponsor } from '../../domain/entities/sponsor/Sponsor'; +import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship'; +import { Season } from '../../domain/entities/season/Season'; import { League } from '../../domain/entities/League'; import { Money } from '../../domain/value-objects/Money'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetSponsorSponsorshipsUseCase', () => { let useCase: GetSponsorSponsorshipsUseCase; @@ -32,6 +39,7 @@ describe('GetSponsorSponsorshipsUseCase', () => { let raceRepository: { findByLeagueId: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { sponsorRepository = { @@ -52,6 +60,10 @@ describe('GetSponsorSponsorshipsUseCase', () => { raceRepository = { findByLeagueId: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetSponsorSponsorshipsUseCase( sponsorRepository as unknown as ISponsorRepository, seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, @@ -59,10 +71,11 @@ describe('GetSponsorSponsorshipsUseCase', () => { leagueRepository as unknown as ILeagueRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, raceRepository as unknown as IRaceRepository, + output, ); }); - it('should return sponsor sponsorships for existing sponsor', async () => { + it('should present sponsor sponsorships for existing sponsor', async () => { const sponsorId = 'sponsor-1'; const sponsor = Sponsor.create({ id: sponsorId, @@ -99,34 +112,55 @@ describe('GetSponsorSponsorshipsUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); raceRepository.findByLeagueId.mockResolvedValue(races); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorSponsorshipsInput = { sponsorId }; + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data?.sponsorId).toBe(sponsorId); - expect(data?.sponsorships).toHaveLength(1); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0][0] as GetSponsorSponsorshipsResult; + + expect(presented.sponsor).toBe(sponsor); + expect(presented.sponsorships).toHaveLength(1); + const summary = presented.summary; + expect(summary.totalSponsorships).toBe(1); + expect(summary.activeSponsorships).toBe(0); // status default may not be 'active' + expect(summary.totalInvestment.amount).toBe(10000); + expect(summary.totalInvestment.currency).toBe('USD'); }); - it('should return null for non-existing sponsor', async () => { + it('should return SPONSOR_NOT_FOUND error for non-existing sponsor', async () => { const sponsorId = 'sponsor-1'; sponsorRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorSponsorshipsInput = { sponsorId }; + const result = await useCase.execute(input); - expect(result.isOk()).toBe(true); - expect(result.unwrap()).toBe(null); + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + GetSponsorSponsorshipsErrorCode, + { message: string } + >; + expect(error.code).toBe('SPONSOR_NOT_FOUND'); + expect(error.details.message).toBe('Sponsor not found'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return error on repository failure', async () => { + it('should return REPOSITORY_ERROR on repository failure', async () => { const sponsorId = 'sponsor-1'; sponsorRepository.findById.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ sponsorId }); + const input: GetSponsorSponsorshipsInput = { sponsorId }; + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch sponsor sponsorships', - }); + const error = result.unwrapErr() as ApplicationErrorCode< + GetSponsorSponsorshipsErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts index 06dcb5407..36a983965 100644 --- a/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts @@ -4,65 +4,60 @@ * Returns detailed sponsorship information for a sponsor's campaigns/sponsorships page. */ -import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; -import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; -import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship'; -import type { SponsorSponsorshipsOutputPort } from '../ports/output/SponsorSponsorshipsOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import type { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship'; +import type { League } from '../../domain/entities/League'; +import type { Season } from '../../domain/entities/season/Season'; +import type { Sponsor } from '../../domain/entities/sponsor/Sponsor'; +import { Money } from '../../domain/value-objects/Money'; -export interface GetSponsorSponsorshipsQueryParams { +export type GetSponsorSponsorshipsInput = { sponsorId: string; -} +}; -export interface SponsorshipDetailDTO { - id: string; - leagueId: string; - leagueName: string; - seasonId: string; - seasonName: string; - seasonStartDate?: Date; - seasonEndDate?: Date; - tier: SponsorshipTier; - status: SponsorshipStatus; - pricing: { - amount: number; - currency: string; - }; - platformFee: { - amount: number; - currency: string; - }; - netAmount: { - amount: number; - currency: string; - }; - metrics: { - drivers: number; - races: number; - completedRaces: number; - impressions: number; - }; - createdAt: Date; - activatedAt?: Date; -} +export type SponsorshipMetrics = { + drivers: number; + races: number; + completedRaces: number; + impressions: number; +}; -export interface SponsorSponsorshipsDTO { - sponsorId: string; - sponsorName: string; - sponsorships: SponsorshipDetailDTO[]; - summary: { - totalSponsorships: number; - activeSponsorships: number; - totalInvestment: number; - totalPlatformFees: number; - currency: string; - }; -} +export type SponsorshipFinancials = { + pricing: Money; + platformFee: Money; + netAmount: Money; +}; + +export type SponsorSponsorshipSummary = { + sponsorship: SeasonSponsorship; + league: League; + season: Season; + metrics: SponsorshipMetrics; + financials: SponsorshipFinancials; +}; + +export type GetSponsorSponsorshipsResultSummary = { + totalSponsorships: number; + activeSponsorships: number; + totalInvestment: Money; + totalPlatformFees: Money; +}; + +export type GetSponsorSponsorshipsResult = { + sponsor: Sponsor; + sponsorships: SponsorSponsorshipSummary[]; + summary: GetSponsorSponsorshipsResultSummary; +}; + +export type GetSponsorSponsorshipsErrorCode = 'SPONSOR_NOT_FOUND' | 'REPOSITORY_ERROR'; export class GetSponsorSponsorshipsUseCase { constructor( @@ -72,103 +67,96 @@ export class GetSponsorSponsorshipsUseCase { private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: GetSponsorSponsorshipsQueryParams, - ): Promise>> { + params: GetSponsorSponsorshipsInput, + ): Promise>> { try { const { sponsorId } = params; const sponsor = await this.sponsorRepository.findById(sponsorId); if (!sponsor) { - return Result.ok(null); + return Result.err({ + code: 'SPONSOR_NOT_FOUND', + details: { + message: 'Sponsor not found', + }, + }); } - // Get all sponsorships for this sponsor const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId); - const sponsorshipDetails: SponsorshipDetailDTO[] = []; - let totalInvestment = 0; - let totalPlatformFees = 0; + const sponsorshipSummaries: SponsorSponsorshipSummary[] = []; + let totalInvestment = Money.create(0, 'USD'); + let totalPlatformFees = Money.create(0, 'USD'); for (const sponsorship of sponsorships) { - // Get season to find league const season = await this.seasonRepository.findById(sponsorship.seasonId); if (!season) continue; const league = await this.leagueRepository.findById(season.leagueId); if (!league) continue; - // Get membership count for this league const memberships = await this.leagueMembershipRepository.getLeagueMembers(season.leagueId); const driverCount = memberships.length; - // Get races for this league const races = await this.raceRepository.findByLeagueId(season.leagueId); const completedRaces = races.filter(r => r.status === 'completed').length; - // Calculate impressions const impressions = completedRaces * driverCount * 100; - // Calculate platform fee (10%) - const platformFee = sponsorship.getPlatformFee(); - const netAmount = sponsorship.getNetAmount(); + const platformFeeMoney = sponsorship.getPlatformFee(); + const netAmountMoney = sponsorship.getNetAmount(); + const pricingMoney = Money.create(sponsorship.pricing.amount, sponsorship.pricing.currency); - totalInvestment += sponsorship.pricing.amount; - totalPlatformFees += platformFee.amount; + totalInvestment = totalInvestment.add(pricingMoney); + totalPlatformFees = totalPlatformFees.add(platformFeeMoney); - sponsorshipDetails.push({ - id: sponsorship.id, - leagueId: league.id, - leagueName: league.name, - seasonId: season.id, - seasonName: season.name, - ...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}), - ...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}), - tier: sponsorship.tier, - status: sponsorship.status, - pricing: { - amount: sponsorship.pricing.amount, - currency: sponsorship.pricing.currency, - }, - platformFee: { - amount: platformFee.amount, - currency: platformFee.currency, - }, - netAmount: { - amount: netAmount.amount, - currency: netAmount.currency, - }, + sponsorshipSummaries.push({ + sponsorship, + league, + season, metrics: { drivers: driverCount, races: races.length, completedRaces, impressions, }, - createdAt: sponsorship.createdAt, - ...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}), + financials: { + pricing: pricingMoney, + platformFee: platformFeeMoney, + netAmount: netAmountMoney, + }, }); } const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; - const outputPort: SponsorSponsorshipsOutputPort = { - sponsorId, - sponsorName: sponsor.name, - sponsorships: sponsorshipDetails, + const result: GetSponsorSponsorshipsResult = { + sponsor, + sponsorships: sponsorshipSummaries, summary: { totalSponsorships: sponsorships.length, activeSponsorships, totalInvestment, totalPlatformFees, - currency: 'USD', }, }; - return Result.ok(outputPort); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor sponsorships' }); + this.output.present(result); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to load sponsor sponsorships', + }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetSponsorUseCase.ts b/core/racing/application/use-cases/GetSponsorUseCase.ts index 5f2015aa9..901bd2531 100644 --- a/core/racing/application/use-cases/GetSponsorUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorUseCase.ts @@ -7,34 +7,57 @@ import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { Sponsor } from '../../domain/entities/sponsor/Sponsor'; -export interface GetSponsorQueryParams { +export type GetSponsorInput = { sponsorId: string; -} +}; + +export type GetSponsorResult = { + sponsor: Sponsor; +}; + +export type GetSponsorErrorCode = 'SPONSOR_NOT_FOUND' | 'REPOSITORY_ERROR'; export class GetSponsorUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: GetSponsorQueryParams): Promise>> { + async execute(input: GetSponsorInput): Promise>> { try { - const sponsor = await this.sponsorRepository.findById(params.sponsorId); + const sponsor = await this.sponsorRepository.findById(input.sponsorId); if (!sponsor) { - return Result.ok(null); + return Result.err({ + code: 'SPONSOR_NOT_FOUND', + details: { + message: 'Sponsor not found', + }, + }); } - const sponsorData = { - id: sponsor.id, - name: sponsor.name, - logoUrl: sponsor.logoUrl, - websiteUrl: sponsor.websiteUrl, + const result: GetSponsorResult = { + sponsor, }; - return Result.ok({ sponsor: sponsorData }); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor' }); + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && typeof error.message === 'string' + ? error.message + : 'Failed to load sponsor'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message, + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorsUseCase.test.ts b/core/racing/application/use-cases/GetSponsorsUseCase.test.ts index 479175de1..36d14bea8 100644 --- a/core/racing/application/use-cases/GetSponsorsUseCase.test.ts +++ b/core/racing/application/use-cases/GetSponsorsUseCase.test.ts @@ -1,20 +1,28 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { GetSponsorsUseCase } from './GetSponsorsUseCase'; import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import { Sponsor } from '../../domain/entities/Sponsor'; +import { Sponsor } from '../../domain/entities/sponsor/Sponsor'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; describe('GetSponsorsUseCase', () => { let useCase: GetSponsorsUseCase; let sponsorRepository: { findAll: Mock; }; + let output: { + present: Mock; + }; beforeEach(() => { sponsorRepository = { findAll: vi.fn(), }; + output = { + present: vi.fn(), + }; useCase = new GetSponsorsUseCase( sponsorRepository as unknown as ISponsorRepository, + output as unknown as UseCaseOutputPort, ); }); @@ -38,26 +46,8 @@ describe('GetSponsorsUseCase', () => { const result = await useCase.execute(); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - sponsors: [ - { - id: 'sponsor-1', - name: 'Sponsor One', - contactEmail: 'one@example.com', - websiteUrl: undefined, - logoUrl: 'logo1.png', - createdAt: expect.any(Date), - }, - { - id: 'sponsor-2', - name: 'Sponsor Two', - contactEmail: 'two@example.com', - websiteUrl: undefined, - logoUrl: undefined, - createdAt: expect.any(Date), - }, - ], - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledWith({ sponsors }); }); it('should return error on repository failure', async () => { @@ -70,5 +60,6 @@ describe('GetSponsorsUseCase', () => { code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsors', }); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorsUseCase.ts b/core/racing/application/use-cases/GetSponsorsUseCase.ts index e5ffc9080..898600b32 100644 --- a/core/racing/application/use-cases/GetSponsorsUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorsUseCase.ts @@ -5,31 +5,28 @@ */ import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { GetSponsorsOutputPort } from '../ports/output/GetSponsorsOutputPort'; +import type { Sponsor } from '../../domain/entities/sponsor/Sponsor'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +type GetSponsorsResult = { + sponsors: Sponsor[]; +}; + export class GetSponsorsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute(): Promise>> { try { const sponsors = await this.sponsorRepository.findAll(); - const outputPort: GetSponsorsOutputPort = { - sponsors: sponsors.map(sponsor => ({ - id: sponsor.id, - name: sponsor.name, - contactEmail: sponsor.contactEmail, - websiteUrl: sponsor.websiteUrl, - logoUrl: sponsor.logoUrl, - createdAt: sponsor.createdAt, - })), - }; + this.output.present({ sponsors }); - return Result.ok(outputPort); + return Result.ok(undefined); } catch { return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsors' }); } diff --git a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts index 172394353..8c1365732 100644 --- a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts +++ b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts @@ -1,18 +1,35 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { GetSponsorshipPricingUseCase } from './GetSponsorshipPricingUseCase'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + GetSponsorshipPricingUseCase, + GetSponsorshipPricingResult, + GetSponsorshipPricingInput, + GetSponsorshipPricingErrorCode, +} from './GetSponsorshipPricingUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetSponsorshipPricingUseCase', () => { let useCase: GetSponsorshipPricingUseCase; + let output: UseCaseOutputPort & { present: ReturnType }; beforeEach(() => { - useCase = new GetSponsorshipPricingUseCase(); + output = { present: vi.fn() } as unknown as UseCaseOutputPort< + GetSponsorshipPricingResult + > & { present: ReturnType }; + useCase = new GetSponsorshipPricingUseCase(output); }); - it('should return sponsorship pricing tiers', async () => { - const result = await useCase.execute(); + it('should present sponsorship pricing tiers', async () => { + const input: GetSponsorshipPricingInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + entityType: 'season', + entityId: '', pricing: [ { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, @@ -20,4 +37,25 @@ describe('GetSponsorshipPricingUseCase', () => { ], }); }); + + it('should return repository error when execution fails', async () => { + const error = new Error('Something went wrong'); + const failingUseCase = new GetSponsorshipPricingUseCase({ + present: () => { + throw error; + }, + } as unknown as UseCaseOutputPort); + + const input: GetSponsorshipPricingInput = {}; + + const result = await failingUseCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + GetSponsorshipPricingErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Something went wrong'); + }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts index 54ca55d8d..05d781180 100644 --- a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts @@ -4,24 +4,57 @@ * Retrieves general sponsorship pricing tiers. */ -import type { GetSponsorshipPricingOutputPort } from '../ports/output/GetSponsorshipPricingOutputPort'; import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +export type GetSponsorshipPricingInput = Record; + +export type GetSponsorshipPricingResult = { + entityType: 'season'; + entityId: string; + pricing: { + id: string; + level: string; + price: number; + currency: string; + }[]; +}; + +export type GetSponsorshipPricingErrorCode = 'REPOSITORY_ERROR'; + export class GetSponsorshipPricingUseCase { - constructor() {} + constructor(private readonly output: UseCaseOutputPort) {} - async execute(): Promise>> { - const outputPort: GetSponsorshipPricingOutputPort = { - entityType: 'season', - entityId: '', - pricing: [ - { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, - { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, - { id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' }, - ], - }; + async execute( + _input: GetSponsorshipPricingInput, + ): Promise< + Result> + > { + try { + const result: GetSponsorshipPricingResult = { + entityType: 'season', + entityId: '', + pricing: [ + { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, + { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, + { id: 'tier-gold', level: 'Gold', price: 500, currency: 'USD' }, + ], + }; - return Result.ok(outputPort); + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to load sponsorship pricing', + }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts b/core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts index 5b3755847..4d1e0686d 100644 --- a/core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts +++ b/core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTeamDetailsUseCase } from './GetTeamDetailsUseCase'; +import { + GetTeamDetailsUseCase, + type GetTeamDetailsInput, + type GetTeamDetailsResult, + type GetTeamDetailsErrorCode, +} from './GetTeamDetailsUseCase'; import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { Team } from '../../domain/entities/Team'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTeamDetailsUseCase', () => { let useCase: GetTeamDetailsUseCase; @@ -12,6 +20,7 @@ describe('GetTeamDetailsUseCase', () => { let membershipRepository: { getMembership: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { teamRepository = { @@ -20,9 +29,13 @@ describe('GetTeamDetailsUseCase', () => { membershipRepository = { getMembership: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new GetTeamDetailsUseCase( teamRepository as unknown as ITeamRepository, membershipRepository as unknown as ITeamMembershipRepository, + output, ); }); @@ -37,23 +50,28 @@ describe('GetTeamDetailsUseCase', () => { ownerId: 'owner-1', leagues: [], }); - const membership = { + const membership: TeamMembership = { + teamId, driverId, - role: 'member' as const, - status: 'active' as const, + role: 'driver', + status: 'active', joinedAt: new Date(), }; teamRepository.findById.mockResolvedValue(team); membershipRepository.getMembership.mockResolvedValue(membership); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamDetailsInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.team.id).toBe(teamId); - expect(viewModel.membership?.role).toBe('member'); - expect(viewModel.canManage).toBe(false); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetTeamDetailsResult; + expect(presented.team).toBe(team); + expect(presented.membership).toEqual(membership); + expect(presented.canManage).toBe(false); }); it('should return team details for owner', async () => { @@ -67,21 +85,26 @@ describe('GetTeamDetailsUseCase', () => { ownerId: driverId, leagues: [], }); - const membership = { + const membership: TeamMembership = { + teamId, driverId, - role: 'owner' as const, - status: 'active' as const, + role: 'owner', + status: 'active', joinedAt: new Date(), }; teamRepository.findById.mockResolvedValue(team); membershipRepository.getMembership.mockResolvedValue(membership); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamDetailsInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const viewModel = result.unwrap(); - expect(viewModel.canManage).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetTeamDetailsResult; + expect(presented.canManage).toBe(true); }); it('should return error for non-existing team', async () => { @@ -89,13 +112,18 @@ describe('GetTeamDetailsUseCase', () => { const driverId = 'driver-1'; teamRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamDetailsInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'TEAM_NOT_FOUND', - message: 'Team not found', - }); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetTeamDetailsErrorCode, + { message: string } + >; + expect(errorResult.code).toBe('TEAM_NOT_FOUND'); + expect(errorResult.details?.message).toBe('Team not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { @@ -103,12 +131,17 @@ describe('GetTeamDetailsUseCase', () => { const driverId = 'driver-1'; teamRepository.findById.mockRejectedValue(new Error('DB error')); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamDetailsInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - message: 'Failed to fetch team details', - }); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetTeamDetailsErrorCode, + { message: string } + >; + expect(errorResult.code).toBe('REPOSITORY_ERROR'); + expect(errorResult.details?.message).toBe('Failed to load team details'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamDetailsUseCase.ts b/core/racing/application/use-cases/GetTeamDetailsUseCase.ts index bb87567fc..f8725c122 100644 --- a/core/racing/application/use-cases/GetTeamDetailsUseCase.ts +++ b/core/racing/application/use-cases/GetTeamDetailsUseCase.ts @@ -1,8 +1,23 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { GetTeamDetailsOutputPort } from '../ports/output/GetTeamDetailsOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Team } from '../../domain/entities/Team'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; + +export type GetTeamDetailsInput = { + teamId: string; + driverId: string; +}; + +export type GetTeamDetailsResult = { + team: Team; + membership: TeamMembership | null; + canManage: boolean; +}; + +export type GetTeamDetailsErrorCode = 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; /** * Use Case for retrieving team details. @@ -11,41 +26,42 @@ export class GetTeamDetailsUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: { teamId: string; driverId: string }, - ): Promise>> { + input: GetTeamDetailsInput, + ): Promise>> { try { - const { teamId, driverId } = params; + const { teamId, driverId } = input; const team = await this.teamRepository.findById(teamId); if (!team) { - return Result.err({ code: 'TEAM_NOT_FOUND', message: 'Team not found' }); + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { + message: 'Team not found', + }, + }); } const membership = await this.membershipRepository.getMembership(teamId, driverId); - const outputPort: GetTeamDetailsOutputPort = { - team: { - id: team.id, - name: team.name.value, - tag: team.tag?.value ?? '', - description: team.description?.value ?? '', - ownerId: team.ownerId, - leagues: team.leagues.map(l => l), // assuming leagues are strings - createdAt: team.createdAt instanceof Date ? team.createdAt : new Date(team.createdAt.value), - }, - membership: membership ? { - role: membership.role as 'owner' | 'manager' | 'member', - joinedAt: membership.joinedAt instanceof Date ? membership.joinedAt : new Date(membership.joinedAt), - isActive: membership.status === 'active', - } : null, + this.output.present({ + team, + membership, canManage: membership ? membership.role === 'owner' || membership.role === 'manager' : false, - }; + }); - return Result.ok(outputPort); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch team details' }); + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to load team details', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts index e4103b686..a36878b6f 100644 --- a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts @@ -1,9 +1,17 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTeamJoinRequestsUseCase } from './GetTeamJoinRequestsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetTeamJoinRequestsUseCase, + type GetTeamJoinRequestsInput, + type GetTeamJoinRequestsResult, + type GetTeamJoinRequestsErrorCode, +} from './GetTeamJoinRequestsUseCase'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { Driver } from '../../domain/entities/Driver'; -import type { Logger } from '@core/shared/application'; +import { Team } from '../../domain/entities/Team'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTeamJoinRequestsUseCase', () => { let useCase: GetTeamJoinRequestsUseCase; @@ -13,13 +21,10 @@ describe('GetTeamJoinRequestsUseCase', () => { let driverRepository: { findById: Mock; }; - let getDriverAvatar: Mock; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; + let teamRepository: { + findById: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { membershipRepository = { @@ -28,102 +33,106 @@ describe('GetTeamJoinRequestsUseCase', () => { driverRepository = { findById: vi.fn(), }; - getDriverAvatar = vi.fn(); - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), + teamRepository = { + findById: vi.fn(), }; + output = { + present: vi.fn(), + }; + useCase = new GetTeamJoinRequestsUseCase( membershipRepository as unknown as ITeamMembershipRepository, driverRepository as unknown as IDriverRepository, - getDriverAvatar, - logger as unknown as Logger, + teamRepository as unknown as ITeamRepository, + output, ); }); - it('should return join requests with driver names and avatar urls', async () => { + it('should return join requests with drivers when team exists', async () => { const teamId = 'team-1'; + const input: GetTeamJoinRequestsInput = { teamId }; + const joinRequests = [ { id: 'req-1', teamId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }, - { id: 'req-2', teamId, driverId: 'driver-2', requestedAt: new Date() }, ]; - const driver1 = Driver.create({ + + const driver = Driver.create({ id: 'driver-1', iracingId: '123', name: 'Driver 1', country: 'US', }); - const driver2 = Driver.create({ - id: 'driver-2', - iracingId: '456', - name: 'Driver 2', - country: 'UK', + + const team = Team.create({ + id: teamId, + name: 'Team 1', + tag: 'T1', + description: 'Description', + ownerId: 'owner-1', + leagues: [], }); + teamRepository.findById.mockResolvedValue(team); membershipRepository.getJoinRequests.mockResolvedValue(joinRequests); - driverRepository.findById.mockImplementation((id: string) => { - if (id === 'driver-1') return Promise.resolve(driver1); - if (id === 'driver-2') return Promise.resolve(driver2); - return Promise.resolve(null); - }); - 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' }); - }); + driverRepository.findById.mockResolvedValue(driver); - const result = await useCase.execute({ teamId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - requests: joinRequests, - driverNames: { - 'driver-1': 'Driver 1', - 'driver-2': 'Driver 2', - }, - avatarUrls: { - 'driver-1': 'avatar-driver-1', - 'driver-2': 'avatar-driver-2', - }, + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as GetTeamJoinRequestsResult; + + expect(presented.team).toBe(team); + expect(presented.joinRequests).toHaveLength(1); + expect(presented.joinRequests[0]).toMatchObject({ + id: 'req-1', + teamId, + driverId: 'driver-1', + message: 'msg', }); + expect(presented.joinRequests[0].driver).toBe(driver); }); - it('should handle driver not found', async () => { - const teamId = 'team-1'; - const joinRequests = [ - { id: 'req-1', teamId, driverId: 'driver-1', requestedAt: new Date() }, - ]; + it('should return TEAM_NOT_FOUND error when team does not exist', async () => { + const teamId = 'missing-team'; + const input: GetTeamJoinRequestsInput = { teamId }; - membershipRepository.getJoinRequests.mockResolvedValue(joinRequests); - driverRepository.findById.mockResolvedValue(null); - getDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver-1' }); + teamRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ teamId }); - - expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - requests: joinRequests, - driverNames: {}, - avatarUrls: { - 'driver-1': 'avatar-driver-1', - }, - }); - }); - - it('should return error on repository failure', async () => { - const teamId = 'team-1'; - const error = new Error('Repository error'); - - membershipRepository.getJoinRequests.mockRejectedValue(error); - - const result = await useCase.execute({ teamId }); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve team join requests' }, - }); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetTeamJoinRequestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('TEAM_NOT_FOUND'); + expect(err.details.message).toBe('Team not found'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const teamId = 'team-1'; + const input: GetTeamJoinRequestsInput = { teamId }; + const error = new Error('Repository failure'); + + teamRepository.findById.mockRejectedValue(error); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetTeamJoinRequestsErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts index 679898067..d1c2f19e3 100644 --- a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts @@ -1,67 +1,82 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort'; -import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort'; -import type { TeamJoinRequestsOutputPort } from '../ports/output/TeamJoinRequestsOutputPort'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { TeamJoinRequest } from '../../domain/types/TeamMembership'; +import type { Driver } from '../../domain/entities/Driver'; +import type { Team } from '../../domain/entities/Team'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; -/** - * Use Case for retrieving team join requests. - */ -export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string }, TeamJoinRequestsOutputPort, 'REPOSITORY_ERROR'> -{ +export type GetTeamJoinRequestsInput = { + teamId: string; +}; + +export type GetTeamJoinRequestsErrorCode = 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export type TeamJoinRequestWithDriver = TeamJoinRequest & { + driver: Driver; +}; + +export type GetTeamJoinRequestsResult = { + team: Team; + joinRequests: TeamJoinRequestWithDriver[]; +}; + +export class GetTeamJoinRequestsUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, - private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise, - private readonly logger: Logger, + private readonly teamRepository: ITeamRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: { teamId: string }): Promise>> { - this.logger.debug('Executing GetTeamJoinRequestsUseCase', { teamId: input.teamId }); - + async execute( + input: GetTeamJoinRequestsInput, + ): Promise>> { try { - const requests = await this.membershipRepository.getJoinRequests(input.teamId); - this.logger.info('Successfully retrieved team join requests', { teamId: input.teamId, count: requests.length }); + const team = await this.teamRepository.findById(input.teamId); - const driverNames: Record = {}; - const avatarUrls: Record = {}; - - for (const request of requests) { - const driver = await this.driverRepository.findById(request.driverId); - if (driver) { - driverNames[request.driverId] = driver.name.value; - } else { - this.logger.warn(`Driver not found for ID: ${request.driverId} during join request processing.`); - } - - 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 }); + if (!team) { + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { message: 'Team not found' }, + }); } - const requestsViewModel = requests.map(request => ({ - requestId: request.id, - driverId: request.driverId, - driverName: driverNames[request.driverId] || 'Unknown', - teamId: request.teamId, - status: request.status as 'pending' | 'approved' | 'rejected', - requestedAt: request.requestedAt instanceof Date ? request.requestedAt : new Date(request.requestedAt), - avatarUrl: avatarUrls[request.driverId] || '', - })); + const joinRequests = await this.membershipRepository.getJoinRequests(input.teamId); + const driverIds = [...new Set(joinRequests.map(request => request.driverId))]; + const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id))); - const outputPort: TeamJoinRequestsOutputPort = { - requests: requestsViewModel, - pendingCount: requests.filter(r => r.status === 'pending').length, - totalCount: requests.length, + const driverMap = new Map( + drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]), + ); + + const enrichedJoinRequests: TeamJoinRequestWithDriver[] = joinRequests + .filter(request => driverMap.has(request.driverId)) + .map(request => ({ + ...request, + driver: driverMap.get(request.driverId)!, + })); + + const result: GetTeamJoinRequestsResult = { + team, + joinRequests: enrichedJoinRequests, }; - return Result.ok(outputPort); - } catch (error) { - this.logger.error('Error retrieving team join requests', { teamId: input.teamId, err: error }); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve team join requests' } }); + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string' + ? (error as any).message + : 'Failed to load team join requests'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetTeamMembersUseCase.test.ts b/core/racing/application/use-cases/GetTeamMembersUseCase.test.ts index 882a08e0b..2df8b9b96 100644 --- a/core/racing/application/use-cases/GetTeamMembersUseCase.test.ts +++ b/core/racing/application/use-cases/GetTeamMembersUseCase.test.ts @@ -1,9 +1,18 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTeamMembersUseCase } from './GetTeamMembersUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetTeamMembersUseCase, + type GetTeamMembersInput, + type GetTeamMembersResult, + type GetTeamMembersErrorCode, +} from './GetTeamMembersUseCase'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { Driver } from '../../domain/entities/Driver'; +import { Team } from '../../domain/entities/Team'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTeamMembersUseCase', () => { let useCase: GetTeamMembersUseCase; @@ -13,13 +22,16 @@ describe('GetTeamMembersUseCase', () => { let driverRepository: { findById: Mock; }; - let getDriverAvatar: Mock; + let teamRepository: { + findById: Mock; + }; let logger: { debug: Mock; info: Mock; warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { membershipRepository = { @@ -28,33 +40,52 @@ describe('GetTeamMembersUseCase', () => { driverRepository = { findById: vi.fn(), }; - getDriverAvatar = vi.fn(); + teamRepository = { + findById: vi.fn(), + }; logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetTeamMembersUseCase( membershipRepository as unknown as ITeamMembershipRepository, driverRepository as unknown as IDriverRepository, - getDriverAvatar, + teamRepository as unknown as ITeamRepository, logger as unknown as Logger, + output, ); }); - it('should return team members with driver names and avatar urls', async () => { - const teamId = 'team-1'; + it('should return team members with driver entities', async () => { + const team = Team.create({ + id: 'team-1', + name: 'Team Name', + tag: 'TAG', + description: 'Description', + ownerId: 'driver-1', + leagues: [], + }); + + const input: GetTeamMembersInput = { teamId: team.id }; + const memberships = [ - { teamId, driverId: 'driver-1', role: 'owner' as const, status: 'active' as const, joinedAt: new Date() }, - { teamId, driverId: 'driver-2', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, + { teamId: team.id, driverId: 'driver-1', role: 'owner' as const, status: 'active' as const, joinedAt: new Date() }, + { teamId: team.id, driverId: 'driver-2', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, ]; + const driver1 = Driver.create({ id: 'driver-1', iracingId: '123', name: 'Driver 1', country: 'US', }); + const driver2 = Driver.create({ id: 'driver-2', iracingId: '456', @@ -62,68 +93,109 @@ describe('GetTeamMembersUseCase', () => { country: 'UK', }); + teamRepository.findById.mockResolvedValue(team); membershipRepository.getTeamMembers.mockResolvedValue(memberships); driverRepository.findById.mockImplementation((id: string) => { if (id === 'driver-1') return Promise.resolve(driver1); if (id === 'driver-2') return Promise.resolve(driver2); return Promise.resolve(null); }); - 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(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - memberships, - driverNames: { - 'driver-1': 'Driver 1', - 'driver-2': 'Driver 2', - }, - avatarUrls: { - 'driver-1': 'avatar-driver-1', - 'driver-2': 'avatar-driver-2', - }, - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]?.[0] as GetTeamMembersResult; + + expect(presented.team).toBe(team); + expect(presented.members).toHaveLength(2); + expect(presented.members[0]).toEqual({ membership: memberships[0], driver: driver1 }); + expect(presented.members[1]).toEqual({ membership: memberships[1], driver: driver2 }); }); it('should handle driver not found', async () => { - const teamId = 'team-1'; + const team = Team.create({ + id: 'team-1', + name: 'Team Name', + tag: 'TAG', + description: 'Description', + ownerId: 'driver-1', + leagues: [], + }); + + const input: GetTeamMembersInput = { teamId: team.id }; + const memberships = [ - { teamId, driverId: 'driver-1', role: 'owner' as const, status: 'active' as const, joinedAt: new Date() }, + { teamId: team.id, driverId: 'driver-1', role: 'owner' as const, status: 'active' as const, joinedAt: new Date() }, ]; + teamRepository.findById.mockResolvedValue(team); membershipRepository.getTeamMembers.mockResolvedValue(memberships); driverRepository.findById.mockResolvedValue(null); - getDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver-1' }); - const result = await useCase.execute({ teamId }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - memberships, - driverNames: {}, - avatarUrls: { - 'driver-1': 'avatar-driver-1', - }, - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]?.[0] as GetTeamMembersResult; + + expect(presented.team).toBe(team); + expect(presented.members).toEqual([ + { membership: memberships[0], driver: null }, + ]); }); - it('should return error on repository failure', async () => { - const teamId = 'team-1'; - const error = new Error('Repository error'); + it('should return TEAM_NOT_FOUND when team does not exist', async () => { + const input: GetTeamMembersInput = { teamId: 'missing-team' }; - membershipRepository.getTeamMembers.mockRejectedValue(error); + teamRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ teamId }); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve team members' }, - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetTeamMembersErrorCode, + { message: string } + >; + + expect(error.code).toBe('TEAM_NOT_FOUND'); + expect(error.details.message).toBe('Team not found'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('should return REPOSITORY_ERROR when repository throws', async () => { + const team = Team.create({ + id: 'team-1', + name: 'Team Name', + tag: 'TAG', + description: 'Description', + ownerId: 'driver-1', + leagues: [], + }); + + const input: GetTeamMembersInput = { teamId: team.id }; + + const error = new Error('Repository failure'); + + teamRepository.findById.mockResolvedValue(team); + membershipRepository.getTeamMembers.mockRejectedValue(error); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetTeamMembersErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/GetTeamMembersUseCase.ts b/core/racing/application/use-cases/GetTeamMembersUseCase.ts index 283f5bf25..5140e5946 100644 --- a/core/racing/application/use-cases/GetTeamMembersUseCase.ts +++ b/core/racing/application/use-cases/GetTeamMembersUseCase.ts @@ -1,68 +1,96 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort'; -import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort'; -import type { TeamMembersOutputPort } from '../ports/output/TeamMembersOutputPort'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; +import type { Team } from '../../domain/entities/Team'; +import type { Driver } from '../../domain/entities/Driver'; + +export type GetTeamMembersInput = { + teamId: string; +}; + +export type TeamMemberDetail = { + membership: TeamMembership; + driver: Driver | null; +}; + +export type GetTeamMembersResult = { + team: Team; + members: TeamMemberDetail[]; +}; + +export type GetTeamMembersErrorCode = 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; /** * Use Case for retrieving team members. */ -export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, TeamMembersOutputPort, 'REPOSITORY_ERROR'> -{ +export class GetTeamMembersUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, - private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise, + private readonly teamRepository: ITeamRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: { teamId: string }): Promise>> { + async execute( + input: GetTeamMembersInput, + ): Promise>> { this.logger.debug(`Executing GetTeamMembersUseCase for teamId: ${input.teamId}`); try { + const team = await this.teamRepository.findById(input.teamId); + + if (!team) { + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { message: 'Team not found' }, + }); + } + const memberships = await this.membershipRepository.getTeamMembers(input.teamId); this.logger.info(`Found ${memberships.length} memberships for teamId: ${input.teamId}`); - const driverNames: Record = {}; - const avatarUrls: Record = {}; + const members: TeamMemberDetail[] = []; for (const membership of memberships) { this.logger.debug(`Processing membership for driverId: ${membership.driverId}`); const driver = await this.driverRepository.findById(membership.driverId); - if (driver) { - driverNames[membership.driverId] = driver.name.value; - } else { - this.logger.warn(`Driver with ID ${membership.driverId} not found while fetching team members for team ${input.teamId}.`); + + if (!driver) { + this.logger.warn( + `Driver with ID ${membership.driverId} not found while fetching team members for team ${input.teamId}.`, + ); + members.push({ membership, driver: null }); + continue; } - - const avatarResult = await this.getDriverAvatar({ driverId: membership.driverId }); - avatarUrls[membership.driverId] = avatarResult.avatarUrl; + + members.push({ membership, driver }); } - const members = memberships.map(membership => ({ - driverId: membership.driverId, - driverName: driverNames[membership.driverId] || 'Unknown', - role: membership.role as 'owner' | 'manager' | 'member', - joinedAt: membership.joinedAt instanceof Date ? membership.joinedAt : new Date(membership.joinedAt), - isActive: membership.status === 'active', - avatarUrl: avatarUrls[membership.driverId] || '', - })); - - const outputPort: TeamMembersOutputPort = { + this.output.present({ + team, members, - totalCount: memberships.length, - ownerCount: memberships.filter(m => m.role === 'owner').length, - managerCount: memberships.filter(m => m.role === 'manager').length, - memberCount: memberships.filter(m => m.role === 'member').length, - }; + }); - return Result.ok(outputPort); - } catch (error) { - this.logger.error(`Error in GetTeamMembersUseCase for teamId: ${input.teamId}`, error as Error, { teamId: input.teamId }); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve team members' } }); + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + this.logger.error( + `Error in GetTeamMembersUseCase for teamId: ${input.teamId}`, + err as Error, + { teamId: input.teamId }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: error?.message ?? 'Failed to load team members' }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetTeamMembershipUseCase.test.ts b/core/racing/application/use-cases/GetTeamMembershipUseCase.test.ts index 5dca2f27a..7ac39309a 100644 --- a/core/racing/application/use-cases/GetTeamMembershipUseCase.test.ts +++ b/core/racing/application/use-cases/GetTeamMembershipUseCase.test.ts @@ -1,7 +1,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GetTeamMembershipUseCase } from './GetTeamMembershipUseCase'; +import { + GetTeamMembershipUseCase, + type GetTeamMembershipInput, + type GetTeamMembershipResult, + type GetTeamMembershipErrorCode, +} from './GetTeamMembershipUseCase'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTeamMembershipUseCase', () => { const mockGetMembership = vi.fn(); @@ -24,16 +31,20 @@ describe('GetTeamMembershipUseCase', () => { error: vi.fn(), }; + let output: UseCaseOutputPort & { present: ReturnType }; + let useCase: GetTeamMembershipUseCase; + beforeEach(() => { vi.clearAllMocks(); + + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: ReturnType; + }; + + useCase = new GetTeamMembershipUseCase(mockMembershipRepo, mockLogger, output); }); - it('should return membership data when membership exists', async () => { - const useCase = new GetTeamMembershipUseCase( - mockMembershipRepo, - mockLogger, - ); - + it('should present membership data when membership exists', async () => { const teamId = 'team1'; const driverId = 'driver1'; const membership = { @@ -46,39 +57,43 @@ describe('GetTeamMembershipUseCase', () => { mockGetMembership.mockResolvedValue(membership); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamMembershipInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toEqual({ + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetTeamMembershipResult; + + expect(presented.membership).toEqual({ role: 'manager', joinedAt: '2023-01-01T00:00:00.000Z', isActive: true, }); }); - it('should return null when no membership exists', async () => { - const useCase = new GetTeamMembershipUseCase( - mockMembershipRepo, - mockLogger, - ); - + it('should present null membership when no membership exists', async () => { const teamId = 'team1'; const driverId = 'driver1'; mockGetMembership.mockResolvedValue(null); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamMembershipInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value).toBe(null); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetTeamMembershipResult; + + expect(presented.membership).toBeNull(); }); it('should map driver role to member', async () => { - const useCase = new GetTeamMembershipUseCase( - mockMembershipRepo, - mockLogger, - ); - const teamId = 'team1'; const driverId = 'driver1'; const membership = { @@ -91,28 +106,40 @@ describe('GetTeamMembershipUseCase', () => { mockGetMembership.mockResolvedValue(membership); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamMembershipInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.value?.role).toBe('member'); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as GetTeamMembershipResult; + + expect(presented.membership?.role).toBe('member'); }); it('should return error when repository throws', async () => { - const useCase = new GetTeamMembershipUseCase( - mockMembershipRepo, - mockLogger, - ); - const teamId = 'team1'; const driverId = 'driver1'; const error = new Error('Repository error'); mockGetMembership.mockRejectedValue(error); - const result = await useCase.execute({ teamId, driverId }); + const input: GetTeamMembershipInput = { teamId, driverId }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); - expect(result.unwrapErr().details.message).toBe('Failed to retrieve team membership'); + + const err = result.unwrapErr() as ApplicationErrorCode< + GetTeamMembershipErrorCode, + { message: string } + >; + + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamMembershipUseCase.ts b/core/racing/application/use-cases/GetTeamMembershipUseCase.ts index 0b6bdd00b..76304c4a7 100644 --- a/core/racing/application/use-cases/GetTeamMembershipUseCase.ts +++ b/core/racing/application/use-cases/GetTeamMembershipUseCase.ts @@ -1,40 +1,77 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; + +export type GetTeamMembershipInput = { + teamId: string; + driverId: string; +}; + +export type GetTeamMembership = { + role: 'owner' | 'manager' | 'member'; + joinedAt: string; + isActive: boolean; +}; + +export type GetTeamMembershipResult = { + membership: GetTeamMembership | null; +}; + +export type GetTeamMembershipErrorCode = 'REPOSITORY_ERROR'; /** * Use Case for retrieving a driver's membership in a team. */ -export class GetTeamMembershipUseCase - implements AsyncUseCase<{ teamId: string; driverId: string }, { role: 'owner' | 'manager' | 'member'; joinedAt: string; isActive: boolean } | null, 'REPOSITORY_ERROR'> -{ +export class GetTeamMembershipUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(input: { teamId: string; driverId: string }): Promise>> { + async execute( + input: GetTeamMembershipInput, + ): Promise>> { this.logger.debug(`Executing GetTeamMembershipUseCase for teamId: ${input.teamId}, driverId: ${input.driverId}`); try { const membership = await this.membershipRepository.getMembership(input.teamId, input.driverId); + if (!membership) { this.logger.debug(`No membership found for teamId: ${input.teamId}, driverId: ${input.driverId}`); - return Result.ok(null); + + this.output.present({ membership: null }); + + return Result.ok(undefined); } - const result = { - role: membership.role === 'driver' ? 'member' : membership.role as 'owner' | 'manager' | 'member', + const presentableMembership: GetTeamMembership = { + role: membership.role === 'driver' ? 'member' : (membership.role as 'owner' | 'manager' | 'member'), joinedAt: membership.joinedAt.toISOString(), isActive: membership.status === 'active', }; + this.output.present({ membership: presentableMembership }); + this.logger.info(`Successfully retrieved membership for teamId: ${input.teamId}, driverId: ${input.driverId}`); - return Result.ok(result); - } catch (error) { - this.logger.error(`Error in GetTeamMembershipUseCase for teamId: ${input.teamId}, driverId: ${input.driverId}`, error as Error); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve team membership' } }); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + this.logger.error( + `Error in GetTeamMembershipUseCase for teamId: ${input.teamId}, driverId: ${input.driverId}`, + err as Error, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to retrieve team membership', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts index d42b76023..f705e8836 100644 --- a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTeamsLeaderboardUseCase } from './GetTeamsLeaderboardUseCase'; +import { + GetTeamsLeaderboardUseCase, + type GetTeamsLeaderboardResult, + type GetTeamsLeaderboardInput, + type GetTeamsLeaderboardErrorCode, +} from './GetTeamsLeaderboardUseCase'; import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { Team } from '../../domain/entities/Team'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTeamsLeaderboardUseCase', () => { let useCase: GetTeamsLeaderboardUseCase; @@ -24,6 +31,7 @@ describe('GetTeamsLeaderboardUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { teamRepository = { @@ -42,12 +50,16 @@ describe('GetTeamsLeaderboardUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as any; useCase = new GetTeamsLeaderboardUseCase( teamRepository as unknown as ITeamRepository, teamMembershipRepository as unknown as ITeamMembershipRepository, driverRepository as unknown as IDriverRepository, getDriverStats, logger as unknown as Logger, + output, ); }); @@ -89,33 +101,35 @@ describe('GetTeamsLeaderboardUseCase', () => { return null; }); - const result = await useCase.execute(); + const input: GetTeamsLeaderboardInput = { leagueId: 'league-1' }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const data = result.unwrap(); - expect(data.recruitingCount).toBe(2); // both teams are recruiting - expect(data.teams).toHaveLength(2); - expect(data.teams[0]).toMatchObject({ - id: 'team-1', - name: 'Team Alpha', + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as unknown as Mock).mock.calls[0][0] as GetTeamsLeaderboardResult; + + expect(presented.recruitingCount).toBe(2); // both teams are recruiting + expect(presented.items).toHaveLength(2); + expect(presented.items[0]).toMatchObject({ + team: team1, memberCount: 2, rating: 1550, // (1500 + 1600) / 2 totalWins: 8, totalRaces: 18, performanceLevel: expect.any(String), isRecruiting: true, - description: 'Description 1', }); - expect(data.teams[1]).toMatchObject({ - id: 'team-2', - name: 'Team Beta', + expect(presented.items[1]).toMatchObject({ + team: team2, memberCount: 1, rating: null, totalWins: 2, totalRaces: 5, performanceLevel: expect.any(String), isRecruiting: true, - description: 'Description 2', }); }); @@ -124,12 +138,17 @@ describe('GetTeamsLeaderboardUseCase', () => { teamRepository.findAll.mockRejectedValue(error); - const result = await useCase.execute(); + const input: GetTeamsLeaderboardInput = { leagueId: 'league-1' }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve teams leaderboard' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + GetTeamsLeaderboardErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Failed to load teams leaderboard'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts index 3a2f1b34a..d303a9a08 100644 --- a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts @@ -1,11 +1,12 @@ import type { ITeamRepository } from '@core/racing/domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '@core/racing/domain/repositories/ITeamMembershipRepository'; import type { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; -import type { TeamsLeaderboardOutputPort, SkillLevel } from '../ports/output/TeamsLeaderboardOutputPort'; -import { SkillLevelService } from '@core/racing/domain/services/SkillLevelService'; +import { SkillLevelService, type SkillLevel } from '@core/racing/domain/services/SkillLevelService'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Team } from '@core/racing/domain/entities/Team'; interface DriverStatsAdapter { rating: number | null; @@ -13,36 +14,50 @@ interface DriverStatsAdapter { totalRaces: number; } -interface TeamLeaderboardItem { - id: string; - name: string; +export type GetTeamsLeaderboardInput = { + leagueId: string; + seasonId?: string; +}; + +export interface TeamLeaderboardItem { + team: Team; memberCount: number; rating: number | null; totalWins: number; totalRaces: number; - performanceLevel: string; + performanceLevel: SkillLevel; isRecruiting: boolean; createdAt: Date; - description: string; } +export interface GetTeamsLeaderboardResult { + items: TeamLeaderboardItem[]; + recruitingCount: number; + groupsBySkillLevel: Record; + topItems: TeamLeaderboardItem[]; +} + +export type GetTeamsLeaderboardErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'REPOSITORY_ERROR'; + /** * Use case: GetTeamsLeaderboardUseCase */ -export class GetTeamsLeaderboardUseCase implements AsyncUseCase -{ +export class GetTeamsLeaderboardUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly teamMembershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetTeamsLeaderboardInput, + ): Promise>> { try { const allTeams = await this.teamRepository.findAll(); - const teams: TeamLeaderboardItem[] = []; + const items: TeamLeaderboardItem[] = []; await Promise.all( allTeams.map(async (team) => { @@ -70,9 +85,8 @@ export class GetTeamsLeaderboardUseCase implements AsyncUseCase 0 ? ratingSum / ratingCount : null; const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating); - teams.push({ - id: team.id, - name: team.name, + items.push({ + team, memberCount, rating: averageRating, totalWins, @@ -80,38 +94,46 @@ export class GetTeamsLeaderboardUseCase implements AsyncUseCase t.isRecruiting).length; + const recruitingCount = items.filter((t) => t.isRecruiting).length; - const groupsBySkillLevel: Record = { + const groupsBySkillLevel: Record = { beginner: [], intermediate: [], advanced: [], pro: [], }; - teams.forEach(team => { - const level = team.performanceLevel as SkillLevel; - groupsBySkillLevel[level].push(team); + items.forEach((item) => { + const level = item.performanceLevel; + groupsBySkillLevel[level].push(item); }); - const topTeams = teams.slice(0, 10); // Assuming top 10 + const topItems = items + .slice() + .sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)) + .slice(0, 10); - const outputPort: TeamsLeaderboardOutputPort = { - teams, + this.output.present({ + items, recruitingCount, groupsBySkillLevel, - topTeams, - }; + topItems, + }); - return Result.ok(outputPort); - } catch (error) { - this.logger.error('Error retrieving teams leaderboard', error as Error); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve teams leaderboard' } }); + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + this.logger.error('Error retrieving teams leaderboard', err as Error); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: error?.message ?? 'Failed to load teams leaderboard' }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/GetTotalDriversUseCase.test.ts b/core/racing/application/use-cases/GetTotalDriversUseCase.test.ts index 0c8473489..2abdc5563 100644 --- a/core/racing/application/use-cases/GetTotalDriversUseCase.test.ts +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.test.ts @@ -1,51 +1,51 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTotalDriversUseCase } from './GetTotalDriversUseCase'; +import { + GetTotalDriversUseCase, + GetTotalDriversInput, + GetTotalDriversResult, + GetTotalDriversErrorCode, +} from './GetTotalDriversUseCase'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import { Driver } from '../../domain/entities/Driver'; -import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTotalDriversUseCase', () => { let useCase: GetTotalDriversUseCase; let driverRepository: { findAll: Mock; }; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; - }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { driverRepository = { findAll: vi.fn(), }; - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetTotalDriversUseCase( driverRepository as unknown as IDriverRepository, - logger as unknown as Logger, + output, ); }); it('should return total number of drivers', async () => { - const drivers = [ - Driver.create({ id: '1', iracingId: '123', name: 'Driver 1', country: 'US' }), - Driver.create({ id: '2', iracingId: '456', name: 'Driver 2', country: 'UK' }), - ]; + const drivers = [{ id: '1' }, { id: '2' }]; driverRepository.findAll.mockResolvedValue(drivers); - const result = await useCase.execute(); + const input: GetTotalDriversInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - totalDrivers: 2, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith<[{ totalDrivers: number }]>( + expect.objectContaining({ totalDrivers: 2 }), + ); }); it('should return error on repository failure', async () => { @@ -53,12 +53,19 @@ describe('GetTotalDriversUseCase', () => { driverRepository.findAll.mockRejectedValue(error); - const result = await useCase.execute(); + const input: GetTotalDriversInput = {}; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve total drivers' }, - }); + + const unwrappedError = result.unwrapErr() as ApplicationErrorCode< + GetTotalDriversErrorCode, + { message: string } + >; + + expect(unwrappedError.code).toBe('REPOSITORY_ERROR'); + expect(unwrappedError.details.message).toBe(error.message); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalDriversUseCase.ts b/core/racing/application/use-cases/GetTotalDriversUseCase.ts index 8fa659c8e..0067d32b8 100644 --- a/core/racing/application/use-cases/GetTotalDriversUseCase.ts +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.ts @@ -1,30 +1,45 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { TotalDriversOutputPort } from '../ports/output/TotalDriversOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application'; /** - * Use Case for retrieving total number of drivers. + * Input type for retrieving total number of drivers. */ -export class GetTotalDriversUseCase implements AsyncUseCase -{ +export type GetTotalDriversInput = {}; + +/** + * Domain result model for the total number of drivers. + */ +export type GetTotalDriversResult = { + totalDrivers: number; +}; + +export type GetTotalDriversErrorCode = 'REPOSITORY_ERROR'; + +export class GetTotalDriversUseCase { constructor( private readonly driverRepository: IDriverRepository, - private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetTotalDriversInput, + ): Promise>> { try { const drivers = await this.driverRepository.findAll(); - const output: TotalDriversOutputPort = { - totalDrivers: drivers.length, - }; + const result: GetTotalDriversResult = { totalDrivers: drivers.length }; - return Result.ok(output); + this.output.present(result); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Error retrieving total drivers', error as Error); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total drivers' } }); + const message = (error as Error | undefined)?.message ?? 'Failed to compute total drivers'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts b/core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts index 98419ad6a..f4d32e5a6 100644 --- a/core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts @@ -1,33 +1,33 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTotalLeaguesUseCase } from './GetTotalLeaguesUseCase'; -import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { Logger } from '@core/shared/application'; +import { + GetTotalLeaguesUseCase, + type GetTotalLeaguesInput, + type GetTotalLeaguesResult, + type GetTotalLeaguesErrorCode, +} from './GetTotalLeaguesUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTotalLeaguesUseCase', () => { let useCase: GetTotalLeaguesUseCase; let leagueRepository: { findAll: Mock; }; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; - }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueRepository = { findAll: vi.fn(), }; - logger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetTotalLeaguesUseCase( leagueRepository as unknown as ILeagueRepository, - logger as unknown as Logger, + output, ); }); @@ -40,25 +40,36 @@ describe('GetTotalLeaguesUseCase', () => { leagueRepository.findAll.mockResolvedValue(leagues); - const result = await useCase.execute(); + const input: GetTotalLeaguesInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith['present']>>({ totalLeagues: 3, }); }); it('should return error on repository failure', async () => { - const error = new Error('Repository error'); + const repositoryError = new Error('Repository error'); - leagueRepository.findAll.mockRejectedValue(error); + leagueRepository.findAll.mockRejectedValue(repositoryError); - const result = await useCase.execute(); + const input: GetTotalLeaguesInput = {}; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve total leagues' }, - }); + + const error = result.unwrapErr() as ApplicationErrorCode< + GetTotalLeaguesErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts index 3bd1a8a2c..cad13ddf4 100644 --- a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts @@ -1,25 +1,41 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { GetTotalLeaguesOutputPort } from '../ports/output/GetTotalLeaguesOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application'; -export class GetTotalLeaguesUseCase implements AsyncUseCase -{ +export type GetTotalLeaguesInput = {}; + +export type GetTotalLeaguesResult = { + totalLeagues: number; +}; + +export type GetTotalLeaguesErrorCode = 'REPOSITORY_ERROR'; + +export class GetTotalLeaguesUseCase { constructor( private readonly leagueRepository: ILeagueRepository, - private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute( + _input: GetTotalLeaguesInput, + ): Promise>> { try { const leagues = await this.leagueRepository.findAll(); - const output: GetTotalLeaguesOutputPort = { totalLeagues: leagues.length }; + const result: GetTotalLeaguesResult = { totalLeagues: leagues.length }; - return Result.ok(output); - } catch (error) { - this.logger.error('Error retrieving total leagues', error as Error); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total leagues' } }); + this.output.present(result); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to compute total leagues', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTotalRacesUseCase.test.ts b/core/racing/application/use-cases/GetTotalRacesUseCase.test.ts index 89a75276b..60967619d 100644 --- a/core/racing/application/use-cases/GetTotalRacesUseCase.test.ts +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.test.ts @@ -1,7 +1,13 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { GetTotalRacesUseCase } from './GetTotalRacesUseCase'; -import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { Logger } from '@core/shared/application'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + GetTotalRacesUseCase, + type GetTotalRacesInput, + type GetTotalRacesResult, + type GetTotalRacesErrorCode, +} from './GetTotalRacesUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('GetTotalRacesUseCase', () => { let useCase: GetTotalRacesUseCase; @@ -14,6 +20,7 @@ describe('GetTotalRacesUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { @@ -25,9 +32,14 @@ describe('GetTotalRacesUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new GetTotalRacesUseCase( raceRepository as unknown as IRaceRepository, logger as unknown as Logger, + output, ); }); @@ -39,25 +51,37 @@ describe('GetTotalRacesUseCase', () => { raceRepository.findAll.mockResolvedValue(races); - const result = await useCase.execute(); + const input: GetTotalRacesInput = {}; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - totalRaces: 2, - }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const payload = output.present.mock.calls[0][0] as GetTotalRacesResult; + expect(payload.totalRaces).toBe(2); }); it('should return error on repository failure', async () => { - const error = new Error('Repository error'); + const repositoryError = new Error('Repository error'); - raceRepository.findAll.mockRejectedValue(error); + raceRepository.findAll.mockRejectedValue(repositoryError); - const result = await useCase.execute(); + const input: GetTotalRacesInput = {}; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Failed to retrieve total races' }, - }); + + const errorResult = result.unwrapErr() as ApplicationErrorCode< + GetTotalRacesErrorCode, + { message: string } + >; + + expect(errorResult.code).toBe('REPOSITORY_ERROR'); + expect(errorResult.details.message).toBe('Repository error'); + + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}) diff --git a/core/racing/application/use-cases/GetTotalRacesUseCase.ts b/core/racing/application/use-cases/GetTotalRacesUseCase.ts index c22251c3e..c61601636 100644 --- a/core/racing/application/use-cases/GetTotalRacesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.ts @@ -1,25 +1,41 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { GetTotalRacesOutputPort } from '../ports/output/GetTotalRacesOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; -export class GetTotalRacesUseCase implements AsyncUseCase -{ +export type GetTotalRacesInput = {}; + +export interface GetTotalRacesResult { + totalRaces: number; +} + +export type GetTotalRacesErrorCode = 'REPOSITORY_ERROR'; + +export class GetTotalRacesUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(): Promise>> { + async execute(_input: GetTotalRacesInput): Promise< + Result> + > { try { const races = await this.raceRepository.findAll(); - const output: GetTotalRacesOutputPort = { totalRaces: races.length }; - return Result.ok(output); + this.output.present({ totalRaces: races.length }); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Error retrieving total races', error as Error); - return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to retrieve total races' } }); + const err = error as Error; + + this.logger.error('Error retrieving total races', err); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to compute total races' }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts index c2b109df1..b879d5c8a 100644 --- a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts +++ b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts @@ -1,60 +1,46 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ImportRaceResultsApiUseCase } from './ImportRaceResultsApiUseCase'; -import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import { IResultRepository } from '../../domain/repositories/IResultRepository'; -import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + ImportRaceResultsApiUseCase, + type ImportRaceResultsApiInput, + type ImportRaceResultsApiResult, + type ImportRaceResultsApiErrorCode, +} from './ImportRaceResultsApiUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('ImportRaceResultsApiUseCase', () => { let useCase: ImportRaceResultsApiUseCase; - let raceRepository: { - findById: Mock; - }; - let leagueRepository: { - findById: Mock; - }; - let resultRepository: { - existsByRaceId: Mock; - createMany: Mock; - }; - let driverRepository: { - findByIRacingId: Mock; - }; - let standingRepository: { - recalculate: Mock; - }; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; - }; + let raceRepository: { findById: Mock }; + let leagueRepository: { findById: Mock }; + let resultRepository: { existsByRaceId: Mock; createMany: Mock }; + let driverRepository: { findByIRacingId: Mock }; + let standingRepository: { recalculate: Mock }; + let logger: { debug: Mock; info: Mock; warn: Mock; error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { - raceRepository = { - findById: vi.fn(), - }; - leagueRepository = { - findById: vi.fn(), - }; - resultRepository = { - existsByRaceId: vi.fn(), - createMany: vi.fn(), - }; - driverRepository = { - findByIRacingId: vi.fn(), - }; - standingRepository = { - recalculate: vi.fn(), - }; + raceRepository = { findById: vi.fn() }; + leagueRepository = { findById: vi.fn() }; + resultRepository = { existsByRaceId: vi.fn(), createMany: vi.fn() }; + driverRepository = { findByIRacingId: vi.fn() }; + standingRepository = { recalculate: vi.fn() }; logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new ImportRaceResultsApiUseCase( raceRepository as unknown as IRaceRepository, leagueRepository as unknown as ILeagueRepository, @@ -62,85 +48,129 @@ describe('ImportRaceResultsApiUseCase', () => { driverRepository as unknown as IDriverRepository, standingRepository as unknown as IStandingRepository, logger as unknown as Logger, + output, ); }); it('should return parse error for invalid JSON', async () => { - const params = { raceId: 'race-1', resultsFileContent: 'invalid json' }; + const input: ImportRaceResultsApiInput = { raceId: 'race-1', resultsFileContent: 'invalid json' }; - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'PARSE_ERROR', - details: { message: 'Invalid JSON in results file content' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } + >; + + expect(err.code).toBe('PARSE_ERROR'); + expect(err.details?.message).toBe('Invalid JSON in results file content'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return race not found error', async () => { - const params = { raceId: 'race-1', resultsFileContent: '[]' }; + const input: ImportRaceResultsApiInput = { raceId: 'race-1', resultsFileContent: '[]' }; raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'RACE_NOT_FOUND', - details: { message: 'Race race-1 not found' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } + >; + + expect(err.code).toBe('RACE_NOT_FOUND'); + expect(err.details?.message).toBe('Race race-1 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return league not found error', async () => { - const params = { raceId: 'race-1', resultsFileContent: '[]' }; + const input: ImportRaceResultsApiInput = { raceId: 'race-1', resultsFileContent: '[]' }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'LEAGUE_NOT_FOUND', - details: { message: 'League league-1 not found' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } + >; + + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(err.details?.message).toBe('League league-1 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return results exist error', async () => { - const params = { raceId: 'race-1', resultsFileContent: '[]' }; + const input: ImportRaceResultsApiInput = { raceId: 'race-1', resultsFileContent: '[]' }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); resultRepository.existsByRaceId.mockResolvedValue(true); - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'RESULTS_EXIST', - details: { message: 'Results already exist for this race' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } + >; + + expect(err.code).toBe('RESULTS_EXIST'); + expect(err.details?.message).toBe('Results already exist for this race'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return driver not found error', async () => { - const params = { raceId: 'race-1', resultsFileContent: '[{"id":"result-1","raceId":"race-1","driverId":"123","position":1,"fastestLap":100,"incidents":0,"startPosition":1}]' }; + const input: ImportRaceResultsApiInput = { + raceId: 'race-1', + resultsFileContent: + '[{"id":"result-1","raceId":"race-1","driverId":"123","position":1,"fastestLap":100,"incidents":0,"startPosition":1}]', + }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); resultRepository.existsByRaceId.mockResolvedValue(false); driverRepository.findByIRacingId.mockResolvedValue(null); - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'DRIVER_NOT_FOUND', - details: { message: 'Driver with iRacing ID 123 not found' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } + >; + + expect(err.code).toBe('DRIVER_NOT_FOUND'); + expect(err.details?.message).toBe('Driver with iRacing ID 123 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should import results successfully', async () => { - const params = { raceId: 'race-1', resultsFileContent: '[{"id":"result-1","raceId":"race-1","driverId":"123","position":1,"fastestLap":100,"incidents":0,"startPosition":1}]' }; + const input: ImportRaceResultsApiInput = { + raceId: 'race-1', + resultsFileContent: + '[{"id":"result-1","raceId":"race-1","driverId":"123","position":1,"fastestLap":100,"incidents":0,"startPosition":1}]', + }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); @@ -149,16 +179,25 @@ describe('ImportRaceResultsApiUseCase', () => { resultRepository.createMany.mockResolvedValue(undefined); standingRepository.recalculate.mockResolvedValue(undefined); - const result = await useCase.execute(params); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - success: true, - raceId: 'race-1', - driversProcessed: 1, - resultsRecorded: 1, - errors: [], - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0][0] as ImportRaceResultsApiResult; + + expect(presented.success).toBe(true); + expect(presented.raceId).toBe('race-1'); + expect(presented.leagueId).toBe('league-1'); + expect(presented.driversProcessed).toBe(1); + expect(presented.resultsRecorded).toBe(1); + expect(presented.errors).toEqual([]); + expect(resultRepository.createMany).toHaveBeenCalledWith([ expect.objectContaining({ id: 'result-1', diff --git a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts index 433ddbbbe..0da6c90a1 100644 --- a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts +++ b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts @@ -3,13 +3,12 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import { Result } from '../../domain/entities/Result'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import { Result as RaceResult } from '../../domain/entities/Result'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { ImportRaceResultsApiOutputPort } from '../ports/output/ImportRaceResultsApiOutputPort'; -export interface ImportRaceResultDTO { +export type ImportRaceResultDTO = { id: string; raceId: string; driverId: string; @@ -17,9 +16,23 @@ export interface ImportRaceResultDTO { fastestLap: number; incidents: number; startPosition: number; -} +}; -type ImportRaceResultsApiErrorCode = +export type ImportRaceResultsApiInput = { + raceId: string; + resultsFileContent: string; +}; + +export type ImportRaceResultsApiResult = { + success: true; + raceId: string; + leagueId: string; + driversProcessed: number; + resultsRecorded: number; + errors: string[]; +}; + +export type ImportRaceResultsApiErrorCode = | 'PARSE_ERROR' | 'RACE_NOT_FOUND' | 'LEAGUE_NOT_FOUND' @@ -27,9 +40,12 @@ type ImportRaceResultsApiErrorCode = | 'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'; -type ImportRaceResultsApiApplicationError = ApplicationErrorCode; +type ImportRaceResultsApiApplicationError = ApplicationErrorCode< + ImportRaceResultsApiErrorCode, + { message: string } +>; -export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: string; resultsFileContent: string }, ImportRaceResultsApiOutputPort, ImportRaceResultsApiErrorCode> { +export class ImportRaceResultsApiUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, @@ -37,77 +53,132 @@ export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: strin private readonly driverRepository: IDriverRepository, private readonly standingRepository: IStandingRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: { raceId: string; resultsFileContent: string }): Promise>> { - this.logger.debug('ImportRaceResultsApiUseCase:execute', { raceId: params.raceId }); - const { raceId, resultsFileContent } = params; + async execute( + input: ImportRaceResultsApiInput, + ): Promise>> { + const { raceId, resultsFileContent } = input; + + this.logger.debug('ImportRaceResultsApiUseCase:execute', { raceId }); let results: ImportRaceResultDTO[]; try { results = JSON.parse(resultsFileContent); } catch (error) { - this.logger.error('ImportRaceResultsApiUseCase:parse error', error instanceof Error ? error : new Error('Parse error')); - return SharedResult.err({ code: 'PARSE_ERROR', details: { message: 'Invalid JSON in results file content' } }); + this.logger.error( + 'ImportRaceResultsApiUseCase:parse error', + error instanceof Error ? error : new Error('Parse error'), + ); + + return Result.err({ + code: 'PARSE_ERROR', + details: { message: 'Invalid JSON in results file content' }, + }); } try { const race = await this.raceRepository.findById(raceId); + if (!race) { this.logger.warn(`ImportRaceResultsApiUseCase: Race with ID ${raceId} not found.`); - return SharedResult.err({ code: 'RACE_NOT_FOUND', details: { message: `Race ${raceId} not found` } }); + + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: `Race ${raceId} not found` }, + }); } + this.logger.debug(`ImportRaceResultsApiUseCase: Race ${raceId} found.`); const league = await this.leagueRepository.findById(race.leagueId); + if (!league) { - this.logger.warn(`ImportRaceResultsApiUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`); - return SharedResult.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League ${race.leagueId} not found` } }); + this.logger.warn( + `ImportRaceResultsApiUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`, + ); + + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League ${race.leagueId} not found` }, + }); } + this.logger.debug(`ImportRaceResultsApiUseCase: League ${league.id} found.`); const existing = await this.resultRepository.existsByRaceId(raceId); + if (existing) { - this.logger.warn(`ImportRaceResultsApiUseCase: Results already exist for race ID: ${raceId}.`); - return SharedResult.err({ code: 'RESULTS_EXIST', details: { message: 'Results already exist for this race' } }); + this.logger.warn( + `ImportRaceResultsApiUseCase: Results already exist for race ID: ${raceId}.`, + ); + + return Result.err({ + code: 'RESULTS_EXIST', + details: { message: 'Results already exist for this race' }, + }); } + this.logger.debug(`ImportRaceResultsApiUseCase: No existing results for race ${raceId}.`); - // Lookup drivers by iracingId and create results with driver.id - const entities: SharedResult[] = await Promise.all( - results.map(async (dto) => { + const entities: Result[] = await Promise.all( + results.map(async dto => { const driver = await this.driverRepository.findByIRacingId(dto.driverId); + if (!driver) { - this.logger.warn(`ImportRaceResultsApiUseCase: Driver with iRacing ID ${dto.driverId} not found for race ${raceId}.`); - return SharedResult.err({ code: 'DRIVER_NOT_FOUND', details: { message: `Driver with iRacing ID ${dto.driverId} not found` } }); + this.logger.warn( + `ImportRaceResultsApiUseCase: Driver with iRacing ID ${dto.driverId} not found for race ${raceId}.`, + ); + + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: `Driver with iRacing ID ${dto.driverId} not found` }, + }); } - return SharedResult.ok(Result.create({ - id: dto.id, - raceId: dto.raceId, - driverId: driver.id, - position: dto.position, - fastestLap: dto.fastestLap, - incidents: dto.incidents, - startPosition: dto.startPosition, - })); + + return Result.ok( + RaceResult.create({ + id: dto.id, + raceId: dto.raceId, + driverId: driver.id, + position: dto.position, + fastestLap: dto.fastestLap, + incidents: dto.incidents, + startPosition: dto.startPosition, + }), + ); }), ); - const errors = entities.filter(e => e.isErr()).map(e => (e.error as ImportRaceResultsApiApplicationError).details.message); + const errors = entities + .filter(entity => entity.isErr()) + .map(entity => (entity.error as ImportRaceResultsApiApplicationError).details.message); + if (errors.length > 0) { - return SharedResult.err({ code: 'DRIVER_NOT_FOUND', details: { message: errors.join('; ') } }); + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: errors.join('; ') }, + }); } - const validEntities = entities.filter(e => e.isOk()).map(e => e.unwrap()); - this.logger.debug('ImportRaceResultsApiUseCase:entities created', { count: validEntities.length }); + const validEntities = entities.filter(entity => entity.isOk()).map(entity => entity.unwrap()); + + this.logger.debug('ImportRaceResultsApiUseCase:entities created', { + count: validEntities.length, + }); await this.resultRepository.createMany(validEntities); + this.logger.info('ImportRaceResultsApiUseCase:race results created', { raceId }); await this.standingRepository.recalculate(league.id); - this.logger.info('ImportRaceResultsApiUseCase:standings recalculated', { leagueId: league.id }); - const output: ImportRaceResultsApiOutputPort = { + this.logger.info('ImportRaceResultsApiUseCase:standings recalculated', { + leagueId: league.id, + }); + + const result: ImportRaceResultsApiResult = { success: true, raceId, leagueId: league.id, @@ -116,10 +187,24 @@ export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: strin errors: [], }; - return SharedResult.ok(dto); - } catch (error) { - this.logger.error('ImportRaceResultsApiUseCase:execution error', error instanceof Error ? error : new Error('Unknown error')); - return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + this.logger.error( + 'ImportRaceResultsApiUseCase:execution error', + error instanceof Error ? error : new Error('Unknown error'), + ); + + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to import race results via API'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts b/core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts index 415ad7c8c..4f709fd56 100644 --- a/core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts +++ b/core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts @@ -1,11 +1,17 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ImportRaceResultsUseCase } from './ImportRaceResultsUseCase'; +import { + ImportRaceResultsUseCase, + type ImportRaceResultsInput, + type ImportRaceResultsResult, + type ImportRaceResultsErrorCode, +} from './ImportRaceResultsUseCase'; import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import { IResultRepository } from '../../domain/repositories/IResultRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('ImportRaceResultsUseCase', () => { let useCase: ImportRaceResultsUseCase; @@ -31,6 +37,7 @@ describe('ImportRaceResultsUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { raceRepository = { @@ -55,6 +62,10 @@ describe('ImportRaceResultsUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new ImportRaceResultsUseCase( raceRepository as unknown as IRaceRepository, leagueRepository as unknown as ILeagueRepository, @@ -62,73 +73,118 @@ describe('ImportRaceResultsUseCase', () => { driverRepository as unknown as IDriverRepository, standingRepository as unknown as IStandingRepository, logger as unknown as Logger, + output, ); }); it('should return race not found error', async () => { - const params = { raceId: 'race-1', results: [] }; + const input: ImportRaceResultsInput = { raceId: 'race-1', rows: [] }; raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } + >; + expect(error).toEqual({ code: 'RACE_NOT_FOUND', details: { message: 'Race race-1 not found' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return league not found error', async () => { - const params = { raceId: 'race-1', results: [] }; + const input: ImportRaceResultsInput = { raceId: 'race-1', rows: [] }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } + >; + expect(error).toEqual({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League league-1 not found' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return results exist error', async () => { - const params = { raceId: 'race-1', results: [] }; + const input: ImportRaceResultsInput = { raceId: 'race-1', rows: [] }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); resultRepository.existsByRaceId.mockResolvedValue(true); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } + >; + expect(error).toEqual({ code: 'RESULTS_EXIST', details: { message: 'Results already exist for this race' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return driver not found error', async () => { - const params = { raceId: 'race-1', results: [{ id: 'result-1', raceId: 'race-1', driverId: '123', position: 1, fastestLap: 100, incidents: 0, startPosition: 1 }] }; + const input: ImportRaceResultsInput = { + raceId: 'race-1', + rows: [ + { + id: 'result-1', + driverExternalId: '123', + position: 1, + fastestLap: 100, + incidents: 0, + startPosition: 1, + }, + ], + }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); resultRepository.existsByRaceId.mockResolvedValue(false); driverRepository.findByIRacingId.mockResolvedValue(null); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } + >; + expect(error).toEqual({ code: 'DRIVER_NOT_FOUND', details: { message: 'Driver with iRacing ID 123 not found' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should import results successfully', async () => { - const params = { raceId: 'race-1', results: [{ id: 'result-1', raceId: 'race-1', driverId: '123', position: 1, fastestLap: 100, incidents: 0, startPosition: 1 }] }; + const input: ImportRaceResultsInput = { + raceId: 'race-1', + rows: [ + { + id: 'result-1', + driverExternalId: '123', + position: 1, + fastestLap: 100, + incidents: 0, + startPosition: 1, + }, + ], + }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); @@ -137,21 +193,52 @@ describe('ImportRaceResultsUseCase', () => { resultRepository.createMany.mockResolvedValue(undefined); standingRepository.recalculate.mockResolvedValue(undefined); - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); - expect(resultRepository.createMany).toHaveBeenCalledWith([ - expect.objectContaining({ - id: 'result-1', - raceId: 'race-1', - driverId: 'driver-1', - position: 1, - fastestLap: 100, - incidents: 0, - startPosition: 1, - }), - ]); - expect(standingRepository.recalculate).toHaveBeenCalledWith('league-1'); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]?.[0] as ImportRaceResultsResult; + expect(presented).toEqual({ + raceId: 'race-1', + leagueId: 'league-1', + driversProcessed: 1, + resultsRecorded: 1, + errors: [], + }); + }); + + it('should return repository error when persistence fails', async () => { + const input: ImportRaceResultsInput = { + raceId: 'race-1', + rows: [ + { + id: 'result-1', + driverExternalId: '123', + position: 1, + fastestLap: 100, + incidents: 0, + startPosition: 1, + }, + ], + }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + resultRepository.existsByRaceId.mockResolvedValue(false); + driverRepository.findByIRacingId.mockResolvedValue({ id: 'driver-1' }); + resultRepository.createMany.mockRejectedValue(new Error('DB failure')); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ImportRaceResultsUseCase.ts b/core/racing/application/use-cases/ImportRaceResultsUseCase.ts index b4434596e..be38d1679 100644 --- a/core/racing/application/use-cases/ImportRaceResultsUseCase.ts +++ b/core/racing/application/use-cases/ImportRaceResultsUseCase.ts @@ -3,38 +3,46 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; -import { Result } from '../../domain/entities/Result'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import { Result as RaceResult } from '../../domain/entities/Result'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface ImportRaceResultDTO { +export type ImportRaceResultRow = { id: string; - raceId: string; - driverId: string; + driverExternalId: string; position: number; fastestLap: number; incidents: number; startPosition: number; -} +}; -export interface ImportRaceResultsParams { +export type ImportRaceResultsInput = { raceId: string; - results: ImportRaceResultDTO[]; -} + rows: ImportRaceResultRow[]; +}; -type ImportRaceResultsErrorCode = +export type ImportRaceResultsResult = { + raceId: string; + leagueId: string; + driversProcessed: number; + resultsRecorded: number; + errors: string[]; +}; + +export type ImportRaceResultsErrorCode = | 'RACE_NOT_FOUND' | 'LEAGUE_NOT_FOUND' | 'RESULTS_EXIST' | 'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'; -type ImportRaceResultsApplicationError = ApplicationErrorCode; +type ImportRaceResultsApplicationError = ApplicationErrorCode< + ImportRaceResultsErrorCode, + { message: string } +>; -export class ImportRaceResultsUseCase - implements AsyncUseCase -{ +export class ImportRaceResultsUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, @@ -42,72 +50,142 @@ export class ImportRaceResultsUseCase private readonly driverRepository: IDriverRepository, private readonly standingRepository: IStandingRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: ImportRaceResultsParams): Promise> { - this.logger.debug('ImportRaceResultsUseCase:execute', { params }); - const { raceId, results } = params; + async execute( + input: ImportRaceResultsInput, + ): Promise>> { + const { raceId, rows } = input; - const race = await this.raceRepository.findById(raceId); - if (!race) { - this.logger.warn(`ImportRaceResultsUseCase: Race with ID ${raceId} not found.`); - return SharedResult.err({ code: 'RACE_NOT_FOUND', details: { message: `Race ${raceId} not found` } }); - } - this.logger.debug(`ImportRaceResultsUseCase: Race ${raceId} found.`); - - const league = await this.leagueRepository.findById(race.leagueId); - if (!league) { - this.logger.warn(`ImportRaceResultsUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`); - return SharedResult.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League ${race.leagueId} not found` } }); - } - this.logger.debug(`ImportRaceResultsUseCase: League ${league.id} found.`); - - const existing = await this.resultRepository.existsByRaceId(raceId); - if (existing) { - this.logger.warn(`ImportRaceResultsUseCase: Results already exist for race ID: ${raceId}.`); - return SharedResult.err({ code: 'RESULTS_EXIST', details: { message: 'Results already exist for this race' } }); - } - this.logger.debug(`ImportRaceResultsUseCase: No existing results for race ${raceId}.`); - - // Lookup drivers by iracingId and create results with driver.id - const entities: SharedResult[] = await Promise.all( - results.map(async (dto) => { - const driver = await this.driverRepository.findByIRacingId(dto.driverId); - if (!driver) { - this.logger.warn(`ImportRaceResultsUseCase: Driver with iRacing ID ${dto.driverId} not found for race ${raceId}.`); - return SharedResult.err({ code: 'DRIVER_NOT_FOUND', details: { message: `Driver with iRacing ID ${dto.driverId} not found` } }); - } - return SharedResult.ok(Result.create({ - id: dto.id, - raceId: dto.raceId, - driverId: driver.id, - position: dto.position, - fastestLap: dto.fastestLap, - incidents: dto.incidents, - startPosition: dto.startPosition, - })); - }), - ); - - const errors = entities.filter(e => e.isErr()).map(e => (e.error as ImportRaceResultsApplicationError).details.message); - if (errors.length > 0) { - return SharedResult.err({ code: 'DRIVER_NOT_FOUND', details: { message: errors.join('; ') } }); - } - - const validEntities = entities.filter(e => e.isOk()).map(e => e.unwrap()); - this.logger.debug('ImportRaceResultsUseCase:entities created', { count: validEntities.length }); + this.logger.debug('ImportRaceResultsUseCase:execute', { raceId }); try { + const race = await this.raceRepository.findById(raceId); + + if (!race) { + this.logger.warn(`ImportRaceResultsUseCase: Race with ID ${raceId} not found.`); + + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: `Race ${raceId} not found` }, + }); + } + + this.logger.debug(`ImportRaceResultsUseCase: Race ${raceId} found.`); + + const league = await this.leagueRepository.findById(race.leagueId); + + if (!league) { + this.logger.warn( + `ImportRaceResultsUseCase: League with ID ${race.leagueId} not found for race ${raceId}.`, + ); + + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League ${race.leagueId} not found` }, + }); + } + + this.logger.debug(`ImportRaceResultsUseCase: League ${league.id} found.`); + + const existing = await this.resultRepository.existsByRaceId(raceId); + + if (existing) { + this.logger.warn( + `ImportRaceResultsUseCase: Results already exist for race ID: ${raceId}.`, + ); + + return Result.err({ + code: 'RESULTS_EXIST', + details: { message: 'Results already exist for this race' }, + }); + } + + this.logger.debug(`ImportRaceResultsUseCase: No existing results for race ${raceId}.`); + + const entities: Result[] = await Promise.all( + rows.map(async row => { + const driver = await this.driverRepository.findByIRacingId(row.driverExternalId); + + if (!driver) { + this.logger.warn( + `ImportRaceResultsUseCase: Driver with iRacing ID ${row.driverExternalId} not found for race ${raceId}.`, + ); + + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: `Driver with iRacing ID ${row.driverExternalId} not found` }, + }); + } + + return Result.ok( + RaceResult.create({ + id: row.id, + raceId, + driverId: driver.id, + position: row.position, + fastestLap: row.fastestLap, + incidents: row.incidents, + startPosition: row.startPosition, + }), + ); + }), + ); + + const errors = entities + .filter(entity => entity.isErr()) + .map(entity => (entity.error as ImportRaceResultsApplicationError).details.message); + + if (errors.length > 0) { + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { message: errors.join('; ') }, + }); + } + + const validEntities = entities.filter(entity => entity.isOk()).map(entity => entity.unwrap()); + + this.logger.debug('ImportRaceResultsUseCase:entities created', { + count: validEntities.length, + }); + await this.resultRepository.createMany(validEntities); + this.logger.info('ImportRaceResultsUseCase:race results created', { raceId }); await this.standingRepository.recalculate(league.id); - this.logger.info('ImportRaceResultsUseCase:standings recalculated', { leagueId: league.id }); - return SharedResult.ok(undefined); - } catch (error) { - this.logger.error('ImportRaceResultsUseCase:execution error', error instanceof Error ? error : new Error('Unknown error')); - return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + this.logger.info('ImportRaceResultsUseCase:standings recalculated', { + leagueId: league.id, + }); + + const result: ImportRaceResultsResult = { + raceId, + leagueId: league.id, + driversProcessed: rows.length, + resultsRecorded: validEntities.length, + errors: [], + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + this.logger.error( + 'ImportRaceResultsUseCase:execution error', + error instanceof Error ? error : new Error('Unknown error'), + ); + + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to import race results'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } } diff --git a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts index 7ec4d68af..63545d56a 100644 --- a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { IsDriverRegisteredForRaceUseCase } from './IsDriverRegisteredForRaceUseCase'; +import { + IsDriverRegisteredForRaceUseCase, + type IsDriverRegisteredForRaceInput, + type IsDriverRegisteredForRaceResult, + type IsDriverRegisteredForRaceErrorCode, +} from './IsDriverRegisteredForRaceUseCase'; import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('IsDriverRegisteredForRaceUseCase', () => { let useCase: IsDriverRegisteredForRaceUseCase; @@ -14,6 +20,9 @@ describe('IsDriverRegisteredForRaceUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { + present: Mock; + }; beforeEach(() => { registrationRepository = { @@ -25,36 +34,54 @@ describe('IsDriverRegisteredForRaceUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new IsDriverRegisteredForRaceUseCase( registrationRepository as unknown as IRaceRegistrationRepository, logger as unknown as Logger, + output as UseCaseOutputPort, ); }); it('should return true when driver is registered', async () => { - const params = { raceId: 'race-1', driverId: 'driver-1' }; + const params: IsDriverRegisteredForRaceInput = { raceId: 'race-1', driverId: 'driver-1' }; registrationRepository.isRegistered.mockResolvedValue(true); const result = await useCase.execute(params); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = (output.present as Mock).mock.calls as [[IsDriverRegisteredForRaceResult]]; + expect(presented).toEqual({ + raceId: params.raceId, + driverId: params.driverId, + isRegistered: true, + }); }); it('should return false when driver is not registered', async () => { - const params = { raceId: 'race-1', driverId: 'driver-1' }; + const params: IsDriverRegisteredForRaceInput = { raceId: 'race-1', driverId: 'driver-1' }; registrationRepository.isRegistered.mockResolvedValue(false); const result = await useCase.execute(params); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toBe(false); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [[presented]] = (output.present as Mock).mock.calls as [[IsDriverRegisteredForRaceResult]]; + expect(presented).toEqual({ + raceId: params.raceId, + driverId: params.driverId, + isRegistered: false, + }); }); it('should return error on repository failure', async () => { - const params = { raceId: 'race-1', driverId: 'driver-1' }; + const params: IsDriverRegisteredForRaceInput = { raceId: 'race-1', driverId: 'driver-1' }; const error = new Error('Repository error'); registrationRepository.isRegistered.mockRejectedValue(error); @@ -62,9 +89,12 @@ describe('IsDriverRegisteredForRaceUseCase', () => { const result = await useCase.execute(params); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Repository error' }, - }); + const errorResult = result.unwrapErr() as ApplicationErrorCode< + IsDriverRegisteredForRaceErrorCode, + { message: string } + >; + expect(errorResult.code).toBe('REPOSITORY_ERROR'); + expect(errorResult.details?.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts index db1cd132e..de3b06bcf 100644 --- a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts @@ -1,37 +1,57 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; -import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; -import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { DriverRegistrationStatusOutputPort } from '../ports/output/DriverRegistrationStatusOutputPort'; -type IsDriverRegisteredForRaceErrorCode = 'REPOSITORY_ERROR'; +export type IsDriverRegisteredForRaceErrorCode = 'REPOSITORY_ERROR'; -type IsDriverRegisteredForRaceApplicationError = ApplicationErrorCode; +type IsDriverRegisteredForRaceApplicationError = ApplicationErrorCode< + IsDriverRegisteredForRaceErrorCode, + { message: string } +>; + +export type IsDriverRegisteredForRaceInput = { + raceId: string; + driverId: string; +}; + +export type IsDriverRegisteredForRaceResult = { + raceId: string; + driverId: string; + isRegistered: boolean; +}; /** * Use Case: IsDriverRegisteredForRaceUseCase * * Checks if a driver is registered for a specific race. */ -export class IsDriverRegisteredForRaceUseCase - implements AsyncUseCase -{ +export class IsDriverRegisteredForRaceUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise> { + async execute(params: IsDriverRegisteredForRaceInput): Promise> { this.logger.debug('IsDriverRegisteredForRaceUseCase:execute', { params }); const { raceId, driverId } = params; try { const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); - return SharedResult.ok({ isRegistered, raceId, driverId }); + + this.output.present({ isRegistered, raceId, driverId }); + + return Result.ok(undefined); } catch (error) { - this.logger.error('IsDriverRegisteredForRaceUseCase:execution error', error instanceof Error ? error : new Error('Unknown error')); - return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + this.logger.error( + 'IsDriverRegisteredForRaceUseCase:execution error', + error instanceof Error ? error : new Error('Unknown error'), + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: error instanceof Error ? error.message : 'Unknown error' }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/JoinLeagueUseCase.test.ts b/core/racing/application/use-cases/JoinLeagueUseCase.test.ts index 6815f5ac5..fb73e1415 100644 --- a/core/racing/application/use-cases/JoinLeagueUseCase.test.ts +++ b/core/racing/application/use-cases/JoinLeagueUseCase.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { JoinLeagueUseCase } from './JoinLeagueUseCase'; +import { JoinLeagueUseCase, type JoinLeagueResult, type JoinLeagueInput, type JoinLeagueErrorCode } from './JoinLeagueUseCase'; import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { Logger } from '@core/shared/application'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('JoinLeagueUseCase', () => { let useCase: JoinLeagueUseCase; @@ -15,6 +17,7 @@ describe('JoinLeagueUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { membershipRepository = { @@ -27,37 +30,56 @@ describe('JoinLeagueUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new JoinLeagueUseCase( membershipRepository as unknown as ILeagueMembershipRepository, logger as unknown as Logger, + output, ); }); it('should join league successfully', async () => { - const command = { leagueId: 'league-1', driverId: 'driver-1' }; + const command: JoinLeagueInput = { leagueId: 'league-1', driverId: 'driver-1' }; membershipRepository.getMembership.mockResolvedValue(null); membershipRepository.saveMembership.mockResolvedValue({ id: 'membership-1', - leagueId: 'league-1', - driverId: 'driver-1', - role: 'member', - status: 'active', - joinedAt: new Date(), + leagueId: { + value: 'league-1', + }, + driverId: { + value: 'driver-1', + }, + role: { + value: 'member', + }, + status: { + value: 'active', + }, + joinedAt: { + value: expect.any(Date), + }, }); const result = await useCase.execute(command); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - membershipId: 'membership-1', - leagueId: 'league-1', - status: 'active', - }); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as JoinLeagueResult; + expect(presented.membership.id).toBe('membership-1'); + expect(presented.membership.leagueId.toString()).toBe('league-1'); + expect(presented.membership.driverId.toString()).toBe('driver-1'); + expect(presented.membership.role.toString()).toBe('member'); + expect(presented.membership.status.toString()).toBe('active'); }); it('should return error when already a member', async () => { - const command = { leagueId: 'league-1', driverId: 'driver-1' }; + const command: JoinLeagueInput = { leagueId: 'league-1', driverId: 'driver-1' }; membershipRepository.getMembership.mockResolvedValue({ id: 'membership-1', @@ -71,13 +93,14 @@ describe('JoinLeagueUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'ALREADY_MEMBER', - }); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('ALREADY_MEMBER'); + expect(err.details?.message).toBe('Driver is already a member of this league or has a pending membership.'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { - const command = { leagueId: 'league-1', driverId: 'driver-1' }; + const command: JoinLeagueInput = { leagueId: 'league-1', driverId: 'driver-1' }; const error = new Error('Repository error'); membershipRepository.getMembership.mockRejectedValue(error); @@ -85,8 +108,9 @@ describe('JoinLeagueUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - }); + const err = result.unwrapErr() as ApplicationErrorCode; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details?.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/JoinLeagueUseCase.ts b/core/racing/application/use-cases/JoinLeagueUseCase.ts index d1a8bef94..a4e740b50 100644 --- a/core/racing/application/use-cases/JoinLeagueUseCase.ts +++ b/core/racing/application/use-cases/JoinLeagueUseCase.ts @@ -1,28 +1,29 @@ -import type { Logger , AsyncUseCase } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; -import { LeagueMembership, type MembershipRole, type MembershipStatus } from '../../domain/entities/LeagueMembership'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import { LeagueMembership } from '../../domain/entities/LeagueMembership'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { JoinLeagueOutputPort } from '../ports/output/JoinLeagueOutputPort'; +import type { UseCaseOutputPort } from '@core/shared/application'; -import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO'; +export type JoinLeagueErrorCode = 'ALREADY_MEMBER' | 'REPOSITORY_ERROR'; -type JoinLeagueErrorCode = 'ALREADY_MEMBER' | 'REPOSITORY_ERROR'; +export interface JoinLeagueInput { + leagueId: string; + driverId: string; +} -export class JoinLeagueUseCase implements AsyncUseCase { +export interface JoinLeagueResult { + membership: LeagueMembership; +} + +export class JoinLeagueUseCase { constructor( private readonly membershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - /** - * Joins a driver to a league as an active member. - * - * Mirrors the behavior of the legacy joinLeague function: - * - Returns error when membership already exists for this league/driver. - * - Creates a new active membership with role "member" and current timestamp. - */ - async execute(command: JoinLeagueCommandDTO): Promise>> { + async execute(command: JoinLeagueInput): Promise>> { this.logger.debug('Attempting to join league', { command }); const { leagueId, driverId } = command; @@ -30,26 +31,34 @@ export class JoinLeagueUseCase implements AsyncUseCase { let useCase: JoinTeamUseCase; @@ -20,6 +26,7 @@ describe('JoinTeamUseCase', () => { warn: Mock; error: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { teamRepository = { @@ -36,29 +43,48 @@ describe('JoinTeamUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new JoinTeamUseCase( teamRepository as unknown as ITeamRepository, membershipRepository as unknown as ITeamMembershipRepository, logger as unknown as Logger, + output, ); }); it('should join team successfully', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: JoinTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); membershipRepository.getMembership.mockResolvedValue(null); teamRepository.findById.mockResolvedValue({ id: 'team-1' }); - membershipRepository.saveMembership.mockResolvedValue(undefined); + membershipRepository.saveMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: new Date(), + }); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0]![0] as JoinTeamResult; + expect(presented.team.id).toBe('team-1'); + expect(presented.membership.teamId).toBe('team-1'); + expect(presented.membership.driverId).toBe('driver-1'); + expect(presented.membership.role).toBe('driver'); + expect(presented.membership.status).toBe('active'); }); it('should return error when driver already in a team', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: JoinTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ teamId: 'team-2', @@ -68,17 +94,20 @@ describe('JoinTeamUseCase', () => { joinedAt: new Date(), }); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'ALREADY_IN_TEAM', - details: { message: 'Driver already belongs to a team' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + JoinTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('ALREADY_IN_TEAM'); + expect(err.details.message).toBe('Driver already belongs to a team'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when already a member', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: JoinTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); membershipRepository.getMembership.mockResolvedValue({ @@ -89,43 +118,54 @@ describe('JoinTeamUseCase', () => { joinedAt: new Date(), }); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'ALREADY_MEMBER', - details: { message: 'Already a member or have a pending request' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + JoinTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('ALREADY_MEMBER'); + expect(err.details.message).toBe( + 'Already a member or have a pending request', + ); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when team not found', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: JoinTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); membershipRepository.getMembership.mockResolvedValue(null); teamRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'TEAM_NOT_FOUND', - details: { message: 'Team team-1 not found' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + JoinTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('TEAM_NOT_FOUND'); + expect(err.details.message).toBe('Team team-1 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: JoinTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; const error = new Error('Repository error'); membershipRepository.getActiveMembershipForDriver.mockRejectedValue(error); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Repository error' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + JoinTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/JoinTeamUseCase.ts b/core/racing/application/use-cases/JoinTeamUseCase.ts index abe28e365..91aa80f71 100644 --- a/core/racing/application/use-cases/JoinTeamUseCase.ts +++ b/core/racing/application/use-cases/JoinTeamUseCase.ts @@ -1,65 +1,108 @@ -import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Team } from '../../domain/entities/Team'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; -import type { - TeamMembership, - TeamMembershipStatus, - TeamRole, -} from '../../domain/types/TeamMembership'; -import type { JoinTeamInputPort } from '../ports/input/JoinTeamInputPort'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; -type JoinTeamErrorCode = 'ALREADY_IN_TEAM' | 'ALREADY_MEMBER' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; +export type JoinTeamErrorCode = + | 'ALREADY_IN_TEAM' + | 'ALREADY_MEMBER' + | 'TEAM_NOT_FOUND' + | 'REPOSITORY_ERROR'; -type JoinTeamApplicationError = ApplicationErrorCode; +export interface JoinTeamInput { + teamId: string; + driverId: string; +} -export class JoinTeamUseCase implements AsyncUseCase { +export interface JoinTeamResult { + team: Team; + membership: TeamMembership; +} + +export class JoinTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: JoinTeamInputPort): Promise> { - this.logger.debug('Attempting to join team', { command }); - const { teamId, driverId } = command; + async execute( + input: JoinTeamInput, + ): Promise< + Result> + > { + this.logger.debug('Attempting to join team', { input }); + const { teamId, driverId } = input; try { - const existingActive = await this.membershipRepository.getActiveMembershipForDriver( - driverId, - ); + const existingActive = + await this.membershipRepository.getActiveMembershipForDriver(driverId); if (existingActive) { - this.logger.warn('Driver already belongs to a team', { driverId, teamId }); - return SharedResult.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } }); + this.logger.warn('Driver already belongs to a team', { + driverId, + teamId, + }); + return Result.err({ + code: 'ALREADY_IN_TEAM', + details: { message: 'Driver already belongs to a team' }, + }); } - const existingMembership = await this.membershipRepository.getMembership(teamId, driverId); + const existingMembership = + await this.membershipRepository.getMembership(teamId, driverId); if (existingMembership) { - this.logger.warn('Driver already has a pending or active membership request', { driverId, teamId }); - return SharedResult.err({ code: 'ALREADY_MEMBER', details: { message: 'Already a member or have a pending request' } }); + this.logger.warn( + 'Driver already has a pending or active membership request', + { driverId, teamId }, + ); + return Result.err({ + code: 'ALREADY_MEMBER', + details: { message: 'Already a member or have a pending request' }, + }); } const team = await this.teamRepository.findById(teamId); if (!team) { - this.logger.error('Team not found', { entity: 'team', id: teamId }); - return SharedResult.err({ code: 'TEAM_NOT_FOUND', details: { message: `Team ${teamId} not found` } }); + this.logger.error('Team not found', new Error(`Team ${teamId} not found`)); + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { message: `Team ${teamId} not found` }, + }); } const membership: TeamMembership = { teamId, driverId, - role: 'driver' as TeamRole, - status: 'active' as TeamMembershipStatus, + role: 'driver', + status: 'active', joinedAt: new Date(), }; - await this.membershipRepository.saveMembership(membership); + const savedMembership = + await this.membershipRepository.saveMembership(membership); this.logger.info('Driver successfully joined team', { driverId, teamId }); - return SharedResult.ok(undefined); + + this.output.present({ + team, + membership: savedMembership, + }); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Failed to join team due to an unexpected error', error instanceof Error ? error : new Error('Unknown error')); - return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + this.logger.error( + 'Failed to join team due to an unexpected error', + error instanceof Error ? error : new Error('Unknown error'), + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/LeaveTeamUseCase.test.ts b/core/racing/application/use-cases/LeaveTeamUseCase.test.ts index 1c83d1ad4..79311d5c4 100644 --- a/core/racing/application/use-cases/LeaveTeamUseCase.test.ts +++ b/core/racing/application/use-cases/LeaveTeamUseCase.test.ts @@ -1,22 +1,29 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { LeaveTeamUseCase } from './LeaveTeamUseCase'; -import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { Logger } from '@core/shared/application'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + LeaveTeamUseCase, + type LeaveTeamInput, + type LeaveTeamResult, + type LeaveTeamErrorCode, +} from './LeaveTeamUseCase'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('LeaveTeamUseCase', () => { let useCase: LeaveTeamUseCase; + let teamRepository: { findById: Mock }; let membershipRepository: { getMembership: Mock; removeMembership: Mock; }; - let logger: { - debug: Mock; - info: Mock; - warn: Mock; - error: Mock; - }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { + teamRepository = { + findById: vi.fn(), + }; membershipRepository = { getMembership: vi.fn(), removeMembership: vi.fn(), @@ -26,48 +33,83 @@ describe('LeaveTeamUseCase', () => { info: vi.fn(), warn: vi.fn(), error: vi.fn(), - }; + } as unknown as Logger; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new LeaveTeamUseCase( + teamRepository as unknown as ITeamRepository, membershipRepository as unknown as ITeamMembershipRepository, - logger as unknown as Logger, + logger, + output, ); }); it('should leave team successfully', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: LeaveTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; - membershipRepository.getMembership.mockResolvedValue({ + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + const membership = { teamId: 'team-1', driverId: 'driver-1', role: 'driver', status: 'active', joinedAt: new Date(), - }); + }; + membershipRepository.getMembership.mockResolvedValue(membership); membershipRepository.removeMembership.mockResolvedValue(undefined); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as LeaveTeamResult; + expect(presented.team.id).toBe('team-1'); + expect(presented.previousMembership).toEqual(membership); + }); + + it('should return error when team is not found', async () => { + const input: LeaveTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; + + teamRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + LeaveTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('TEAM_NOT_FOUND'); + expect(err.details.message).toBe('Team team-1 not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when not a member', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: LeaveTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); membershipRepository.getMembership.mockResolvedValue(null); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'NOT_MEMBER', - details: { message: 'Not a member of this team' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + LeaveTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('NOT_MEMBER'); + expect(err.details.message).toBe('Not a member of this team'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when owner tries to leave', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: LeaveTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); membershipRepository.getMembership.mockResolvedValue({ teamId: 'team-1', driverId: 'driver-1', @@ -76,27 +118,35 @@ describe('LeaveTeamUseCase', () => { joinedAt: new Date(), }); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'OWNER_CANNOT_LEAVE', - details: { message: 'Team owner cannot leave. Transfer ownership or disband team first.' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + LeaveTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('OWNER_CANNOT_LEAVE'); + expect(err.details.message).toBe( + 'Team owner cannot leave. Transfer ownership or disband team first.', + ); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error on repository failure', async () => { - const command = { teamId: 'team-1', driverId: 'driver-1' }; + const input: LeaveTeamInput = { teamId: 'team-1', driverId: 'driver-1' }; const error = new Error('Repository error'); - membershipRepository.getMembership.mockRejectedValue(error); + teamRepository.findById.mockRejectedValue(error); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'REPOSITORY_ERROR', - details: { message: 'Repository error' }, - }); + const err = result.unwrapErr() as ApplicationErrorCode< + LeaveTeamErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository error'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/LeaveTeamUseCase.ts b/core/racing/application/use-cases/LeaveTeamUseCase.ts index d4dd37980..aa086b9ee 100644 --- a/core/racing/application/use-cases/LeaveTeamUseCase.ts +++ b/core/racing/application/use-cases/LeaveTeamUseCase.ts @@ -1,44 +1,101 @@ -import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/application/Result'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Team } from '../../domain/entities/Team'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { LeaveTeamInputPort } from '../ports/input/LeaveTeamInputPort'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; -type LeaveTeamErrorCode = 'NOT_MEMBER' | 'OWNER_CANNOT_LEAVE' | 'REPOSITORY_ERROR'; +export type LeaveTeamErrorCode = + | 'TEAM_NOT_FOUND' + | 'NOT_MEMBER' + | 'OWNER_CANNOT_LEAVE' + | 'REPOSITORY_ERROR'; -type LeaveTeamApplicationError = ApplicationErrorCode; +export type LeaveTeamInput = { + teamId: string; + driverId: string; +}; -export class LeaveTeamUseCase implements AsyncUseCase { +export interface LeaveTeamResult { + team: Team; + previousMembership: TeamMembership; +} + +export class LeaveTeamUseCase { constructor( + private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: LeaveTeamInputPort): Promise> { - this.logger.debug('Attempting to leave team', { command }); - const { teamId, driverId } = command; + async execute( + input: LeaveTeamInput, + ): Promise< + Result> + > { + this.logger.debug('Attempting to leave team', { input }); + const { teamId, driverId } = input; try { - const membership = await this.membershipRepository.getMembership(teamId, driverId); + const team = await this.teamRepository.findById(teamId); + if (!team) { + const error = new Error(`Team ${teamId} not found`); + this.logger.error('Team not found', error); + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { message: `Team ${teamId} not found` }, + }); + } + + const membership = await this.membershipRepository.getMembership( + teamId, + driverId, + ); if (!membership) { - this.logger.warn('Driver is not a member of this team', { driverId, teamId }); - return SharedResult.err({ code: 'NOT_MEMBER', details: { message: 'Not a member of this team' } }); + this.logger.warn('Driver is not a member of this team', { + driverId, + teamId, + }); + return Result.err({ + code: 'NOT_MEMBER', + details: { message: 'Not a member of this team' }, + }); } if (membership.role === 'owner') { this.logger.warn('Team owner cannot leave', { driverId, teamId }); - return SharedResult.err({ + return Result.err({ code: 'OWNER_CANNOT_LEAVE', - details: { message: 'Team owner cannot leave. Transfer ownership or disband team first.' } + details: { + message: + 'Team owner cannot leave. Transfer ownership or disband team first.', + }, }); } await this.membershipRepository.removeMembership(teamId, driverId); this.logger.info('Driver successfully left team', { driverId, teamId }); - return SharedResult.ok(undefined); + + const result: LeaveTeamResult = { + team, + previousMembership: membership, + }; + this.output.present(result); + + return Result.ok(undefined); } catch (error) { - this.logger.error('Failed to leave team due to an unexpected error', error instanceof Error ? error : new Error('Unknown error')); - return SharedResult.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); + this.logger.error( + 'Failed to leave team due to an unexpected error', + error instanceof Error ? error : new Error('Unknown error'), + ); + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts index a7e440aa1..fe323f286 100644 --- a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts +++ b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts @@ -1,9 +1,18 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { ListLeagueScoringPresetsUseCase } from './ListLeagueScoringPresetsUseCase'; - +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + ListLeagueScoringPresetsUseCase, + type ListLeagueScoringPresetsInput, + type ListLeagueScoringPresetsResult, + type ListLeagueScoringPresetsErrorCode, +} from './ListLeagueScoringPresetsUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Result } from '@core/shared/application/Result'; + describe('ListLeagueScoringPresetsUseCase', () => { let useCase: ListLeagueScoringPresetsUseCase; - + let output: UseCaseOutputPort & { present: Mock }; + beforeEach(() => { const mockPresets = [ { @@ -27,14 +36,30 @@ describe('ListLeagueScoringPresetsUseCase', () => { createConfig: vi.fn(), }, ]; - useCase = new ListLeagueScoringPresetsUseCase(mockPresets); + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new ListLeagueScoringPresetsUseCase(mockPresets, output); }); - + it('should list presets successfully', async () => { - const result = await useCase.execute(); + const input: ListLeagueScoringPresetsInput = {}; + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); + expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const firstCall = output.present.mock.calls[0]!; + const presented = firstCall[0] as ListLeagueScoringPresetsResult; + + expect(presented).toEqual({ presets: [ { id: 'preset-1', @@ -57,4 +82,35 @@ describe('ListLeagueScoringPresetsUseCase', () => { ], }); }); + + it('should wrap repository errors in ApplicationErrorCode and not call output', async () => { + const failingPresets = { + map: () => { + throw new Error('Repository failure'); + }, + } as unknown as never[]; + + useCase = new ListLeagueScoringPresetsUseCase( + failingPresets, + output, + ); + + const input: ListLeagueScoringPresetsInput = {}; + + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const error = result.unwrapErr() as ApplicationErrorCode< + ListLeagueScoringPresetsErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts index bf5b410ee..70d231981 100644 --- a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts +++ b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts @@ -1,22 +1,43 @@ -import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort'; -import type { LeagueScoringPresetsOutputPort } from '../ports/output/LeagueScoringPresetsOutputPort'; -import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { LeagueScoringPreset as BootstrapLeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; + +export type ListLeagueScoringPresetsInput = {}; + +export type LeagueScoringPreset = { + id: string; + name: string; + description: string; + primaryChampionshipType: string; + sessionSummary: string; + bonusSummary: string; + dropPolicySummary: string; +}; + +export interface ListLeagueScoringPresetsResult { + presets: LeagueScoringPreset[]; +} + +export type ListLeagueScoringPresetsErrorCode = 'REPOSITORY_ERROR'; /** * Use Case for listing league scoring presets. * Returns preset data without business logic. */ -export class ListLeagueScoringPresetsUseCase - implements AsyncUseCase -{ - constructor(private readonly presets: LeagueScoringPreset[]) {} +export class ListLeagueScoringPresetsUseCase { + constructor( + private readonly presets: BootstrapLeagueScoringPreset[], + private readonly output: UseCaseOutputPort, + ) {} - async execute(): Promise>> { - const output: LeagueScoringPresetsOutputPort = { - presets: this.presets.map(p => ({ + async execute( + _input: ListLeagueScoringPresetsInput, + ): Promise< + Result> + > { + try { + const presets: LeagueScoringPreset[] = this.presets.map(p => ({ id: p.id, name: p.name, description: p.description, @@ -24,9 +45,23 @@ export class ListLeagueScoringPresetsUseCase sessionSummary: p.sessionSummary, bonusSummary: p.bonusSummary, dropPolicySummary: p.dropPolicySummary, - } as LeagueScoringPresetOutputPort)), - }; + })); - return Result.ok(output); + const result: ListLeagueScoringPresetsResult = { presets }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to list league scoring presets'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as ApplicationErrorCode); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts index 94107d677..7667d94ea 100644 --- a/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts +++ b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts @@ -1,8 +1,17 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ListSeasonsForLeagueUseCase } from './ListSeasonsForLeagueUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + ListSeasonsForLeagueUseCase, + type ListSeasonsForLeagueInput, + type ListSeasonsForLeagueResult, + type ListSeasonsForLeagueErrorCode, +} from './ListSeasonsForLeagueUseCase'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import { Season } from '../../domain/entities/Season'; +import { Season } from '../../domain/entities/season/Season'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; + describe('ListSeasonsForLeagueUseCase', () => { let useCase: ListSeasonsForLeagueUseCase; @@ -12,6 +21,7 @@ describe('ListSeasonsForLeagueUseCase', () => { let seasonRepository: { listByLeague: Mock; }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueRepository = { @@ -20,25 +30,30 @@ describe('ListSeasonsForLeagueUseCase', () => { seasonRepository = { listByLeague: vi.fn(), }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort< + ListSeasonsForLeagueResult + > & { present: Mock }; useCase = new ListSeasonsForLeagueUseCase( leagueRepository as unknown as ILeagueRepository, seasonRepository as unknown as ISeasonRepository, + output, ); }); - it('lists seasons for a league with summaries', async () => { - const league = { id: 'league-1' }; + it('lists seasons for a league with domain entities and presents them via output port', async () => { + const leagueId = 'league-1'; + const league = { id: leagueId }; const seasons = [ Season.create({ id: 'season-1', - leagueId: 'league-1', + leagueId, gameId: 'iracing', name: 'Season One', status: 'planned', }), Season.create({ id: 'season-2', - leagueId: 'league-1', + leagueId, gameId: 'iracing', name: 'Season Two', status: 'active', @@ -48,27 +63,69 @@ describe('ListSeasonsForLeagueUseCase', () => { leagueRepository.findById.mockResolvedValue(league); seasonRepository.listByLeague.mockResolvedValue(seasons); - const result = await useCase.execute({ leagueId: 'league-1' }); + const input: ListSeasonsForLeagueInput = { leagueId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.items.map((i) => i.seasonId).sort()).toEqual([ - 'season-1', - 'season-2', - ]); - expect(dto.items.every((i) => i.leagueId === 'league-1')).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0]?.[0] as ListSeasonsForLeagueResult; + + expect(presented.leagueId).toBe(leagueId); + expect(presented.seasons).toHaveLength(2); + const presentedIds = presented.seasons.map((s) => s.id).sort(); + expect(presentedIds).toEqual(['season-1', 'season-2']); + + const firstSeason = presented.seasons.find((s) => s.id === 'season-1'); + const secondSeason = presented.seasons.find((s) => s.id === 'season-2'); + + expect(firstSeason?.name).toBe('Season One'); + expect(firstSeason?.status).toBe('planned'); + expect(secondSeason?.name).toBe('Season Two'); + expect(secondSeason?.status).toBe('active'); }); - it('returns error when league not found', async () => { + it('returns error when league not found and does not call output', async () => { leagueRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ leagueId: 'league-1' }); + const input: ListSeasonsForLeagueInput = { leagueId: 'league-1' }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'LEAGUE_NOT_FOUND', - details: { message: 'League not found: league-1' }, - }); + const error = result.unwrapErr() as ApplicationErrorCode< + ListSeasonsForLeagueErrorCode, + { message: string } + >; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toContain('League not found'); + + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + it('wraps repository failures and does not call output', async () => { + const leagueId = 'league-1'; + const league = { id: leagueId }; + const thrown = new Error('DB is down'); + + leagueRepository.findById.mockResolvedValue(league); + seasonRepository.listByLeague.mockRejectedValue(thrown); + + const input: ListSeasonsForLeagueInput = { leagueId }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + ListSeasonsForLeagueErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB is down'); + + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts index 54376d447..2b9a21574 100644 --- a/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts +++ b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts @@ -1,27 +1,28 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { SeasonStatus } from '../../domain/entities/season/Season'; +import type { UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface SeasonSummaryDTO { - seasonId: string; +export type ListSeasonsForLeagueInput = { leagueId: string; +}; + +export type LeagueSeasonSummary = { + id: string; name: string; - status: import('../../domain/entities/Season').SeasonStatus; - startDate?: Date; - endDate?: Date; - isPrimary: boolean; -} + status: SeasonStatus; + startsAt: Date | undefined; + endsAt: Date | undefined; +}; -export interface ListSeasonsForLeagueQuery { +export type ListSeasonsForLeagueResult = { leagueId: string; -} + seasons: LeagueSeasonSummary[]; +}; -export interface ListSeasonsForLeagueResultDTO { - items: SeasonSummaryDTO[]; -} - -type ListSeasonsForLeagueErrorCode = 'LEAGUE_NOT_FOUND'; +export type ListSeasonsForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR'; /** * ListSeasonsForLeagueUseCase @@ -30,31 +31,49 @@ export class ListSeasonsForLeagueUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - query: ListSeasonsForLeagueQuery, - ): Promise>> { - const league = await this.leagueRepository.findById(query.leagueId); - if (!league) { + query: ListSeasonsForLeagueInput, + ): Promise< + Result> + > { + try { + const league = await this.leagueRepository.findById(query.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League not found: ${query.leagueId}` }, + }); + } + + const seasons = await this.seasonRepository.listByLeague(query.leagueId); + + const result: ListSeasonsForLeagueResult = { + leagueId: query.leagueId, + seasons: seasons.map((season) => ({ + id: season.id, + name: season.name, + status: season.status, + startsAt: season.startDate, + endsAt: season.endDate, + })), + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to list seasons for league'; + return Result.err({ - code: 'LEAGUE_NOT_FOUND', - details: { message: `League not found: ${query.leagueId}` }, + code: 'REPOSITORY_ERROR', + details: { + message, + }, }); } - - const seasons = await this.seasonRepository.listByLeague(league.id); - const items: SeasonSummaryDTO[] = seasons.map((s) => ({ - seasonId: s.id, - leagueId: s.leagueId, - name: s.name, - status: s.status, - ...(s.startDate !== undefined ? { startDate: s.startDate } : {}), - ...(s.endDate !== undefined ? { endDate: s.endDate } : {}), - // League currently does not track primarySeasonId, so mark false for now. - isPrimary: false, - })); - - return Result.ok({ items }); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts index e0ce4a1a3..5c0738cb5 100644 --- a/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts +++ b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts @@ -1,8 +1,16 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ManageSeasonLifecycleUseCase, type ManageSeasonLifecycleCommand } from './ManageSeasonLifecycleUseCase'; +import { + ManageSeasonLifecycleUseCase, + type ManageSeasonLifecycleInput, + type ManageSeasonLifecycleResult, + type ManageSeasonLifecycleErrorCode, +} from './ManageSeasonLifecycleUseCase'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import { Season } from '../../domain/entities/Season'; +import { Season } from '../../domain/entities/season/Season'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('ManageSeasonLifecycleUseCase', () => { let useCase: ManageSeasonLifecycleUseCase; @@ -13,7 +21,10 @@ describe('ManageSeasonLifecycleUseCase', () => { findById: Mock; update: Mock; }; - + let output: UseCaseOutputPort & { + present: Mock; + }; + beforeEach(() => { leagueRepository = { findById: vi.fn(), @@ -22,12 +33,18 @@ describe('ManageSeasonLifecycleUseCase', () => { findById: vi.fn(), update: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { + present: Mock; + }; useCase = new ManageSeasonLifecycleUseCase( leagueRepository as unknown as ILeagueRepository, seasonRepository as unknown as ISeasonRepository, + output, ); }); - + it('applies activate → complete → archive transitions and persists state', async () => { const league = { id: 'league-1' }; let currentSeason = Season.create({ @@ -37,45 +54,63 @@ describe('ManageSeasonLifecycleUseCase', () => { 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 = { + + const activateInput: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'activate', }; - - const activated = await useCase.execute(activateCommand); + + const activated = await useCase.execute(activateInput); expect(activated.isOk()).toBe(true); - expect(activated.unwrap().status).toBe('active'); - - const completeCommand: ManageSeasonLifecycleCommand = { + expect(activated.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const [firstCall] = output.present.mock.calls; + const [firstArg] = firstCall as [ManageSeasonLifecycleResult]; + let presented = firstArg; + expect(presented.season.status).toBe('active'); + + (output.present as Mock).mockClear(); + + const completeInput: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'complete', }; - - const completed = await useCase.execute(completeCommand); + + const completed = await useCase.execute(completeInput); expect(completed.isOk()).toBe(true); - expect(completed.unwrap().status).toBe('completed'); - - const archiveCommand: ManageSeasonLifecycleCommand = { + expect(completed.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + { + const [[arg]] = output.present.mock.calls as [[ManageSeasonLifecycleResult]]; + presented = arg; + } + expect(presented.season.status).toBe('completed'); + + (output.present as Mock).mockClear(); + + const archiveInput: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: currentSeason.id, transition: 'archive', }; - - const archived = await useCase.execute(archiveCommand); + + const archived = await useCase.execute(archiveInput); expect(archived.isOk()).toBe(true); - expect(archived.unwrap().status).toBe('archived'); + expect(archived.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + presented = output.present.mock.calls[0][0] as ManageSeasonLifecycleResult; + expect(presented.season.status).toBe('archived'); }); - + it('propagates domain invariant errors for invalid transitions', async () => { const league = { id: 'league-1' }; const season = Season.create({ @@ -85,54 +120,67 @@ describe('ManageSeasonLifecycleUseCase', () => { name: 'Lifecycle Season', status: 'planned', }); - + leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(season); - - const completeCommand: ManageSeasonLifecycleCommand = { + + const completeInput: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: season.id, transition: 'complete', }; - - const result = await useCase.execute(completeCommand); + + const result = await useCase.execute(completeInput); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().code).toEqual('INVALID_TRANSITION'); + const error = result.unwrapErr() as ApplicationErrorCode< + ManageSeasonLifecycleErrorCode, + { message: string } + >; + expect(error.code).toEqual('INVALID_TRANSITION'); + expect(output.present).not.toHaveBeenCalled(); }); - + it('returns error when league not found', async () => { leagueRepository.findById.mockResolvedValue(null); - - const command: ManageSeasonLifecycleCommand = { + + const input: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: 'season-1', transition: 'activate', }; - - const result = await useCase.execute(command); + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'LEAGUE_NOT_FOUND', - details: { message: 'League not found: league-1' }, - }); + const error = result.unwrapErr() as ApplicationErrorCode< + ManageSeasonLifecycleErrorCode, + { message: string } + >; + expect(error.code).toEqual('LEAGUE_NOT_FOUND'); + expect(error.details).toEqual({ message: 'League not found: league-1' }); + expect(output.present).not.toHaveBeenCalled(); }); - + it('returns error when season not found', async () => { const league = { id: 'league-1' }; leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(null); - - const command: ManageSeasonLifecycleCommand = { + + const input: ManageSeasonLifecycleInput = { leagueId: 'league-1', seasonId: 'season-1', transition: 'activate', }; - - const result = await useCase.execute(command); + + const result = await useCase.execute(input); 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' }, + const error = result.unwrapErr() as ApplicationErrorCode< + ManageSeasonLifecycleErrorCode, + { message: string } + >; + expect(error.code).toEqual('SEASON_NOT_FOUND'); + expect(error.details).toEqual({ + message: 'Season season-1 does not belong to league league-1', }); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts index 7ab08ed95..639aa8098 100644 --- a/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts +++ b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts @@ -1,7 +1,9 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { SeasonStatus } from '../../domain/entities/season/Season'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application'; export type SeasonLifecycleTransition = | 'activate' @@ -9,20 +11,29 @@ export type SeasonLifecycleTransition = | 'archive' | 'cancel'; -export interface ManageSeasonLifecycleCommand { +export type ManageSeasonLifecycleInput = { leagueId: string; seasonId: string; transition: SeasonLifecycleTransition; -} +}; -export interface ManageSeasonLifecycleResultDTO { - seasonId: string; - status: import('../../domain/entities/Season').SeasonStatus; +export type ManagedSeasonState = { + id: string; + leagueId: string; + status: SeasonStatus; startDate?: Date; endDate?: Date; -} +}; -type ManageSeasonLifecycleErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'INVALID_TRANSITION'; +export type ManageSeasonLifecycleResult = { + season: ManagedSeasonState; +}; + +export type ManageSeasonLifecycleErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'INVALID_TRANSITION' + | 'REPOSITORY_ERROR'; /** * ManageSeasonLifecycleUseCase @@ -31,59 +42,76 @@ export class ManageSeasonLifecycleUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( - command: ManageSeasonLifecycleCommand, - ): Promise>> { - const league = await this.leagueRepository.findById(command.leagueId); - if (!league) { - return Result.err({ - code: 'LEAGUE_NOT_FOUND', - details: { message: `League not found: ${command.leagueId}` }, - }); - } - - const season = await this.seasonRepository.findById(command.seasonId); - if (!season || season.leagueId !== league.id) { - return Result.err({ - code: 'SEASON_NOT_FOUND', - details: { message: `Season ${command.seasonId} does not belong to league ${league.id}` }, - }); - } - - let updated; + input: ManageSeasonLifecycleInput, + ): Promise>> { try { - switch (command.transition) { - case 'activate': - updated = season.activate(); - break; - case 'complete': - updated = season.complete(); - break; - case 'archive': - updated = season.archive(); - break; - case 'cancel': - updated = season.cancel(); - break; - default: - throw new Error(`Unsupported Season lifecycle transition`); + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League not found: ${input.leagueId}` }, + }); } + + const season = await this.seasonRepository.findById(input.seasonId); + if (!season || season.leagueId !== league.id) { + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { message: `Season ${input.seasonId} does not belong to league ${league.id}` }, + }); + } + + let updated; + try { + switch (input.transition) { + case 'activate': + updated = season.activate(); + break; + case 'complete': + updated = season.complete(); + break; + case 'archive': + updated = season.archive(); + break; + case 'cancel': + updated = season.cancel(); + break; + default: + throw new Error('Unsupported Season lifecycle transition'); + } + } catch (error) { + return Result.err({ + code: 'INVALID_TRANSITION', + details: { message: `Invalid transition: ${(error as Error).message}` }, + }); + } + + await this.seasonRepository.update(updated); + + const result: ManageSeasonLifecycleResult = { + season: { + id: updated.id, + leagueId: updated.leagueId, + status: updated.status, + ...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}), + ...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}), + }, + }; + + this.output.present(result); + + return Result.ok(undefined); } catch (error) { return Result.err({ - code: 'INVALID_TRANSITION', - details: { message: `Invalid transition: ${(error as Error).message}` }, + code: 'REPOSITORY_ERROR', + details: { + message: (error as Error).message || 'Failed to manage season lifecycle', + }, }); } - - await this.seasonRepository.update(updated); - - return Result.ok({ - seasonId: updated.id, - status: updated.status, - ...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}), - ...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}), - }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts index 719b8da2a..792e5e38e 100644 --- a/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts +++ b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts @@ -1,8 +1,15 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { PreviewLeagueScheduleUseCase } from './PreviewLeagueScheduleUseCase'; +import { + PreviewLeagueScheduleUseCase, + type PreviewLeagueScheduleResult, + type PreviewLeagueScheduleInput, + type PreviewLeagueScheduleErrorCode, +} from './PreviewLeagueScheduleUseCase'; import type { Logger } from '@core/shared/application'; - -describe('PreviewLeagueScheduleUseCase', () => { +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + + describe('PreviewLeagueScheduleUseCase', () => { let useCase: PreviewLeagueScheduleUseCase; let logger: { debug: Mock; @@ -10,59 +17,111 @@ describe('PreviewLeagueScheduleUseCase', () => { warn: Mock; error: Mock; }; - - beforeEach(() => { + let output: { present: Mock } & + UseCaseOutputPort; + + beforeEach(() => { logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as typeof output; useCase = new PreviewLeagueScheduleUseCase( undefined, logger as unknown as Logger, + output, ); }); - + it('should preview schedule successfully', async () => { - const params = { + const params: PreviewLeagueScheduleInput = { schedule: { seasonStartDate: '2024-01-01', - recurrenceStrategy: 'weekly' as const, - weekdays: ['Mon' as const], + recurrenceStrategy: 'weekly', + weekdays: ['Mon'], raceStartTime: '20:00', timezoneId: 'UTC', plannedRounds: 5, }, maxRounds: 3, }; - + const result = await useCase.execute(params); - + expect(result.isOk()).toBe(true); - const preview = result.unwrap(); - expect(preview.rounds.length).toBeGreaterThan(0); - expect(preview.summary).toContain('Every Mon'); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = + output.present.mock.calls[0][0] as PreviewLeagueScheduleResult; + expect(presented.rounds.length).toBeGreaterThan(0); + expect(presented.summary).toContain('Every Mon'); }); + + it('should return error for invalid schedule', async () => { + const params: PreviewLeagueScheduleInput = { + schedule: { + seasonStartDate: 'invalid', + recurrenceStrategy: 'weekly', + weekdays: ['Mon'], + raceStartTime: '20:00', + timezoneId: 'UTC', + plannedRounds: 5, + }, + }; - it('should return error for invalid schedule', async () => { - const params = { - schedule: { - seasonStartDate: 'invalid', - recurrenceStrategy: 'weekly' as const, - weekdays: ['Mon' as const], - raceStartTime: '20:00', - timezoneId: 'UTC', - plannedRounds: 5, - }, - }; + const result = await useCase.execute(params); - const result = await useCase.execute(params); + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + PreviewLeagueScheduleErrorCode, + { message: string } + >; + expect(err.code).toBe('INVALID_SCHEDULE'); + expect(err.details.message).toBe('Invalid schedule data'); + expect(output.present).not.toHaveBeenCalled(); + }); - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'INVALID_SCHEDULE', - details: { message: 'Invalid schedule data' }, - }); - }); + it('should return REPOSITORY_ERROR when generator throws', async () => { + const throwingGenerator = { + generateSlotsUpTo: vi.fn(() => { + throw new Error('boom'); + }), + } as unknown as Pick< + typeof import('../../domain/services/SeasonScheduleGenerator').SeasonScheduleGenerator, + 'generateSlotsUpTo' + >; + + const throwingUseCase = new PreviewLeagueScheduleUseCase( + throwingGenerator, + logger as unknown as Logger, + output, + ); + + const params: PreviewLeagueScheduleInput = { + schedule: { + seasonStartDate: '2024-01-01', + recurrenceStrategy: 'weekly', + weekdays: ['Mon'], + raceStartTime: '20:00', + timezoneId: 'UTC', + plannedRounds: 5, + }, + maxRounds: 3, + }; + + const result = await throwingUseCase.execute(params); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + PreviewLeagueScheduleErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('boom'); + expect(output.present).not.toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts index 096bfd8d2..73e8c0c3c 100644 --- a/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts +++ b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts @@ -1,65 +1,128 @@ import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator'; -import type { LeagueScheduleDTO } from '../dto/LeagueScheduleDTO'; import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO'; -import type { LeagueSchedulePreviewOutputPort } from '../ports/output/LeagueSchedulePreviewOutputPort'; -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { Logger } from '@core/shared/application'; import type { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; -interface PreviewLeagueScheduleQueryParams { - schedule: LeagueScheduleDTO; +export type PreviewLeagueScheduleSeasonConfig = { + seasonStartDate: string; + recurrenceStrategy: string; + weekdays?: string[]; + raceStartTime: string; + timezoneId: string; + plannedRounds: number; + intervalWeeks?: number; + monthlyOrdinal?: 1 | 2 | 3 | 4; + monthlyWeekday?: string; +}; + +export type PreviewLeagueScheduleInput = { + schedule: PreviewLeagueScheduleSeasonConfig; maxRounds?: number; +}; + +export interface PreviewLeagueScheduleRound { + roundNumber: number; + scheduledAt: string; + timezoneId: string; } -type PreviewLeagueScheduleErrorCode = 'INVALID_SCHEDULE'; +export interface PreviewLeagueScheduleResult { + rounds: PreviewLeagueScheduleRound[]; + summary: string; +} -type PreviewLeagueScheduleApplicationError = ApplicationErrorCode; +export type PreviewLeagueScheduleErrorCode = + | 'INVALID_SCHEDULE' + | 'REPOSITORY_ERROR'; -export class PreviewLeagueScheduleUseCase implements AsyncUseCase { +export class PreviewLeagueScheduleUseCase { constructor( - private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator, + private readonly scheduleGenerator: Pick< + typeof SeasonScheduleGenerator, + 'generateSlotsUpTo' + > = SeasonScheduleGenerator, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: PreviewLeagueScheduleQueryParams): Promise> { + async execute( + params: PreviewLeagueScheduleInput, + ): Promise< + Result< + void, + ApplicationErrorCode + > + > { this.logger.debug('Previewing league schedule', { params }); - let seasonSchedule: SeasonSchedule; try { - seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule); + let seasonSchedule: SeasonSchedule; + try { + seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule as any); + } catch (error) { + this.logger.warn('Invalid schedule data provided', { + schedule: params.schedule, + error: error instanceof Error ? error.message : 'Unknown error', + }); + return Result.err({ + code: 'INVALID_SCHEDULE', + details: { message: 'Invalid schedule data' }, + }); + } + + const maxRounds = + params.maxRounds && params.maxRounds > 0 + ? Math.min(params.maxRounds, seasonSchedule.plannedRounds) + : seasonSchedule.plannedRounds; + + const slots = this.scheduleGenerator.generateSlotsUpTo( + seasonSchedule, + maxRounds, + ); + + const rounds: PreviewLeagueScheduleRound[] = slots.map((slot) => ({ + roundNumber: slot.roundNumber, + scheduledAt: slot.scheduledAt.toISOString(), + timezoneId: slot.timezone.id, + })); + + const summary = this.buildSummary(params.schedule, rounds); + + const result: PreviewLeagueScheduleResult = { + rounds, + summary, + }; + + this.logger.info('Successfully generated league schedule preview', { + roundCount: rounds.length, + }); + + this.output.present(result); + + return Result.ok(undefined); } catch (error) { - this.logger.warn('Invalid schedule data provided', { schedule: params.schedule, error: error instanceof Error ? error.message : 'Unknown error' }); - return Result.err({ code: 'INVALID_SCHEDULE', details: { message: 'Invalid schedule data' } }); + this.logger.error( + 'Failed to preview league schedule due to an unexpected error', + error instanceof Error ? error : new Error('Unknown error'), + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to preview league schedule', + }, + }); } - - const maxRounds = - params.maxRounds && params.maxRounds > 0 - ? Math.min(params.maxRounds, seasonSchedule.plannedRounds) - : seasonSchedule.plannedRounds; - - const slots = this.scheduleGenerator.generateSlotsUpTo(seasonSchedule, maxRounds); - - const rounds = slots.map((slot) => ({ - roundNumber: slot.roundNumber, - scheduledAt: slot.scheduledAt.toISOString(), - timezoneId: slot.timezone.getId(), - })); - - const summary = this.buildSummary(params.schedule, rounds); - - const result: LeagueSchedulePreviewOutputPort = { - rounds, - summary, - }; - - this.logger.info('Successfully generated league schedule preview', { roundCount: rounds.length }); - return Result.ok(result); } private buildSummary( - schedule: LeagueScheduleDTO, + schedule: PreviewLeagueScheduleSeasonConfig, rounds: Array<{ roundNumber: number; scheduledAt: string; timezoneId: string }>, ): string { if (rounds.length === 0) { diff --git a/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts b/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts index be21eb5c9..51eaedd03 100644 --- a/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts +++ b/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { QuickPenaltyUseCase } from './QuickPenaltyUseCase'; +import { QuickPenaltyUseCase, type QuickPenaltyInput, type QuickPenaltyResult, type QuickPenaltyErrorCode } from './QuickPenaltyUseCase'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + describe('QuickPenaltyUseCase', () => { let useCase: QuickPenaltyUseCase; @@ -22,6 +25,7 @@ describe('QuickPenaltyUseCase', () => { warn: Mock; error: Mock; }; + let output: (UseCaseOutputPort & { present: Mock }); beforeEach(() => { penaltyRepository = { @@ -39,96 +43,113 @@ describe('QuickPenaltyUseCase', () => { warn: vi.fn(), error: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new QuickPenaltyUseCase( penaltyRepository as unknown as IPenaltyRepository, raceRepository as unknown as IRaceRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, logger as unknown as Logger, + output, ); }); - it('should apply penalty successfully', async () => { - const command = { + const input: QuickPenaltyInput = { raceId: 'race-1', driverId: 'driver-1', adminId: 'admin-1', - infractionType: 'track_limits' as const, - severity: 'minor' as const, + infractionType: 'track_limits', + severity: 'minor', notes: 'Test penalty', }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ - { driverId: 'admin-1', role: 'admin', status: 'active' }, + { driverId: { toString: () => 'admin-1' }, role: { toString: () => 'admin' }, status: { toString: () => 'active' } }, ]); penaltyRepository.create.mockResolvedValue(undefined); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toHaveProperty('penaltyId'); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0]![0] as QuickPenaltyResult; + expect(presented.raceId).toBe('race-1'); + expect(presented.driverId).toBe('driver-1'); + expect(presented.penaltyId).toBeDefined(); + expect(presented.type).toBeDefined(); }); it('should return error when race not found', async () => { - const command = { + const input: QuickPenaltyInput = { raceId: 'race-1', driverId: 'driver-1', adminId: 'admin-1', - infractionType: 'track_limits' as const, - severity: 'minor' as const, + infractionType: 'track_limits', + severity: 'minor', }; raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error).toEqual({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return error when admin unauthorized', async () => { - const command = { + const input: QuickPenaltyInput = { raceId: 'race-1', driverId: 'driver-1', adminId: 'admin-1', - infractionType: 'track_limits' as const, - severity: 'minor' as const, + infractionType: 'track_limits', + severity: 'minor', }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ - { driverId: 'admin-1', role: 'member', status: 'active' }, + { driverId: { toString: () => 'admin-1' }, role: { toString: () => 'member' }, status: { toString: () => 'active' } }, ]); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error).toEqual({ code: 'UNAUTHORIZED', details: { message: 'Only league owners and admins can issue penalties' }, }); + expect(output.present).not.toHaveBeenCalled(); }); it('should handle other infraction type', async () => { - const command = { + const input: QuickPenaltyInput = { raceId: 'race-1', driverId: 'driver-1', adminId: 'admin-1', - infractionType: 'other' as const, - severity: 'major' as const, + infractionType: 'other', + severity: 'major', }; raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ - { driverId: 'admin-1', role: 'admin', status: 'active' }, + { driverId: { toString: () => 'admin-1' }, role: { toString: () => 'admin' }, status: { toString: () => 'active' } }, ]); penaltyRepository.create.mockResolvedValue(undefined); - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/QuickPenaltyUseCase.ts b/core/racing/application/use-cases/QuickPenaltyUseCase.ts index 2c6daed8d..024f240ae 100644 --- a/core/racing/application/use-cases/QuickPenaltyUseCase.ts +++ b/core/racing/application/use-cases/QuickPenaltyUseCase.ts @@ -5,67 +5,76 @@ * Designed for fast, common penalty scenarios like track limits, warnings, etc. */ -import { Penalty, type PenaltyType } from '../../domain/entities/Penalty'; +import { Penalty } from '../../domain/entities/Penalty'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -type QuickPenaltyErrorCode = 'RACE_NOT_FOUND' | 'UNAUTHORIZED' | 'UNKNOWN_INFRACTION' | 'REPOSITORY_ERROR'; +export type QuickPenaltyErrorCode = 'RACE_NOT_FOUND' | 'UNAUTHORIZED' | 'UNKNOWN_INFRACTION' | 'REPOSITORY_ERROR'; -type QuickPenaltyApplicationError = ApplicationErrorCode; +export type QuickPenaltyApplicationError = ApplicationErrorCode; -export interface QuickPenaltyCommand { +export type QuickPenaltyInput = { raceId: string; driverId: string; adminId: string; infractionType: 'track_limits' | 'unsafe_rejoin' | 'aggressive_driving' | 'false_start' | 'other'; severity: 'warning' | 'minor' | 'major' | 'severe'; notes?: string; -} +}; -export class QuickPenaltyUseCase - implements AsyncUseCase { +export type QuickPenaltyResult = { + penaltyId: string; + raceId: string; + driverId: string; + type: string; + value?: number; + reason: string; +}; + +export class QuickPenaltyUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly raceRepository: IRaceRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: QuickPenaltyCommand): Promise> { - this.logger.debug('Executing QuickPenaltyUseCase', { command }); + async execute(input: QuickPenaltyInput): Promise> { + this.logger.debug('Executing QuickPenaltyUseCase', { input }); try { // Validate race exists - const race = await this.raceRepository.findById(command.raceId); + const race = await this.raceRepository.findById(input.raceId); if (!race) { - this.logger.warn('Race not found', { raceId: command.raceId }); + this.logger.warn('Race not found', { raceId: input.raceId }); return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); } // Validate admin has authority const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); const adminMembership = memberships.find( - m => m.driverId === command.adminId && m.status === 'active' + m => m.driverId.toString() === input.adminId && m.status.toString() === 'active' ); - if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) { - this.logger.warn('Unauthorized admin attempting to issue penalty', { adminId: command.adminId, leagueId: race.leagueId }); + if (!adminMembership || (adminMembership.role.toString() !== 'owner' && adminMembership.role.toString() !== 'admin')) { + this.logger.warn('Unauthorized admin attempting to issue penalty', { adminId: input.adminId, leagueId: race.leagueId }); return Result.err({ code: 'UNAUTHORIZED', details: { message: 'Only league owners and admins can issue penalties' } }); } // Map infraction + severity to penalty type and value const penaltyMapping = this.mapInfractionToPenalty( - command.infractionType, - command.severity + input.infractionType, + input.severity ); if (!penaltyMapping) { - this.logger.error('Unknown infraction type', { infractionType: command.infractionType, severity: command.severity }); + this.logger.error('Unknown infraction type', { infractionType: input.infractionType, severity: input.severity }); return Result.err({ code: 'UNKNOWN_INFRACTION', details: { message: 'Unknown infraction type' } }); } @@ -75,22 +84,33 @@ export class QuickPenaltyUseCase const penalty = Penalty.create({ id: randomUUID(), leagueId: race.leagueId, - raceId: command.raceId, - driverId: command.driverId, + raceId: input.raceId, + driverId: input.driverId, type, ...(value !== undefined ? { value } : {}), reason, - issuedBy: command.adminId, + issuedBy: input.adminId, status: 'applied', // Quick penalties are applied immediately issuedAt: new Date(), appliedAt: new Date(), - ...(command.notes !== undefined ? { notes: command.notes } : {}), + ...(input.notes !== undefined ? { notes: input.notes } : {}), }); await this.penaltyRepository.create(penalty); - this.logger.info('Quick penalty applied successfully', { penaltyId: penalty.id, raceId: command.raceId, driverId: command.driverId }); - return Result.ok({ penaltyId: penalty.id }); + const result: QuickPenaltyResult = { + penaltyId: penalty.id, + raceId: input.raceId, + driverId: input.driverId, + type, + ...(value !== undefined ? { value } : {}), + reason, + }; + + this.output.present(result); + + this.logger.info('Quick penalty applied successfully', { penaltyId: penalty.id, raceId: input.raceId, driverId: input.driverId }); + return Result.ok(undefined); } catch (error) { this.logger.error('Failed to apply quick penalty', { error: error instanceof Error ? error.message : 'Unknown error' }); return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } }); @@ -98,10 +118,10 @@ export class QuickPenaltyUseCase } private mapInfractionToPenalty( - infractionType: QuickPenaltyCommand['infractionType'], - severity: QuickPenaltyCommand['severity'] - ): { type: PenaltyType; value?: number; reason: string } | null { - const severityMultipliers = { + infractionType: QuickPenaltyInput['infractionType'], + severity: QuickPenaltyInput['severity'] + ): { type: string; value?: number; reason: string } | null { + const severityMultipliers: Record = { warning: 1, minor: 2, major: 3, diff --git a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts index ee4181782..adedf7c23 100644 --- a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts +++ b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts @@ -1,18 +1,26 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RecalculateChampionshipStandingsUseCase } from './RecalculateChampionshipStandingsUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + RecalculateChampionshipStandingsUseCase, + type RecalculateChampionshipStandingsInput, + type RecalculateChampionshipStandingsResult, + type RecalculateChampionshipStandingsErrorCode, +} from './RecalculateChampionshipStandingsUseCase'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IChampionshipStandingRepository } from '../../domain/repositories/IChampionshipStandingRepository'; -import type { Result } from '../../domain/entities/Result'; import type { Penalty } from '../../domain/entities/Penalty'; import { EventScoringService } from '../../domain/services/EventScoringService'; import { ChampionshipAggregator } from '../../domain/services/ChampionshipAggregator'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + describe('RecalculateChampionshipStandingsUseCase', () => { let useCase: RecalculateChampionshipStandingsUseCase; + let leagueRepository: { findById: Mock }; let seasonRepository: { findById: Mock }; let leagueScoringConfigRepository: { findBySeasonId: Mock }; let raceRepository: { findByLeagueId: Mock }; @@ -21,8 +29,13 @@ describe('RecalculateChampionshipStandingsUseCase', () => { let championshipStandingRepository: { saveAll: Mock }; let eventScoringService: { scoreSession: Mock }; let championshipAggregator: { aggregate: Mock }; + let logger: Logger; + let output: UseCaseOutputPort & { + present: ReturnType; + }; beforeEach(() => { + leagueRepository = { findById: vi.fn() }; seasonRepository = { findById: vi.fn() }; leagueScoringConfigRepository = { findBySeasonId: vi.fn() }; raceRepository = { findByLeagueId: vi.fn() }; @@ -31,7 +44,16 @@ describe('RecalculateChampionshipStandingsUseCase', () => { championshipStandingRepository = { saveAll: vi.fn() }; eventScoringService = { scoreSession: vi.fn() }; championshipAggregator = { aggregate: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as typeof output; + useCase = new RecalculateChampionshipStandingsUseCase( + leagueRepository as unknown as ISeasonRepository, seasonRepository as unknown as ISeasonRepository, leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, raceRepository as unknown as IRaceRepository, @@ -40,56 +62,96 @@ describe('RecalculateChampionshipStandingsUseCase', () => { championshipStandingRepository as unknown as IChampionshipStandingRepository, eventScoringService as unknown as EventScoringService, championshipAggregator as unknown as ChampionshipAggregator, + logger, + output, ); }); - it('should return season not found error', async () => { + it('returns league not found error', async () => { + leagueRepository.findById.mockResolvedValue(null); + + const input: RecalculateChampionshipStandingsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + RecalculateChampionshipStandingsErrorCode, + { message: string } + >; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toContain('league-1'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns season not found error when season does not exist', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); seasonRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + const input: RecalculateChampionshipStandingsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'SEASON_NOT_FOUND', - details: { message: 'Season not found: season-1' }, - }); + const error = result.unwrapErr() as ApplicationErrorCode< + RecalculateChampionshipStandingsErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return league scoring config not found error', async () => { - seasonRepository.findById.mockResolvedValue({ id: 'season-1', leagueId: 'league-1' }); - leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(null); + it('returns season not found error when season belongs to different league', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + seasonRepository.findById.mockResolvedValue({ id: 'season-1', leagueId: 'other-league' }); - const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + const input: RecalculateChampionshipStandingsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'LEAGUE_SCORING_CONFIG_NOT_FOUND', - details: { message: 'League scoring config not found for season: season-1' }, - }); + const error = result.unwrapErr() as ApplicationErrorCode< + RecalculateChampionshipStandingsErrorCode, + { message: string } + >; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return championship config not found error', async () => { - seasonRepository.findById.mockResolvedValue({ id: 'season-1', leagueId: 'league-1' }); - leagueScoringConfigRepository.findBySeasonId.mockResolvedValue({ championships: [] }); - - const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); - - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'CHAMPIONSHIP_CONFIG_NOT_FOUND', - details: { message: 'Championship config not found: champ-1' }, - }); - }); - - it('should recalculate standings successfully', async () => { + it('recalculates standings successfully and presents result', async () => { + const league = { id: 'league-1' }; const season = { id: 'season-1', leagueId: 'league-1' }; - const championship = { id: 'champ-1', name: 'Champ 1', sessionTypes: ['main'], pointsTableBySessionType: {}, dropScorePolicy: {} }; + const championship = { + id: 'champ-1', + name: 'Champ 1', + sessionTypes: ['main'], + pointsTableBySessionType: {}, + dropScorePolicy: {}, + }; const leagueScoringConfig = { championships: [championship] }; - const races = [{ id: 'race-1', sessionType: 'race' }]; - const results: Result[] = []; + const races = [{ id: 'race-1', sessionType: 'race' as const }]; + const results: unknown[] = []; const penalties: Penalty[] = []; - const standings = [{ participant: 'driver-1', position: 1, totalPoints: 25, resultsCounted: 1, resultsDropped: 0 }]; + const standings = [ + { + participant: { id: 'driver-1' }, + position: { toNumber: () => 1 }, + totalPoints: { toNumber: () => 25 }, + resultsCounted: { toNumber: () => 1 }, + resultsDropped: { toNumber: () => 0 }, + }, + ]; + leagueRepository.findById.mockResolvedValue(league); seasonRepository.findById.mockResolvedValue(season); leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(leagueScoringConfig); raceRepository.findByLeagueId.mockResolvedValue(races); @@ -99,13 +161,50 @@ describe('RecalculateChampionshipStandingsUseCase', () => { championshipAggregator.aggregate.mockReturnValue(standings); championshipStandingRepository.saveAll.mockResolvedValue(undefined); - const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + const input: RecalculateChampionshipStandingsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - const dto = result.unwrap(); - expect(dto.seasonId).toBe('season-1'); - expect(dto.championshipId).toBe('champ-1'); - expect(dto.championshipName).toBe('Champ 1'); - expect(dto.rows).toEqual([{ participant: 'driver-1', position: 1, totalPoints: 25, resultsCounted: 1, resultsDropped: 0 }]); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + + const presented = output.present.mock.calls[0][0] as RecalculateChampionshipStandingsResult; + expect(presented.leagueId).toBe('league-1'); + expect(presented.seasonId).toBe('season-1'); + expect(presented.entries).toHaveLength(1); + expect(presented.entries[0]).toEqual({ + driverId: 'driver-1', + teamId: null, + position: 1, + points: 25, + }); }); -}); \ No newline at end of file + + it('wraps repository failures in REPOSITORY_ERROR and does not call output', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + seasonRepository.findById.mockResolvedValue({ id: 'season-1', leagueId: 'league-1' }); + leagueScoringConfigRepository.findBySeasonId.mockImplementation(() => { + throw new Error('boom'); + }); + + const input: RecalculateChampionshipStandingsInput = { + leagueId: 'league-1', + seasonId: 'season-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + RecalculateChampionshipStandingsErrorCode, + { message: string } + >; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toContain('boom'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts index b33afcb4b..8f4da793e 100644 --- a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts +++ b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts @@ -4,6 +4,7 @@ import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepo import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenaltyRepository'; import type { IChampionshipStandingRepository } from '@core/racing/domain/repositories/IChampionshipStandingRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; import type { SessionType } from '@core/racing/domain/types/SessionType'; @@ -11,27 +12,36 @@ import type { ChampionshipStanding } from '@core/racing/domain/entities/champion import { EventScoringService } from '@core/racing/domain/services/EventScoringService'; import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator'; -import type { ChampionshipStandingsOutputPort } from '../ports/output/ChampionshipStandingsOutputPort'; -import type { ChampionshipStandingsRowOutputPort } from '../ports/output/ChampionshipStandingsRowOutputPort'; - -import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface RecalculateChampionshipStandingsParams { +export type RecalculateChampionshipStandingsInput = { + leagueId: string; seasonId: string; - championshipId: string; -} +}; -type RecalculateChampionshipStandingsErrorCode = +export type ChampionshipStandingsEntry = { + driverId: string | null; + teamId: string | null; + position: number; + points: number; +}; + +export type RecalculateChampionshipStandingsResult = { + leagueId: string; + seasonId: string; + entries: ChampionshipStandingsEntry[]; +}; + +export type RecalculateChampionshipStandingsErrorCode = + | 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' - | 'LEAGUE_SCORING_CONFIG_NOT_FOUND' - | 'CHAMPIONSHIP_CONFIG_NOT_FOUND'; + | 'REPOSITORY_ERROR'; -export class RecalculateChampionshipStandingsUseCase - implements AsyncUseCase -{ +export class RecalculateChampionshipStandingsUseCase { constructor( + private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly raceRepository: IRaceRepository, @@ -40,83 +50,120 @@ export class RecalculateChampionshipStandingsUseCase private readonly championshipStandingRepository: IChampionshipStandingRepository, private readonly eventScoringService: EventScoringService, private readonly championshipAggregator: ChampionshipAggregator, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(params: RecalculateChampionshipStandingsParams): Promise>> { - const { seasonId, championshipId } = params; + async execute( + input: RecalculateChampionshipStandingsInput, + ): Promise< + Result< + void, + ApplicationErrorCode + > + > { + const { leagueId, seasonId } = input; - const season = await this.seasonRepository.findById(seasonId); - if (!season) { - return Result.err({ code: 'SEASON_NOT_FOUND', details: { message: `Season not found: ${seasonId}` } }); - } - - const leagueScoringConfig = - await this.leagueScoringConfigRepository.findBySeasonId(seasonId); - if (!leagueScoringConfig) { - return Result.err({ code: 'LEAGUE_SCORING_CONFIG_NOT_FOUND', details: { message: `League scoring config not found for season: ${seasonId}` } }); - } - - const championship = leagueScoringConfig.championships.find((c) => c.id === championshipId); - if (!championship) { - return Result.err({ code: 'CHAMPIONSHIP_CONFIG_NOT_FOUND', details: { message: `Championship config not found: ${championshipId}` } }); - } - - const races = await this.raceRepository.findByLeagueId(season.leagueId); - - const eventPointsByEventId: Record> = - {}; - - for (const race of races) { - // Map existing Race.sessionType into scoring SessionType where possible. - const sessionType = this.mapRaceSessionType(race.sessionType); - if (!championship.sessionTypes.includes(sessionType)) { - continue; + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League not found: ${leagueId}` }, + }); } - const results = await this.resultRepository.findByRaceId(race.id); + const season = await this.seasonRepository.findById(seasonId); + if (!season || season.leagueId !== leagueId) { + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { + message: `Season not found for league: leagueId=${leagueId}, seasonId=${seasonId}`, + }, + }); + } - // Fetch penalties for this specific race - const penalties = await this.penaltyRepository.findByRaceId(race.id); + const leagueScoringConfig = + await this.leagueScoringConfigRepository.findBySeasonId(seasonId); + if (!leagueScoringConfig) { + throw new Error(`League scoring config not found for season: ${seasonId}`); + } - const participantPoints = this.eventScoringService.scoreSession({ + const championship = this.findChampionshipConfig(leagueScoringConfig.championships); + + const races = await this.raceRepository.findByLeagueId(leagueId); + + const eventPointsByEventId: Record> = + {}; + + for (const race of races) { + const sessionType = this.mapRaceSessionType(race.sessionType); + if (!championship.sessionTypes.includes(sessionType)) { + continue; + } + + const results = await this.resultRepository.findByRaceId(race.id); + const penalties = await this.penaltyRepository.findByRaceId(race.id); + + const participantPoints = this.eventScoringService.scoreSession({ + seasonId, + championship, + sessionType, + results, + penalties, + }); + + eventPointsByEventId[race.id] = participantPoints; + } + + const standings: ChampionshipStanding[] = this.championshipAggregator.aggregate({ seasonId, championship, - sessionType, - results, - penalties, + eventPointsByEventId, }); - eventPointsByEventId[race.id] = participantPoints; + await this.championshipStandingRepository.saveAll(standings); + + const result: RecalculateChampionshipStandingsResult = { + leagueId, + seasonId, + entries: standings.map((standing) => ({ + driverId: standing.participant?.id ?? null, + teamId: null, + position: standing.position.toNumber(), + points: standing.totalPoints.toNumber(), + })), + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const err = error as Error; + this.logger.error('Failed to recalculate championship standings', err, { + leagueId, + seasonId, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + err.message || 'Failed to recalculate championship standings', + }, + }); } - - const standings: ChampionshipStanding[] = this.championshipAggregator.aggregate({ - seasonId, - championship, - eventPointsByEventId, - }); - - await this.championshipStandingRepository.saveAll(standings); - - const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({ - participant: s.participant, - position: s.position.toNumber(), - totalPoints: s.totalPoints.toNumber(), - resultsCounted: s.resultsCounted.toNumber(), - resultsDropped: s.resultsDropped.toNumber(), - })); - - const dto: ChampionshipStandingsOutputPort = { - seasonId, - championshipId: championship.id, - championshipName: championship.name, - rows, - }; - - return Result.ok(dto); } + private findChampionshipConfig(championships: ChampionshipConfig[]): ChampionshipConfig { + if (!championships || championships.length === 0) { + throw new Error('No championship configurations found'); + } - private mapRaceSessionType(sessionType: string): SessionType { + return championships[0]!; + } + + private mapRaceSessionType(sessionType: SessionType | string): SessionType { if (sessionType === 'race') { return 'main'; } @@ -129,4 +176,4 @@ export class RecalculateChampionshipStandingsUseCase } return 'main'; } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts b/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts index 753601acd..fb38eb70c 100644 --- a/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts +++ b/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts @@ -1,78 +1,123 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RegisterForRaceUseCase } from './RegisterForRaceUseCase'; +import { + RegisterForRaceErrorCode, + RegisterForRaceInput, + RegisterForRaceResult, + RegisterForRaceUseCase, +} from './RegisterForRaceUseCase'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { Logger } from '@core/shared/application'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('RegisterForRaceUseCase', () => { let useCase: RegisterForRaceUseCase; let registrationRepository: { isRegistered: Mock; register: Mock }; let membershipRepository: { getMembership: Mock }; let logger: { debug: Mock; warn: Mock; error: Mock; info: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { registrationRepository = { isRegistered: vi.fn(), register: vi.fn() }; membershipRepository = { getMembership: vi.fn() }; logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new RegisterForRaceUseCase( registrationRepository as unknown as IRaceRegistrationRepository, membershipRepository as unknown as ILeagueMembershipRepository, logger as unknown as Logger, + output, ); }); - it('should return already registered error', async () => { - registrationRepository.isRegistered.mockResolvedValue(true); - - const result = await useCase.execute({ raceId: 'race-1', leagueId: 'league-1', driverId: 'driver-1' }); - - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'ALREADY_REGISTERED', - details: { message: 'Already registered for this race' }, - }); + const buildInput = (overrides: Partial = {}): RegisterForRaceInput => ({ + raceId: 'race-1', + leagueId: 'league-1', + driverId: 'driver-1', + ...overrides, }); - it('should return not active member error', async () => { + const unwrapErr = ( + result: Result< + void, + ApplicationErrorCode< + RegisterForRaceErrorCode, + { + message: string; + } + > + >, + ): ApplicationErrorCode => result.unwrapErr(); + + it('returns already registered error when driver is already registered', async () => { + registrationRepository.isRegistered.mockResolvedValue(true); + + const result = await useCase.execute(buildInput()); + + expect(result.isErr()).toBe(true); + const error = unwrapErr(result); + expect(error.code).toBe('ALREADY_REGISTERED'); + expect(error.details.message).toBe('Already registered for this race'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns not active member error when membership is missing', async () => { registrationRepository.isRegistered.mockResolvedValue(false); membershipRepository.getMembership.mockResolvedValue(null); - const result = await useCase.execute({ raceId: 'race-1', leagueId: 'league-1', driverId: 'driver-1' }); + const result = await useCase.execute(buildInput()); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'NOT_ACTIVE_MEMBER', - details: { message: 'Must be an active league member to register for races' }, - }); + const error = unwrapErr(result); + expect(error.code).toBe('NOT_ACTIVE_MEMBER'); + expect(error.details.message).toBe('Must be an active league member to register for races'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return not active member error for inactive membership', async () => { + it('returns not active member error for inactive membership', async () => { registrationRepository.isRegistered.mockResolvedValue(false); membershipRepository.getMembership.mockResolvedValue({ status: 'inactive' }); - const result = await useCase.execute({ raceId: 'race-1', leagueId: 'league-1', driverId: 'driver-1' }); + const result = await useCase.execute(buildInput()); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'NOT_ACTIVE_MEMBER', - details: { message: 'Must be an active league member to register for races' }, - }); + const error = unwrapErr(result); + expect(error.code).toBe('NOT_ACTIVE_MEMBER'); + expect(error.details.message).toBe('Must be an active league member to register for races'); + expect(output.present).not.toHaveBeenCalled(); }); - it('should register successfully', async () => { + it('registers successfully and presents result', async () => { registrationRepository.isRegistered.mockResolvedValue(false); membershipRepository.getMembership.mockResolvedValue({ status: 'active' }); registrationRepository.register.mockResolvedValue(undefined); - const result = await useCase.execute({ raceId: 'race-1', leagueId: 'league-1', driverId: 'driver-1' }); + const result = await useCase.execute(buildInput()); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); - expect(registrationRepository.register).toHaveBeenCalledWith( - expect.objectContaining({ - raceId: 'race-1', - driverId: 'driver-1', - }), - ); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as RegisterForRaceResult; + expect(presented).toEqual({ + raceId: 'race-1', + driverId: 'driver-1', + status: 'registered', + }); + }); + + it('wraps unexpected repository errors', async () => { + const error = new Error('db is down'); + registrationRepository.isRegistered.mockRejectedValue(error); + + const result = await useCase.execute(buildInput()); + + expect(result.isErr()).toBe(true); + const err = unwrapErr(result); + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('db is down'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/RegisterForRaceUseCase.ts b/core/racing/application/use-cases/RegisterForRaceUseCase.ts index 3379c5d4d..31783df36 100644 --- a/core/racing/application/use-cases/RegisterForRaceUseCase.ts +++ b/core/racing/application/use-cases/RegisterForRaceUseCase.ts @@ -1,26 +1,33 @@ import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration'; -import type { AsyncUseCase } from '@core/shared/application'; +import { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import { Logger } from '@core/shared/application'; -export interface RegisterForRaceParams { +export type RegisterForRaceInput = { raceId: string; leagueId: string; driverId: string; -} +}; -type RegisterForRaceErrorCode = 'ALREADY_REGISTERED' | 'NOT_ACTIVE_MEMBER'; +export type RegisterForRaceResult = { + raceId: string; + driverId: string; + status: 'registered'; +}; -export class RegisterForRaceUseCase - implements AsyncUseCase -{ +export type RegisterForRaceErrorCode = + | 'ALREADY_REGISTERED' + | 'NOT_ACTIVE_MEMBER' + | 'REPOSITORY_ERROR'; + +export class RegisterForRaceUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, private readonly membershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} /** @@ -29,30 +36,75 @@ export class RegisterForRaceUseCase * - validates active league membership * - registers driver for race */ - async execute(params: RegisterForRaceParams): Promise>> { - const { raceId, leagueId, driverId } = params; + async execute( + input: RegisterForRaceInput, + ): Promise< + Result< + void, + ApplicationErrorCode< + RegisterForRaceErrorCode, + { + message: string; + } + > + > + > { + const { raceId, leagueId, driverId } = input; this.logger.debug('RegisterForRaceUseCase: executing params', { raceId, leagueId, driverId }); - const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId); - if (alreadyRegistered) { - this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`); - return Result.err({ code: 'ALREADY_REGISTERED', details: { message: 'Already registered for this race' } }); + try { + const alreadyRegistered = await this.registrationRepository.isRegistered(raceId, driverId); + if (alreadyRegistered) { + this.logger.warn(`RegisterForRaceUseCase: driver ${driverId} already registered for race ${raceId}`); + return Result.err({ + code: 'ALREADY_REGISTERED', + details: { message: 'Already registered for this race' }, + }); + } + + const membership = await this.membershipRepository.getMembership(leagueId, driverId); + if (!membership || membership.status !== 'active') { + this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`); + return Result.err({ + code: 'NOT_ACTIVE_MEMBER', + details: { message: 'Must be an active league member to register for races' }, + }); + } + + const registration = RaceRegistration.create({ + raceId, + driverId, + }); + + await this.registrationRepository.register(registration); + this.logger.info(`RegisterForRaceUseCase: driver ${driverId} successfully registered for race ${raceId}`); + + const result: RegisterForRaceResult = { + raceId: registration.raceId.toString(), + driverId: registration.driverId.toString(), + status: 'registered', + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to register for race'; + + this.logger.error('RegisterForRaceUseCase: unexpected error during registration', { + raceId, + leagueId, + driverId, + error, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - const membership = await this.membershipRepository.getMembership(leagueId, driverId); - if (!membership || membership.status !== 'active') { - this.logger.error(`RegisterForRaceUseCase: driver ${driverId} not an active member of league ${leagueId}`); - return Result.err({ code: 'NOT_ACTIVE_MEMBER', details: { message: 'Must be an active league member to register for races' } }); - } - - const registration = RaceRegistration.create({ - raceId, - driverId, - }); - - await this.registrationRepository.register(registration); - this.logger.info(`RegisterForRaceUseCase: driver ${driverId} successfully registered for race ${raceId}`); - - return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts index 63da79b6c..1979e1af2 100644 --- a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.test.ts @@ -1,36 +1,225 @@ -import { describe, it, expect, vi, type Mock } from 'vitest'; -import { RejectLeagueJoinRequestUseCase, type RejectLeagueJoinRequestUseCaseParams } from './RejectLeagueJoinRequestUseCase'; +import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest'; +import { + RejectLeagueJoinRequestUseCase, + type RejectLeagueJoinRequestInput, + type RejectLeagueJoinRequestResult, + type RejectLeagueJoinRequestErrorCode, +} from './RejectLeagueJoinRequestUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; + +interface LeagueRepositoryMock { + findById: Mock; +} interface LeagueMembershipRepositoryMock { + getMembership: Mock; + getJoinRequests: Mock; removeJoinRequest: Mock; } describe('RejectLeagueJoinRequestUseCase', () => { + let leagueRepository: LeagueRepositoryMock; let leagueMembershipRepository: LeagueMembershipRepositoryMock; + let logger: Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: RejectLeagueJoinRequestUseCase; beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + } as unknown as ILeagueRepository as any; + leagueMembershipRepository = { + getMembership: vi.fn(), + getJoinRequests: vi.fn(), removeJoinRequest: vi.fn(), } as unknown as ILeagueMembershipRepository as any; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new RejectLeagueJoinRequestUseCase( + leagueRepository as unknown as ILeagueRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, + logger, + output, ); }); - it('removes the join request and returns success output', async () => { - const params: RejectLeagueJoinRequestUseCaseParams = { + it('rejects a pending join request successfully and presents result', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'league-1', + adminId: 'admin-1', requestId: 'req-1', }; - (leagueMembershipRepository.removeJoinRequest as Mock).mockResolvedValue(undefined); + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + leagueMembershipRepository.getMembership.mockResolvedValue({ + leagueId: 'league-1', + driverId: 'admin-1', + role: { toString: () => 'owner' }, + status: { toString: () => 'active' }, + }); + leagueMembershipRepository.getJoinRequests.mockResolvedValue([ + { + id: 'req-1', + leagueId: 'league-1', + driverId: 'driver-1', + status: 'pending', + }, + ]); + leagueMembershipRepository.removeJoinRequest.mockResolvedValue(undefined); - const result = await useCase.execute(params); + const result = await useCase.execute(input); - expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith('req-1'); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ success: true, message: 'Join request rejected.' }); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as RejectLeagueJoinRequestResult; + expect(presented.leagueId).toBe('league-1'); + expect(presented.requestId).toBe('req-1'); + expect(presented.status).toBe('rejected'); + expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith('req-1'); + }); + + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'missing-league', + adminId: 'admin-1', + requestId: 'req-1', + }; + + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectLeagueJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('LEAGUE_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); + expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns UNAUTHORIZED when admin is not authorized', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'league-1', + adminId: 'user-1', + requestId: 'req-1', + }; + + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + leagueMembershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectLeagueJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('UNAUTHORIZED'); + expect(output.present).not.toHaveBeenCalled(); + expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns REQUEST_NOT_FOUND when join request does not exist', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'league-1', + adminId: 'admin-1', + requestId: 'missing-req', + }; + + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + leagueMembershipRepository.getMembership.mockResolvedValue({ + leagueId: 'league-1', + driverId: 'admin-1', + role: { toString: () => 'owner' }, + status: { toString: () => 'active' }, + }); + leagueMembershipRepository.getJoinRequests.mockResolvedValue([]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectLeagueJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('REQUEST_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); + expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns INVALID_REQUEST_STATE when join request is not pending', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'league-1', + adminId: 'admin-1', + requestId: 'req-1', + }; + + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + leagueMembershipRepository.getMembership.mockResolvedValue({ + leagueId: 'league-1', + driverId: 'admin-1', + role: { toString: () => 'owner' }, + status: { toString: () => 'active' }, + }); + leagueMembershipRepository.getJoinRequests.mockResolvedValue([ + { + id: 'req-1', + leagueId: 'league-1', + driverId: 'driver-1', + status: 'approved', + }, + ]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectLeagueJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('INVALID_REQUEST_STATE'); + expect(output.present).not.toHaveBeenCalled(); + expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + const input: RejectLeagueJoinRequestInput = { + leagueId: 'league-1', + adminId: 'admin-1', + requestId: 'req-1', + }; + + const repoError = new Error('Repository failure'); + leagueRepository.findById.mockRejectedValue(repoError); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectLeagueJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(leagueMembershipRepository.removeJoinRequest).not.toHaveBeenCalled(); }); }); diff --git a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts index 8f3a35247..e42ac9e10 100644 --- a/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/RejectLeagueJoinRequestUseCase.ts @@ -1,19 +1,125 @@ -import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { RejectLeagueJoinRequestOutputPort } from '../ports/output/RejectLeagueJoinRequestOutputPort'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -export interface RejectLeagueJoinRequestUseCaseParams { +export type RejectLeagueJoinRequestInput = { + leagueId: string; + adminId: string; requestId: string; -} + reason?: string; +}; -export class RejectLeagueJoinRequestUseCase implements AsyncUseCase { - constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} +export type RejectLeagueJoinRequestResult = { + leagueId: string; + requestId: string; + status: 'rejected'; +}; - async execute(params: RejectLeagueJoinRequestUseCaseParams): Promise>> { - await this.leagueMembershipRepository.removeJoinRequest(params.requestId); - const port: RejectLeagueJoinRequestOutputPort = { success: true, message: 'Join request rejected.' }; - return Result.ok(port); +export type RejectLeagueJoinRequestErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'REQUEST_NOT_FOUND' + | 'UNAUTHORIZED' + | 'INVALID_REQUEST_STATE' + | 'REPOSITORY_ERROR'; + +export class RejectLeagueJoinRequestUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + input: RejectLeagueJoinRequestInput, + ): Promise>> { + const { leagueId, adminId, requestId, reason } = input; + + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + this.logger.warn('League not found when rejecting join request', { leagueId, adminId, requestId }); + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const adminMembership = await this.leagueMembershipRepository.getMembership(leagueId, adminId); + if ( + !adminMembership || + adminMembership.status.toString() !== 'active' || + (adminMembership.role.toString() !== 'owner' && adminMembership.role.toString() !== 'admin') + ) { + this.logger.warn('User is not authorized to reject league join requests', { + leagueId, + adminId, + requestId, + }); + return Result.err({ + code: 'UNAUTHORIZED', + details: { message: 'User is not authorized to reject league join requests' }, + }); + } + + const joinRequests = await this.leagueMembershipRepository.getJoinRequests(leagueId); + const joinRequest = joinRequests.find(r => r.id === requestId); + if (!joinRequest) { + this.logger.warn('Join request not found when rejecting', { leagueId, adminId, requestId }); + return Result.err({ + code: 'REQUEST_NOT_FOUND', + details: { message: 'Join request not found' }, + }); + } + + const currentStatus = (joinRequest as any).status ?? 'pending'; + if (currentStatus !== 'pending') { + this.logger.warn('Join request is in invalid state for rejection', { + leagueId, + adminId, + requestId, + currentStatus, + }); + return Result.err({ + code: 'INVALID_REQUEST_STATE', + details: { message: 'Join request is not in a pending state' }, + }); + } + + await this.leagueMembershipRepository.removeJoinRequest(requestId); + + const result: RejectLeagueJoinRequestResult = { + leagueId, + requestId, + status: 'rejected', + }; + + this.output.present(result); + + this.logger.info('League join request rejected successfully', { + leagueId, + adminId, + requestId, + reason, + }); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + this.logger.error('Failed to reject league join request', err, { + leagueId, + adminId, + requestId, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: err.message ?? 'Failed to reject league join request', + }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts index fb3b34515..7245bdd5e 100644 --- a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts +++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts @@ -1,37 +1,64 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RejectSponsorshipRequestUseCase } from './RejectSponsorshipRequestUseCase'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { + RejectSponsorshipRequestUseCase, + type RejectSponsorshipRequestInput, + type RejectSponsorshipRequestResult, + type RejectSponsorshipRequestErrorCode, +} from './RejectSponsorshipRequestUseCase'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; describe('RejectSponsorshipRequestUseCase', () => { let useCase: RejectSponsorshipRequestUseCase; let sponsorshipRequestRepo: { findById: Mock; update: Mock }; - let notificationPort: { notifySponsorshipRequestRejected: Mock }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { sponsorshipRequestRepo = { findById: vi.fn(), update: vi.fn() }; - notificationPort = { notifySponsorshipRequestRejected: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { + present: Mock; + }; + useCase = new RejectSponsorshipRequestUseCase( sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, - notificationPort as any, + logger, + output, ); }); - it('should return not found error when request does not exist', async () => { + const unwrapError = ( + result: Result>, + ): ApplicationErrorCode => result.unwrapErr(); + + it('should return not found error when request does not exist and not call output', async () => { sponsorshipRequestRepo.findById.mockResolvedValue(null); - const result = await useCase.execute({ + const input: RejectSponsorshipRequestInput = { requestId: 'request-1', respondedBy: 'driver-1', reason: 'Not interested', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'SPONSORSHIP_REQUEST_NOT_FOUND', - }); + const error = unwrapError(result); + expect(error.code).toBe('SPONSORSHIP_REQUEST_NOT_FOUND'); + expect(error.details?.message).toBe('Sponsorship request not found'); + expect(sponsorshipRequestRepo.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); - it('should return not pending error when request is not pending', async () => { + it('should return not pending error when request is not pending and not call output', async () => { const mockRequest = { id: 'request-1', status: 'accepted', @@ -39,27 +66,26 @@ describe('RejectSponsorshipRequestUseCase', () => { }; sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); - const result = await useCase.execute({ + const input: RejectSponsorshipRequestInput = { requestId: 'request-1', respondedBy: 'driver-1', reason: 'Not interested', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ - code: 'SPONSORSHIP_REQUEST_NOT_PENDING', - }); + const error = unwrapError(result); + expect(error.code).toBe('SPONSORSHIP_REQUEST_NOT_PENDING'); + expect(error.details?.message).toBe('Sponsorship request is not pending'); + expect(sponsorshipRequestRepo.update).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); - it('should reject the request successfully and notify sponsor with reason', async () => { + it('should reject the request successfully with reason and present result once', async () => { const respondedAt = new Date('2023-01-01T00:00:00Z'); const mockRequest = { id: 'request-1', - sponsorId: 'sponsor-1', - entityType: 'season', - entityId: 'season-1', - tier: 'main', - offeredAmount: { amount: 1000, currency: 'USD' }, status: 'pending', isPending: vi.fn().mockReturnValue(true), reject: vi.fn().mockReturnValue({ @@ -71,44 +97,31 @@ describe('RejectSponsorshipRequestUseCase', () => { sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); sponsorshipRequestRepo.update.mockResolvedValue(undefined); - const result = await useCase.execute({ + const input: RejectSponsorshipRequestInput = { requestId: 'request-1', respondedBy: 'driver-1', reason: 'Not interested', - }); + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - requestId: 'request-1', - status: 'rejected', - rejectedAt: respondedAt, - reason: 'Not interested', - }); - expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject()); - expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledTimes(1); - expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledWith({ - requestId: 'request-1', - sponsorId: 'sponsor-1', - entityType: 'season', - entityId: 'season-1', - tier: 'main', - offeredAmountCents: 1000, - currency: 'USD', - rejectedAt: respondedAt, - rejectedBy: 'driver-1', - rejectionReason: 'Not interested', - }); + expect(result.unwrap()).toBeUndefined(); + expect(sponsorshipRequestRepo.update).toHaveBeenCalledTimes(1); + expect(mockRequest.reject).toHaveBeenCalledWith('driver-1', 'Not interested'); + expect(output.present).toHaveBeenCalledTimes(1); + + const [[presented]] = output.present.mock.calls as [[RejectSponsorshipRequestResult]]; + expect(presented.requestId).toBe('request-1'); + expect(presented.status).toBe('rejected'); + expect(presented.respondedAt).toBe(respondedAt); + expect(presented.rejectionReason).toBe('Not interested'); }); - it('should reject the request successfully and notify sponsor without reason', async () => { + it('should reject the request successfully without reason and present result once', async () => { const respondedAt = new Date('2023-01-01T00:00:00Z'); const mockRequest = { id: 'request-1', - sponsorId: 'sponsor-1', - entityType: 'season', - entityId: 'season-1', - tier: 'main', - offeredAmount: { amount: 1000, currency: 'USD' }, status: 'pending', isPending: vi.fn().mockReturnValue(true), reject: vi.fn().mockReturnValue({ @@ -120,30 +133,41 @@ describe('RejectSponsorshipRequestUseCase', () => { sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); sponsorshipRequestRepo.update.mockResolvedValue(undefined); - const result = await useCase.execute({ + const input: RejectSponsorshipRequestInput = { requestId: 'request-1', respondedBy: 'driver-1', - }); + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - requestId: 'request-1', - status: 'rejected', - rejectedAt: respondedAt, - }); - expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject()); - expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledTimes(1); - expect(notificationPort.notifySponsorshipRequestRejected).toHaveBeenCalledWith({ - requestId: 'request-1', - sponsorId: 'sponsor-1', - entityType: 'season', - entityId: 'season-1', - tier: 'main', - offeredAmountCents: 1000, - currency: 'USD', - rejectedAt: respondedAt, - rejectedBy: 'driver-1', - rejectionReason: undefined, - }); + expect(result.unwrap()).toBeUndefined(); + expect(sponsorshipRequestRepo.update).toHaveBeenCalledTimes(1); + expect(mockRequest.reject).toHaveBeenCalledWith('driver-1', undefined); + expect(output.present).toHaveBeenCalledTimes(1); + + const [[presented]] = output.present.mock.calls as [[RejectSponsorshipRequestResult]]; + expect(presented.requestId).toBe('request-1'); + expect(presented.status).toBe('rejected'); + expect(presented.respondedAt).toBe(respondedAt); + expect(presented.rejectionReason).toBeUndefined(); }); -}); \ No newline at end of file + + it('should wrap repository errors in REPOSITORY_ERROR and not call output', async () => { + const error = new Error('DB failure'); + sponsorshipRequestRepo.findById.mockRejectedValue(error); + + const input: RejectSponsorshipRequestInput = { + requestId: 'request-1', + respondedBy: 'driver-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const appError = unwrapError(result); + expect(appError.code).toBe('REPOSITORY_ERROR'); + expect(appError.details?.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts index 1220ede60..248c15996 100644 --- a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts @@ -4,65 +4,99 @@ * Allows an entity owner to reject a sponsorship request. */ -import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { SponsorshipRejectionNotificationPort } from '../ports/output/SponsorshipRejectionNotificationPort'; +import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; -export interface RejectSponsorshipRequestDTO { +export type RejectSponsorshipRequestInput = { requestId: string; - respondedBy: string; // driverId of the person rejecting + respondedBy: string; reason?: string; -} +}; -export interface RejectSponsorshipRequestResultDTO { +export type RejectSponsorshipRequestResult = { requestId: string; status: 'rejected'; - rejectedAt: Date; - reason?: string; -} + respondedAt: Date; + rejectionReason: string | undefined; +}; + +export type RejectSponsorshipRequestErrorCode = + | 'SPONSORSHIP_REQUEST_NOT_FOUND' + | 'SPONSORSHIP_REQUEST_NOT_PENDING' + | 'REPOSITORY_ERROR'; export class RejectSponsorshipRequestUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, - private readonly sponsorshipRejectionNotificationPort: SponsorshipRejectionNotificationPort, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(dto: RejectSponsorshipRequestDTO): Promise>> { - // Find the request - const request = await this.sponsorshipRequestRepo.findById(dto.requestId); - if (!request) { - return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }); - } + async execute( + input: RejectSponsorshipRequestInput, + ): Promise< + Result> + > { + const { requestId, respondedBy, reason } = input; - if (!request.isPending()) { - return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_PENDING' }); - } - - // Reject the request - const rejectedRequest = request.reject(dto.respondedBy, dto.reason); - await this.sponsorshipRequestRepo.update(rejectedRequest); - - await this.sponsorshipRejectionNotificationPort.notifySponsorshipRequestRejected({ - requestId: request.id, - sponsorId: request.sponsorId, - entityType: request.entityType, - entityId: request.entityId, - tier: request.tier, - offeredAmountCents: request.offeredAmount.amount, - currency: request.offeredAmount.currency, - rejectedAt: rejectedRequest.respondedAt!, - rejectedBy: dto.respondedBy, - rejectionReason: rejectedRequest.rejectionReason, + this.logger.debug('Executing RejectSponsorshipRequestUseCase', { + requestId, + respondedBy, }); - return Result.ok({ - requestId: rejectedRequest.id, - status: 'rejected', - rejectedAt: rejectedRequest.respondedAt!, - ...(rejectedRequest.rejectionReason !== undefined - ? { reason: rejectedRequest.rejectionReason } - : {}), - }); + try { + const request = await this.sponsorshipRequestRepo.findById(requestId); + if (!request) { + this.logger.warn('Sponsorship request not found', { requestId, respondedBy }); + return Result.err({ + code: 'SPONSORSHIP_REQUEST_NOT_FOUND', + details: { message: 'Sponsorship request not found' }, + }); + } + + if (!request.isPending()) { + this.logger.warn('Sponsorship request is not pending', { + requestId, + respondedBy, + status: request.status, + }); + return Result.err({ + code: 'SPONSORSHIP_REQUEST_NOT_PENDING', + details: { message: 'Sponsorship request is not pending' }, + }); + } + + const rejectedRequest = request.reject(respondedBy, reason); + await this.sponsorshipRequestRepo.update(rejectedRequest); + + const result: RejectSponsorshipRequestResult = { + requestId: rejectedRequest.id, + status: 'rejected', + respondedAt: rejectedRequest.respondedAt ?? new Date(), + rejectionReason: rejectedRequest.rejectionReason, + }; + + this.output.present(result); + + this.logger.info('Sponsorship request rejected successfully', { + requestId, + respondedBy, + }); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + this.logger.error('Failed to reject sponsorship request', err, { + requestId, + respondedBy, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err.message ?? 'Failed to reject sponsorship request' }, + }); + } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts index b3e73a9db..67b0ec8ed 100644 --- a/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts @@ -1,27 +1,232 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RejectTeamJoinRequestUseCase } from './RejectTeamJoinRequestUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + RejectTeamJoinRequestUseCase, + type RejectTeamJoinRequestInput, + type RejectTeamJoinRequestResult, + type RejectTeamJoinRequestErrorCode, +} from './RejectTeamJoinRequestUseCase'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; + +interface TeamRepositoryMock { + findById: Mock; +} + +interface TeamMembershipRepositoryMock { + getMembership: Mock; + getJoinRequests: Mock; + removeJoinRequest: Mock; +} describe('RejectTeamJoinRequestUseCase', () => { + let teamRepository: TeamRepositoryMock; + let membershipRepository: TeamMembershipRepositoryMock; + let logger: Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock }; + let output: UseCaseOutputPort & { present: Mock }; let useCase: RejectTeamJoinRequestUseCase; - let membershipRepository: { removeJoinRequest: Mock }; beforeEach(() => { - membershipRepository = { removeJoinRequest: vi.fn() }; + teamRepository = { + findById: vi.fn(), + } as unknown as ITeamRepository as any; + + membershipRepository = { + getMembership: vi.fn(), + getJoinRequests: vi.fn(), + removeJoinRequest: vi.fn(), + } as unknown as ITeamMembershipRepository as any; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { info: Mock; warn: Mock; error: Mock; debug: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new RejectTeamJoinRequestUseCase( + teamRepository as unknown as ITeamRepository, membershipRepository as unknown as ITeamMembershipRepository, + logger, + output, ); }); - it('should reject the join request successfully', async () => { + it('rejects a pending join request successfully and presents result', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'team-1', + managerId: 'manager-1', + requestId: 'req-1', + }; + + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'manager-1', + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + membershipRepository.getJoinRequests.mockResolvedValue([ + { + id: 'req-1', + teamId: 'team-1', + driverId: 'driver-1', + requestedAt: new Date(), + message: 'please', + status: 'pending', + }, + ]); membershipRepository.removeJoinRequest.mockResolvedValue(undefined); - const result = await useCase.execute({ - requestId: 'request-1', - }); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); - expect(membershipRepository.removeJoinRequest).toHaveBeenCalledWith('request-1'); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as RejectTeamJoinRequestResult; + expect(presented.teamId).toBe('team-1'); + expect(presented.requestId).toBe('req-1'); + expect(presented.status).toBe('rejected'); + expect(membershipRepository.removeJoinRequest).toHaveBeenCalledWith('req-1'); + }); + + it('returns TEAM_NOT_FOUND when team does not exist', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'missing-team', + managerId: 'manager-1', + requestId: 'req-1', + }; + + teamRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectTeamJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('TEAM_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); + expect(membershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns UNAUTHORIZED when manager is not authorized', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'team-1', + managerId: 'user-1', + requestId: 'req-1', + }; + + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + membershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectTeamJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('UNAUTHORIZED'); + expect(output.present).not.toHaveBeenCalled(); + expect(membershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns REQUEST_NOT_FOUND when join request does not exist', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'team-1', + managerId: 'manager-1', + requestId: 'missing-req', + }; + + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'manager-1', + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + membershipRepository.getJoinRequests.mockResolvedValue([]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectTeamJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('REQUEST_NOT_FOUND'); + expect(output.present).not.toHaveBeenCalled(); + expect(membershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns INVALID_REQUEST_STATE when join request is not pending', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'team-1', + managerId: 'manager-1', + requestId: 'req-1', + }; + + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'manager-1', + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + membershipRepository.getJoinRequests.mockResolvedValue([ + { + id: 'req-1', + teamId: 'team-1', + driverId: 'driver-1', + requestedAt: new Date(), + message: 'please', + status: 'approved', + }, + ]); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectTeamJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('INVALID_REQUEST_STATE'); + expect(output.present).not.toHaveBeenCalled(); + expect(membershipRepository.removeJoinRequest).not.toHaveBeenCalled(); + }); + + it('returns REPOSITORY_ERROR when repository throws', async () => { + const input: RejectTeamJoinRequestInput = { + teamId: 'team-1', + managerId: 'manager-1', + requestId: 'req-1', + }; + + const repoError = new Error('Repository failure'); + teamRepository.findById.mockRejectedValue(repoError); + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const err = result.unwrapErr() as ApplicationErrorCode< + RejectTeamJoinRequestErrorCode, + { message: string } + >; + expect(err.code).toBe('REPOSITORY_ERROR'); + expect(err.details.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(membershipRepository.removeJoinRequest).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts index 70c812053..2d5cb0868 100644 --- a/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts @@ -1,16 +1,121 @@ +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { RejectTeamJoinRequestInputPort } from '../ports/input/RejectTeamJoinRequestInputPort'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; + +export type RejectTeamJoinRequestInput = { + teamId: string; + managerId: string; + requestId: string; + reason?: string; +}; + +export type RejectTeamJoinRequestResult = { + teamId: string; + requestId: string; + status: 'rejected'; +}; + +export type RejectTeamJoinRequestErrorCode = + | 'TEAM_NOT_FOUND' + | 'REQUEST_NOT_FOUND' + | 'UNAUTHORIZED' + | 'INVALID_REQUEST_STATE' + | 'REPOSITORY_ERROR'; export class RejectTeamJoinRequestUseCase { constructor( + private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: RejectTeamJoinRequestInputPort): Promise>> { - const { requestId } = command; - await this.membershipRepository.removeJoinRequest(requestId); - return Result.ok(undefined); + async execute( + input: RejectTeamJoinRequestInput, + ): Promise>> { + const { teamId, managerId, requestId, reason } = input; + + try { + const team = await this.teamRepository.findById(teamId); + if (!team) { + this.logger.warn('Team not found when rejecting join request', { teamId, managerId, requestId }); + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { message: 'Team not found' }, + }); + } + + const managerMembership = await this.membershipRepository.getMembership(teamId, managerId); + if (!managerMembership || managerMembership.status !== 'active' || !['owner', 'manager'].includes(managerMembership.role)) { + this.logger.warn('User is not authorized to reject team join requests', { + teamId, + managerId, + requestId, + }); + return Result.err({ + code: 'UNAUTHORIZED', + details: { message: 'User is not authorized to reject team join requests' }, + }); + } + + const joinRequests = await this.membershipRepository.getJoinRequests(teamId); + const joinRequest = joinRequests.find(r => r.id === requestId); + if (!joinRequest) { + this.logger.warn('Join request not found when rejecting', { teamId, managerId, requestId }); + return Result.err({ + code: 'REQUEST_NOT_FOUND', + details: { message: 'Join request not found' }, + }); + } + + const currentStatus = (joinRequest as any).status ?? 'pending'; + if (currentStatus !== 'pending') { + this.logger.warn('Join request is in invalid state for rejection', { + teamId, + managerId, + requestId, + currentStatus, + }); + return Result.err({ + code: 'INVALID_REQUEST_STATE', + details: { message: 'Join request is not in a pending state' }, + }); + } + + await this.membershipRepository.removeJoinRequest(requestId); + + const result: RejectTeamJoinRequestResult = { + teamId, + requestId, + status: 'rejected', + }; + + this.output.present(result); + + this.logger.info('Team join request rejected successfully', { + teamId, + managerId, + requestId, + reason, + }); + + return Result.ok(undefined); + } catch (error) { + const err = error instanceof Error ? error : new Error('Unknown error'); + this.logger.error('Failed to reject team join request', err, { + teamId, + managerId, + requestId, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: err.message ?? 'Failed to reject team join request', + }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts index df2bf0e0b..045e1c267 100644 --- a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts +++ b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts @@ -1,47 +1,106 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RemoveLeagueMemberUseCase } from './RemoveLeagueMemberUseCase'; +import { + RemoveLeagueMemberUseCase, + type RemoveLeagueMemberInput, + type RemoveLeagueMemberResult, + type RemoveLeagueMemberErrorCode, +} from './RemoveLeagueMemberUseCase'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('RemoveLeagueMemberUseCase', () => { let useCase: RemoveLeagueMemberUseCase; - let leagueMembershipRepository: { getLeagueMembers: Mock; saveMembership: Mock }; + let leagueMembershipRepository: { getMembership: Mock; saveMembership: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { leagueMembershipRepository = { - getLeagueMembers: vi.fn(), + getMembership: vi.fn(), saveMembership: vi.fn(), }; + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new RemoveLeagueMemberUseCase( leagueMembershipRepository as unknown as ILeagueMembershipRepository, + output, ); }); it('should remove league member by setting status to inactive', async () => { const leagueId = 'league-1'; const targetDriverId = 'driver-1'; - const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }]; + const membership = { + id: `${leagueId}:${targetDriverId}`, + leagueId: { toString: () => leagueId }, + driverId: { toString: () => targetDriverId }, + role: { toString: () => 'member' }, + status: { toString: () => 'active' }, + joinedAt: { toDate: () => new Date() }, + }; - leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + leagueMembershipRepository.getMembership.mockResolvedValue(membership); - const result = await useCase.execute({ leagueId, targetDriverId }); + const input: RemoveLeagueMemberInput = { leagueId, targetDriverId }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ success: true }); - expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + expect(result.unwrap()).toBeUndefined(); + + expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledTimes(1); + const savedMembership = leagueMembershipRepository.saveMembership.mock.calls[0][0]; + expect(savedMembership.status.toString()).toBe('inactive'); + + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ leagueId, - driverId: targetDriverId, - role: 'member', - status: 'inactive', - joinedAt: expect.any(Date), - }); + memberId: targetDriverId, + removedRole: 'member', + } satisfies RemoveLeagueMemberResult); }); it('should return error if membership not found', async () => { - leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + leagueMembershipRepository.getMembership.mockResolvedValue(null); - const result = await useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }); + const input: RemoveLeagueMemberInput = { leagueId: 'league-1', targetDriverId: 'driver-1' }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'MEMBERSHIP_NOT_FOUND' }); + + const error = result.unwrapErr() as ApplicationErrorCode< + RemoveLeagueMemberErrorCode, + { message: string } + >; + + expect(error.code).toBe('MEMBERSHIP_NOT_FOUND'); + expect(error.details.message).toBe('Membership not found for given league and driver'); + + expect(output.present).not.toHaveBeenCalled(); + }); + + it('should return repository error when an exception occurs', async () => { + const leagueId = 'league-1'; + const targetDriverId = 'driver-1'; + + leagueMembershipRepository.getMembership.mockRejectedValue(new Error('DB error')); + + const input: RemoveLeagueMemberInput = { leagueId, targetDriverId }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + + const error = result.unwrapErr() as ApplicationErrorCode< + RemoveLeagueMemberErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('DB error'); + + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts index 349da44db..dace36b18 100644 --- a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts +++ b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts @@ -1,26 +1,69 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { Result } from '@core/shared/application/Result'; +import { UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { RemoveLeagueMemberOutputPort } from '../ports/output/RemoveLeagueMemberOutputPort'; +import { LeagueMembership } from '../../domain/entities/LeagueMembership'; -export interface RemoveLeagueMemberUseCaseParams { +export interface RemoveLeagueMemberInput { leagueId: string; targetDriverId: string; } -export class RemoveLeagueMemberUseCase { - constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} +export interface RemoveLeagueMemberResult { + leagueId: string; + memberId: string; + removedRole: string; +} - async execute(params: RemoveLeagueMemberUseCaseParams): Promise>> { - const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); - const membership = memberships.find(m => m.driverId === params.targetDriverId); - if (!membership) { - return Result.err({ code: 'MEMBERSHIP_NOT_FOUND' }); +export type RemoveLeagueMemberErrorCode = 'MEMBERSHIP_NOT_FOUND' | 'REPOSITORY_ERROR'; + +export class RemoveLeagueMemberUseCase { + constructor( + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute( + params: RemoveLeagueMemberInput, + ): Promise>> { + try { + const membership = await this.leagueMembershipRepository.getMembership( + params.leagueId, + params.targetDriverId, + ); + + if (!membership) { + return Result.err({ + code: 'MEMBERSHIP_NOT_FOUND', + details: { message: 'Membership not found for given league and driver' }, + }); + } + + const updatedMembership = LeagueMembership.create({ + id: membership.id, + leagueId: membership.leagueId.toString(), + driverId: membership.driverId.toString(), + role: membership.role.toString(), + status: 'inactive', + joinedAt: membership.joinedAt.toDate(), + }); + + await this.leagueMembershipRepository.saveMembership(updatedMembership); + + this.output.present({ + leagueId: params.leagueId, + memberId: params.targetDriverId, + removedRole: membership.role.toString(), + }); + + return Result.ok(undefined); + } catch (error) { + const err = error as Error; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message: err?.message ?? 'Failed to remove league member' }, + }); } - await this.leagueMembershipRepository.saveMembership({ - ...membership, - status: 'inactive', - }); - return Result.ok({ success: true }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ReopenRaceUseCase.ts b/core/racing/application/use-cases/ReopenRaceUseCase.ts index 40ad47de7..18a2a0b38 100644 --- a/core/racing/application/use-cases/ReopenRaceUseCase.ts +++ b/core/racing/application/use-cases/ReopenRaceUseCase.ts @@ -1,8 +1,24 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { AsyncUseCase , Logger } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -import type { ReopenRaceCommandDTO } from '../dto/ReopenRaceCommandDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Race } from '../../domain/entities/Race'; + +export type ReopenRaceInput = { + raceId: string; + reopenedById: string; +}; + +export type ReopenRaceErrorCode = + | 'RACE_NOT_FOUND' + | 'UNAUTHORIZED' + | 'INVALID_RACE_STATE' + | 'REPOSITORY_ERROR'; + +export type ReopenRaceResult = { + race: Race; +}; /** * Use Case: ReopenRaceUseCase @@ -13,42 +29,67 @@ import type { ReopenRaceCommandDTO } from '../dto/ReopenRaceCommandDTO'; * - delegates transition rules to the Race domain entity via `reopen()` * - persists the updated race via the repository. */ -export class ReopenRaceUseCase - implements AsyncUseCase { +export class ReopenRaceUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: ReopenRaceCommandDTO): Promise>> { - const { raceId } = command; + async execute( + input: ReopenRaceInput, + ): Promise>> { + const { raceId } = input; this.logger.debug(`[ReopenRaceUseCase] Executing for raceId: ${raceId}`); try { const race = await this.raceRepository.findById(raceId); if (!race) { this.logger.warn(`[ReopenRaceUseCase] Race with ID ${raceId} not found.`); - return Result.err({ code: 'RACE_NOT_FOUND' }); + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { message: `Race with ID ${raceId} not found` }, + }); } const reopenedRace = race.reopen(); await this.raceRepository.update(reopenedRace); this.logger.info(`[ReopenRaceUseCase] Race ${raceId} re-opened successfully.`); + + const result: ReopenRaceResult = { + race: reopenedRace, + }; + + this.output.present(result); + return Result.ok(undefined); } catch (error) { if (error instanceof Error && error.message.includes('already scheduled')) { this.logger.warn(`[ReopenRaceUseCase] Domain error re-opening race ${raceId}: ${error.message}`); - return Result.err({ code: 'RACE_ALREADY_SCHEDULED' }); + return Result.err({ + code: 'INVALID_RACE_STATE', + details: { message: error.message }, + }); } if (error instanceof Error && error.message.includes('running race')) { this.logger.warn(`[ReopenRaceUseCase] Domain error re-opening race ${raceId}: ${error.message}`); - return Result.err({ code: 'CANNOT_REOPEN_RUNNING_RACE' }); + return Result.err({ + code: 'INVALID_RACE_STATE', + details: { message: error.message }, + }); } + this.logger.error( `[ReopenRaceUseCase] Unexpected error re-opening race ${raceId}`, error instanceof Error ? error : new Error(String(error)), ); - return Result.err({ code: 'UNEXPECTED_ERROR' }); + + const message = error instanceof Error ? error.message : 'Failed to reopen race'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } } } diff --git a/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts b/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts index bc9b904d7..e480ef18a 100644 --- a/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts +++ b/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts @@ -1,92 +1,138 @@ -import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { RequestProtestDefenseUseCase } from './RequestProtestDefenseUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + RequestProtestDefenseUseCase, + type RequestProtestDefenseInput, + type RequestProtestDefenseResult, + type RequestProtestDefenseErrorCode, +} from './RequestProtestDefenseUseCase'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; +import type { Logger } from '@core/shared/application/Logger'; describe('RequestProtestDefenseUseCase', () => { let useCase: RequestProtestDefenseUseCase; let protestRepository: { findById: Mock; update: Mock }; let raceRepository: { findById: Mock }; let membershipRepository: { getMembership: Mock }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { protestRepository = { findById: vi.fn(), update: vi.fn() }; raceRepository = { findById: vi.fn() }; membershipRepository = { getMembership: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + useCase = new RequestProtestDefenseUseCase( protestRepository as unknown as IProtestRepository, raceRepository as unknown as IRaceRepository, membershipRepository as unknown as ILeagueMembershipRepository, + logger, + output, ); }); + const createInput = (overrides: Partial = {}): RequestProtestDefenseInput => ({ + protestId: 'protest-1', + stewardId: 'steward-1', + ...overrides, + }); + + const unwrapError = ( + result: Result>, + ): ApplicationErrorCode => { + expect(result.isErr()).toBe(true); + return result.unwrapErr(); + }; + it('should return protest not found error', async () => { protestRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ - protestId: 'protest-1', - stewardId: 'steward-1', - }); + const result = await useCase.execute(createInput()); - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + const error = unwrapError(result); + expect(error.code).toBe('PROTEST_NOT_FOUND'); + expect(error.details?.message).toBe('Protest not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return race not found error', async () => { - const mockProtest = { raceId: 'race-1', accusedDriverId: 'driver-1', id: 'protest-1', canRequestDefense: vi.fn().mockReturnValue(true) }; + const mockProtest = { + raceId: 'race-1', + accusedDriverId: 'driver-1', + id: 'protest-1', + canRequestDefense: vi.fn().mockReturnValue(true), + }; protestRepository.findById.mockResolvedValue(mockProtest); raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ - protestId: 'protest-1', - stewardId: 'steward-1', - }); + const result = await useCase.execute(createInput()); - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); + const error = unwrapError(result); + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(error.details?.message).toBe('Race not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return insufficient permissions error', async () => { - const mockProtest = { raceId: 'race-1', accusedDriverId: 'driver-1', id: 'protest-1', canRequestDefense: vi.fn().mockReturnValue(true) }; + const mockProtest = { + raceId: 'race-1', + accusedDriverId: 'driver-1', + id: 'protest-1', + canRequestDefense: vi.fn().mockReturnValue(true), + }; const mockRace = { leagueId: 'league-1' }; protestRepository.findById.mockResolvedValue(mockProtest); raceRepository.findById.mockResolvedValue(mockRace); membershipRepository.getMembership.mockResolvedValue(null); - const result = await useCase.execute({ - protestId: 'protest-1', - stewardId: 'steward-1', - }); + const result = await useCase.execute(createInput()); - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'INSUFFICIENT_PERMISSIONS' }); + const error = unwrapError(result); + expect(error.code).toBe('INSUFFICIENT_PERMISSIONS'); + expect(error.details?.message).toBe('Insufficient permissions to request defense'); + expect(output.present).not.toHaveBeenCalled(); }); it('should return defense cannot be requested error', async () => { - const mockProtest = { raceId: 'race-1', accusedDriverId: 'driver-1', id: 'protest-1', canRequestDefense: vi.fn().mockReturnValue(false) }; + const mockProtest = { + raceId: 'race-1', + accusedDriverId: 'driver-1', + id: 'protest-1', + canRequestDefense: vi.fn().mockReturnValue(false), + }; const mockRace = { leagueId: 'league-1' }; const mockMembership = { role: 'steward' }; protestRepository.findById.mockResolvedValue(mockProtest); raceRepository.findById.mockResolvedValue(mockRace); membershipRepository.getMembership.mockResolvedValue(mockMembership); - const result = await useCase.execute({ - protestId: 'protest-1', - stewardId: 'steward-1', - }); + const result = await useCase.execute(createInput()); - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'DEFENSE_CANNOT_BE_REQUESTED' }); + const error = unwrapError(result); + expect(error.code).toBe('DEFENSE_CANNOT_BE_REQUESTED'); + expect(error.details?.message).toBe('Defense cannot be requested for this protest'); + expect(output.present).not.toHaveBeenCalled(); }); it('should request defense successfully', async () => { + const updatedProtest = {}; const mockProtest = { raceId: 'race-1', accusedDriverId: 'driver-1', id: 'protest-1', canRequestDefense: vi.fn().mockReturnValue(true), - requestDefense: vi.fn().mockReturnValue({}), + requestDefense: vi.fn().mockReturnValue(updatedProtest), }; const mockRace = { leagueId: 'league-1' }; const mockMembership = { role: 'steward' }; @@ -95,17 +141,33 @@ describe('RequestProtestDefenseUseCase', () => { membershipRepository.getMembership.mockResolvedValue(mockMembership); protestRepository.update.mockResolvedValue(undefined); - const result = await useCase.execute({ - protestId: 'protest-1', - stewardId: 'steward-1', - }); + const result = await useCase.execute(createInput()); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ - success: true, - accusedDriverId: 'driver-1', + expect(result.unwrap()).toBeUndefined(); + + expect(protestRepository.update).toHaveBeenCalledWith(updatedProtest); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as RequestProtestDefenseResult; + expect(presented).toEqual({ + leagueId: 'league-1', protestId: 'protest-1', + accusedDriverId: 'driver-1', + status: 'defense_requested', }); - expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.requestDefense()); }); -}); \ No newline at end of file + + it('should wrap repository errors into REPOSITORY_ERROR and not present output', async () => { + const mockError = new Error('Repository failed'); + protestRepository.findById.mockRejectedValue(mockError); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('Repository failed'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts b/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts index 606b40f0b..0a47da544 100644 --- a/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts +++ b/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts @@ -10,58 +10,83 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles'; import { Result } from '@core/shared/application/Result'; +import { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface RequestProtestDefenseCommand { +export type RequestProtestDefenseInput = { protestId: string; stewardId: string; -} +}; -export interface RequestProtestDefenseResult { - success: boolean; - accusedDriverId: string; +export type RequestProtestDefenseResult = { + leagueId: string; protestId: string; -} + accusedDriverId: string; + status: 'defense_requested'; +}; + +export type RequestProtestDefenseErrorCode = + | 'PROTEST_NOT_FOUND' + | 'RACE_NOT_FOUND' + | 'INSUFFICIENT_PERMISSIONS' + | 'DEFENSE_CANNOT_BE_REQUESTED' + | 'REPOSITORY_ERROR'; export class RequestProtestDefenseUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly raceRepository: IRaceRepository, private readonly membershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: RequestProtestDefenseCommand): Promise>> { - // Get the protest - const protest = await this.protestRepository.findById(command.protestId); - if (!protest) { - return Result.err({ code: 'PROTEST_NOT_FOUND' }); + async execute( + input: RequestProtestDefenseInput, + ): Promise>> { + try { + const protest = await this.protestRepository.findById(input.protestId); + if (!protest) { + return Result.err({ code: 'PROTEST_NOT_FOUND', details: { message: 'Protest not found' } }); + } + + const race = await this.raceRepository.findById(protest.raceId); + if (!race) { + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); + } + + const membership = await this.membershipRepository.getMembership(race.leagueId, input.stewardId); + if (!membership || !isLeagueStewardOrHigherRole(membership.role)) { + return Result.err({ + code: 'INSUFFICIENT_PERMISSIONS', + details: { message: 'Insufficient permissions to request defense' }, + }); + } + + if (!protest.canRequestDefense()) { + return Result.err({ + code: 'DEFENSE_CANNOT_BE_REQUESTED', + details: { message: 'Defense cannot be requested for this protest' }, + }); + } + + const updatedProtest = protest.requestDefense(input.stewardId); + await this.protestRepository.update(updatedProtest); + + const result: RequestProtestDefenseResult = { + leagueId: race.leagueId, + protestId: protest.id, + accusedDriverId: protest.accusedDriverId, + status: 'defense_requested', + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to request protest defense'; + this.logger.error('RequestProtestDefenseUseCase.execute failed', error instanceof Error ? error : undefined); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } - - // Get the race to find the league - const race = await this.raceRepository.findById(protest.raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND' }); - } - - // Verify the steward has permission - const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId); - if (!membership || !isLeagueStewardOrHigherRole(membership.role)) { - return Result.err({ code: 'INSUFFICIENT_PERMISSIONS' }); - } - - // Check if defense can be requested - if (!protest.canRequestDefense()) { - return Result.err({ code: 'DEFENSE_CANNOT_BE_REQUESTED' }); - } - - // Request defense - const updatedProtest = protest.requestDefense(command.stewardId); - await this.protestRepository.update(updatedProtest); - - return Result.ok({ - success: true, - accusedDriverId: protest.accusedDriverId, - protestId: protest.id, - }); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/ReviewProtestUseCase.test.ts b/core/racing/application/use-cases/ReviewProtestUseCase.test.ts index ca023b52d..2069fa4a1 100644 --- a/core/racing/application/use-cases/ReviewProtestUseCase.test.ts +++ b/core/racing/application/use-cases/ReviewProtestUseCase.test.ts @@ -1,38 +1,59 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { ReviewProtestUseCase } from './ReviewProtestUseCase'; +import { ReviewProtestUseCase, type ReviewProtestInput, type ReviewProtestResult, type ReviewProtestErrorCode } from './ReviewProtestUseCase'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; describe('ReviewProtestUseCase', () => { let useCase: ReviewProtestUseCase; let protestRepository: { findById: Mock; update: Mock }; let raceRepository: { findById: Mock }; let leagueMembershipRepository: { getLeagueMembers: Mock }; + let logger: { debug: Mock; info: Mock; warn: Mock; error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; beforeEach(() => { protestRepository = { findById: vi.fn(), update: vi.fn() }; raceRepository = { findById: vi.fn() }; leagueMembershipRepository = { getLeagueMembers: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; useCase = new ReviewProtestUseCase( protestRepository as unknown as IProtestRepository, raceRepository as unknown as IRaceRepository, leagueMembershipRepository as unknown as ILeagueMembershipRepository, + logger as unknown as Logger, + output, ); }); it('should return protest not found error', async () => { protestRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ + const input: ReviewProtestInput = { protestId: 'protest-1', stewardId: 'steward-1', decision: 'uphold', decisionNotes: 'Notes', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error).toEqual({ + code: 'PROTEST_NOT_FOUND', + details: { message: 'Protest not found' }, + }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return race not found error', async () => { @@ -40,15 +61,22 @@ describe('ReviewProtestUseCase', () => { protestRepository.findById.mockResolvedValue(mockProtest); raceRepository.findById.mockResolvedValue(null); - const result = await useCase.execute({ + const input: ReviewProtestInput = { protestId: 'protest-1', stewardId: 'steward-1', decision: 'uphold', decisionNotes: 'Notes', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error).toEqual({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found' }, + }); + expect(output.present).not.toHaveBeenCalled(); }); it('should return not league admin error', async () => { @@ -58,19 +86,26 @@ describe('ReviewProtestUseCase', () => { raceRepository.findById.mockResolvedValue(mockRace); leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); - const result = await useCase.execute({ + const input: ReviewProtestInput = { protestId: 'protest-1', stewardId: 'steward-1', decision: 'uphold', decisionNotes: 'Notes', - }); + }; + + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'NOT_LEAGUE_ADMIN' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error).toEqual({ + code: 'NOT_LEAGUE_ADMIN', + details: { message: 'Only league owners and admins can review protests' }, + }); + expect(output.present).not.toHaveBeenCalled(); }); it('should uphold protest successfully', async () => { - const mockProtest = { raceId: 'race-1', uphold: vi.fn().mockReturnValue({}), dismiss: vi.fn() }; + const mockProtest = { id: 'protest-1', raceId: 'race-1', uphold: vi.fn().mockReturnValue({}), dismiss: vi.fn() }; const mockRace = { leagueId: 'league-1' }; const memberships = [{ driverId: 'steward-1', status: 'active', role: 'admin' }]; protestRepository.findById.mockResolvedValue(mockProtest); @@ -78,20 +113,29 @@ describe('ReviewProtestUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); protestRepository.update.mockResolvedValue(undefined); - const result = await useCase.execute({ + const input: ReviewProtestInput = { protestId: 'protest-1', stewardId: 'steward-1', decision: 'uphold', decisionNotes: 'Notes', - }); + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.uphold()); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as ReviewProtestResult; + expect(presented).toEqual({ + leagueId: 'league-1', + protestId: 'protest-1', + status: 'upheld', + }); }); it('should dismiss protest successfully', async () => { - const mockProtest = { raceId: 'race-1', uphold: vi.fn(), dismiss: vi.fn().mockReturnValue({}) }; + const mockProtest = { id: 'protest-1', raceId: 'race-1', uphold: vi.fn(), dismiss: vi.fn().mockReturnValue({}) }; const mockRace = { leagueId: 'league-1' }; const memberships = [{ driverId: 'steward-1', status: 'active', role: 'owner' }]; protestRepository.findById.mockResolvedValue(mockProtest); @@ -99,15 +143,49 @@ describe('ReviewProtestUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); protestRepository.update.mockResolvedValue(undefined); - const result = await useCase.execute({ + const input: ReviewProtestInput = { protestId: 'protest-1', stewardId: 'steward-1', decision: 'dismiss', decisionNotes: 'Notes', - }); + }; + + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); expect(result.unwrap()).toBeUndefined(); expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.dismiss()); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0]![0] as ReviewProtestResult; + expect(presented).toEqual({ + leagueId: 'league-1', + protestId: 'protest-1', + status: 'dismissed', + }); + }); + + it('should return repository error when update throws', async () => { + const mockProtest = { id: 'protest-1', raceId: 'race-1', uphold: vi.fn().mockReturnValue({}), dismiss: vi.fn() }; + const mockRace = { leagueId: 'league-1' }; + const memberships = [{ driverId: 'steward-1', status: 'active', role: 'admin' }]; + protestRepository.findById.mockResolvedValue(mockProtest); + raceRepository.findById.mockResolvedValue(mockRace); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + protestRepository.update.mockRejectedValue(new Error('DB error')); + + const input: ReviewProtestInput = { + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'uphold', + decisionNotes: 'Notes', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('Failed to review protest'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ReviewProtestUseCase.ts b/core/racing/application/use-cases/ReviewProtestUseCase.ts index cdefe4226..33e5fc796 100644 --- a/core/racing/application/use-cases/ReviewProtestUseCase.ts +++ b/core/racing/application/use-cases/ReviewProtestUseCase.ts @@ -7,55 +7,91 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export interface ReviewProtestCommand { +export type ReviewProtestErrorCode = 'PROTEST_NOT_FOUND' | 'RACE_NOT_FOUND' | 'NOT_LEAGUE_ADMIN' | 'REPOSITORY_ERROR'; + +export type ReviewProtestApplicationError = ApplicationErrorCode; + +export interface ReviewProtestInput { protestId: string; stewardId: string; decision: 'uphold' | 'dismiss'; decisionNotes: string; } +export interface ReviewProtestResult { + leagueId: string; + protestId: string; + status: 'upheld' | 'dismissed'; +} + export class ReviewProtestUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly raceRepository: IRaceRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: ReviewProtestCommand): Promise>> { - // Load the protest - const protest = await this.protestRepository.findById(command.protestId); - if (!protest) { - return Result.err({ code: 'PROTEST_NOT_FOUND' }); + async execute(input: ReviewProtestInput): Promise> { + this.logger.debug('Executing ReviewProtestUseCase', { input }); + + try { + // Load the protest + const protest = await this.protestRepository.findById(input.protestId); + if (!protest) { + this.logger.warn('Protest not found', { protestId: input.protestId }); + return Result.err({ code: 'PROTEST_NOT_FOUND', details: { message: 'Protest not found' } }); + } + + // Load the race to get league ID + const race = await this.raceRepository.findById(protest.raceId); + if (!race) { + this.logger.warn('Race not found for protest', { protestId: input.protestId, raceId: protest.raceId }); + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); + } + + // Validate steward has authority (owner or admin of the league) + const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); + const stewardMembership = memberships.find( + m => m.driverId === input.stewardId && m.status === 'active' + ); + + if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { + this.logger.warn('Unauthorized steward attempting to review protest', { stewardId: input.stewardId, leagueId: race.leagueId }); + return Result.err({ code: 'NOT_LEAGUE_ADMIN', details: { message: 'Only league owners and admins can review protests' } }); + } + + // Apply the decision + const updatedProtest = input.decision === 'uphold' + ? protest.uphold(input.stewardId, input.decisionNotes) + : protest.dismiss(input.stewardId, input.decisionNotes); + + await this.protestRepository.update(updatedProtest); + + const result: ReviewProtestResult = { + leagueId: race.leagueId, + protestId: typeof protest.id === 'string' ? protest.id : (protest as any).id, + status: input.decision === 'uphold' ? 'upheld' : 'dismissed', + }; + + this.output.present(result); + + this.logger.info('Protest reviewed successfully', { + protestId: result.protestId, + leagueId: result.leagueId, + status: result.status, + }); + + return Result.ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to review protest'; + this.logger.error('Failed to review protest', { error: message }); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } - - // Load the race to get league ID - const race = await this.raceRepository.findById(protest.raceId); - if (!race) { - return Result.err({ code: 'RACE_NOT_FOUND' }); - } - - // Validate steward has authority (owner or admin of the league) - const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); - const stewardMembership = memberships.find( - m => m.driverId === command.stewardId && m.status === 'active' - ); - - if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { - return Result.err({ code: 'NOT_LEAGUE_ADMIN' }); - } - - // Apply the decision - let updatedProtest; - if (command.decision === 'uphold') { - updatedProtest = protest.uphold(command.stewardId, command.decisionNotes); - } else { - updatedProtest = protest.dismiss(command.stewardId, command.decisionNotes); - } - - await this.protestRepository.update(updatedProtest); - return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/SeasonUseCases.test.ts b/core/racing/application/use-cases/SeasonUseCases.test.ts index e4b57e8b1..3c7212a69 100644 --- a/core/racing/application/use-cases/SeasonUseCases.test.ts +++ b/core/racing/application/use-cases/SeasonUseCases.test.ts @@ -1,8 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; -import { - InMemorySeasonRepository, -} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; import { Season } from '@core/racing/domain/entities/season/Season'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; @@ -13,8 +10,18 @@ import { ManageSeasonLifecycleUseCase, type CreateSeasonForLeagueCommand, type ManageSeasonLifecycleCommand, + type CreateSeasonForLeagueResult, + type ListSeasonsForLeagueResult, + type GetSeasonDetailsResult, + type ManageSeasonLifecycleResult, + type CreateSeasonForLeagueErrorCode, + type ListSeasonsForLeagueErrorCode, + type GetSeasonDetailsErrorCode, + type ManageSeasonLifecycleErrorCode, } from '@core/racing/application/use-cases/SeasonUseCases'; import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { return { @@ -82,129 +89,28 @@ function createLeagueConfigFormModel(overrides?: Partial) }; } -describe('InMemorySeasonRepository', () => { - it('add and findById provide a roundtrip for Season', async () => { - const repo = new InMemorySeasonRepository(); - const season = Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Test Season', - status: 'planned', - }); - - await repo.add(season); - const loaded = await repo.findById(season.id); - - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(season.id); - expect(loaded!.leagueId).toBe(season.leagueId); - expect(loaded!.status).toBe('planned'); - }); - - it('update persists changed Season state', async () => { - const repo = new InMemorySeasonRepository(); - const season = Season.create({ - id: 'season-1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Initial Season', - status: 'planned', - }); - - await repo.add(season); - const activated = season.activate(); - - await repo.update(activated); - - const loaded = await repo.findById(season.id); - expect(loaded).not.toBeNull(); - expect(loaded!.status).toBe('active'); - }); - - it('listByLeague returns only seasons for that league', async () => { - const repo = new InMemorySeasonRepository(); - const s1 = Season.create({ - id: 's1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'L1 S1', - status: 'planned', - }); - const s2 = Season.create({ - id: 's2', - leagueId: 'league-1', - gameId: 'iracing', - name: 'L1 S2', - status: 'active', - }); - const s3 = Season.create({ - id: 's3', - leagueId: 'league-2', - gameId: 'iracing', - name: 'L2 S1', - status: 'planned', - }); - - await repo.add(s1); - await repo.add(s2); - await repo.add(s3); - - const league1Seasons = await repo.listByLeague('league-1'); - const league2Seasons = await repo.listByLeague('league-2'); - - expect(league1Seasons.map((s) => s.id).sort()).toEqual(['s1', 's2']); - expect(league2Seasons.map((s) => s.id)).toEqual(['s3']); - }); - - it('listActiveByLeague returns only active seasons for a league', async () => { - const repo = new InMemorySeasonRepository(); - const s1 = Season.create({ - id: 's1', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Planned', - status: 'planned', - }); - const s2 = Season.create({ - id: 's2', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Active', - status: 'active', - }); - const s3 = Season.create({ - id: 's3', - leagueId: 'league-1', - gameId: 'iracing', - name: 'Completed', - status: 'completed', - }); - const s4 = Season.create({ - id: 's4', - leagueId: 'league-2', - gameId: 'iracing', - name: 'Other League Active', - status: 'active', - }); - - await repo.add(s1); - await repo.add(s2); - await repo.add(s3); - await repo.add(s4); - - const activeInLeague1 = await repo.listActiveByLeague('league-1'); - - expect(activeInLeague1.map((s) => s.id)).toEqual(['s2']); - }); -}); - describe('CreateSeasonForLeagueUseCase', () => { - it('creates a planned Season for an existing league with config-derived props', async () => { + function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); - const seasonRepo = new InMemorySeasonRepository(); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; - const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo, output); + + return { leagueRepo, seasonRepo, output, useCase }; + } + + it('creates a planned Season for an existing league with config-derived props', async () => { + const { seasonRepo, output, useCase } = setup(); const config = createLeagueConfigFormModel({ basics: { @@ -229,6 +135,8 @@ describe('CreateSeasonForLeagueUseCase', () => { }, }); + (seasonRepo.add as unknown as ReturnType).mockResolvedValue(undefined); + const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', name: 'Season from Config', @@ -238,12 +146,13 @@ describe('CreateSeasonForLeagueUseCase', () => { const result = await useCase.execute(command); - expect(result.seasonId).toBeDefined(); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); - const created = await seasonRepo.findById(result.seasonId); - expect(created).not.toBeNull(); - const season = created!; + expect(output.present).toHaveBeenCalledTimes(1); + const payload = (output.present as ReturnType).mock.calls[0][0] as CreateSeasonForLeagueResult; + const season = payload.season; expect(season.leagueId).toBe('league-1'); expect(season.gameId).toBe('iracing'); expect(season.name).toBe('Season from Config'); @@ -264,8 +173,7 @@ describe('CreateSeasonForLeagueUseCase', () => { }); it('clones configuration from a source season when sourceSeasonId is provided', async () => { - const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); - const seasonRepo = new InMemorySeasonRepository(); + const { seasonRepo, output, useCase } = setup(); const sourceSeason = Season.create({ id: 'source-season', @@ -275,9 +183,8 @@ describe('CreateSeasonForLeagueUseCase', () => { status: 'planned', }).withMaxDrivers(40); - await seasonRepo.add(sourceSeason); - - const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); + (seasonRepo.findById as unknown as ReturnType).mockResolvedValueOnce(sourceSeason); + (seasonRepo.add as unknown as ReturnType).mockResolvedValue(undefined); const command: CreateSeasonForLeagueCommand = { leagueId: 'league-1', @@ -287,11 +194,14 @@ describe('CreateSeasonForLeagueUseCase', () => { }; const result = await useCase.execute(command); - const created = await seasonRepo.findById(result.seasonId); - expect(created).not.toBeNull(); - const season = created!; + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + const payload = (output.present as ReturnType).mock.calls[0][0] as CreateSeasonForLeagueResult; + + const season = payload.season; expect(season.id).not.toBe(sourceSeason.id); expect(season.leagueId).toBe(sourceSeason.leagueId); expect(season.gameId).toBe(sourceSeason.gameId); @@ -302,12 +212,62 @@ describe('CreateSeasonForLeagueUseCase', () => { expect(season.dropPolicy).toBe(sourceSeason.dropPolicy); expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig); }); + + it('returns LEAGUE_NOT_FOUND error when league does not exist', async () => { + const leagueRepo = createFakeLeagueRepository([]); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo, output); + + const command: CreateSeasonForLeagueCommand = { + leagueId: 'missing-league', + name: 'Season', + gameId: 'iracing', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toContain('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); }); + describe('ListSeasonsForLeagueUseCase', () => { - it('lists seasons for a league with summaries', async () => { + function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); - const seasonRepo = new InMemorySeasonRepository(); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo, output); + + return { leagueRepo, seasonRepo, output, useCase }; + } + + it('lists seasons for a league with summaries', async () => { + const { seasonRepo, output, useCase } = setup(); const s1 = Season.create({ id: 'season-1', @@ -331,26 +291,73 @@ describe('ListSeasonsForLeagueUseCase', () => { status: 'planned', }); - await seasonRepo.add(s1); - await seasonRepo.add(s2); - await seasonRepo.add(sOtherLeague); - - const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo); + (seasonRepo.listByLeague as unknown as ReturnType).mockResolvedValue([ + s1, + s2, + sOtherLeague, + ]); const result = await useCase.execute({ leagueId: 'league-1' }); - expect(result.items.map((i) => i.seasonId).sort()).toEqual([ - 'season-1', - 'season-2', - ]); - expect(result.items.every((i) => i.leagueId === 'league-1')).toBe(true); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const payload = (output.present as ReturnType).mock.calls[0][0] as ListSeasonsForLeagueResult; + + const league1Seasons = payload.seasons.filter((s) => s.leagueId === 'league-1'); + expect(league1Seasons.map((s) => s.id).sort()).toEqual(['season-1', 'season-2']); + }); + + it('returns LEAGUE_NOT_FOUND error when league does not exist', async () => { + const leagueRepo = createFakeLeagueRepository([]); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo, output); + + const result = await useCase.execute({ leagueId: 'missing-league' }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toContain('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); }); + describe('GetSeasonDetailsUseCase', () => { + function setup(leagueSeed: Array<{ id: string }>) { + const leagueRepo = createFakeLeagueRepository(leagueSeed); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo, output); + + return { leagueRepo, seasonRepo, output, useCase }; + } + it('returns full details for a season belonging to the league', async () => { - const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); - const seasonRepo = new InMemorySeasonRepository(); + const { seasonRepo, output, useCase } = setup([{ id: 'league-1' }]); const season = Season.create({ id: 'season-1', @@ -360,28 +367,147 @@ describe('GetSeasonDetailsUseCase', () => { status: 'planned', }).withMaxDrivers(24); - await seasonRepo.add(season); + (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); - const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo); - - const dto = await useCase.execute({ + const result = await useCase.execute({ leagueId: 'league-1', seasonId: 'season-1', }); - expect(dto.seasonId).toBe('season-1'); - expect(dto.leagueId).toBe('league-1'); - expect(dto.gameId).toBe('iracing'); - expect(dto.name).toBe('Detailed Season'); - expect(dto.status).toBe('planned'); - expect(dto.maxDrivers).toBe(24); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + + expect(output.present).toHaveBeenCalledTimes(1); + const payload = (output.present as ReturnType).mock.calls[0][0] as GetSeasonDetailsResult; + + expect(payload.season.id).toBe('season-1'); + expect(payload.season.leagueId).toBe('league-1'); + expect(payload.season.gameId).toBe('iracing'); + expect(payload.season.name).toBe('Detailed Season'); + expect(payload.season.status).toBe('planned'); + expect(payload.season.maxDrivers).toBe(24); + }); + + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + const { output, useCase } = setup([]); + + const result = await useCase.execute({ + leagueId: 'missing-league', + seasonId: 'season-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toContain('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league', async () => { + const { seasonRepo, output, useCase } = setup([{ id: 'league-1' }]); + + const season = Season.create({ + id: 'season-1', + leagueId: 'other-league', + gameId: 'iracing', + name: 'Detailed Season', + status: 'planned', + }); + + (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + }); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(error.details?.message).toContain('does not belong to league'); + expect(output.present).not.toHaveBeenCalled(); }); }); + describe('ManageSeasonLifecycleUseCase', () => { - function setupLifecycleTest() { + function setup() { const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); - const seasonRepo = new InMemorySeasonRepository(); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output); + + return { leagueRepo, seasonRepo, output, useCase }; + } + + it('applies activate → complete → archive transitions and persists state', async () => { + const { seasonRepo, output, useCase } = setup(); + + let currentSeason = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Lifecycle Season', + status: 'planned', + }); + + (seasonRepo.findById as unknown as ReturnType).mockImplementation(async () => currentSeason); + (seasonRepo.update as unknown as ReturnType).mockImplementation(async (updated: Season) => { + currentSeason = updated; + return updated; + }); + + const activateCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: currentSeason.id, + transition: 'activate', + }; + + const activated = await useCase.execute(activateCommand); + expect(activated.isOk()).toBe(true); + + const activatePayload = (output.present as ReturnType).mock.calls[0][0] as ManageSeasonLifecycleResult; + expect(activatePayload.season.status).toBe('active'); + + const completeCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: currentSeason.id, + transition: 'complete', + }; + + const completed = await useCase.execute(completeCommand); + expect(completed.isOk()).toBe(true); + + const completePayload = (output.present as ReturnType).mock.calls[1][0] as ManageSeasonLifecycleResult; + expect(completePayload.season.status).toBe('completed'); + + const archiveCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: currentSeason.id, + transition: 'archive', + }; + + const archived = await useCase.execute(archiveCommand); + expect(archived.isOk()).toBe(true); + + const archivePayload = (output.present as ReturnType).mock.calls[2][0] as ManageSeasonLifecycleResult; + expect(archivePayload.season.status).toBe('archived'); + + expect(currentSeason.status).toBe('archived'); + }); + + it('returns INVALID_LIFECYCLE_TRANSITION for invalid transitions and does not call output', async () => { + const { seasonRepo, output, useCase } = setup(); const season = Season.create({ id: 'season-1', @@ -391,59 +517,92 @@ describe('ManageSeasonLifecycleUseCase', () => { status: 'planned', }); - seasonRepo.seed(season); + (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); - const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); + const invalidCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: season.id, + transition: 'complete', + }; - return { leagueRepo, seasonRepo, useCase, season }; - } + const result = await useCase.execute(invalidCommand); - it('applies activate → complete → archive transitions and persists state', async () => { - const { useCase, seasonRepo, season } = setupLifecycleTest(); + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('INVALID_LIFECYCLE_TRANSITION'); + expect(error.details?.message).toBeDefined(); + expect(output.present).not.toHaveBeenCalled(); + }); - const activateCommand: ManageSeasonLifecycleCommand = { + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + const leagueRepo = createFakeLeagueRepository([]); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output); + + const command: ManageSeasonLifecycleCommand = { + leagueId: 'missing-league', + seasonId: 'season-1', + transition: 'activate', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toContain('League not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns SEASON_NOT_FOUND when season does not belong to league', async () => { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo: ISeasonRepository = { + add: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + listByLeague: vi.fn(), + listActiveByLeague: vi.fn(), + } as unknown as ISeasonRepository; + + const output: UseCaseOutputPort & { present: ReturnType } = { + present: vi.fn(), + } as any; + + const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo, output); + + const season = Season.create({ + id: 'season-1', + leagueId: 'other-league', + gameId: 'iracing', + name: 'Lifecycle Season', + status: 'planned', + }); + + (seasonRepo.findById as unknown as ReturnType).mockResolvedValue(season); + + const command: ManageSeasonLifecycleCommand = { leagueId: 'league-1', seasonId: season.id, transition: 'activate', }; - const activated = await useCase.execute(activateCommand); - expect(activated.status).toBe('active'); + const result = await useCase.execute(command); - const completeCommand: ManageSeasonLifecycleCommand = { - leagueId: 'league-1', - seasonId: season.id, - transition: 'complete', - }; - - const completed = await useCase.execute(completeCommand); - expect(completed.status).toBe('completed'); - - const archiveCommand: ManageSeasonLifecycleCommand = { - leagueId: 'league-1', - seasonId: season.id, - transition: 'archive', - }; - - const archived = await useCase.execute(archiveCommand); - expect(archived.status).toBe('archived'); - - const persisted = await seasonRepo.findById(season.id); - expect(persisted!.status).toBe('archived'); + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('SEASON_NOT_FOUND'); + expect(error.details?.message).toContain('does not belong to league'); + expect(output.present).not.toHaveBeenCalled(); }); - - it('propagates domain invariant errors for invalid transitions', async () => { - const { useCase, seasonRepo, season } = setupLifecycleTest(); - - const completeCommand: ManageSeasonLifecycleCommand = { - leagueId: 'league-1', - seasonId: season.id, - transition: 'complete', - }; - - await expect(useCase.execute(completeCommand)).rejects.toThrow(); - - const persisted = await seasonRepo.findById(season.id); - expect(persisted!.status).toBe('planned'); - }); -}); \ No newline at end of file +}); diff --git a/core/racing/application/use-cases/SeasonUseCases.ts b/core/racing/application/use-cases/SeasonUseCases.ts index 12ea32374..f53649787 100644 --- a/core/racing/application/use-cases/SeasonUseCases.ts +++ b/core/racing/application/use-cases/SeasonUseCases.ts @@ -12,15 +12,16 @@ import { RecurrenceStrategyFactory } from '../../domain/value-objects/Recurrence import { WeekdaySet } from '../../domain/value-objects/WeekdaySet'; import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern'; import type { Weekday } from '../../domain/types/Weekday'; -import { normalizeVisibility } from '../dto/LeagueConfigFormDTO'; -import { LeagueVisibility } from '../../domain/value-objects/LeagueVisibility'; import { v4 as uuidv4 } from 'uuid'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; /** - * DTOs and helpers shared across Season-focused use cases. + * Input, result and error models shared across Season-focused use cases. */ -export interface CreateSeasonForLeagueCommand { +export interface CreateSeasonForLeagueInput { leagueId: string; name: string; gameId: string; @@ -32,86 +33,95 @@ export interface CreateSeasonForLeagueCommand { config?: LeagueConfigFormModel; } -export interface CreateSeasonForLeagueResultDTO { - seasonId: string; -} +// Backwards-compatible alias for existing wiring/tests. +export type CreateSeasonForLeagueCommand = CreateSeasonForLeagueInput; -export interface SeasonSummaryDTO { - seasonId: string; - leagueId: string; - name: string; - status: import('../../domain/entities/Season').SeasonStatus; - startDate?: Date; - endDate?: Date; - isPrimary: boolean; -} +export type CreateSeasonForLeagueResult = { + season: Season; +}; -export interface ListSeasonsForLeagueQuery { +export type CreateSeasonForLeagueErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SOURCE_SEASON_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export type CreateSeasonForLeagueApplicationError = ApplicationErrorCode< + CreateSeasonForLeagueErrorCode, + { message: string } +>; + +export interface ListSeasonsForLeagueInput { leagueId: string; } -export interface ListSeasonsForLeagueResultDTO { - items: SeasonSummaryDTO[]; +// Backwards-compatible alias for existing wiring/tests. +export type ListSeasonsForLeagueQuery = ListSeasonsForLeagueInput; + +export interface ListSeasonsForLeagueResult { + seasons: Season[]; } -export interface GetSeasonDetailsQuery { +export type ListSeasonsForLeagueErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export type ListSeasonsForLeagueApplicationError = ApplicationErrorCode< + ListSeasonsForLeagueErrorCode, + { message: string } +>; + +export interface GetSeasonDetailsInput { leagueId: string; seasonId: string; } -export interface SeasonDetailsDTO { - seasonId: string; - leagueId: string; - gameId: string; - name: string; - status: import('../../domain/entities/Season').SeasonStatus; - startDate?: Date; - endDate?: Date; - maxDrivers?: number; - schedule?: { - startDate: Date; - plannedRounds: number; - }; - scoring?: { - scoringPresetId: string; - customScoringEnabled: boolean; - }; - dropPolicy?: { - strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy; - n?: number; - }; - stewarding?: { - decisionMode: import('../../domain/entities/League').StewardingDecisionMode; - requiredVotes?: number; - requireDefense: boolean; - defenseTimeLimit: number; - voteTimeLimit: number; - protestDeadlineHours: number; - stewardingClosesHours: number; - notifyAccusedOnProtest: boolean; - notifyOnVoteRequired: boolean; - }; +// Backwards-compatible alias for existing wiring/tests. +export type GetSeasonDetailsQuery = GetSeasonDetailsInput; + +export interface GetSeasonDetailsResult { + season: Season; } +export type GetSeasonDetailsErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +export type GetSeasonDetailsApplicationError = ApplicationErrorCode< + GetSeasonDetailsErrorCode, + { message: string } +>; + export type SeasonLifecycleTransition = | 'activate' | 'complete' | 'archive' | 'cancel'; -export interface ManageSeasonLifecycleCommand { +export interface ManageSeasonLifecycleInput { leagueId: string; seasonId: string; transition: SeasonLifecycleTransition; } -export interface ManageSeasonLifecycleResultDTO { - seasonId: string; - status: import('../../domain/entities/Season').SeasonStatus; - startDate?: Date; - endDate?: Date; +// Backwards-compatible alias for existing wiring/tests. +export type ManageSeasonLifecycleCommand = ManageSeasonLifecycleInput; + +export interface ManageSeasonLifecycleResult { + season: Season; } +export type ManageSeasonLifecycleErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'SEASON_NOT_FOUND' + | 'INVALID_LIFECYCLE_TRANSITION' + | 'REPOSITORY_ERROR'; + +export type ManageSeasonLifecycleApplicationError = ApplicationErrorCode< + ManageSeasonLifecycleErrorCode, + { message: string } +>; + /** * CreateSeasonForLeagueUseCase * @@ -122,73 +132,99 @@ export class CreateSeasonForLeagueUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( command: CreateSeasonForLeagueCommand, - ): Promise { - const league = await this.leagueRepository.findById(command.leagueId); - if (!league) { - throw new Error(`League not found: ${command.leagueId}`); - } - - let baseSeasonProps: { - schedule?: SeasonSchedule; - scoringConfig?: SeasonScoringConfig; - dropPolicy?: SeasonDropPolicy; - stewardingConfig?: SeasonStewardingConfig; - maxDrivers?: number; - } = {}; - - if (command.sourceSeasonId) { - const source = await this.seasonRepository.findById(command.sourceSeasonId); - if (!source) { - throw new Error(`Source Season not found: ${command.sourceSeasonId}`); + ): Promise> { + try { + const league = await this.leagueRepository.findById(command.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { + message: `League not found: ${command.leagueId}`, + }, + }); } - baseSeasonProps = { - ...(source.schedule !== undefined ? { schedule: source.schedule } : {}), - ...(source.scoringConfig !== undefined - ? { scoringConfig: source.scoringConfig } + + let baseSeasonProps: { + schedule?: SeasonSchedule; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + } = {}; + + if (command.sourceSeasonId) { + const source = await this.seasonRepository.findById(command.sourceSeasonId); + if (!source) { + return Result.err({ + code: 'SOURCE_SEASON_NOT_FOUND', + details: { + message: `Source Season not found: ${command.sourceSeasonId}`, + }, + }); + } + baseSeasonProps = { + ...(source.schedule !== undefined ? { schedule: source.schedule } : {}), + ...(source.scoringConfig !== undefined + ? { scoringConfig: source.scoringConfig } + : {}), + ...(source.dropPolicy !== undefined + ? { dropPolicy: source.dropPolicy } + : {}), + ...(source.stewardingConfig !== undefined + ? { stewardingConfig: source.stewardingConfig } + : {}), + ...(source.maxDrivers !== undefined + ? { maxDrivers: source.maxDrivers } + : {}), + }; + } else if (command.config) { + baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config); + } + + const seasonId = uuidv4(); + + const season = Season.create({ + id: seasonId, + leagueId: league.id, + gameId: command.gameId, + name: command.name, + year: new Date().getFullYear(), + status: 'planned', + ...(baseSeasonProps?.schedule + ? { schedule: baseSeasonProps.schedule } : {}), - ...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}), - ...(source.stewardingConfig !== undefined - ? { stewardingConfig: source.stewardingConfig } + ...(baseSeasonProps?.scoringConfig + ? { scoringConfig: baseSeasonProps.scoringConfig } : {}), - ...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}), - }; - } else if (command.config) { - baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config); + ...(baseSeasonProps?.dropPolicy + ? { dropPolicy: baseSeasonProps.dropPolicy } + : {}), + ...(baseSeasonProps?.stewardingConfig + ? { stewardingConfig: baseSeasonProps.stewardingConfig } + : {}), + ...(baseSeasonProps?.maxDrivers !== undefined + ? { maxDrivers: baseSeasonProps.maxDrivers } + : {}), + }); + + await this.seasonRepository.add(season); + + this.output.present({ season }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } - - const seasonId = uuidv4(); - - const season = Season.create({ - id: seasonId, - leagueId: league.id, - gameId: command.gameId, - name: command.name, - year: new Date().getFullYear(), - status: 'planned', - ...(baseSeasonProps?.schedule - ? { schedule: baseSeasonProps.schedule } - : {}), - ...(baseSeasonProps?.scoringConfig - ? { scoringConfig: baseSeasonProps.scoringConfig } - : {}), - ...(baseSeasonProps?.dropPolicy - ? { dropPolicy: baseSeasonProps.dropPolicy } - : {}), - ...(baseSeasonProps?.stewardingConfig - ? { stewardingConfig: baseSeasonProps.stewardingConfig } - : {}), - ...(baseSeasonProps?.maxDrivers !== undefined - ? { maxDrivers: baseSeasonProps.maxDrivers } - : {}), - }); - - await this.seasonRepository.add(season); - - return { seasonId }; } private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): { @@ -226,7 +262,7 @@ export class CreateSeasonForLeagueUseCase { typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 ? structure.maxDrivers : undefined; - + return { ...(schedule !== undefined ? { schedule } : {}), scoringConfig, @@ -297,29 +333,36 @@ export class ListSeasonsForLeagueUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( query: ListSeasonsForLeagueQuery, - ): Promise { - const league = await this.leagueRepository.findById(query.leagueId); - if (!league) { - throw new Error(`League not found: ${query.leagueId}`); + ): Promise> { + try { + const league = await this.leagueRepository.findById(query.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { + message: `League not found: ${query.leagueId}`, + }, + }); + } + + const seasons = await this.seasonRepository.listByLeague(league.id); + + this.output.present({ seasons }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } - - const seasons = await this.seasonRepository.listByLeague(league.id); - const items: SeasonSummaryDTO[] = seasons.map((s) => ({ - seasonId: s.id, - leagueId: s.leagueId, - name: s.name, - status: s.status, - ...(s.startDate !== undefined ? { startDate: s.startDate } : {}), - ...(s.endDate !== undefined ? { endDate: s.endDate } : {}), - // League currently does not track primarySeasonId, so mark false for now. - isPrimary: false, - })); - - return { items }; } } @@ -330,79 +373,44 @@ export class GetSeasonDetailsUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(query: GetSeasonDetailsQuery): Promise { - const league = await this.leagueRepository.findById(query.leagueId); - if (!league) { - throw new Error(`League not found: ${query.leagueId}`); - } + async execute( + query: GetSeasonDetailsQuery, + ): Promise> { + try { + const league = await this.leagueRepository.findById(query.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { + message: `League not found: ${query.leagueId}`, + }, + }); + } - const season = await this.seasonRepository.findById(query.seasonId); - if (!season || season.leagueId !== league.id) { - throw new Error( - `Season ${query.seasonId} does not belong to league ${league.id}`, - ); - } + const season = await this.seasonRepository.findById(query.seasonId); + if (!season || season.leagueId !== league.id) { + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { + message: `Season ${query.seasonId} does not belong to league ${league.id}`, + }, + }); + } - return { - seasonId: season.id, - leagueId: season.leagueId, - gameId: season.gameId, - name: season.name, - status: season.status, - ...(season.startDate !== undefined ? { startDate: season.startDate } : {}), - ...(season.endDate !== undefined ? { endDate: season.endDate } : {}), - ...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}), - ...(season.schedule - ? { - schedule: { - startDate: season.schedule.startDate, - plannedRounds: season.schedule.plannedRounds, - }, - } - : {}), - ...(season.scoringConfig - ? { - scoring: { - scoringPresetId: season.scoringConfig.scoringPresetId, - customScoringEnabled: - season.scoringConfig.customScoringEnabled ?? false, - }, - } - : {}), - ...(season.dropPolicy - ? { - dropPolicy: { - strategy: season.dropPolicy.strategy, - ...(season.dropPolicy.n !== undefined - ? { n: season.dropPolicy.n } - : {}), - }, - } - : {}), - ...(season.stewardingConfig - ? { - stewarding: { - decisionMode: season.stewardingConfig.decisionMode, - ...(season.stewardingConfig.requiredVotes !== undefined - ? { requiredVotes: season.stewardingConfig.requiredVotes } - : {}), - requireDefense: season.stewardingConfig.requireDefense, - defenseTimeLimit: season.stewardingConfig.defenseTimeLimit, - voteTimeLimit: season.stewardingConfig.voteTimeLimit, - protestDeadlineHours: - season.stewardingConfig.protestDeadlineHours, - stewardingClosesHours: - season.stewardingConfig.stewardingClosesHours, - notifyAccusedOnProtest: - season.stewardingConfig.notifyAccusedOnProtest, - notifyOnVoteRequired: - season.stewardingConfig.notifyOnVoteRequired, - }, - } - : {}), - }; + this.output.present({ season }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); + } } } @@ -413,48 +421,87 @@ export class ManageSeasonLifecycleUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, + private readonly output: UseCaseOutputPort, ) {} async execute( command: ManageSeasonLifecycleCommand, - ): Promise { - const league = await this.leagueRepository.findById(command.leagueId); - if (!league) { - throw new Error(`League not found: ${command.leagueId}`); + ): Promise> { + try { + const league = await this.leagueRepository.findById(command.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { + message: `League not found: ${command.leagueId}`, + }, + }); + } + + const season = await this.seasonRepository.findById(command.seasonId); + if (!season || season.leagueId !== league.id) { + return Result.err({ + code: 'SEASON_NOT_FOUND', + details: { + message: `Season ${command.seasonId} does not belong to league ${league.id}`, + }, + }); + } + + let updated: Season; + try { + switch (command.transition) { + case 'activate': + updated = season.activate(); + break; + case 'complete': + updated = season.complete(); + break; + case 'archive': + updated = season.archive(); + break; + case 'cancel': + updated = season.cancel(); + break; + default: + return Result.err({ + code: 'INVALID_LIFECYCLE_TRANSITION', + details: { + message: `Unsupported Season lifecycle transition: ${command.transition}`, + }, + }); + } + } catch (error) { + return Result.err({ + code: 'INVALID_LIFECYCLE_TRANSITION', + details: { + message: + error instanceof Error ? error.message : 'Invalid lifecycle transition', + }, + }); + } + + try { + await this.seasonRepository.update(updated); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); + } + + this.output.present({ season: updated }); + + return Result.ok(undefined); + } catch (error) { + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error instanceof Error ? error.message : 'Unknown error', + }, + }); } - - const season = await this.seasonRepository.findById(command.seasonId); - if (!season || season.leagueId !== league.id) { - throw new Error( - `Season ${command.seasonId} does not belong to league ${league.id}`, - ); - } - - let updated: Season; - switch (command.transition) { - case 'activate': - updated = season.activate(); - break; - case 'complete': - updated = season.complete(); - break; - case 'archive': - updated = season.archive(); - break; - case 'cancel': - updated = season.cancel(); - break; - default: - throw new Error(`Unsupported Season lifecycle transition`); - } - - await this.seasonRepository.update(updated); - - return { - seasonId: updated.id, - status: updated.status, - ...(updated.startDate !== undefined ? { startDate: updated.startDate } : {}), - ...(updated.endDate !== undefined ? { endDate: updated.endDate } : {}), - }; } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts b/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts index 0caf4c31d..b3e84f771 100644 --- a/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts @@ -1,25 +1,84 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + SendFinalResultsUseCase, + type SendFinalResultsInput, + type SendFinalResultsResult, + type SendFinalResultsErrorCode, +} from './SendFinalResultsUseCase'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; -import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import { SendFinalResultsUseCase } from './SendFinalResultsUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; +import type { Logger } from '@core/shared/application/Logger'; + +const unwrapError = ( + result: Result>, +): ApplicationErrorCode => { + expect(result.isErr()).toBe(true); + return result.unwrapErr(); +}; describe('SendFinalResultsUseCase', () => { - it('sends final results notifications to all participating drivers', async () => { - const mockNotificationService = { - sendNotification: vi.fn(), - } as unknown as NotificationService; + let notificationService: { sendNotification: Mock }; + let raceEventRepository: { findById: Mock }; + let resultRepository: { findByRaceId: Mock }; + let leagueRepository: { findById: Mock }; + let membershipRepository: { getMembership: Mock }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: SendFinalResultsUseCase; + beforeEach(() => { + notificationService = { sendNotification: vi.fn() }; + raceEventRepository = { findById: vi.fn() }; + resultRepository = { findByRaceId: vi.fn() }; + leagueRepository = { findById: vi.fn() }; + membershipRepository = { getMembership: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new SendFinalResultsUseCase( + notificationService as unknown as NotificationService, + raceEventRepository as unknown as IRaceEventRepository, + resultRepository as unknown as IResultRepository, + leagueRepository as unknown as ILeagueRepository, + membershipRepository as unknown as ILeagueMembershipRepository, + logger, + output, + ); + }); + + const createInput = (overrides: Partial = {}): SendFinalResultsInput => ({ + leagueId: 'league-1', + raceId: 'race-1', + triggeredById: 'user-1', + ...overrides, + }); + + it('sends final results notifications to all participating drivers and presents result', async () => { const mockRaceEvent = { id: 'race-1', + leagueId: 'league-1', name: 'Test Race', + status: 'closed', getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1' }), }; - const mockRaceEventRepository = { - findById: vi.fn().mockResolvedValue(mockRaceEvent), - } as unknown as IRaceEventRepository; + const mockLeague = { id: 'league-1' }; + const mockMembership = { role: 'steward' }; + + leagueRepository.findById.mockResolvedValue(mockLeague); + raceEventRepository.findById.mockResolvedValue(mockRaceEvent); + membershipRepository.getMembership.mockResolvedValue(mockMembership); const mockResults = [ { @@ -36,155 +95,111 @@ describe('SendFinalResultsUseCase', () => { }, ]; - const mockResultRepository = { - findByRaceId: vi.fn().mockResolvedValue(mockResults), - } as unknown as IResultRepository; + resultRepository.findByRaceId.mockResolvedValue(mockResults); - const useCase = new SendFinalResultsUseCase( - mockNotificationService, - mockRaceEventRepository, - mockResultRepository, - ); + const input = createInput(); - const event: RaceEventStewardingClosedEvent = { - eventType: 'RaceEventStewardingClosed', - aggregateId: 'race-1', - occurredAt: new Date(), - eventData: { - raceEventId: 'race-1', - seasonId: 'season-1', - leagueId: 'league-1', - driverIds: ['driver-1', 'driver-2'], - hadPenaltiesApplied: false, - closedAt: new Date(), - }, - }; - - const result = await useCase.execute(event); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(mockRaceEventRepository.findById).toHaveBeenCalledWith('race-1'); - expect(mockResultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); - expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2); + expect(result.unwrap()).toBeUndefined(); - // Check first notification - expect(mockNotificationService.sendNotification).toHaveBeenCalledWith( - expect.objectContaining({ - recipientId: 'driver-1', - type: 'race_final_results', - title: 'Final Results: Test Race', - body: expect.stringContaining('Final result: P1 (+2 positions). Clean race! +35 rating.'), - data: expect.objectContaining({ - raceEventId: 'race-1', - sessionId: 'session-1', - leagueId: 'league-1', - position: 1, - positionChange: 2, - incidents: 0, - finalRatingChange: 35, - hadPenaltiesApplied: false, - }), - }), - ); + expect(leagueRepository.findById).toHaveBeenCalledWith('league-1'); + expect(raceEventRepository.findById).toHaveBeenCalledWith('race-1'); + expect(resultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); + expect(notificationService.sendNotification).toHaveBeenCalledTimes(2); - // Check second notification - expect(mockNotificationService.sendNotification).toHaveBeenCalledWith( - expect.objectContaining({ - recipientId: 'driver-2', - type: 'race_final_results', - title: 'Final Results: Test Race', - body: expect.stringContaining('Final result: P2 (-1 positions). 1 incident +20 rating.'), - data: expect.objectContaining({ - position: 2, - positionChange: -1, - incidents: 1, - finalRatingChange: 20, - }), - }), - ); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as SendFinalResultsResult; + expect(presented).toEqual({ + leagueId: 'league-1', + raceId: 'race-1', + notificationsSent: 2, + }); }); - it('skips sending notifications if race event not found', async () => { - const mockNotificationService = { - sendNotification: vi.fn(), - } as unknown as NotificationService; + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + leagueRepository.findById.mockResolvedValue(null); - const mockRaceEventRepository = { - findById: vi.fn().mockResolvedValue(null), - } as unknown as IRaceEventRepository; + const result = await useCase.execute(createInput()); - const mockResultRepository = { - findByRaceId: vi.fn(), - } as unknown as IResultRepository; - - const useCase = new SendFinalResultsUseCase( - mockNotificationService, - mockRaceEventRepository, - mockResultRepository, - ); - - const event: RaceEventStewardingClosedEvent = { - eventType: 'RaceEventStewardingClosed', - aggregateId: 'race-1', - occurredAt: new Date(), - eventData: { - raceEventId: 'race-1', - seasonId: 'season-1', - leagueId: 'league-1', - driverIds: ['driver-1'], - hadPenaltiesApplied: false, - closedAt: new Date(), - }, - }; - - const result = await useCase.execute(event); - - expect(result.isOk()).toBe(true); - expect(mockNotificationService.sendNotification).not.toHaveBeenCalled(); + const error = unwrapError(result); + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); - it('skips sending notifications if no main race session', async () => { - const mockNotificationService = { - sendNotification: vi.fn(), - } as unknown as NotificationService; + it('returns RACE_NOT_FOUND when race event does not exist', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue(null); - const mockRaceEvent = { + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(error.details?.message).toBe('Race event not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns INSUFFICIENT_PERMISSIONS when user is not steward or higher', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1', status: 'closed' }); + membershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('INSUFFICIENT_PERMISSIONS'); + expect(error.details?.message).toBe('Insufficient permissions to send final results'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns RESULTS_NOT_FINAL when race is not closed', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ id: 'race-1', + leagueId: 'league-1', name: 'Test Race', - getMainRaceSession: vi.fn().mockReturnValue(null), - }; + status: 'in_progress', + getMainRaceSession: vi.fn(), + }); - const mockRaceEventRepository = { - findById: vi.fn().mockResolvedValue(mockRaceEvent), - } as unknown as IRaceEventRepository; + const result = await useCase.execute(createInput()); - const mockResultRepository = { - findByRaceId: vi.fn(), - } as unknown as IResultRepository; + const error = unwrapError(result); + expect(error.code).toBe('RESULTS_NOT_FINAL'); + expect(error.details?.message).toBe('Race results are not in a final state'); + expect(output.present).not.toHaveBeenCalled(); + }); - const useCase = new SendFinalResultsUseCase( - mockNotificationService, - mockRaceEventRepository, - mockResultRepository, - ); + it('returns RESULTS_NOT_FINAL when main race session is missing', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ + id: 'race-1', + leagueId: 'league-1', + name: 'Test Race', + status: 'closed', + getMainRaceSession: vi.fn().mockReturnValue(undefined), + }); - const event: RaceEventStewardingClosedEvent = { - eventType: 'RaceEventStewardingClosed', - aggregateId: 'race-1', - occurredAt: new Date(), - eventData: { - raceEventId: 'race-1', - seasonId: 'season-1', - leagueId: 'league-1', - driverIds: ['driver-1'], - hadPenaltiesApplied: false, - closedAt: new Date(), - }, - }; + const result = await useCase.execute(createInput()); - const result = await useCase.execute(event); + const error = unwrapError(result); + expect(error.code).toBe('RESULTS_NOT_FINAL'); + expect(error.details?.message).toBe('Main race session not found for race event'); + expect(output.present).not.toHaveBeenCalled(); + }); - expect(result.isOk()).toBe(true); - expect(mockNotificationService.sendNotification).not.toHaveBeenCalled(); + it('wraps repository errors into REPOSITORY_ERROR and does not present output', async () => { + const mockError = new Error('Repository failure'); + leagueRepository.findById.mockRejectedValue(mockError); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/SendFinalResultsUseCase.ts b/core/racing/application/use-cases/SendFinalResultsUseCase.ts index ad976407c..f0c72691e 100644 --- a/core/racing/application/use-cases/SendFinalResultsUseCase.ts +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.ts @@ -1,60 +1,118 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; import type { Result as RaceResult } from '../../domain/entities/Result'; -import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles'; + +export type SendFinalResultsInput = { + leagueId: string; + raceId: string; + triggeredById: string; +}; + +export type SendFinalResultsResult = { + leagueId: string; + raceId: string; + notificationsSent: number; +}; + +export type SendFinalResultsErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'RACE_NOT_FOUND' + | 'INSUFFICIENT_PERMISSIONS' + | 'RESULTS_NOT_FINAL' + | 'REPOSITORY_ERROR'; /** * Use Case: SendFinalResultsUseCase * - * Triggered by RaceEventStewardingClosed domain event. - * Sends final results modal notifications to all drivers who participated, - * including any penalty adjustments applied during stewarding. + * Sends final results notifications to all drivers who participated + * in the main race session for a given race event. */ export class SendFinalResultsUseCase { constructor( private readonly notificationService: NotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly membershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(event: RaceEventStewardingClosedEvent): Promise>> { - const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData; + async execute( + input: SendFinalResultsInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); + } + + const raceEvent = await this.raceEventRepository.findById(input.raceId); + if (!raceEvent) { + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race event not found' } }); + } + + const membership = await this.membershipRepository.getMembership(league.id, input.triggeredById); + if (!membership || !isLeagueStewardOrHigherRole(membership.role)) { + return Result.err({ + code: 'INSUFFICIENT_PERMISSIONS', + details: { message: 'Insufficient permissions to send final results' }, + }); + } + + if (raceEvent.status !== 'closed') { + return Result.err({ + code: 'RESULTS_NOT_FINAL', + details: { message: 'Race results are not in a final state' }, + }); + } + + const mainRaceSession = raceEvent.getMainRaceSession(); + if (!mainRaceSession) { + return Result.err({ + code: 'RESULTS_NOT_FINAL', + details: { message: 'Main race session not found for race event' }, + }); + } + + const results = await this.resultRepository.findByRaceId(mainRaceSession.id); + + let notificationsSent = 0; + + for (const driverResult of results) { + await this.sendFinalResultsNotification( + driverResult.driverId, + raceEvent, + driverResult, + league.id, + false, + ); + notificationsSent += 1; + } + + const result: SendFinalResultsResult = { + leagueId: league.id, + raceId: raceEvent.id, + notificationsSent, + }; + + this.output.present(result); - // Get race event to include context - const raceEvent = await this.raceEventRepository.findById(raceEventId); - if (!raceEvent) { - // RaceEvent not found, skip return Result.ok(undefined); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to send final results'; + this.logger.error('SendFinalResultsUseCase.execute failed', error instanceof Error ? error : undefined); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } - - // Get final results for the main race session - const mainRaceSession = raceEvent.getMainRaceSession(); - if (!mainRaceSession) { - // No main race session, skip - return Result.ok(undefined); - } - - const results = await this.resultRepository.findByRaceId(mainRaceSession.id); - - // Send final results to each participating driver - for (const driverId of driverIds) { - const driverResult = results.find(r => r.driverId === driverId); - - await this.sendFinalResultsNotification( - driverId, - raceEvent, - driverResult, - leagueId, - hadPenaltiesApplied - ); - } - - return Result.ok(undefined); } private async sendFinalResultsNotification( @@ -62,17 +120,16 @@ export class SendFinalResultsUseCase { raceEvent: RaceEvent, driverResult: RaceResult | undefined, leagueId: string, - hadPenaltiesApplied: boolean + hadPenaltiesApplied: boolean, ): Promise { const position = driverResult?.position ?? 'DNF'; const positionChange = driverResult?.getPositionChange() ?? 0; const incidents = driverResult?.incidents ?? 0; - // Calculate final rating change (could include penalty adjustments) const finalRatingChange = this.calculateFinalRatingChange( driverResult?.position, driverResult?.incidents, - hadPenaltiesApplied + hadPenaltiesApplied, ); const title = `Final Results: ${raceEvent.name}`; @@ -81,7 +138,7 @@ export class SendFinalResultsUseCase { positionChange, incidents, finalRatingChange, - hadPenaltiesApplied + hadPenaltiesApplied, ); await this.notificationService.sendNotification({ @@ -113,7 +170,7 @@ export class SendFinalResultsUseCase { href: `/leagues/${leagueId}/races/${raceEvent.id}`, }, ], - requiresResponse: false, // Can be dismissed, shows final results + requiresResponse: false, }); } @@ -122,17 +179,13 @@ export class SendFinalResultsUseCase { positionChange: number, incidents: number, finalRatingChange: number, - hadPenaltiesApplied: boolean + hadPenaltiesApplied: boolean, ): string { const positionText = position === 'DNF' ? 'DNF' : `P${position}`; - const positionChangeText = positionChange > 0 ? `+${positionChange}` : - positionChange < 0 ? `${positionChange}` : '±0'; + const positionChangeText = positionChange > 0 ? `+${positionChange}` : positionChange < 0 ? `${positionChange}` : '±0'; const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`; - const ratingText = finalRatingChange >= 0 ? - `+${finalRatingChange} rating` : - `${finalRatingChange} rating`; - const penaltyText = hadPenaltiesApplied ? - ' (including stewarding adjustments)' : ''; + const ratingText = finalRatingChange >= 0 ? `+${finalRatingChange} rating` : `${finalRatingChange} rating`; + const penaltyText = hadPenaltiesApplied ? ' (including stewarding adjustments)' : ''; return `Final result: ${positionText} (${positionChangeText} positions). ${incidentsText} ${ratingText}${penaltyText}.`; } @@ -140,22 +193,18 @@ export class SendFinalResultsUseCase { private calculateFinalRatingChange( position?: number, incidents?: number, - hadPenaltiesApplied?: boolean + hadPenaltiesApplied?: boolean, ): number { - if (!position) return -10; // DNF penalty + if (!position) return -10; - // Base calculation (same as provisional) const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5; const positionBonus = Math.max(0, (20 - position) * 2); const incidentPenalty = (incidents ?? 0) * -5; let finalChange = baseChange + positionBonus + incidentPenalty; - // Additional penalty adjustments if stewarding applied penalties if (hadPenaltiesApplied) { - // In a real implementation, this would check actual penalties applied - // For now, we'll assume some penalties might have been applied - finalChange = Math.max(finalChange - 5, -20); // Cap penalty at -20 + finalChange = Math.max(finalChange - 5, -20); } return finalChange; diff --git a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts index c9653a9c7..83c140aa8 100644 --- a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts +++ b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts @@ -1,25 +1,88 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { + SendPerformanceSummaryUseCase, + type SendPerformanceSummaryInput, + type SendPerformanceSummaryResult, + type SendPerformanceSummaryErrorCode, +} from './SendPerformanceSummaryUseCase'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; -import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; -import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; +import type { Logger } from '@core/shared/application/Logger'; + +const unwrapError = ( + result: Result>, +): ApplicationErrorCode => { + expect(result.isErr()).toBe(true); + return result.unwrapErr(); +}; describe('SendPerformanceSummaryUseCase', () => { - it('sends performance summary notifications to all participating drivers', async () => { - const mockNotificationService = { - sendNotification: vi.fn(), - } as unknown as NotificationService; + let notificationService: { sendNotification: Mock }; + let raceEventRepository: { findById: Mock }; + let resultRepository: { findByRaceId: Mock }; + let leagueRepository: { findById: Mock }; + let membershipRepository: { getMembership: Mock }; + let driverRepository: { findById: Mock }; + let logger: Logger; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: SendPerformanceSummaryUseCase; + beforeEach(() => { + notificationService = { sendNotification: vi.fn() }; + raceEventRepository = { findById: vi.fn() }; + resultRepository = { findByRaceId: vi.fn() }; + leagueRepository = { findById: vi.fn() }; + membershipRepository = { getMembership: vi.fn() }; + driverRepository = { findById: vi.fn() }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + output = { present: vi.fn() } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new SendPerformanceSummaryUseCase( + notificationService as unknown as NotificationService, + raceEventRepository as unknown as IRaceEventRepository, + resultRepository as unknown as IResultRepository, + leagueRepository as unknown as ILeagueRepository, + membershipRepository as unknown as ILeagueMembershipRepository, + driverRepository as unknown as IDriverRepository, + logger, + output, + ); + }); + + const createInput = (overrides: Partial = {}): SendPerformanceSummaryInput => ({ + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + triggeredById: 'driver-1', + ...overrides, + }); + + it('sends performance summary notification and presents result on success', async () => { const mockRaceEvent = { id: 'race-1', + leagueId: 'league-1', name: 'Test Race', - getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1' }), + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }), }; - const mockRaceEventRepository = { - findById: vi.fn().mockResolvedValue(mockRaceEvent), - } as unknown as IRaceEventRepository; + const mockLeague = { id: 'league-1' }; + const mockDriver = { id: 'driver-1' }; + + leagueRepository.findById.mockResolvedValue(mockLeague); + raceEventRepository.findById.mockResolvedValue(mockRaceEvent); + driverRepository.findById.mockResolvedValue(mockDriver); const mockResults = [ { @@ -28,117 +91,149 @@ describe('SendPerformanceSummaryUseCase', () => { incidents: 0, getPositionChange: vi.fn().mockReturnValue(2), }, - { - driverId: 'driver-2', - position: 2, - incidents: 1, - getPositionChange: vi.fn().mockReturnValue(-1), - }, ]; - const mockResultRepository = { - findByRaceId: vi.fn().mockResolvedValue(mockResults), - } as unknown as IResultRepository; + resultRepository.findByRaceId.mockResolvedValue(mockResults); - const useCase = new SendPerformanceSummaryUseCase( - mockNotificationService, - mockRaceEventRepository, - mockResultRepository, - ); + const input = createInput(); - const event: MainRaceCompletedEvent = { - eventType: 'MainRaceCompleted', - aggregateId: 'race-1', - occurredAt: new Date(), - eventData: { - raceEventId: 'race-1', - sessionId: 'session-1', - seasonId: 'season-1', - leagueId: 'league-1', - driverIds: ['driver-1', 'driver-2'], - completedAt: new Date(), - }, - }; - - const result = await useCase.execute(event); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(mockRaceEventRepository.findById).toHaveBeenCalledWith('race-1'); - expect(mockResultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); - expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2); + expect(result.unwrap()).toBeUndefined(); - // Check first notification - expect(mockNotificationService.sendNotification).toHaveBeenCalledWith( + expect(leagueRepository.findById).toHaveBeenCalledWith('league-1'); + expect(raceEventRepository.findById).toHaveBeenCalledWith('race-1'); + expect(driverRepository.findById).toHaveBeenCalledWith('driver-1'); + expect(resultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); + + expect(notificationService.sendNotification).toHaveBeenCalledTimes(1); + expect(notificationService.sendNotification).toHaveBeenCalledWith( expect.objectContaining({ recipientId: 'driver-1', type: 'race_performance_summary', title: 'Race Complete: Test Race', - body: expect.stringContaining('You finished P1 (+2 positions). Clean race! Provisional +35 rating.'), - data: expect.objectContaining({ - raceEventId: 'race-1', - sessionId: 'session-1', - leagueId: 'league-1', - position: 1, - positionChange: 2, - incidents: 0, - provisionalRatingChange: 35, - }), }), ); - // Check second notification - expect(mockNotificationService.sendNotification).toHaveBeenCalledWith( - expect.objectContaining({ - recipientId: 'driver-2', - type: 'race_performance_summary', - title: 'Race Complete: Test Race', - body: expect.stringContaining('You finished P2 (-1 positions). 1 incident Provisional +20 rating.'), - data: expect.objectContaining({ - position: 2, - positionChange: -1, - incidents: 1, - provisionalRatingChange: 20, - }), - }), - ); + expect(output.present).toHaveBeenCalledTimes(1); + const presented = output.present.mock.calls[0][0] as SendPerformanceSummaryResult; + expect(presented).toEqual({ + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + notificationsSent: 1, + }); }); - it('skips sending notifications if race event not found', async () => { - const mockNotificationService = { - sendNotification: vi.fn(), - } as unknown as NotificationService; + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + leagueRepository.findById.mockResolvedValue(null); - const mockRaceEventRepository = { - findById: vi.fn().mockResolvedValue(null), - } as unknown as IRaceEventRepository; + const result = await useCase.execute(createInput()); - const mockResultRepository = { - findByRaceId: vi.fn(), - } as unknown as IResultRepository; - - const useCase = new SendPerformanceSummaryUseCase( - mockNotificationService, - mockRaceEventRepository, - mockResultRepository, - ); - - const event: MainRaceCompletedEvent = { - eventType: 'MainRaceCompleted', - aggregateId: 'race-1', - occurredAt: new Date(), - eventData: { - raceEventId: 'race-1', - sessionId: 'session-1', - seasonId: 'season-1', - leagueId: 'league-1', - driverIds: ['driver-1'], - completedAt: new Date(), - }, - }; - - const result = await useCase.execute(event); - - expect(result.isOk()).toBe(true); - expect(mockNotificationService.sendNotification).not.toHaveBeenCalled(); + const error = unwrapError(result); + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('returns RACE_NOT_FOUND when race event does not exist', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(error.details?.message).toBe('Race event not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns DRIVER_NOT_FOUND when driver does not exist', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ + id: 'race-1', + leagueId: 'league-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }), + }); + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('DRIVER_NOT_FOUND'); + expect(error.details?.message).toBe('Driver not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns INSUFFICIENT_PERMISSIONS when triggeredBy is not driver and not steward or higher', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ + id: 'race-1', + leagueId: 'league-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }), + }); + driverRepository.findById.mockResolvedValue({ id: 'driver-1' }); + membershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute(createInput({ triggeredById: 'user-1' })); + + const error = unwrapError(result); + expect(error.code).toBe('INSUFFICIENT_PERMISSIONS'); + expect(error.details?.message).toBe('Insufficient permissions to send performance summary'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns SUMMARY_NOT_AVAILABLE when main race session is missing or not completed', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ + id: 'race-1', + leagueId: 'league-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'in_progress' }), + }); + driverRepository.findById.mockResolvedValue({ id: 'driver-1' }); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('SUMMARY_NOT_AVAILABLE'); + expect(error.details?.message).toBe('Performance summary is not available for this race'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns SUMMARY_NOT_AVAILABLE when no result exists for driver', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + raceEventRepository.findById.mockResolvedValue({ + id: 'race-1', + leagueId: 'league-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }), + }); + driverRepository.findById.mockResolvedValue({ id: 'driver-1' }); + + resultRepository.findByRaceId.mockResolvedValue([]); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('SUMMARY_NOT_AVAILABLE'); + expect(error.details?.message).toBe('Performance summary is not available for this driver'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('wraps repository errors into REPOSITORY_ERROR and does not present output', async () => { + const mockError = new Error('Repository failure'); + leagueRepository.findById.mockRejectedValue(mockError); + + const result = await useCase.execute(createInput()); + + const error = unwrapError(result); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('Repository failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts index c36d3a6b8..df94c5d8e 100644 --- a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts +++ b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts @@ -1,77 +1,140 @@ +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { NotificationService } from '../../../notifications/application/ports/NotificationService'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; import type { Result as RaceResult } from '../../domain/entities/Result'; -import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { isLeagueStewardOrHigherRole } from '../../domain/types/LeagueRoles'; + +export type SendPerformanceSummaryInput = { + leagueId: string; + raceId: string; + driverId: string; + triggeredById: string; +}; + +export type SendPerformanceSummaryResult = { + leagueId: string; + raceId: string; + driverId: string; + notificationsSent: number; +}; + +export type SendPerformanceSummaryErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'RACE_NOT_FOUND' + | 'DRIVER_NOT_FOUND' + | 'INSUFFICIENT_PERMISSIONS' + | 'SUMMARY_NOT_AVAILABLE' + | 'REPOSITORY_ERROR'; /** * Use Case: SendPerformanceSummaryUseCase * - * Triggered by MainRaceCompleted domain event. - * Sends immediate performance summary modal notifications to all drivers who participated in the main race. + * Sends an immediate performance summary notification to a driver + * for a specific race event. */ export class SendPerformanceSummaryUseCase { constructor( private readonly notificationService: NotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly membershipRepository: ILeagueMembershipRepository, + private readonly driverRepository: IDriverRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(event: MainRaceCompletedEvent): Promise>> { - const { raceEventId, sessionId, leagueId, driverIds } = event.eventData; + async execute( + input: SendPerformanceSummaryInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); + } + + const raceEvent = await this.raceEventRepository.findById(input.raceId); + if (!raceEvent) { + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race event not found' } }); + } + + const driver = await this.driverRepository.findById(input.driverId); + if (!driver) { + return Result.err({ code: 'DRIVER_NOT_FOUND', details: { message: 'Driver not found' } }); + } + + if (input.triggeredById !== input.driverId) { + const membership = await this.membershipRepository.getMembership(league.id, input.triggeredById); + if (!membership || !isLeagueStewardOrHigherRole(membership.role)) { + return Result.err({ + code: 'INSUFFICIENT_PERMISSIONS', + details: { message: 'Insufficient permissions to send performance summary' }, + }); + } + } + + const mainRaceSession = raceEvent.getMainRaceSession(); + if (!mainRaceSession || mainRaceSession.status !== 'completed') { + return Result.err({ + code: 'SUMMARY_NOT_AVAILABLE', + details: { message: 'Performance summary is not available for this race' }, + }); + } + + const results = await this.resultRepository.findByRaceId(mainRaceSession.id); + const driverResult = results.find(r => r.driverId === input.driverId); + + if (!driverResult) { + return Result.err({ + code: 'SUMMARY_NOT_AVAILABLE', + details: { message: 'Performance summary is not available for this driver' }, + }); + } + + let notificationsSent = 0; + + await this.sendPerformanceSummaryNotification(input.driverId, raceEvent, driverResult, league.id); + notificationsSent += 1; + + const result: SendPerformanceSummaryResult = { + leagueId: league.id, + raceId: raceEvent.id, + driverId: input.driverId, + notificationsSent, + }; + + this.output.present(result); - // Get race event to include context - const raceEvent = await this.raceEventRepository.findById(raceEventId); - if (!raceEvent) { - // RaceEvent not found, skip return Result.ok(undefined); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Failed to send performance summary'; + this.logger.error('SendPerformanceSummaryUseCase.execute failed', error instanceof Error ? error : undefined); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message } }); } - - // Get results for the main race session to calculate performance data - const results = await this.resultRepository.findByRaceId(sessionId); - - // Send performance summary to each participating driver - for (const driverId of driverIds) { - const driverResult = results.find(r => r.driverId === driverId); - - await this.sendPerformanceSummaryNotification( - driverId, - raceEvent, - driverResult, - leagueId - ); - } - - return Result.ok(undefined); } private async sendPerformanceSummaryNotification( driverId: string, raceEvent: RaceEvent, driverResult: RaceResult | undefined, - leagueId: string + leagueId: string, ): Promise { const position = driverResult?.position ?? 'DNF'; const positionChange = driverResult?.getPositionChange() ?? 0; const incidents = driverResult?.incidents ?? 0; - // Calculate provisional rating change (simplified version) - const provisionalRatingChange = this.calculateProvisionalRatingChange( - driverResult?.position, - driverResult?.incidents - ); + const provisionalRatingChange = this.calculateProvisionalRatingChange(driverResult?.position, driverResult?.incidents); const title = `Race Complete: ${raceEvent.name}`; - const body = this.buildPerformanceSummaryBody( - position, - positionChange, - incidents, - provisionalRatingChange - ); + const body = this.buildPerformanceSummaryBody(position, positionChange, incidents, provisionalRatingChange); await this.notificationService.sendNotification({ recipientId: driverId, @@ -96,7 +159,7 @@ export class SendPerformanceSummaryUseCase { href: `/leagues/${leagueId}/races/${raceEvent.id}`, }, ], - requiresResponse: false, // Can be dismissed, but shows performance data + requiresResponse: false, }); } @@ -104,27 +167,23 @@ export class SendPerformanceSummaryUseCase { position: number | 'DNF', positionChange: number, incidents: number, - provisionalRatingChange: number + provisionalRatingChange: number, ): string { const positionText = position === 'DNF' ? 'DNF' : `P${position}`; - const positionChangeText = positionChange > 0 ? `+${positionChange}` : - positionChange < 0 ? `${positionChange}` : '±0'; + const positionChangeText = positionChange > 0 ? `+${positionChange}` : positionChange < 0 ? `${positionChange}` : '±0'; const incidentsText = incidents === 0 ? 'Clean race!' : `${incidents} incident${incidents > 1 ? 's' : ''}`; - const ratingText = provisionalRatingChange >= 0 ? - `+${provisionalRatingChange} rating` : - `${provisionalRatingChange} rating`; + const ratingText = provisionalRatingChange >= 0 ? `+${provisionalRatingChange} rating` : `${provisionalRatingChange} rating`; return `You finished ${positionText} (${positionChangeText} positions). ${incidentsText} Provisional ${ratingText}.`; } private calculateProvisionalRatingChange(position?: number, incidents?: number): number { - if (!position) return -10; // DNF penalty + if (!position) return -10; - // Simplified rating calculation (matches existing GetRaceDetailUseCase logic) const baseChange = position <= 3 ? 25 : position <= 10 ? 10 : -5; const positionBonus = Math.max(0, (20 - position) * 2); const incidentPenalty = (incidents ?? 0) * -5; return baseChange + positionBonus + incidentPenalty; } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts index f43f5e86b..46b244e6e 100644 --- a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts +++ b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts @@ -1,105 +1,212 @@ -import { describe, it, expect, vi } from 'vitest'; -import { SubmitProtestDefenseUseCase } from './SubmitProtestDefenseUseCase'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + SubmitProtestDefenseUseCase, + type SubmitProtestDefenseInput, + type SubmitProtestDefenseResult, + type SubmitProtestDefenseErrorCode, +} from './SubmitProtestDefenseUseCase'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; + +interface MockProtest { + id: string; + accusedDriverId: string; + canSubmitDefense: ReturnType; + submitDefense: ReturnType; +} describe('SubmitProtestDefenseUseCase', () => { + let leagueRepository: ILeagueRepository & { findById: ReturnType }; + let protestRepository: IProtestRepository & { findById: ReturnType; update: ReturnType }; + let logger: Logger & { error: ReturnType }; + let output: UseCaseOutputPort & { present: ReturnType }; + let useCase: SubmitProtestDefenseUseCase; + + const createInput = (overrides: Partial = {}): SubmitProtestDefenseInput => ({ + leagueId: 'league-1', + protestId: 'protest-1', + driverId: 'driver-1', + defenseText: 'My defense', + videoUrl: 'http://video.com', + ...overrides, + }); + + const unwrapError = ( + result: Result>, + ): ApplicationErrorCode => { + expect(result.isErr()).toBe(true); + return result.unwrapErr(); + }; + + beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + findAll: vi.fn(), + findByOwnerId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + searchByName: vi.fn(), + } as unknown as ILeagueRepository & { findById: ReturnType }; + + protestRepository = { + findById: vi.fn(), + findByRaceId: vi.fn(), + findByProtestingDriverId: vi.fn(), + findByAccusedDriverId: vi.fn(), + findPending: vi.fn(), + findUnderReviewBy: vi.fn(), + create: vi.fn(), + update: vi.fn(), + exists: vi.fn(), + } as unknown as IProtestRepository & { + findById: ReturnType; + update: ReturnType; + }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: ReturnType }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: ReturnType }; + + useCase = new SubmitProtestDefenseUseCase( + leagueRepository as unknown as ILeagueRepository, + protestRepository as unknown as IProtestRepository, + logger as unknown as Logger, + output, + ); + }); + it('submits defense successfully', async () => { - const mockProtest = { + const mockProtest: MockProtest = { id: 'protest-1', accusedDriverId: 'driver-1', canSubmitDefense: vi.fn().mockReturnValue(true), - submitDefense: vi.fn().mockReturnValue({}), - }; + submitDefense: vi.fn().mockReturnValue({ id: 'protest-1' }), + } as unknown as MockProtest; - const mockProtestRepository = { - findById: vi.fn().mockResolvedValue(mockProtest), - update: vi.fn().mockResolvedValue(undefined), - } as unknown as IProtestRepository; + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + protestRepository.findById.mockResolvedValue(mockProtest); + protestRepository.update.mockResolvedValue(undefined); - const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + const input = createInput(); - const command = { - protestId: 'protest-1', - driverId: 'driver-1', - statement: 'My defense', - videoUrl: 'http://video.com', - }; - - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual({ protestId: 'protest-1' }); - expect(mockProtestRepository.findById).toHaveBeenCalledWith('protest-1'); + expect(result.unwrap()).toBeUndefined(); + expect(leagueRepository.findById).toHaveBeenCalledWith('league-1'); + expect(protestRepository.findById).toHaveBeenCalledWith('protest-1'); expect(mockProtest.canSubmitDefense).toHaveBeenCalled(); expect(mockProtest.submitDefense).toHaveBeenCalledWith('My defense', 'http://video.com'); - expect(mockProtestRepository.update).toHaveBeenCalledWith({}); + expect(protestRepository.update).toHaveBeenCalled(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + leagueId: 'league-1', + protestId: 'protest-1', + driverId: 'driver-1', + status: 'defense_submitted', + }); + }); + + it('returns error when league not found', async () => { + leagueRepository.findById.mockResolvedValue(null); + + const input = createInput(); + + const result = await useCase.execute(input); + + const error = unwrapError(result); + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toBe('League not found'); + expect(output.present).not.toHaveBeenCalled(); }); it('returns error when protest not found', async () => { - const mockProtestRepository = { - findById: vi.fn().mockResolvedValue(null), - } as unknown as IProtestRepository; + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + protestRepository.findById.mockResolvedValue(null); - const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + const input = createInput(); - const command = { - protestId: 'protest-1', - driverId: 'driver-1', - statement: 'My defense', - }; + const result = await useCase.execute(input); - const result = await useCase.execute(command); - - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + const error = unwrapError(result); + expect(error.code).toBe('PROTEST_NOT_FOUND'); + expect(error.details?.message).toBe('Protest not found'); + expect(output.present).not.toHaveBeenCalled(); }); - it('returns error when driver is not the accused', async () => { + it('returns error when driver is not allowed', async () => { const mockProtest = { id: 'protest-1', accusedDriverId: 'driver-2', - }; + } as unknown as MockProtest; - const mockProtestRepository = { - findById: vi.fn().mockResolvedValue(mockProtest), - } as unknown as IProtestRepository; + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + protestRepository.findById.mockResolvedValue(mockProtest); - const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + const input = createInput({ driverId: 'driver-1' }); - const command = { - protestId: 'protest-1', - driverId: 'driver-1', - statement: 'My defense', - }; + const result = await useCase.execute(input); - const result = await useCase.execute(command); - - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'NOT_ACCUSED_DRIVER' }); + const error = unwrapError(result); + expect(error.code).toBe('DRIVER_NOT_ALLOWED'); + expect(error.details?.message).toBe('Driver is not allowed to submit a defense for this protest'); + expect(output.present).not.toHaveBeenCalled(); }); - it('returns error when defense cannot be submitted', async () => { - const mockProtest = { + it('returns error when defense cannot be submitted due to invalid state', async () => { + const mockProtest: MockProtest = { id: 'protest-1', accusedDriverId: 'driver-1', canSubmitDefense: vi.fn().mockReturnValue(false), - }; + submitDefense: vi.fn(), + } as unknown as MockProtest; - const mockProtestRepository = { - findById: vi.fn().mockResolvedValue(mockProtest), - } as unknown as IProtestRepository; + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + protestRepository.findById.mockResolvedValue(mockProtest); - const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + const input = createInput(); - const command = { - protestId: 'protest-1', - driverId: 'driver-1', - statement: 'My defense', - }; + const result = await useCase.execute(input); - const result = await useCase.execute(command); - - expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'DEFENSE_CANNOT_BE_SUBMITTED' }); + const error = unwrapError(result); + expect(error.code).toBe('INVALID_PROTEST_STATE'); + expect(error.details?.message).toBe('Defense cannot be submitted for this protest'); + expect(output.present).not.toHaveBeenCalled(); + expect(mockProtest.submitDefense).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('returns repository error when update throws', async () => { + const mockProtest: MockProtest = { + id: 'protest-1', + accusedDriverId: 'driver-1', + canSubmitDefense: vi.fn().mockReturnValue(true), + submitDefense: vi.fn().mockReturnValue({ id: 'protest-1' }), + } as unknown as MockProtest; + + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + protestRepository.findById.mockResolvedValue(mockProtest); + protestRepository.update.mockRejectedValue(new Error('DB failure')); + + const input = createInput(); + + const result = await useCase.execute(input); + + const error = unwrapError(result); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts index 07153dfd7..fdda26a65 100644 --- a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts +++ b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts @@ -5,44 +5,103 @@ */ import { Result } from '@core/shared/application/Result'; +import { Logger, UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; -export interface SubmitProtestDefenseCommand { +export type SubmitProtestDefenseInput = { + leagueId: string; protestId: string; driverId: string; - statement: string; + defenseText: string; videoUrl?: string; -} +}; -type SubmitProtestDefenseErrorCode = 'PROTEST_NOT_FOUND' | 'NOT_ACCUSED_DRIVER' | 'DEFENSE_CANNOT_BE_SUBMITTED'; +export type SubmitProtestDefenseResult = { + leagueId: string; + protestId: string; + driverId: string; + status: 'defense_submitted'; +}; + +export type SubmitProtestDefenseErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'PROTEST_NOT_FOUND' + | 'DRIVER_NOT_ALLOWED' + | 'INVALID_PROTEST_STATE' + | 'REPOSITORY_ERROR'; export class SubmitProtestDefenseUseCase { constructor( + private readonly leagueRepository: ILeagueRepository, private readonly protestRepository: IProtestRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: SubmitProtestDefenseCommand): Promise>> { - // Get the protest - const protest = await this.protestRepository.findById(command.protestId); - if (!protest) { - return Result.err({ code: 'PROTEST_NOT_FOUND' }); + async execute( + input: SubmitProtestDefenseInput, + ): Promise>> { + try { + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League not found' }, + }); + } + + const protest = await this.protestRepository.findById(input.protestId); + if (!protest) { + return Result.err({ + code: 'PROTEST_NOT_FOUND', + details: { message: 'Protest not found' }, + }); + } + + if (protest.accusedDriverId !== input.driverId) { + return Result.err({ + code: 'DRIVER_NOT_ALLOWED', + details: { message: 'Driver is not allowed to submit a defense for this protest' }, + }); + } + + if (!protest.canSubmitDefense()) { + return Result.err({ + code: 'INVALID_PROTEST_STATE', + details: { message: 'Defense cannot be submitted for this protest' }, + }); + } + + const updatedProtest = protest.submitDefense(input.defenseText, input.videoUrl); + await this.protestRepository.update(updatedProtest); + + const result: SubmitProtestDefenseResult = { + leagueId: input.leagueId, + protestId: protest.id, + driverId: input.driverId, + status: 'defense_submitted', + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && typeof error.message === 'string' + ? error.message + : 'Failed to submit protest defense'; + + this.logger.error( + 'SubmitProtestDefenseUseCase.execute failed', + error instanceof Error ? error : undefined, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - - // Verify the submitter is the accused driver - if (protest.accusedDriverId !== command.driverId) { - return Result.err({ code: 'NOT_ACCUSED_DRIVER' }); - } - - // Check if defense can be submitted - if (!protest.canSubmitDefense()) { - return Result.err({ code: 'DEFENSE_CANNOT_BE_SUBMITTED' }); - } - - // Submit defense - const updatedProtest = protest.submitDefense(command.statement, command.videoUrl); - await this.protestRepository.update(updatedProtest); - - return Result.ok({ protestId: protest.id }); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts index c501f50f9..b02a6096f 100644 --- a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts +++ b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts @@ -1,152 +1,258 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TransferLeagueOwnershipUseCase } from './TransferLeagueOwnershipUseCase'; +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { + TransferLeagueOwnershipUseCase, + type TransferLeagueOwnershipInput, + type TransferLeagueOwnershipResult, + type TransferLeagueOwnershipErrorCode, +} from './TransferLeagueOwnershipUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Result } from '@core/shared/application/Result'; describe('TransferLeagueOwnershipUseCase', () => { + let leagueRepository: ILeagueRepository; + let membershipRepository: ILeagueMembershipRepository; + let logger: Logger & { error: Mock }; + let output: UseCaseOutputPort & { present: Mock }; + let useCase: TransferLeagueOwnershipUseCase; + + beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + update: vi.fn(), + } as unknown as ILeagueRepository; + + membershipRepository = { + getMembership: vi.fn(), + saveMembership: vi.fn(), + } as unknown as ILeagueMembershipRepository; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: Mock }; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: Mock }; + + useCase = new TransferLeagueOwnershipUseCase( + leagueRepository, + membershipRepository, + logger, + output, + ); + }); + it('transfers ownership successfully', async () => { const mockLeague = { id: 'league-1', - ownerId: 'owner-1', + ownerId: { toString: () => 'owner-1' }, update: vi.fn().mockReturnValue({}), - }; + } as unknown as { id: string; ownerId: { toString: () => string }; update: Mock }; const mockNewOwnerMembership = { leagueId: 'league-1', driverId: 'owner-2', - status: 'active', + status: { toString: () => 'active' }, role: 'member', - }; + } as unknown as { leagueId: string; driverId: string; status: { toString: () => string }; role: string }; const mockCurrentOwnerMembership = { leagueId: 'league-1', driverId: 'owner-1', - status: 'active', + status: { toString: () => 'active' }, role: 'owner', - }; + } as unknown as { leagueId: string; driverId: string; status: { toString: () => string }; role: string }; - const mockLeagueRepository = { - findById: vi.fn().mockResolvedValue(mockLeague), - update: vi.fn().mockResolvedValue(undefined), - } as unknown as ILeagueRepository; + (leagueRepository.findById as unknown as Mock).mockResolvedValue(mockLeague); - const mockMembershipRepository = { - getMembership: vi.fn() - .mockResolvedValueOnce(mockNewOwnerMembership) - .mockResolvedValueOnce(mockCurrentOwnerMembership), - saveMembership: vi.fn().mockResolvedValue(undefined), - } as unknown as ILeagueMembershipRepository; + (membershipRepository.getMembership as unknown as Mock) + .mockResolvedValueOnce(mockNewOwnerMembership) + .mockResolvedValueOnce(mockCurrentOwnerMembership); - const useCase = new TransferLeagueOwnershipUseCase( - mockLeagueRepository, - mockMembershipRepository, - ); - - const command = { + const input: TransferLeagueOwnershipInput = { leagueId: 'league-1', currentOwnerId: 'owner-1', newOwnerId: 'owner-2', }; - const result = await useCase.execute(command); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(mockLeagueRepository.findById).toHaveBeenCalledWith('league-1'); - expect(mockMembershipRepository.getMembership).toHaveBeenCalledWith('league-1', 'owner-2'); - expect(mockMembershipRepository.getMembership).toHaveBeenCalledWith('league-1', 'owner-1'); - expect(mockMembershipRepository.saveMembership).toHaveBeenCalledWith({ - ...mockNewOwnerMembership, - role: 'owner', - }); - expect(mockMembershipRepository.saveMembership).toHaveBeenCalledWith({ - ...mockCurrentOwnerMembership, - role: 'admin', - }); + expect(result.unwrap()).toBeUndefined(); + + expect(leagueRepository.findById).toHaveBeenCalledWith('league-1'); + expect(membershipRepository.getMembership).toHaveBeenCalledWith('league-1', 'owner-2'); + expect(membershipRepository.getMembership).toHaveBeenCalledWith('league-1', 'owner-1'); + + expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(2); + + const saveMembershipMock = membershipRepository.saveMembership as unknown as Mock; + const firstSaveCall = saveMembershipMock.mock.calls[0]![0] as { role: string }; + const secondSaveCall = saveMembershipMock.mock.calls[1]![0] as { role: string }; + + expect(firstSaveCall.role).toBe('owner'); + expect(secondSaveCall.role).toBe('admin'); + expect(mockLeague.update).toHaveBeenCalledWith({ ownerId: 'owner-2' }); - expect(mockLeagueRepository.update).toHaveBeenCalledWith({}); + expect(leagueRepository.update).toHaveBeenCalledWith(expect.anything()); + + expect(output.present).toHaveBeenCalledTimes(1); + + const presentMock = output.present as Mock; + const presented = presentMock.mock.calls[0]![0] as TransferLeagueOwnershipResult; + expect(presented).toEqual({ + leagueId: 'league-1', + previousOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }); }); - it('returns error when league not found', async () => { - const mockLeagueRepository = { - findById: vi.fn().mockResolvedValue(null), - } as unknown as ILeagueRepository; + it('returns LEAGUE_NOT_FOUND when league does not exist', async () => { + (leagueRepository.findById as unknown as Mock).mockResolvedValue(null); - const mockMembershipRepository = {} as unknown as ILeagueMembershipRepository; - - const useCase = new TransferLeagueOwnershipUseCase( - mockLeagueRepository, - mockMembershipRepository, - ); - - const command = { - leagueId: 'league-1', + const input: TransferLeagueOwnershipInput = { + leagueId: 'non-existent', currentOwnerId: 'owner-1', newOwnerId: 'owner-2', }; - const result = await useCase.execute(command); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'LEAGUE_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode< + TransferLeagueOwnershipErrorCode, + { message: string } + >; + + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details?.message).toContain('non-existent'); + expect(output.present).not.toHaveBeenCalled(); }); - it('returns error when not current owner', async () => { + it('returns NOT_LEAGUE_OWNER when current owner does not match', async () => { const mockLeague = { id: 'league-1', - ownerId: 'owner-2', - }; + ownerId: { toString: () => 'other-owner' }, + update: vi.fn(), + } as unknown as { id: string; ownerId: { toString: () => string }; update: Mock }; - const mockLeagueRepository = { - findById: vi.fn().mockResolvedValue(mockLeague), - } as unknown as ILeagueRepository; + (leagueRepository.findById as unknown as Mock).mockResolvedValue(mockLeague); - const mockMembershipRepository = {} as unknown as ILeagueMembershipRepository; - - const useCase = new TransferLeagueOwnershipUseCase( - mockLeagueRepository, - mockMembershipRepository, - ); - - const command = { + const input: TransferLeagueOwnershipInput = { leagueId: 'league-1', currentOwnerId: 'owner-1', newOwnerId: 'owner-2', }; - const result = await useCase.execute(command); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'NOT_CURRENT_OWNER' }); + const error = result.unwrapErr() as ApplicationErrorCode< + TransferLeagueOwnershipErrorCode, + { message: string } + >; + + expect(error.code).toBe('NOT_LEAGUE_OWNER'); + expect(output.present).not.toHaveBeenCalled(); }); - it('returns error when new owner is not active member', async () => { + it('returns NEW_OWNER_NOT_MEMBER when new owner is not an active member', async () => { const mockLeague = { id: 'league-1', - ownerId: 'owner-1', - }; + ownerId: { toString: () => 'owner-1' }, + update: vi.fn(), + } as unknown as { id: string; ownerId: { toString: () => string }; update: Mock }; - const mockLeagueRepository = { - findById: vi.fn().mockResolvedValue(mockLeague), - } as unknown as ILeagueRepository; + (leagueRepository.findById as unknown as Mock).mockResolvedValue(mockLeague); - const mockMembershipRepository = { - getMembership: vi.fn().mockResolvedValue(null), - } as unknown as ILeagueMembershipRepository; + (membershipRepository.getMembership as unknown as Mock).mockResolvedValue(null); - const useCase = new TransferLeagueOwnershipUseCase( - mockLeagueRepository, - mockMembershipRepository, - ); - - const command = { + const input: TransferLeagueOwnershipInput = { leagueId: 'league-1', currentOwnerId: 'owner-1', newOwnerId: 'owner-2', }; - const result = await useCase.execute(command); + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'NEW_OWNER_NOT_ACTIVE_MEMBER' }); + const error = result.unwrapErr() as ApplicationErrorCode< + TransferLeagueOwnershipErrorCode, + { message: string } + >; + + expect(error.code).toBe('NEW_OWNER_NOT_MEMBER'); + expect(output.present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('wraps repository errors in REPOSITORY_ERROR and logs the error', async () => { + const mockLeague = { + id: 'league-1', + ownerId: { toString: () => 'owner-1' }, + update: vi.fn().mockReturnValue({}), + } as unknown as { id: string; ownerId: { toString: () => string }; update: Mock }; + + (leagueRepository.findById as unknown as Mock).mockResolvedValue(mockLeague); + + const mockNewOwnerMembership = { + leagueId: 'league-1', + driverId: 'owner-2', + status: { toString: () => 'active' }, + role: 'member', + } as unknown as { leagueId: string; driverId: string; status: { toString: () => string }; role: string }; + + (membershipRepository.getMembership as unknown as Mock) + .mockResolvedValueOnce(mockNewOwnerMembership) + .mockResolvedValueOnce(null); + + const updateError = new Error('update failed'); + (leagueRepository.update as unknown as Mock).mockRejectedValue(updateError); + + const input: TransferLeagueOwnershipInput = { + leagueId: 'league-1', + currentOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }; + + const result: Result< + void, + ApplicationErrorCode + > = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + TransferLeagueOwnershipErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('update failed'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + + const errorMock = logger.error as Mock; + const calls = errorMock.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const loggedMessage = calls[0]?.[0] as string; + expect(loggedMessage).toContain('update failed'); + }); +}); diff --git a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts index a6b009028..df0c691b0 100644 --- a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts +++ b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts @@ -1,62 +1,120 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { UseCaseOutputPort, Logger } from '@core/shared/application'; import type { ILeagueMembershipRepository, } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; -import type { - MembershipRole, -} from '@core/racing/domain/entities/LeagueMembership'; -import type { TransferLeagueOwnershipOutputPort } from '../ports/output/TransferLeagueOwnershipOutputPort'; -export interface TransferLeagueOwnershipCommandDTO { +export type TransferLeagueOwnershipInput = { leagueId: string; currentOwnerId: string; newOwnerId: string; -} +}; -type TransferLeagueOwnershipErrorCode = 'LEAGUE_NOT_FOUND' | 'NOT_CURRENT_OWNER' | 'NEW_OWNER_NOT_ACTIVE_MEMBER'; +export type TransferLeagueOwnershipResult = { + leagueId: string; + previousOwnerId: string; + newOwnerId: string; +}; + +export type TransferLeagueOwnershipErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'NOT_LEAGUE_OWNER' + | 'NEW_OWNER_NOT_MEMBER' + | 'REPOSITORY_ERROR'; export class TransferLeagueOwnershipUseCase { constructor( private readonly leagueRepository: ILeagueRepository, - private readonly membershipRepository: ILeagueMembershipRepository + private readonly membershipRepository: ILeagueMembershipRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: TransferLeagueOwnershipCommandDTO): Promise>> { - const { leagueId, currentOwnerId, newOwnerId } = command; + async execute( + input: TransferLeagueOwnershipInput, + ): Promise< + Result> + > { + const { leagueId, currentOwnerId, newOwnerId } = input; - const league = await this.leagueRepository.findById(leagueId); - if (!league) { - return Result.err({ code: 'LEAGUE_NOT_FOUND' }); - } + try { + const league = await this.leagueRepository.findById(leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League with id ${leagueId} not found` }, + } as ApplicationErrorCode); + } - if (league.ownerId !== currentOwnerId) { - return Result.err({ code: 'NOT_CURRENT_OWNER' }); - } + if (league.ownerId.toString() !== currentOwnerId) { + return Result.err({ + code: 'NOT_LEAGUE_OWNER', + details: { message: 'Current user is not the league owner' }, + } as ApplicationErrorCode); + } - const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId); - if (!newOwnerMembership || newOwnerMembership.status !== 'active') { - return Result.err({ code: 'NEW_OWNER_NOT_ACTIVE_MEMBER' }); - } + const newOwnerMembership = await this.membershipRepository.getMembership( + leagueId, + newOwnerId, + ); + if (!newOwnerMembership || newOwnerMembership.status.toString() !== 'active') { + return Result.err({ + code: 'NEW_OWNER_NOT_MEMBER', + details: { message: 'New owner must be an active league member' }, + } as ApplicationErrorCode); + } - const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId); + const currentOwnerMembership = await this.membershipRepository.getMembership( + leagueId, + currentOwnerId, + ); - await this.membershipRepository.saveMembership({ - ...newOwnerMembership, - role: 'owner' as MembershipRole, - }); + const updatedNewOwnerMembership = { + ...(newOwnerMembership as unknown as { role: string }), + role: 'owner', + } as unknown as import('../../domain/entities/LeagueMembership').LeagueMembership; - if (currentOwnerMembership) { - await this.membershipRepository.saveMembership({ - ...currentOwnerMembership, - role: 'admin' as MembershipRole, + await this.membershipRepository.saveMembership(updatedNewOwnerMembership); + + if (currentOwnerMembership) { + const updatedCurrentOwnerMembership = { + ...(currentOwnerMembership as unknown as { role: string }), + role: 'admin', + } as unknown as import('../../domain/entities/LeagueMembership').LeagueMembership; + + await this.membershipRepository.saveMembership(updatedCurrentOwnerMembership); + } + + const updatedLeague = league.update({ ownerId: newOwnerId }); + await this.leagueRepository.update(updatedLeague); + + const result: TransferLeagueOwnershipResult = { + leagueId, + previousOwnerId: currentOwnerId, + newOwnerId, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : 'Failed to transfer league ownership'; + + this.logger.error(message, error as Error, { + leagueId, + currentOwnerId, + newOwnerId, }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + } as ApplicationErrorCode); } - - const updatedLeague = league.update({ ownerId: newOwnerId }); - await this.leagueRepository.update(updatedLeague); - - return Result.ok({ success: true }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts b/core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts index 3ad6f7174..52e459dbe 100644 --- a/core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts +++ b/core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts @@ -1,29 +1,53 @@ -import { describe, it, expect, vi } from 'vitest'; -import { UpdateDriverProfileUseCase } from './UpdateDriverProfileUseCase'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + UpdateDriverProfileUseCase, + type UpdateDriverProfileInput, + type UpdateDriverProfileResult, + type UpdateDriverProfileErrorCode, +} from './UpdateDriverProfileUseCase'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { Driver } from '../../domain/entities/Driver'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; +import type { Logger } from '@core/shared/application/Logger'; describe('UpdateDriverProfileUseCase', () => { + let driverRepository: IDriverRepository; + let output: UseCaseOutputPort & { present: ReturnType }; + let logger: Logger & { error: ReturnType }; + let useCase: UpdateDriverProfileUseCase; + + beforeEach(() => { + driverRepository = { + findById: vi.fn(), + update: vi.fn(), + } as unknown as IDriverRepository; + + output = { + present: vi.fn(), + } as unknown as UseCaseOutputPort & { present: ReturnType }; + + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: ReturnType }; + + useCase = new UpdateDriverProfileUseCase(driverRepository, output, logger); + }); + it('updates driver profile successfully', async () => { const mockDriver = { id: 'driver-1', - update: vi.fn().mockReturnValue({}), + update: vi.fn().mockReturnValue({ id: 'driver-1' } as Driver), } as unknown as Driver; - const mockUpdatedDriver = { - id: 'driver-1', - bio: 'New bio', - country: 'US', - } as Driver; + (driverRepository.findById as unknown as ReturnType).mockResolvedValue(mockDriver); + (driverRepository.update as unknown as ReturnType).mockResolvedValue(mockDriver); - const mockDriverRepository = { - findById: vi.fn().mockResolvedValue(mockDriver), - update: vi.fn().mockResolvedValue(mockUpdatedDriver), - } as unknown as IDriverRepository; - - const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); - - const input = { + const input: UpdateDriverProfileInput = { driverId: 'driver-1', bio: 'New bio', country: 'US', @@ -32,49 +56,64 @@ describe('UpdateDriverProfileUseCase', () => { const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(result.unwrap()).toEqual(mockUpdatedDriver); - expect(mockDriverRepository.findById).toHaveBeenCalledWith('driver-1'); - expect(mockDriver.update).toHaveBeenCalledWith({ bio: 'New bio', country: 'US' }); - expect(mockDriverRepository.update).toHaveBeenCalledWith({}); + expect(result.unwrap()).toBeUndefined(); + + expect(driverRepository.findById).toHaveBeenCalledWith('driver-1'); + expect((mockDriver.update as unknown as ReturnType)).toHaveBeenCalledWith({ + bio: 'New bio', + country: 'US', + }); + expect(driverRepository.update).toHaveBeenCalled(); + + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1' }); }); it('returns error when driver not found', async () => { - const mockDriverRepository = { - findById: vi.fn().mockResolvedValue(null), - } as unknown as IDriverRepository; + (driverRepository.findById as unknown as ReturnType).mockResolvedValue(null); - const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); - - const input = { + const input: UpdateDriverProfileInput = { driverId: 'driver-1', bio: 'New bio', }; - const result = await useCase.execute(input); + const result: Result> = + await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'DRIVER_NOT_FOUND' }); + const error = result.unwrapErr(); + expect(error.code).toBe('DRIVER_NOT_FOUND'); + expect(error.details?.message).toContain('driver-1'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns error for invalid profile data', async () => { + const input: UpdateDriverProfileInput = { + driverId: 'driver-1', + bio: '', + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('INVALID_PROFILE_DATA'); + expect(error.details?.message).toBe('Profile data is invalid'); + expect(driverRepository.findById).not.toHaveBeenCalled(); + expect(output.present).not.toHaveBeenCalled(); }); it('updates only provided fields', async () => { const mockDriver = { id: 'driver-1', - update: vi.fn().mockReturnValue({}), + update: vi.fn().mockReturnValue({ id: 'driver-1' } as Driver), } as unknown as Driver; - const mockUpdatedDriver = { - id: 'driver-1', - country: 'US', - } as Driver; + (driverRepository.findById as unknown as ReturnType).mockResolvedValue(mockDriver); + (driverRepository.update as unknown as ReturnType).mockResolvedValue(mockDriver); - const mockDriverRepository = { - findById: vi.fn().mockResolvedValue(mockDriver), - update: vi.fn().mockResolvedValue(mockUpdatedDriver), - } as unknown as IDriverRepository; - - const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); - - const input = { + const input: UpdateDriverProfileInput = { driverId: 'driver-1', country: 'US', }; @@ -82,6 +121,33 @@ describe('UpdateDriverProfileUseCase', () => { const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(mockDriver.update).toHaveBeenCalledWith({ country: 'US' }); + expect((mockDriver.update as unknown as ReturnType)).toHaveBeenCalledWith({ country: 'US' }); + expect(output.present).toHaveBeenCalledTimes(1); + }); + + it('returns repository error when persistence fails', async () => { + const mockDriver = { + id: 'driver-1', + update: vi.fn().mockReturnValue({ id: 'driver-1' } as Driver), + } as unknown as Driver; + + (driverRepository.findById as unknown as ReturnType).mockResolvedValue(mockDriver); + (driverRepository.update as unknown as ReturnType).mockRejectedValue(new Error('db error')); + + const input: UpdateDriverProfileInput = { + driverId: 'driver-1', + bio: 'Bio', + }; + + const result: Result> = + await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('db error'); + + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts b/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts index 96dfe51b6..8f1959b98 100644 --- a/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts +++ b/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts @@ -1,36 +1,89 @@ import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger } from '@core/shared/application/Logger'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { Driver } from '../../domain/entities/Driver'; -export interface UpdateDriverProfileInput { +export type UpdateDriverProfileInput = { driverId: string; bio?: string; country?: string; -} +}; + +export type UpdateDriverProfileResult = { + driverId: string; +}; + +export type UpdateDriverProfileErrorCode = + | 'DRIVER_NOT_FOUND' + | 'INVALID_PROFILE_DATA' + | 'REPOSITORY_ERROR'; /** * Application use case responsible for updating basic driver profile details. - * Encapsulates domain entity mutation and returns the updated entity. - * Mapping to DTOs is handled by presenters in the presentation layer. + * Encapsulates domain entity mutation. Mapping to DTOs is handled by presenters + * in the presentation layer through the output port. */ export class UpdateDriverProfileUseCase { - constructor(private readonly driverRepository: IDriverRepository) {} + constructor( + private readonly driverRepository: IDriverRepository, + private readonly output: UseCaseOutputPort, + private readonly logger: Logger, + ) {} - async execute(input: UpdateDriverProfileInput): Promise>> { + async execute( + input: UpdateDriverProfileInput, + ): Promise>> { const { driverId, bio, country } = input; - const existing = await this.driverRepository.findById(driverId); - if (!existing) { - return Result.err({ code: 'DRIVER_NOT_FOUND' }); + if ((bio !== undefined && bio.trim().length === 0) || (country !== undefined && country.trim().length === 0)) { + return Result.err({ + code: 'INVALID_PROFILE_DATA', + details: { + message: 'Profile data is invalid', + }, + }); } - const updated = existing.update({ - ...(bio !== undefined ? { bio } : {}), - ...(country !== undefined ? { country } : {}), - }); + try { + const existing = await this.driverRepository.findById(driverId); + if (!existing) { + return Result.err({ + code: 'DRIVER_NOT_FOUND', + details: { + message: `Driver with id ${driverId} not found`, + }, + }); + } - const persisted = await this.driverRepository.update(updated); - return Result.ok(persisted); + const updated: Driver = existing.update({ + ...(bio !== undefined ? { bio } : {}), + ...(country !== undefined ? { country } : {}), + }); + + await this.driverRepository.update(updated); + + const result: UpdateDriverProfileResult = { + driverId: updated.id, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update driver profile'; + + this.logger.error('Failed to update driver profile', error instanceof Error ? error : undefined, { + driverId, + }); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message, + }, + }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts index fde1a53b3..9355b92e0 100644 --- a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts +++ b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts @@ -1,10 +1,18 @@ -import { describe, it, expect, vi } from 'vitest'; -import { UpdateLeagueMemberRoleUseCase } from './UpdateLeagueMemberRoleUseCase'; +import { describe, it, expect, vi, type Mock } from 'vitest'; +import { + UpdateLeagueMemberRoleUseCase, + type UpdateLeagueMemberRoleInput, + type UpdateLeagueMemberRoleResult, + type UpdateLeagueMemberRoleErrorCode, +} from './UpdateLeagueMemberRoleUseCase'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { UseCaseOutputPort } from '@core/shared/application'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; describe('UpdateLeagueMemberRoleUseCase', () => { it('updates league member role successfully', async () => { const mockMembership = { + id: 'league-1:driver-1', leagueId: 'league-1', driverId: 'driver-1', role: 'member', @@ -17,22 +25,33 @@ describe('UpdateLeagueMemberRoleUseCase', () => { saveMembership: vi.fn().mockResolvedValue(undefined), } as unknown as ILeagueMembershipRepository; - const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + }; - const params = { + const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository, output); + + const input: UpdateLeagueMemberRoleInput = { leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin', }; - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(mockLeagueMembershipRepository.getLeagueMembers).toHaveBeenCalledWith('league-1'); - expect(mockLeagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ - ...mockMembership, - role: 'admin', - }); + expect(mockLeagueMembershipRepository.saveMembership).toHaveBeenCalledTimes(1); + + expect(output.present).toHaveBeenCalledTimes(1); + const presented = (output.present as Mock).mock.calls[0]![0] as UpdateLeagueMemberRoleResult; + + expect(presented.membership.id).toBe('league-1:driver-1'); + expect(presented.membership.leagueId.toString()).toBe('league-1'); + expect(presented.membership.driverId.toString()).toBe('driver-1'); + expect(presented.membership.role.toString()).toBe('admin'); }); it('returns error if membership not found', async () => { @@ -40,17 +59,60 @@ describe('UpdateLeagueMemberRoleUseCase', () => { getLeagueMembers: vi.fn().mockResolvedValue([]), } as unknown as ILeagueMembershipRepository; - const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository); + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + }; - const params = { + const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository, output); + + const input: UpdateLeagueMemberRoleInput = { leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin', }; - const result = await useCase.execute(params); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'MEMBERSHIP_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueMemberRoleErrorCode, + { message: string } + >; + + expect(error.code).toBe('MEMBERSHIP_NOT_FOUND'); + expect(error.details.message).toBe('League membership not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('handles repository errors', async () => { + const mockLeagueMembershipRepository = { + getLeagueMembers: vi + .fn() + .mockRejectedValue(new Error('Database connection failed')), + } as unknown as ILeagueMembershipRepository; + + const output: UseCaseOutputPort & { present: Mock } = { + present: vi.fn(), + }; + + const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository, output); + + const input: UpdateLeagueMemberRoleInput = { + leagueId: 'league-1', + targetDriverId: 'driver-1', + newRole: 'admin', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode< + UpdateLeagueMemberRoleErrorCode, + { message: string } + >; + + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details.message).toBe('Failed to update league member role'); + expect(output.present).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts index e8134f848..0cf2bba82 100644 --- a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts +++ b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts @@ -1,27 +1,69 @@ import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { UpdateLeagueMemberRoleOutputPort } from '../ports/output/UpdateLeagueMemberRoleOutputPort'; +import { LeagueMembership } from '../../domain/entities/LeagueMembership'; -export interface UpdateLeagueMemberRoleUseCaseParams { +export type UpdateLeagueMemberRoleInput = { leagueId: string; targetDriverId: string; newRole: string; -} +}; + +export type UpdateLeagueMemberRoleResult = { + membership: LeagueMembership; +}; + +export type UpdateLeagueMemberRoleErrorCode = + | 'MEMBERSHIP_NOT_FOUND' + | 'REPOSITORY_ERROR'; export class UpdateLeagueMemberRoleUseCase { - constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} + constructor( + private readonly leagueMembershipRepository: ILeagueMembershipRepository, + private readonly output: UseCaseOutputPort, + ) {} - async execute(params: UpdateLeagueMemberRoleUseCaseParams): Promise>> { - const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); - const membership = memberships.find(m => m.driverId === params.targetDriverId); - if (!membership) { - return Result.err({ code: 'MEMBERSHIP_NOT_FOUND' }); + async execute( + input: UpdateLeagueMemberRoleInput, + ): Promise< + Result> + > { + try { + const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId); + const membership = memberships.find(m => m.driverId.toString() === input.targetDriverId); + + if (!membership) { + return Result.err({ + code: 'MEMBERSHIP_NOT_FOUND', + details: { message: 'League membership not found' }, + }); + } + + const updatedMembership = LeagueMembership.create({ + id: membership.id, + leagueId: membership.leagueId.toString(), + driverId: membership.driverId.toString(), + role: input.newRole, + status: membership.status.toString(), + joinedAt: membership.joinedAt.toDate(), + }); + + await this.leagueMembershipRepository.saveMembership(updatedMembership); + + this.output.present({ membership: updatedMembership }); + + return Result.ok(undefined); + } catch (error: unknown) { + const message = + error instanceof Error && typeof error.message === 'string' + ? error.message + : 'Failed to update league member role'; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { message }, + }); } - await this.leagueMembershipRepository.saveMembership({ - ...membership, - role: params.newRole, - }); - return Result.ok({ success: true }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateTeamUseCase.test.ts b/core/racing/application/use-cases/UpdateTeamUseCase.test.ts index 536044991..663ff6682 100644 --- a/core/racing/application/use-cases/UpdateTeamUseCase.test.ts +++ b/core/racing/application/use-cases/UpdateTeamUseCase.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; -import { UpdateTeamUseCase } from './UpdateTeamUseCase'; +import { UpdateTeamUseCase, type UpdateTeamInput, type UpdateTeamResult, type UpdateTeamErrorCode } from './UpdateTeamUseCase'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; @@ -9,9 +11,11 @@ describe('UpdateTeamUseCase', () => { role: 'owner', }; + const mockUpdatedTeam = { id: 'team-1' }; + const mockTeam = { id: 'team-1', - update: vi.fn().mockReturnValue({}), + update: vi.fn().mockReturnValue(mockUpdatedTeam), }; const mockTeamRepository = { @@ -23,24 +27,36 @@ describe('UpdateTeamUseCase', () => { getMembership: vi.fn().mockResolvedValue(mockMembership), } as unknown as ITeamMembershipRepository; - const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository); + const present = vi.fn<(data: UpdateTeamResult) => void>(); + const output: UseCaseOutputPort & { present: typeof present } = { + present, + }; - const command = { + const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository, output); + + const command: UpdateTeamInput = { teamId: 'team-1', - updates: { name: 'New Name', tag: 'NEW' }, updatedBy: 'user-1', + updates: { name: 'New Name', tag: 'NEW' }, }; const result = await useCase.execute(command); expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); expect(mockMembershipRepository.getMembership).toHaveBeenCalledWith('team-1', 'user-1'); expect(mockTeamRepository.findById).toHaveBeenCalledWith('team-1'); expect(mockTeam.update).toHaveBeenCalledWith({ name: 'New Name', tag: 'NEW' }); - expect(mockTeamRepository.update).toHaveBeenCalledWith({}); + expect(mockTeamRepository.update).toHaveBeenCalledWith(mockUpdatedTeam); + + expect(present).toHaveBeenCalledTimes(1); + const firstCall = present.mock.calls[0]; + expect(firstCall).toBeDefined(); + const presented = firstCall![0] as UpdateTeamResult; + expect(presented.team).toBe(mockUpdatedTeam); }); - it('returns error if insufficient permissions', async () => { + it('returns permission denied error if insufficient permissions', async () => { const mockMembership = { role: 'member', }; @@ -49,18 +65,26 @@ describe('UpdateTeamUseCase', () => { getMembership: vi.fn().mockResolvedValue(mockMembership), } as unknown as ITeamMembershipRepository; - const useCase = new UpdateTeamUseCase({} as unknown as ITeamRepository, mockMembershipRepository); + const present = vi.fn<(data: UpdateTeamResult) => void>(); + const output: UseCaseOutputPort & { present: typeof present } = { + present, + }; - const command = { + const useCase = new UpdateTeamUseCase({} as unknown as ITeamRepository, mockMembershipRepository, output); + + const command: UpdateTeamInput = { teamId: 'team-1', - updates: { name: 'New Name' }, updatedBy: 'user-1', + updates: { name: 'New Name' }, }; const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'INSUFFICIENT_PERMISSIONS' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('PERMISSION_DENIED'); + expect(error.details?.message).toBe('User does not have permission to update this team'); + expect(present).not.toHaveBeenCalled(); }); it('returns error if team not found', async () => { @@ -76,17 +100,60 @@ describe('UpdateTeamUseCase', () => { getMembership: vi.fn().mockResolvedValue(mockMembership), } as unknown as ITeamMembershipRepository; - const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository); + const present = vi.fn<(data: UpdateTeamResult) => void>(); + const output: UseCaseOutputPort & { present: typeof present } = { + present, + }; - const command = { + const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository, output); + + const command: UpdateTeamInput = { teamId: 'team-1', - updates: { name: 'New Name' }, updatedBy: 'user-1', + updates: { name: 'New Name' }, }; const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'TEAM_NOT_FOUND' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('TEAM_NOT_FOUND'); + expect(error.details?.message).toBe('Team not found'); + expect(present).not.toHaveBeenCalled(); }); -}); \ No newline at end of file + + it('returns repository error on unexpected failure', async () => { + const mockMembership = { + role: 'owner', + }; + + const mockTeamRepository = { + findById: vi.fn().mockRejectedValue(new Error('db error')), + } as unknown as ITeamRepository; + + const mockMembershipRepository = { + getMembership: vi.fn().mockResolvedValue(mockMembership), + } as unknown as ITeamMembershipRepository; + + const present = vi.fn<(data: UpdateTeamResult) => void>(); + const output: UseCaseOutputPort & { present: typeof present } = { + present, + }; + + const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository, output); + + const command: UpdateTeamInput = { + teamId: 'team-1', + updatedBy: 'user-1', + updates: { name: 'New Name' }, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('db error'); + expect(present).not.toHaveBeenCalled(); + }); +}); diff --git a/core/racing/application/use-cases/UpdateTeamUseCase.ts b/core/racing/application/use-cases/UpdateTeamUseCase.ts index ab9e6225e..0f5e6baf6 100644 --- a/core/racing/application/use-cases/UpdateTeamUseCase.ts +++ b/core/racing/application/use-cases/UpdateTeamUseCase.ts @@ -1,40 +1,81 @@ import { Result } from '@core/shared/application/Result'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Team } from '../../domain/entities/Team'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; -import type { UpdateTeamInputPort } from '../ports/input/UpdateTeamInputPort'; +export type UpdateTeamInput = { + teamId: string; + updatedBy: string; + updates: { + name?: string; + tag?: string; + description?: string; + leagues?: string[]; + }; +}; -type UpdateTeamErrorCode = 'INSUFFICIENT_PERMISSIONS' | 'TEAM_NOT_FOUND'; +export type UpdateTeamResult = { + team: Team; +}; + +export type UpdateTeamErrorCode = 'TEAM_NOT_FOUND' | 'PERMISSION_DENIED' | 'REPOSITORY_ERROR'; export class UpdateTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: UpdateTeamInputPort): Promise>> { - const { teamId, updates, updatedBy } = command; + async execute( + command: UpdateTeamInput, + ): Promise>> { + try { + const { teamId, updatedBy, updates } = command; - const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy); - if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) { - return Result.err({ code: 'INSUFFICIENT_PERMISSIONS' }); + const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy); + if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) { + return Result.err({ + code: 'PERMISSION_DENIED', + details: { + message: 'User does not have permission to update this team', + }, + }); + } + + const existing = await this.teamRepository.findById(teamId); + if (!existing) { + return Result.err({ + code: 'TEAM_NOT_FOUND', + details: { + message: 'Team not found', + }, + }); + } + + const updated = existing.update({ + ...(updates.name !== undefined && { name: updates.name }), + ...(updates.tag !== undefined && { tag: updates.tag }), + ...(updates.description !== undefined && { description: updates.description }), + ...(updates.leagues !== undefined && { leagues: updates.leagues }), + }); + + await this.teamRepository.update(updated); + + this.output.present({ team: updated }); + + return Result.ok(undefined); + } catch (err) { + const error = err as { message?: string } | undefined; + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: error?.message ?? 'Failed to update team', + }, + }); } - - const existing = await this.teamRepository.findById(teamId); - if (!existing) { - return Result.err({ code: 'TEAM_NOT_FOUND' }); - } - - const updated = existing.update({ - ...(updates.name !== undefined && { name: updates.name }), - ...(updates.tag !== undefined && { tag: updates.tag }), - ...(updates.description !== undefined && { description: updates.description }), - ...(updates.leagues !== undefined && { leagues: updates.leagues }), - }); - - await this.teamRepository.update(updated); - - return Result.ok(undefined); } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts index 5eba4349d..b4d55f21a 100644 --- a/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts +++ b/core/racing/application/use-cases/WithdrawFromLeagueWalletUseCase.ts @@ -1,66 +1,142 @@ -import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; -import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; -import type { WithdrawFromLeagueWalletOutputPort } from '../ports/output/WithdrawFromLeagueWalletOutputPort'; import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { Logger, UseCaseOutputPort } from '@core/shared/application'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; +import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository'; import { Money } from '../../domain/value-objects/Money'; import { Transaction } from '../../domain/entities/league-wallet/Transaction'; import { TransactionId } from '../../domain/entities/league-wallet/TransactionId'; import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId'; -export interface WithdrawFromLeagueWalletUseCaseParams { +export type WithdrawFromLeagueWalletInput = { leagueId: string; + requestedById: string; amount: number; - currency: string; - seasonId: string; - destinationAccount: string; -} + currency: 'USD' | 'EUR' | 'GBP'; + reason?: string; +}; + +export type WithdrawFromLeagueWalletResult = { + leagueId: string; + amount: Money; + transactionId: string; + walletBalanceAfter: Money; +}; + +export type WithdrawFromLeagueWalletErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'WALLET_NOT_FOUND' + | 'INSUFFICIENT_FUNDS' + | 'UNAUTHORIZED_WITHDRAWAL' + | 'REPOSITORY_ERROR'; /** * Use Case for withdrawing from league wallet. */ export class WithdrawFromLeagueWalletUseCase { constructor( - private readonly leagueWalletRepository: ILeagueWalletRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly walletRepository: ILeagueWalletRepository, private readonly transactionRepository: ITransactionRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} async execute( - params: WithdrawFromLeagueWalletUseCaseParams, - ): Promise>> { + input: WithdrawFromLeagueWalletInput, + ): Promise< + Result< + void, + ApplicationErrorCode< + WithdrawFromLeagueWalletErrorCode, + { + message: string; + } + > + > + > { try { - const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId); + const league = await this.leagueRepository.findById(input.leagueId); + if (!league) { + return Result.err({ + code: 'LEAGUE_NOT_FOUND', + details: { message: `League with id ${input.leagueId} not found` }, + }); + } + + const wallet = await this.walletRepository.findByLeagueId(input.leagueId); if (!wallet) { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' }); + return Result.err({ + code: 'WALLET_NOT_FOUND', + details: { message: `Wallet for league ${input.leagueId} not found` }, + }); } - // Check if withdrawal is allowed (for now, always false as per mock) - if (!wallet.canWithdraw(Money.create(params.amount, params.currency))) { - return Result.err({ code: 'INSUFFICIENT_BALANCE', message: 'Insufficient balance for withdrawal' }); + if (league.ownerId.toString() !== input.requestedById) { + return Result.err({ + code: 'UNAUTHORIZED_WITHDRAWAL', + details: { message: 'Only the league owner can withdraw from the league wallet' }, + }); } - // For now, always block withdrawal - return Result.err({ code: 'WITHDRAWAL_NOT_ALLOWED', message: 'Season 2 is still active. Withdrawals are available after season completion.' }); + const withdrawalAmount = Money.create(input.amount, input.currency); - // If allowed, create transaction and update wallet - // const transactionId = TransactionId.create(`txn-${Date.now()}`); - // const transaction = Transaction.create({ - // id: transactionId, - // walletId: LeagueWalletId.create(wallet.id.toString()), - // type: 'withdrawal', - // amount: Money.create(params.amount, params.currency), - // description: `Bank Transfer - ${params.seasonId} Payout`, - // metadata: { destinationAccount: params.destinationAccount, seasonId: params.seasonId }, - // }); + if (!wallet.canWithdraw(withdrawalAmount)) { + return Result.err({ + code: 'INSUFFICIENT_FUNDS', + details: { message: 'Insufficient balance for withdrawal' }, + }); + } - // const updatedWallet = wallet.withdrawFunds(Money.create(params.amount, params.currency), transactionId.toString()); + const transactionId = TransactionId.create(`txn-${Date.now()}`); + const transaction = Transaction.create({ + id: transactionId, + walletId: LeagueWalletId.create(wallet.id.toString()), + type: 'withdrawal', + amount: withdrawalAmount, + description: input.reason ?? 'League wallet withdrawal', + metadata: { + reason: input.reason, + requestedById: input.requestedById, + }, + completedAt: undefined, + }); - // await this.transactionRepository.create(transaction); - // await this.leagueWalletRepository.update(updatedWallet); + const updatedWallet = wallet.withdrawFunds(withdrawalAmount, transactionId.toString()); - // return Result.ok({ success: true }); - } catch { - return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to process withdrawal' }); + await this.transactionRepository.create(transaction); + await this.walletRepository.update(updatedWallet); + + const result: WithdrawFromLeagueWalletResult = { + leagueId: input.leagueId, + amount: withdrawalAmount, + transactionId: transactionId.toString(), + walletBalanceAfter: updatedWallet.balance, + }; + + this.output.present(result); + + return Result.ok(undefined); + } catch (error) { + this.logger.error( + 'Failed to withdraw from league wallet', + error instanceof Error ? error : undefined, + { + leagueId: input.leagueId, + requestedById: input.requestedById, + }, + ); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message: + error instanceof Error + ? error.message + : 'Failed to withdraw from league wallet', + }, + }); } } -} \ No newline at end of file +} diff --git a/core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts b/core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts index d07b99c7f..59113c8f2 100644 --- a/core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts +++ b/core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts @@ -1,41 +1,169 @@ -import { describe, it, expect, vi } from 'vitest'; -import { WithdrawFromRaceUseCase } from './WithdrawFromRaceUseCase'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + WithdrawFromRaceUseCase, + type WithdrawFromRaceInput, + type WithdrawFromRaceResult, + type WithdrawFromRaceErrorCode, +} from './WithdrawFromRaceUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { Logger } from '@core/shared/application/Logger'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import { Result } from '@core/shared/application/Result'; describe('WithdrawFromRaceUseCase', () => { - it('withdraws from race successfully', async () => { - const mockRegistrationRepository = { - withdraw: vi.fn().mockResolvedValue(undefined), + let raceRepository: IRaceRepository; + let registrationRepository: IRaceRegistrationRepository; + let logger: Logger; + let output: UseCaseOutputPort & { present: ReturnType }; + + beforeEach(() => { + raceRepository = { + findById: vi.fn(), + } as unknown as IRaceRepository; + + registrationRepository = { + isRegistered: vi.fn(), + withdraw: vi.fn(), } as unknown as IRaceRegistrationRepository; - const useCase = new WithdrawFromRaceUseCase(mockRegistrationRepository); + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; - const command = { + output = { present: vi.fn() } as any; + }); + + const createUseCase = () => + new WithdrawFromRaceUseCase(raceRepository, registrationRepository, logger, output); + + it('withdraws from race successfully', async () => { + const race = { + id: 'race-1', + isUpcoming: vi.fn().mockReturnValue(true), + } as any; + + (raceRepository.findById as any).mockResolvedValue(race); + (registrationRepository.isRegistered as any).mockResolvedValue(true); + (registrationRepository.withdraw as any).mockResolvedValue(undefined); + + const useCase = createUseCase(); + + const input: WithdrawFromRaceInput = { raceId: 'race-1', driverId: 'driver-1', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isOk()).toBe(true); - expect(mockRegistrationRepository.withdraw).toHaveBeenCalledWith('race-1', 'driver-1'); + expect(result.unwrap()).toBeUndefined(); + expect(output.present).toHaveBeenCalledTimes(1); + expect(output.present).toHaveBeenCalledWith({ + raceId: 'race-1', + driverId: 'driver-1', + status: 'withdrawn', + }); + expect(registrationRepository.withdraw).toHaveBeenCalledWith('race-1', 'driver-1'); }); - it('returns error when not registered', async () => { - const mockRegistrationRepository = { - withdraw: vi.fn().mockRejectedValue(new Error('not registered')), - } as unknown as IRaceRegistrationRepository; + it('returns error when race is not found', async () => { + (raceRepository.findById as any).mockResolvedValue(null); - const useCase = new WithdrawFromRaceUseCase(mockRegistrationRepository); + const useCase = createUseCase(); - const command = { + const input: WithdrawFromRaceInput = { + raceId: 'race-unknown', + driverId: 'driver-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('RACE_NOT_FOUND'); + expect(error.details?.message).toContain('Race race-unknown not found'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns error when registration is not found', async () => { + const race = { + id: 'race-1', + isUpcoming: vi.fn().mockReturnValue(true), + } as any; + + (raceRepository.findById as any).mockResolvedValue(race); + (registrationRepository.isRegistered as any).mockResolvedValue(false); + + const useCase = createUseCase(); + + const input: WithdrawFromRaceInput = { + raceId: 'race-1', + driverId: 'driver-unknown', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('REGISTRATION_NOT_FOUND'); + expect(error.details?.message).toContain('Driver driver-unknown is not registered for race race-1'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('returns error when withdrawal is not allowed', async () => { + const race = { + id: 'race-1', + isUpcoming: vi.fn().mockReturnValue(false), + } as any; + + (raceRepository.findById as any).mockResolvedValue(race); + (registrationRepository.isRegistered as any).mockResolvedValue(true); + + const useCase = createUseCase(); + + const input: WithdrawFromRaceInput = { raceId: 'race-1', driverId: 'driver-1', }; - const result = await useCase.execute(command); + const result = await useCase.execute(input); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toEqual({ code: 'NOT_REGISTERED' }); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('WITHDRAWAL_NOT_ALLOWED'); + expect(error.details?.message).toContain('Withdrawal is not allowed for this race'); + expect(output.present).not.toHaveBeenCalled(); + }); + + it('wraps repository errors and logs them', async () => { + const race = { + id: 'race-1', + isUpcoming: vi.fn().mockReturnValue(true), + } as any; + + (raceRepository.findById as any).mockResolvedValue(race); + (registrationRepository.isRegistered as any).mockResolvedValue(true); + (registrationRepository.withdraw as any).mockRejectedValue(new Error('DB failure')); + + const useCase = createUseCase(); + + const input: WithdrawFromRaceInput = { + raceId: 'race-1', + driverId: 'driver-1', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr() as ApplicationErrorCode; + expect(error.code).toBe('REPOSITORY_ERROR'); + expect(error.details?.message).toBe('DB failure'); + expect(output.present).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts b/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts index 53064bcb5..d9d8a32cf 100644 --- a/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts +++ b/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts @@ -1,28 +1,98 @@ import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; -import type { WithdrawFromRaceCommandDTO } from '../dto/WithdrawFromRaceCommandDTO'; +import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository'; +import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { Logger } from '@core/shared/application/Logger'; + +export type WithdrawFromRaceInput = { + raceId: string; + driverId: string; +}; + +export type WithdrawFromRaceResult = { + raceId: string; + driverId: string; + status: 'withdrawn'; +}; + +export type WithdrawFromRaceErrorCode = + | 'RACE_NOT_FOUND' + | 'REGISTRATION_NOT_FOUND' + | 'WITHDRAWAL_NOT_ALLOWED' + | 'REPOSITORY_ERROR'; /** - * Mirrors legacy withdrawFromRace behavior: - * - returns error when driver is not registered - * - removes registration and cleans up empty race sets - * - * The repository encapsulates the in-memory or persistent details. + * Use Case: Withdraw a driver from a race. */ export class WithdrawFromRaceUseCase { constructor( + private readonly raceRepository: IRaceRepository, private readonly registrationRepository: IRaceRegistrationRepository, + private readonly logger: Logger, + private readonly output: UseCaseOutputPort, ) {} - async execute(command: WithdrawFromRaceCommandDTO): Promise>> { - const { raceId, driverId } = command; + async execute(input: WithdrawFromRaceInput): Promise< + Result> + > { + const { raceId, driverId } = input; try { + const race = await this.raceRepository.findById(raceId); + + if (!race) { + return Result.err({ + code: 'RACE_NOT_FOUND', + details: { + message: `Race ${raceId} not found`, + }, + }); + } + + const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); + + if (!isRegistered) { + return Result.err({ + code: 'REGISTRATION_NOT_FOUND', + details: { + message: `Driver ${driverId} is not registered for race ${raceId}`, + }, + }); + } + + if (!race.isUpcoming()) { + return Result.err({ + code: 'WITHDRAWAL_NOT_ALLOWED', + details: { + message: 'Withdrawal is not allowed for this race', + }, + }); + } + await this.registrationRepository.withdraw(raceId, driverId); + + const result: WithdrawFromRaceResult = { + raceId, + driverId, + status: 'withdrawn', + }; + + this.output.present(result); + return Result.ok(undefined); - } catch { - return Result.err({ code: 'NOT_REGISTERED' }); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Failed to withdraw from race'; + + this.logger.error('WithdrawFromRaceUseCase.execute failed', error instanceof Error ? error : undefined); + + return Result.err({ + code: 'REPOSITORY_ERROR', + details: { + message, + }, + }); } } } \ No newline at end of file diff --git a/core/shared/application/UseCaseOutputPort.ts b/core/shared/application/UseCaseOutputPort.ts new file mode 100644 index 000000000..61e252bfb --- /dev/null +++ b/core/shared/application/UseCaseOutputPort.ts @@ -0,0 +1,11 @@ +/** + * Output Port interface for use cases. + * + * Defines how the core communicates outward. A behavioral boundary that allows + * the use case to present results without knowing the presentation details. + * + * @template T - The result type to present + */ +export interface UseCaseOutputPort { + present(data: T): void; +} \ No newline at end of file diff --git a/core/shared/application/index.ts b/core/shared/application/index.ts index 3b5e6abc6..0c2ef222f 100644 --- a/core/shared/application/index.ts +++ b/core/shared/application/index.ts @@ -1,4 +1,5 @@ export * from './UseCase'; export * from './AsyncUseCase'; export * from './Service'; -export * from './Logger'; \ No newline at end of file +export * from './Logger'; +export * from './UseCaseOutputPort'; \ No newline at end of file diff --git a/docs/architecture/USECASES.md b/docs/architecture/USECASES.md new file mode 100644 index 000000000..099c082e5 --- /dev/null +++ b/docs/architecture/USECASES.md @@ -0,0 +1,256 @@ +Use Case Architecture Guide + +This document defines the correct structure and responsibilities of Application Use Cases +according to Clean Architecture, in a NestJS-based system. + +The goal is: + • strict separation of concerns + • correct terminology (no fake “ports”) + • minimal abstractions + • long-term consistency + +This is the canonical reference for all use cases in this codebase. + +⸻ + +1. Core Concepts (Authoritative Definitions) + +Use Case + • Encapsulates application-level business logic + • Is the Input Port + • Is injected via DI + • Knows no API, no DTOs, no transport + • Coordinates domain objects and infrastructure + +The public execute() method is the input port. + +⸻ + +Input + • Pure data + • Not a port + • Not an interface + • May be omitted if the use case has no parameters + +type GetSponsorsInput = { + leagueId: LeagueId +} + + +⸻ + +Result + • The business outcome of a use case + • May contain Entities and Value Objects + • Not a DTO + • Never leaves the core directly + +type GetSponsorsResult = { + sponsors: Sponsor[] +} + + +⸻ + +Output Port + • A behavioral boundary + • Defines how the core communicates outward + • Never a data structure + • Lives in the Application Layer + +export interface UseCaseOutputPort { + present(data: T): void +} + + +⸻ + +Presenter + • Implements UseCaseOutputPort + • Lives in the API / UI layer + • Translates Result → ViewModel / DTO + • Holds internal state + • Is pulled by the controller after execution + +⸻ + +2. Canonical Use Case Structure + +Application Layer + +Use Case + +@Injectable() +export class GetSponsorsUseCase { + constructor( + private readonly sponsorRepository: ISponsorRepository, + private readonly output: UseCaseOutputPort, + ) {} + + async execute(): Promise> { + const sponsors = await this.sponsorRepository.findAll() + + this.output.present({ sponsors }) + + return Result.ok(undefined) + } +} + +Rules: + • execute() is the Input Port + • The use case does not return result data + • All output flows through the OutputPort + • The return value signals success or failure only + +⸻ + +Result Model + +type GetSponsorsResult = { + sponsors: Sponsor[] +} + +Rules: + • Domain objects are allowed + • No DTOs + • No interfaces + • No transport concerns + +⸻ + +3. API Layer + +Presenter + +@Injectable() +export class GetSponsorsPresenter + implements UseCaseOutputPort +{ + private viewModel!: GetSponsorsViewModel + + present(result: GetSponsorsResult): void { + this.viewModel = { + sponsors: result.sponsors.map(s => ({ + id: s.id.value, + name: s.name, + websiteUrl: s.websiteUrl, + })), + } + } + + getViewModel(): GetSponsorsViewModel { + return this.viewModel + } +} + + +⸻ + +Controller + +@Controller('/sponsors') +export class SponsorsController { + constructor( + private readonly useCase: GetSponsorsUseCase, + private readonly presenter: GetSponsorsPresenter, + ) {} + + @Get() + async getSponsors() { + const result = await this.useCase.execute() + + if (result.isErr()) { + throw mapApplicationError(result.unwrapErr()) + } + + return this.presenter.getViewModel() + } +} + + +⸻ + +4. Module Wiring (Composition Root) + +@Module({ + providers: [ + GetSponsorsUseCase, + GetSponsorsPresenter, + { + provide: USE_CASE_OUTPUT_PORT, + useExisting: GetSponsorsPresenter, + }, + ], +}) +export class SponsorsModule {} + +Rules: + • The use case depends only on the OutputPort interface + • The presenter is bound as the OutputPort implementation + • process.env is not used inside the use case + +⸻ + +5. Explicitly Forbidden + +❌ DTOs in use cases +❌ Domain objects returned directly to the API +❌ Output ports used as data structures +❌ present() returning a value +❌ Input data named InputPort +❌ Mapping logic inside use cases +❌ Environment access inside the core + +⸻ + +6. Optional Extensions + +Custom Output Ports + +Only introduce a dedicated OutputPort interface if: + • multiple presentation paths exist + • streaming or progress updates are required + • more than one output method is needed + +interface ComplexOutputPort { + presentSuccess(...) + presentFailure(...) +} + + +⸻ + +Input Port Interfaces + +Only introduce an explicit InputPort interface if: + • multiple implementations of the same use case exist + • feature flags or A/B variants are required + • the use case itself must be substituted + +Otherwise: + +The use case class itself is the input port. + +⸻ + +7. Key Rules (Memorize These) + +Use cases answer what. +Presenters answer how. + +Ports have behavior. +Data does not. + +The core produces truth. +The API interprets it. + +⸻ + +TL;DR + • Use cases are injected via DI + • execute() is the Input Port + • Outputs flow only through Output Ports + • Results are business models, not DTOs + • Interfaces exist only for behavior variability + +This document is the single source of truth for use case architecture in this project. \ No newline at end of file