From 7532c7ed6d573962cb823d23a95676d8333c01f9 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 16 Dec 2025 21:05:01 +0100 Subject: [PATCH] refactor --- .../ports/AuthenticationServicePort.ts | 2 +- .../ports/CheckoutConfirmationPort.ts | 2 +- .../application/ports/CheckoutServicePort.ts | 2 +- .../application/ports/SessionValidatorPort.ts | 2 +- .../CheckAuthenticationUseCase.test.ts | 2 +- .../use-cases/CheckAuthenticationUseCase.ts | 2 +- .../use-cases/ClearSessionUseCase.test.ts | 2 +- .../use-cases/ClearSessionUseCase.ts | 2 +- .../CompleteRaceCreationUseCase.test.ts | 2 +- .../use-cases/CompleteRaceCreationUseCase.ts | 2 +- .../use-cases/ConfirmCheckoutUseCase.test.ts | 2 +- .../use-cases/ConfirmCheckoutUseCase.ts | 2 +- .../use-cases/InitiateLoginUseCase.ts | 2 +- .../VerifyAuthenticatedPageUseCase.test.ts | 2 +- .../VerifyAuthenticatedPageUseCase.ts | 2 +- .../domain/services/PageStateValidator.ts | 2 +- .../services/StepTransitionValidator.ts | 2 +- .../automation/CheckoutPriceExtractor.ts | 2 +- .../auth/PlaywrightAuthSessionService.ts | 2 +- ...onService.verifyPageAuthentication.test.ts | 2 +- .../automation/auth/SessionCookieStore.ts | 2 +- .../core/PlaywrightAutomationAdapter.ts | 2 +- .../automation/core/WizardStepOrchestrator.ts | 2 +- .../ElectronCheckoutConfirmationAdapter.ts | 2 +- .../dto/GetLeagueOwnerSummaryResultDTO.ts | 3 + .../dto/GetLeagueProtestsResultDTO.ts | 26 + .../dto/GetLeagueScheduleResultDTO.ts | 7 + .../errors/RacingApplicationError.ts | 10 +- .../{use-cases => ports}/DriverRatingPort.ts | 0 .../AcceptSponsorshipRequestUseCase.ts | 23 +- .../ApplyForSponsorshipUseCase.test.ts | 12 +- .../use-cases/ApplyForSponsorshipUseCase.ts | 22 +- .../use-cases/ApplyPenaltyUseCase.test.ts | 10 +- .../use-cases/ApplyPenaltyUseCase.ts | 18 +- .../ApproveLeagueJoinRequestUseCase.test.ts | 51 +- .../ApproveLeagueJoinRequestUseCase.ts | 10 +- .../ApproveTeamJoinRequestUseCase.test.ts | 2 +- .../ApproveTeamJoinRequestUseCase.ts | 16 +- .../use-cases/CancelRaceUseCase.test.ts | 14 +- .../use-cases/CancelRaceUseCase.ts | 20 +- .../CloseRaceEventStewardingUseCase.test.ts | 25 +- .../CloseRaceEventStewardingUseCase.ts | 40 +- .../CompleteDriverOnboardingUseCase.test.ts | 7 +- .../CompleteDriverOnboardingUseCase.ts | 14 +- .../use-cases/CompleteRaceUseCase.test.ts | 10 +- .../use-cases/CompleteRaceUseCase.ts | 16 +- .../CompleteRaceUseCaseWithRatings.test.ts | 10 +- .../CompleteRaceUseCaseWithRatings.ts | 16 +- ...eLeagueWithSeasonAndScoringUseCase.test.ts | 20 +- ...CreateLeagueWithSeasonAndScoringUseCase.ts | 50 +- .../CreateSeasonForLeagueUseCase.test.ts | 311 ++++++++ .../use-cases/CreateSeasonForLeagueUseCase.ts | 219 ++++++ .../use-cases/CreateSponsorUseCase.test.ts | 13 +- .../use-cases/CreateSponsorUseCase.ts | 32 +- .../use-cases/CreateTeamUseCase.test.ts | 7 +- .../use-cases/CreateTeamUseCase.ts | 28 +- .../use-cases/DashboardOverviewUseCase.ts | 16 +- .../use-cases/FileProtestUseCase.test.ts | 6 +- .../use-cases/FileProtestUseCase.ts | 23 +- ...AllLeaguesWithCapacityAndScoringUseCase.ts | 7 +- .../GetAllLeaguesWithCapacityUseCase.ts | 8 +- .../use-cases/GetAllRacesPageDataUseCase.ts | 8 +- .../use-cases/GetAllRacesUseCase.ts | 2 +- .../use-cases/GetAllTeamsUseCase.ts | 2 +- .../use-cases/GetDriverTeamUseCase.test.ts | 56 +- .../use-cases/GetDriverTeamUseCase.ts | 14 +- .../use-cases/GetDriversLeaderboardUseCase.ts | 2 +- ...GetEntitySponsorshipPricingUseCase.test.ts | 69 +- .../GetEntitySponsorshipPricingUseCase.ts | 10 +- .../GetLeagueAdminPermissionsUseCase.test.ts | 60 +- .../GetLeagueAdminPermissionsUseCase.ts | 7 +- .../GetLeagueAdminPermissionsUseCaseParams.ts | 4 - .../use-cases/GetLeagueAdminUseCase.test.ts | 29 +- .../use-cases/GetLeagueAdminUseCase.ts | 11 +- .../use-cases/GetLeagueAdminUseCaseParams.ts | 3 - .../GetLeagueDriverSeasonStatsUseCase.test.ts | 7 +- .../GetLeagueDriverSeasonStatsUseCase.ts | 10 +- ...GetLeagueDriverSeasonStatsUseCaseParams.ts | 3 - .../GetLeagueFullConfigUseCase.test.ts | 3 +- .../use-cases/GetLeagueFullConfigUseCase.ts | 12 +- .../use-cases/GetLeagueJoinRequestsUseCase.ts | 22 +- .../use-cases/GetLeagueMembershipsUseCase.ts | 7 +- .../GetLeagueOwnerSummaryUseCase.test.ts | 56 ++ .../use-cases/GetLeagueOwnerSummaryUseCase.ts | 18 +- .../GetLeagueProtestsUseCase.test.ts | 122 +++ .../use-cases/GetLeagueProtestsUseCase.ts | 37 +- .../GetLeagueScheduleUseCase.test.ts | 59 ++ .../use-cases/GetLeagueScheduleUseCase.ts | 24 +- .../GetLeagueScoringConfigUseCase.test.ts | 135 ++++ .../GetLeagueScoringConfigUseCase.ts | 43 +- .../use-cases/GetLeagueSeasonsUseCase.test.ts | 113 +++ .../use-cases/GetLeagueSeasonsUseCase.ts | 35 +- .../GetLeagueStandingsUseCase.test.ts | 101 +++ .../use-cases/GetLeagueStandingsUseCase.ts | 47 +- .../use-cases/GetLeagueStatsUseCase.test.ts | 96 +++ .../use-cases/GetLeagueStatsUseCase.ts | 37 +- ...tPendingSponsorshipRequestsUseCase.test.ts | 90 +++ .../GetPendingSponsorshipRequestsUseCase.ts | 89 +-- .../GetProfileOverviewUseCase.test.ts | 119 +++ .../use-cases/GetProfileOverviewUseCase.ts | 287 +------ .../use-cases/GetRaceDetailUseCase.test.ts | 162 ++++ .../use-cases/GetRaceDetailUseCase.ts | 29 +- .../use-cases/GetRacePenaltiesUseCase.test.ts | 63 ++ .../use-cases/GetRacePenaltiesUseCase.ts | 20 +- .../use-cases/GetRaceProtestsUseCase.test.ts | 64 ++ .../use-cases/GetRaceProtestsUseCase.ts | 20 +- .../GetRaceRegistrationsUseCase.test.ts | 38 + .../use-cases/GetRaceRegistrationsUseCase.ts | 23 +- .../GetRaceResultsDetailUseCase.test.ts | 92 +++ .../use-cases/GetRaceResultsDetailUseCase.ts | 170 ++--- .../use-cases/GetRaceWithSOFUseCase.ts | 44 +- .../use-cases/GetRacesPageDataUseCase.test.ts | 74 ++ .../use-cases/GetRacesPageDataUseCase.ts | 20 +- .../use-cases/GetSeasonDetailsUseCase.test.ts | 60 ++ .../use-cases/GetSeasonDetailsUseCase.ts | 132 ++++ .../GetSponsorDashboardUseCase.test.ts | 132 ++++ .../use-cases/GetSponsorDashboardUseCase.ts | 222 +++--- .../GetSponsorSponsorshipsUseCase.test.ts | 132 ++++ .../GetSponsorSponsorshipsUseCase.ts | 194 +++-- .../use-cases/GetSponsorsUseCase.test.ts | 74 ++ .../use-cases/GetSponsorsUseCase.ts | 48 +- .../GetSponsorshipPricingUseCase.test.ts | 23 + .../use-cases/GetSponsorshipPricingUseCase.ts | 24 +- .../use-cases/GetTeamDetailsUseCase.test.ts | 114 +++ .../use-cases/GetTeamDetailsUseCase.ts | 61 +- .../GetTeamJoinRequestsUseCase.test.ts | 130 ++++ .../use-cases/GetTeamJoinRequestsUseCase.ts | 26 +- .../use-cases/GetTeamMembersUseCase.test.ts | 130 ++++ .../use-cases/GetTeamMembersUseCase.ts | 27 +- .../GetTeamsLeaderboardUseCase.test.ts | 135 ++++ .../use-cases/GetTeamsLeaderboardUseCase.ts | 124 +-- .../use-cases/GetTotalDriversUseCase.test.ts | 64 ++ .../use-cases/GetTotalDriversUseCase.ts | 34 +- .../use-cases/GetTotalLeaguesUseCase.test.ts | 64 ++ .../use-cases/GetTotalLeaguesUseCase.ts | 34 +- .../use-cases/GetTotalRacesUseCase.test.ts | 63 ++ .../use-cases/GetTotalRacesUseCase.ts | 34 +- .../ImportRaceResultsApiUseCase.test.ts | 175 +++++ .../use-cases/ImportRaceResultsApiUseCase.ts | 133 +++- .../ImportRaceResultsUseCase.test.ts | 157 ++++ .../use-cases/ImportRaceResultsUseCase.ts | 131 ++-- .../IsDriverRegisteredForRaceUseCase.test.ts | 70 ++ .../IsDriverRegisteredForRaceUseCase.ts | 31 +- .../use-cases/JoinLeagueUseCase.test.ts | 97 +++ .../use-cases/JoinLeagueUseCase.ts | 30 +- .../use-cases/JoinTeamUseCase.test.ts | 131 ++++ .../application/use-cases/JoinTeamUseCase.ts | 33 +- .../use-cases/LeaveTeamUseCase.test.ts | 102 +++ .../application/use-cases/LeaveTeamUseCase.ts | 43 +- .../ListLeagueScoringPresetsUseCase.test.ts | 35 + .../ListLeagueScoringPresetsUseCase.ts | 19 +- .../ListSeasonsForLeagueUseCase.test.ts | 71 ++ .../use-cases/ListSeasonsForLeagueUseCase.ts | 60 ++ .../ManageSeasonLifecycleUseCase.test.ts | 103 +++ .../use-cases/ManageSeasonLifecycleUseCase.ts | 89 +++ .../use-cases/MembershipUseCases.test.ts | 81 +- .../PreviewLeagueScheduleUseCase.test.ts | 68 ++ .../use-cases/PreviewLeagueScheduleUseCase.ts | 33 +- .../use-cases/QuickPenaltyUseCase.test.ts | 134 ++++ .../use-cases/QuickPenaltyUseCase.ts | 35 +- .../use-cases/RaceDetailUseCases.test.ts | 82 +- .../use-cases/RaceResultsUseCases.test.ts | 716 ------------------ ...culateChampionshipStandingsUseCase.test.ts | 111 +++ ...RecalculateChampionshipStandingsUseCase.ts | 47 +- .../use-cases/RegisterForRaceUseCase.test.ts | 78 ++ .../use-cases/RegisterForRaceUseCase.ts | 43 +- .../RejectSponsorshipRequestUseCase.test.ts | 108 +++ .../RejectSponsorshipRequestUseCase.ts | 18 +- .../RejectTeamJoinRequestUseCase.test.ts | 27 + .../use-cases/RejectTeamJoinRequestUseCase.ts | 5 +- .../RemoveLeagueMemberUseCase.test.ts | 32 +- .../use-cases/RemoveLeagueMemberUseCase.ts | 14 +- .../RequestProtestDefenseUseCase.test.ts | 111 +++ .../use-cases/RequestProtestDefenseUseCase.ts | 18 +- .../use-cases/ReviewProtestUseCase.test.ts | 113 +++ .../use-cases/ReviewProtestUseCase.ts | 22 +- .../use-cases/SendFinalResultsUseCase.test.ts | 190 +++++ .../use-cases/SendFinalResultsUseCase.ts | 25 +- .../SendPerformanceSummaryUseCase.test.ts | 144 ++++ .../SendPerformanceSummaryUseCase.ts | 21 +- .../SubmitProtestDefenseUseCase.test.ts | 105 +++ .../use-cases/SubmitProtestDefenseUseCase.ts | 22 +- .../TransferLeagueOwnershipUseCase.test.ts | 152 ++++ .../TransferLeagueOwnershipUseCase.ts | 17 +- .../UpdateDriverProfileUseCase.test.ts | 95 +++ .../use-cases/UpdateDriverProfileUseCase.ts | 8 +- .../UpdateLeagueMemberRoleUseCase.test.ts | 82 +- .../UpdateLeagueMemberRoleUseCase.ts | 18 +- .../use-cases/UpdateTeamUseCase.test.ts | 92 +++ .../use-cases/UpdateTeamUseCase.ts | 12 +- .../use-cases/WithdrawFromRaceUseCase.test.ts | 41 + .../use-cases/WithdrawFromRaceUseCase.ts | 14 +- core/racing/application/use-cases/index.ts | 4 +- .../domain/entities/LeagueMembership.ts | 2 +- core/shared/application/AsyncUseCase.ts | 18 +- core/shared/application/Logger.ts | 8 +- core/shared/{result => application}/Result.ts | 11 + core/shared/application/Service.ts | 6 +- core/shared/application/UseCase.ts | 17 +- core/shared/domain/DomainEvent.ts | 10 + core/shared/domain/IDomainEvent.ts | 10 - core/shared/domain/Service.ts | 2 +- core/shared/errors/ApplicationError.ts | 2 +- core/shared/errors/ApplicationErrorCode.ts | 10 +- core/shared/index.ts | 2 +- .../RegistrationAndTeamUseCases.test.ts | 0 .../CheckoutPriceExtractor.test.ts | 2 +- 207 files changed, 7861 insertions(+), 2606 deletions(-) create mode 100644 core/racing/application/dto/GetLeagueOwnerSummaryResultDTO.ts create mode 100644 core/racing/application/dto/GetLeagueProtestsResultDTO.ts create mode 100644 core/racing/application/dto/GetLeagueScheduleResultDTO.ts rename core/racing/application/{use-cases => ports}/DriverRatingPort.ts (100%) create mode 100644 core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts create mode 100644 core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts delete mode 100644 core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts delete mode 100644 core/racing/application/use-cases/GetLeagueAdminUseCaseParams.ts delete mode 100644 core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts create mode 100644 core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRaceDetailUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetSeasonDetailsUseCase.ts create mode 100644 core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetSponsorsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTeamMembersUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTotalDriversUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts create mode 100644 core/racing/application/use-cases/GetTotalRacesUseCase.test.ts create mode 100644 core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts create mode 100644 core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts create mode 100644 core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts create mode 100644 core/racing/application/use-cases/JoinLeagueUseCase.test.ts create mode 100644 core/racing/application/use-cases/JoinTeamUseCase.test.ts create mode 100644 core/racing/application/use-cases/LeaveTeamUseCase.test.ts create mode 100644 core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts create mode 100644 core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts create mode 100644 core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts create mode 100644 core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts create mode 100644 core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts create mode 100644 core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts create mode 100644 core/racing/application/use-cases/QuickPenaltyUseCase.test.ts delete mode 100644 core/racing/application/use-cases/RaceResultsUseCases.test.ts create mode 100644 core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts create mode 100644 core/racing/application/use-cases/RegisterForRaceUseCase.test.ts create mode 100644 core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts create mode 100644 core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts create mode 100644 core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts create mode 100644 core/racing/application/use-cases/ReviewProtestUseCase.test.ts create mode 100644 core/racing/application/use-cases/SendFinalResultsUseCase.test.ts create mode 100644 core/racing/application/use-cases/SendPerformanceSummaryUseCase.test.ts create mode 100644 core/racing/application/use-cases/SubmitProtestDefenseUseCase.test.ts create mode 100644 core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts create mode 100644 core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts create mode 100644 core/racing/application/use-cases/UpdateTeamUseCase.test.ts create mode 100644 core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts rename core/shared/{result => application}/Result.ts (74%) create mode 100644 core/shared/domain/DomainEvent.ts delete mode 100644 core/shared/domain/IDomainEvent.ts rename {core/racing/application/use-cases => tests}/RegistrationAndTeamUseCases.test.ts (100%) diff --git a/apps/companion/main/automation/application/ports/AuthenticationServicePort.ts b/apps/companion/main/automation/application/ports/AuthenticationServicePort.ts index e59019979..f3fff62e1 100644 --- a/apps/companion/main/automation/application/ports/AuthenticationServicePort.ts +++ b/apps/companion/main/automation/application/ports/AuthenticationServicePort.ts @@ -1,6 +1,6 @@ import { AuthenticationState } from '../../domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; /** * Port for authentication services implementing zero-knowledge login. diff --git a/apps/companion/main/automation/application/ports/CheckoutConfirmationPort.ts b/apps/companion/main/automation/application/ports/CheckoutConfirmationPort.ts index 6350a69dc..8fa1c26c0 100644 --- a/apps/companion/main/automation/application/ports/CheckoutConfirmationPort.ts +++ b/apps/companion/main/automation/application/ports/CheckoutConfirmationPort.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation'; import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO'; diff --git a/apps/companion/main/automation/application/ports/CheckoutServicePort.ts b/apps/companion/main/automation/application/ports/CheckoutServicePort.ts index 6a93ef10a..ca7c8b7aa 100644 --- a/apps/companion/main/automation/application/ports/CheckoutServicePort.ts +++ b/apps/companion/main/automation/application/ports/CheckoutServicePort.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO'; export interface CheckoutServicePort { diff --git a/apps/companion/main/automation/application/ports/SessionValidatorPort.ts b/apps/companion/main/automation/application/ports/SessionValidatorPort.ts index bd793bf75..dacc83899 100644 --- a/apps/companion/main/automation/application/ports/SessionValidatorPort.ts +++ b/apps/companion/main/automation/application/ports/SessionValidatorPort.ts @@ -1,4 +1,4 @@ -import type { Result } from '@gridpilot/shared/result/Result'; +import type { Result } from '@gridpilot/shared/application/Result'; export interface SessionValidatorPort { validateSession(): Promise>; diff --git a/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.test.ts b/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.test.ts index e48328315..3969257f0 100644 --- a/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.test.ts +++ b/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase'; import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { AuthenticationServicePort } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort'; interface ISessionValidator { diff --git a/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.ts b/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.ts index 6591023a8..a4c3d0a4c 100644 --- a/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase.ts @@ -1,6 +1,6 @@ import { AuthenticationState } from '../../domain/value-objects/AuthenticationState'; import type { Logger } from '@core/shared/application'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort'; import { SessionLifetime } from '../../domain/value-objects/SessionLifetime'; import type { SessionValidatorPort } from '../ports/SessionValidatorPort'; diff --git a/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.test.ts b/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.test.ts index c7f424081..ea992b8c7 100644 --- a/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.test.ts +++ b/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.test.ts @@ -2,7 +2,7 @@ import { vi, Mock } from 'vitest'; import { ClearSessionUseCase } from './ClearSessionUseCase'; import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort'; import type { Logger } from '@core/shared/application'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; describe('ClearSessionUseCase', () => { let useCase: ClearSessionUseCase; diff --git a/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.ts b/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.ts index 29b1ad7b6..250e0c8ae 100644 --- a/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/ClearSessionUseCase.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort'; import type { Logger } from '@core/shared/application'; diff --git a/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.test.ts b/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.test.ts index a9cb9ada4..f9c9b6d6a 100644 --- a/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.test.ts +++ b/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { CompleteRaceCreationUseCase } from 'apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { RaceCreationResult } from 'apps/companion/main/automation/domain/value-objects/RaceCreationResult'; import { CheckoutPrice } from 'apps/companion/main/automation/domain/value-objects/CheckoutPrice'; import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort'; diff --git a/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.ts b/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.ts index c05f6f5be..d21c5c83b 100644 --- a/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult'; import type { CheckoutServicePort } from '../ports/CheckoutServicePort'; import type { Logger } from '@core/shared/application'; diff --git a/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.test.ts b/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.test.ts index 81a24848b..75d473c21 100644 --- a/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.test.ts +++ b/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { ConfirmCheckoutUseCase } from 'apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase'; import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort'; import type { CheckoutConfirmationPort } from 'apps/companion/main/automation/application/ports/CheckoutConfirmationPort'; diff --git a/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.ts b/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.ts index 362f8d820..67584e2b1 100644 --- a/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { Logger } from '@core/shared/application'; import type { CheckoutServicePort } from '../ports/CheckoutServicePort'; import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort'; diff --git a/apps/companion/main/automation/application/use-cases/InitiateLoginUseCase.ts b/apps/companion/main/automation/application/use-cases/InitiateLoginUseCase.ts index 8ca94ec35..015dda457 100644 --- a/apps/companion/main/automation/application/use-cases/InitiateLoginUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/InitiateLoginUseCase.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort'; import type { Logger } from '@core/shared/application/Logger'; diff --git a/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.test.ts b/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.test.ts index 24349fd3d..54d091d5e 100644 --- a/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.test.ts +++ b/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { VerifyAuthenticatedPageUseCase } from 'apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase'; import { AuthenticationServicePort as IAuthenticationService } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState'; import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState'; diff --git a/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.ts b/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.ts index 17e0aa353..afccbcf5a 100644 --- a/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.ts +++ b/apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase.ts @@ -1,5 +1,5 @@ import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState'; import type { Logger } from '@core/shared/application'; diff --git a/apps/companion/main/automation/domain/services/PageStateValidator.ts b/apps/companion/main/automation/domain/services/PageStateValidator.ts index a4f6c0417..1eb01322b 100644 --- a/apps/companion/main/automation/domain/services/PageStateValidator.ts +++ b/apps/companion/main/automation/domain/services/PageStateValidator.ts @@ -1,5 +1,5 @@ import type { IDomainValidationService } from '@core/shared/domain'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; /** * Configuration for page state validation. diff --git a/apps/companion/main/automation/domain/services/StepTransitionValidator.ts b/apps/companion/main/automation/domain/services/StepTransitionValidator.ts index ea46240c7..25d796737 100644 --- a/apps/companion/main/automation/domain/services/StepTransitionValidator.ts +++ b/apps/companion/main/automation/domain/services/StepTransitionValidator.ts @@ -1,7 +1,7 @@ import { StepId } from '../value-objects/StepId'; import type { IDomainValidationService } from '@core/shared/domain'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { SessionState } from '../value-objects/SessionState'; export interface ValidationResult { diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/CheckoutPriceExtractor.ts b/apps/companion/main/automation/infrastructure/adapters/automation/CheckoutPriceExtractor.ts index af0c141ba..ed7e3bcad 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/CheckoutPriceExtractor.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/CheckoutPriceExtractor.ts @@ -1,4 +1,4 @@ -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice'; import { CheckoutState } from '../../../domain/value-objects/CheckoutState'; import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO'; diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.ts b/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.ts index adfc9bf64..f22b0c16e 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.ts @@ -5,7 +5,7 @@ import type { AuthenticationServicePort } from '../../../../application/ports/Au import type { LoggerPort } from '../../../../application/ports/LoggerPort'; import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession'; import { SessionCookieStore } from './SessionCookieStore'; import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow'; diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.verifyPageAuthentication.test.ts b/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.verifyPageAuthentication.test.ts index 30098b17d..04aceea52 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.verifyPageAuthentication.test.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService.verifyPageAuthentication.test.ts @@ -3,7 +3,7 @@ import type { Page, Locator } from 'playwright'; import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState'; import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState'; import type { LoggerPort as Logger } from 'apps/companion/main/automation/application/ports/LoggerPort'; -import type { Result } from '@core/shared/result/Result'; +import type { Result } from '@gridpilot/shared/application/Result'; import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession'; import { IPlaywrightAuthFlow } from './PlaywrightAuthFlow'; import { PlaywrightAuthSessionService } from './PlaywrightAuthSessionService'; diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/auth/SessionCookieStore.ts b/apps/companion/main/automation/infrastructure/adapters/automation/auth/SessionCookieStore.ts index 9d264f24a..ecf74cb32 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/auth/SessionCookieStore.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/auth/SessionCookieStore.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState'; import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { LoggerPort } from '../../../../application/ports/LoggerPort'; interface Cookie { diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts b/apps/companion/main/automation/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts index 939ba0d31..e35d6b452 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/core/PlaywrightAutomationAdapter.ts @@ -16,7 +16,7 @@ import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO' import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO'; import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort'; import type { LoggerPort } from '../../../../application/ports/LoggerPort'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, BLOCKED_KEYWORDS } from '../dom/IRacingSelectors'; import { SessionCookieStore } from '../auth/SessionCookieStore'; import { PlaywrightBrowserSession } from './PlaywrightBrowserSession'; diff --git a/apps/companion/main/automation/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts b/apps/companion/main/automation/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts index 2a86094ec..b612329d0 100644 --- a/apps/companion/main/automation/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts +++ b/apps/companion/main/automation/infrastructure/adapters/automation/core/WizardStepOrchestrator.ts @@ -18,7 +18,7 @@ import type { PageStateValidation, PageStateValidationResult, } from '../../../../domain/services/PageStateValidator'; -import type { Result } from '@gridpilot/shared/result/Result'; +import type { Result } from '@gridpilot/shared/application/Result'; interface WizardStepOrchestratorDeps { config: Required; diff --git a/apps/companion/main/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter.ts b/apps/companion/main/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter.ts index 7e6d95e27..5138c56e5 100644 --- a/apps/companion/main/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter.ts +++ b/apps/companion/main/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter.ts @@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron'; import { ipcMain } from 'electron'; -import { Result } from '@gridpilot/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import type { CheckoutConfirmationPort } from '../../../application/ports/CheckoutConfirmationPort'; import type { CheckoutConfirmationRequestDTO } from '../../../application/dto/CheckoutConfirmationRequestDTO'; import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation'; diff --git a/core/racing/application/dto/GetLeagueOwnerSummaryResultDTO.ts b/core/racing/application/dto/GetLeagueOwnerSummaryResultDTO.ts new file mode 100644 index 000000000..f8edd157a --- /dev/null +++ b/core/racing/application/dto/GetLeagueOwnerSummaryResultDTO.ts @@ -0,0 +1,3 @@ +export interface GetLeagueOwnerSummaryResultDTO { + summary: { driver: { id: string; name: string }; rating: number; rank: number } | null; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueProtestsResultDTO.ts b/core/racing/application/dto/GetLeagueProtestsResultDTO.ts new file mode 100644 index 000000000..28b08983f --- /dev/null +++ b/core/racing/application/dto/GetLeagueProtestsResultDTO.ts @@ -0,0 +1,26 @@ +export interface ProtestDTO { + id: string; + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + submittedAt: Date; + description: string; + status: string; +} + +export interface RaceDTO { + id: string; + name: string; + date: string; +} + +export interface DriverDTO { + id: string; + name: string; +} + +export interface GetLeagueProtestsResultDTO { + protests: ProtestDTO[]; + races: RaceDTO[]; + drivers: DriverDTO[]; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueScheduleResultDTO.ts b/core/racing/application/dto/GetLeagueScheduleResultDTO.ts new file mode 100644 index 000000000..502844671 --- /dev/null +++ b/core/racing/application/dto/GetLeagueScheduleResultDTO.ts @@ -0,0 +1,7 @@ +export interface GetLeagueScheduleResultDTO { + races: Array<{ + id: string; + name: string; + scheduledAt: Date; + }>; +} \ No newline at end of file diff --git a/core/racing/application/errors/RacingApplicationError.ts b/core/racing/application/errors/RacingApplicationError.ts index 0a187a808..67104a79a 100644 --- a/core/racing/application/errors/RacingApplicationError.ts +++ b/core/racing/application/errors/RacingApplicationError.ts @@ -1,8 +1,8 @@ -import type { IApplicationError, CommonApplicationErrorKind } from '@core/shared/errors'; +import type { ApplicationError, CommonApplicationErrorKind } from '@core/shared/errors'; export abstract class RacingApplicationError extends Error - implements IApplicationError + implements ApplicationError { readonly type = 'application' as const; readonly context = 'racing-application'; @@ -33,7 +33,7 @@ export interface EntityNotFoundDetails { export class EntityNotFoundError extends RacingApplicationError - implements IApplicationError<'not_found', EntityNotFoundDetails> + implements ApplicationError<'not_found', EntityNotFoundDetails> { readonly kind = 'not_found' as const; readonly details: EntityNotFoundDetails; @@ -55,7 +55,7 @@ export type PermissionDeniedReason = export class PermissionDeniedError extends RacingApplicationError - implements IApplicationError<'forbidden', PermissionDeniedReason> + implements ApplicationError<'forbidden', PermissionDeniedReason> { readonly kind = 'forbidden' as const; @@ -70,7 +70,7 @@ export class PermissionDeniedError export class BusinessRuleViolationError extends RacingApplicationError - implements IApplicationError<'conflict', undefined> + implements ApplicationError<'conflict', undefined> { readonly kind = 'conflict' as const; diff --git a/core/racing/application/use-cases/DriverRatingPort.ts b/core/racing/application/ports/DriverRatingPort.ts similarity index 100% rename from core/racing/application/use-cases/DriverRatingPort.ts rename to core/racing/application/ports/DriverRatingPort.ts diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts index 520cfc6eb..e46575e61 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts @@ -15,16 +15,13 @@ import type { IWalletRepository } from '@core/payments/domain/repositories/IWall import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { - RacingDomainValidationError, - RacingDomainInvariantError, -} from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO'; import type { AcceptSponsorshipRequestResultDTO } from '../dto/AcceptSponsorshipRequestResultDTO'; export class AcceptSponsorshipRequestUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, @@ -36,19 +33,19 @@ export class AcceptSponsorshipRequestUseCase private readonly logger: Logger, ) {} - async execute(dto: AcceptSponsorshipRequestDTO): Promise> { + async execute(dto: AcceptSponsorshipRequestDTO): Promise>> { this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy }); // Find the request const request = await this.sponsorshipRequestRepo.findById(dto.requestId); if (!request) { this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId }); - return Result.err(new RacingDomainValidationError('Sponsorship request not found')); + 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 }); - return Result.err(new RacingDomainValidationError(`Cannot accept a ${request.status} sponsorship request`)); + 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 }); @@ -66,7 +63,7 @@ export class AcceptSponsorshipRequestUseCase 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 }); - return Result.err(new RacingDomainValidationError('Season not found for sponsorship request')); + return Result.err({ code: 'SEASON_NOT_FOUND' }); } const sponsorship = SeasonSponsorship.create({ @@ -104,20 +101,20 @@ export class AcceptSponsorshipRequestUseCase ); if (!paymentResult.success) { this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id }); - return Result.err(new RacingDomainInvariantError('Payment processing failed')); + 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 }); - return Result.err(new RacingDomainInvariantError('Sponsor wallet not found')); + 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 }); - return Result.err(new RacingDomainInvariantError('League wallet not found')); + return Result.err({ code: 'LEAGUE_WALLET_NOT_FOUND' }); } const netAmount = acceptedRequest.getNetAmount(); diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts index b36f2c708..3cc58e561 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts @@ -60,7 +60,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Sponsor not found'); + expect(result.error!.code).toBe('SPONSOR_NOT_FOUND'); }); it('should return error when sponsorship pricing is not set up', async () => { @@ -83,7 +83,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('This entity has not set up sponsorship pricing'); + expect(result.error!.code).toBe('SPONSORSHIP_PRICING_NOT_SETUP'); }); it('should return error when entity is not accepting applications', async () => { @@ -110,7 +110,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('This entity is not currently accepting sponsorship applications'); + expect(result.error!.code).toBe('ENTITY_NOT_ACCEPTING_APPLICATIONS'); }); it('should return error when no slots are available', async () => { @@ -137,7 +137,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('No main sponsorship slots are available'); + expect(result.error!.code).toBe('NO_SLOTS_AVAILABLE'); }); it('should return error when sponsor has pending request', async () => { @@ -165,7 +165,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('You already have a pending sponsorship request for this entity'); + expect(result.error!.code).toBe('PENDING_REQUEST_EXISTS'); }); it('should return error when offered amount is less than minimum', async () => { @@ -193,7 +193,7 @@ describe('ApplyForSponsorshipUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Offered amount must be at least $15.00'); + expect(result.error!.code).toBe('OFFERED_AMOUNT_TOO_LOW'); }); it('should create sponsorship request and return result on success', async () => { diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index e11052b6d..54688475c 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -11,14 +11,14 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/IS import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import { Money } from '../../domain/value-objects/Money'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@core/shared/application/Result'; import type { Logger } from '@core/shared/application'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplyForSponsorshipDTO } from '../dto/ApplyForSponsorshipDTO'; import type { ApplyForSponsorshipResultDTO } from '../dto/ApplyForSponsorshipResultDTO'; - + export class ApplyForSponsorshipUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, @@ -27,33 +27,33 @@ export class ApplyForSponsorshipUseCase private readonly logger: Logger, ) {} - async execute(dto: ApplyForSponsorshipDTO): Promise> { + async execute(dto: ApplyForSponsorshipDTO): Promise>> { this.logger.debug('Attempting to apply for sponsorship', { dto }); // Validate sponsor exists const sponsor = await this.sponsorRepo.findById(dto.sponsorId); if (!sponsor) { this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId }); - return Result.err(new RacingDomainValidationError('Sponsor not found')); + return Result.err({ code: 'SPONSOR_NOT_FOUND' }); } // Check if entity accepts sponsorship applications const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); if (!pricing) { this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId }); - return Result.err(new RacingDomainValidationError('This entity has not set up sponsorship pricing')); + 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 }); - return Result.err(new RacingDomainValidationError('This entity is not currently accepting sponsorship applications')); + return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' }); } // Check if the requested tier slot is available const slotAvailable = pricing.isSlotAvailable(dto.tier); if (!slotAvailable) { this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`); - return Result.err(new RacingDomainValidationError(`No ${dto.tier} sponsorship slots are available`)); + return Result.err({ code: 'NO_SLOTS_AVAILABLE' }); } // Check if sponsor already has a pending request for this entity @@ -64,14 +64,14 @@ export class ApplyForSponsorshipUseCase ); if (hasPending) { this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId }); - return Result.err(new RacingDomainValidationError('You already have a pending sponsorship request for this entity')); + 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}`); - return Result.err(new RacingDomainValidationError(`Offered amount must be at least ${minPrice.format()}`)); + return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' }); } // Create the sponsorship request diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts index c00e237ac..f1f747b7d 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts @@ -68,7 +68,7 @@ describe('ApplyPenaltyUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Race not found'); + expect(result.error!.code).toBe('RACE_NOT_FOUND'); }); it('should return error when steward does not have authority', async () => { @@ -95,7 +95,7 @@ describe('ApplyPenaltyUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Only league owners and admins can apply penalties'); + expect(result.error!.code).toBe('INSUFFICIENT_AUTHORITY'); }); it('should return error when protest does not exist', async () => { @@ -124,7 +124,7 @@ describe('ApplyPenaltyUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Protest not found'); + expect(result.error!.code).toBe('PROTEST_NOT_FOUND'); }); it('should return error when protest is not upheld', async () => { @@ -153,7 +153,7 @@ describe('ApplyPenaltyUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Can only create penalties for upheld protests'); + expect(result.error!.code).toBe('PROTEST_NOT_UPHELD'); }); it('should return error when protest is not for this race', async () => { @@ -182,7 +182,7 @@ describe('ApplyPenaltyUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Protest is not for this race'); + expect(result.error!.code).toBe('PROTEST_NOT_FOR_RACE'); }); it('should create penalty and return result on success', async () => { diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts index e28e37c0d..506f5dc6c 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts @@ -12,13 +12,13 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@core/shared/application/Result'; import type { Logger } from '@core/shared/application'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand'; export class ApplyPenaltyUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly protestRepository: IProtestRepository, @@ -27,14 +27,14 @@ export class ApplyPenaltyUseCase private readonly logger: Logger, ) {} - async execute(command: ApplyPenaltyCommand): Promise> { + async execute(command: ApplyPenaltyCommand): Promise>> { this.logger.debug('ApplyPenaltyUseCase: Executing with command', command); // Validate race exists const race = await this.raceRepository.findById(command.raceId); if (!race) { this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`); - return Result.err(new RacingDomainValidationError('Race not found')); + return Result.err({ code: 'RACE_NOT_FOUND' }); } this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`); @@ -46,7 +46,7 @@ export class ApplyPenaltyUseCase if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`); - return Result.err(new RacingDomainValidationError('Only league owners and admins can apply penalties')); + return Result.err({ code: 'INSUFFICIENT_AUTHORITY' }); } this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`); @@ -55,15 +55,15 @@ export class ApplyPenaltyUseCase const protest = await this.protestRepository.findById(command.protestId); if (!protest) { this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`); - return Result.err(new RacingDomainValidationError('Protest not found')); + return Result.err({ code: 'PROTEST_NOT_FOUND' }); } if (protest.status !== 'upheld') { this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`); - return Result.err(new RacingDomainValidationError('Can only create penalties for upheld protests')); + return Result.err({ code: 'PROTEST_NOT_UPHELD' }); } if (protest.raceId !== command.raceId) { this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`); - return Result.err(new RacingDomainValidationError('Protest is not for this race')); + return Result.err({ code: 'PROTEST_NOT_FOR_RACE' }); } this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`); } diff --git a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts index b2596ab2a..2198229a4 100644 --- a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.test.ts @@ -1,45 +1,54 @@ -import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase'; -import { ApproveLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter'; - +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ApproveLeagueJoinRequestUseCase } from './ApproveLeagueJoinRequestUseCase'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; describe('ApproveLeagueJoinRequestUseCase', () => { - let useCase: ApproveLeagueJoinRequestUseCase; - let leagueMembershipRepository: jest.Mocked; - let presenter: ApproveLeagueJoinRequestPresenter; + let mockLeagueMembershipRepo: { + getJoinRequests: Mock; + removeJoinRequest: Mock; + saveMembership: Mock; + }; beforeEach(() => { - leagueMembershipRepository = { - getJoinRequests: jest.fn(), - removeJoinRequest: jest.fn(), - saveMembership: jest.fn(), - } as unknown; - presenter = new ApproveLeagueJoinRequestPresenter(); - useCase = new ApproveLeagueJoinRequestUseCase(leagueMembershipRepository); + mockLeagueMembershipRepo = { + getJoinRequests: vi.fn(), + removeJoinRequest: vi.fn(), + saveMembership: vi.fn(), + }; }); it('should approve join request and save membership', async () => { + const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository); + const leagueId = 'league-1'; const requestId = 'req-1'; const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }]; - leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); + mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests); - await useCase.execute({ leagueId, requestId }, presenter); + const result = await useCase.execute({ leagueId, requestId }); - expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId); - expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ success: true, message: 'Join request approved.' }); + expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId); + expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({ + id: expect.any(String), leagueId, driverId: 'driver-1', role: 'member', status: 'active', joinedAt: expect.any(Date), }); - expect(presenter.viewModel).toEqual({ success: true, message: 'Join request approved.' }); }); - it('should throw error if request not found', async () => { - leagueMembershipRepository.getJoinRequests.mockResolvedValue([]); + it('should return error if request not found', async () => { + const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository); - await expect(useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, presenter)).rejects.toThrow('Join request not found'); + mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]); + + const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }); + + expect(result.isOk()).toBe(false); + expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND'); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts index c7b378abe..a61c37840 100644 --- a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts @@ -1,19 +1,19 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { AsyncUseCase } from '@core/shared/application'; import { randomUUID } from 'crypto'; import type { ApproveLeagueJoinRequestUseCaseParams } from './ApproveLeagueJoinRequestUseCaseParams'; import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO'; -export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase> { +export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase { constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} - async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise> { + async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise>> { const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); const request = requests.find(r => r.id === params.requestId); if (!request) { - return Result.err(new RacingDomainValidationError('Join request not found')); + return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' }); } await this.leagueMembershipRepository.removeJoinRequest(params.requestId); await this.leagueMembershipRepository.saveMembership({ diff --git a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts index 5310bfcdb..acda77210 100644 --- a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts +++ b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.test.ts @@ -45,6 +45,6 @@ describe('ApproveTeamJoinRequestUseCase', () => { const result = await useCase.execute({ teamId: 'team-1', requestId: 'req-1' }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Join request not found'); + expect(result.unwrapErr().code).toBe('JOIN_REQUEST_NOT_FOUND'); }); }); \ 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 e36022fb2..20defd0d7 100644 --- a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts @@ -1,36 +1,34 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { TeamMembership, - TeamMembershipStatus, - TeamRole, TeamJoinRequest, } from '../../domain/types/TeamMembership'; import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export class ApproveTeamJoinRequestUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, ) {} - async execute(command: ApproveTeamJoinRequestCommandDTO): Promise> { + async execute(command: ApproveTeamJoinRequestCommandDTO): Promise>> { const { teamId, requestId } = command; const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId); const request = allRequests.find((r) => r.id === requestId); if (!request) { - return Result.err(new RacingDomainValidationError('Join request not found')); + return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' }); } const membership: TeamMembership = { teamId: request.teamId, driverId: request.driverId, - role: 'driver' as TeamRole, - status: 'active' as TeamMembershipStatus, + role: 'driver', + status: 'active', joinedAt: new Date(), }; diff --git a/core/racing/application/use-cases/CancelRaceUseCase.test.ts b/core/racing/application/use-cases/CancelRaceUseCase.test.ts index b685764c5..c03081dc3 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.test.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.test.ts @@ -4,7 +4,6 @@ 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 { RacingDomainInvariantError, RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; describe('CancelRaceUseCase', () => { let useCase: CancelRaceUseCase; @@ -52,7 +51,6 @@ describe('CancelRaceUseCase', () => { expect(result.isOk()).toBe(true); expect(raceRepository.findById).toHaveBeenCalledWith(raceId); expect(raceRepository.update).toHaveBeenCalledWith(expect.objectContaining({ id: raceId, status: 'cancelled' })); - expect(logger.info).toHaveBeenCalledWith(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`); }); it('should return error if race not found', async () => { @@ -62,9 +60,7 @@ describe('CancelRaceUseCase', () => { const result = await useCase.execute({ raceId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Race not found'); - expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Race with ID ${raceId} not found.`); + expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); }); it('should return domain error if race is already cancelled', async () => { @@ -84,9 +80,7 @@ describe('CancelRaceUseCase', () => { const result = await useCase.execute({ raceId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainInvariantError); - expect(result.unwrapErr().message).toBe('Race is already cancelled'); - expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: Race is already cancelled`); + expect(result.unwrapErr().code).toBe('RACE_ALREADY_CANCELLED'); }); it('should return domain error if race is completed', async () => { @@ -106,8 +100,6 @@ describe('CancelRaceUseCase', () => { const result = await useCase.execute({ raceId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainInvariantError); - expect(result.unwrapErr().message).toBe('Cannot cancel a completed race'); - expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: Cannot cancel a completed race`); + expect(result.unwrapErr().code).toBe('CANNOT_CANCEL_COMPLETED_RACE'); }); }); \ 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 b71994af5..f7631ec6d 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.ts @@ -1,8 +1,8 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError, RacingDomainInvariantError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; /** @@ -15,13 +15,13 @@ import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; * - persists the updated race via the repository. */ export class CancelRaceUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly logger: Logger, ) {} - async execute(command: CancelRaceCommandDTO): Promise> { + async execute(command: CancelRaceCommandDTO): Promise>> { const { raceId } = command; this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`); @@ -29,7 +29,7 @@ export class CancelRaceUseCase const race = await this.raceRepository.findById(raceId); if (!race) { this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`); - return Result.err(new RacingDomainValidationError('Race not found')); + return Result.err({ code: 'RACE_NOT_FOUND' }); } const cancelledRace = race.cancel(); @@ -37,12 +37,16 @@ export class CancelRaceUseCase this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`); return Result.ok(undefined); } catch (error) { - if (error instanceof RacingDomainInvariantError || error instanceof RacingDomainValidationError) { + if (error instanceof Error && error.message.includes('already cancelled')) { this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`); - return Result.err(error); + return Result.err({ code: 'RACE_ALREADY_CANCELLED' }); + } + 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' }); } this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error))); - throw error; + return Result.err({ code: 'UNEXPECTED_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 29b130f87..904a970db 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { CloseRaceEventStewardingUseCase } from './CloseRaceEventStewardingUseCase'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; -import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { DomainEventPublisher } from '@/shared/domain/DomainEvent'; 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 { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; describe('CloseRaceEventStewardingUseCase', () => { let useCase: CloseRaceEventStewardingUseCase; @@ -14,6 +15,12 @@ describe('CloseRaceEventStewardingUseCase', () => { findAwaitingStewardingClose: Mock; update: Mock; }; + let raceRegistrationRepository: { + getRegisteredDrivers: Mock; + }; + let penaltyRepository: { + findByRaceId: Mock; + }; let domainEventPublisher: { publish: Mock; }; @@ -26,6 +33,12 @@ describe('CloseRaceEventStewardingUseCase', () => { findAwaitingStewardingClose: vi.fn(), update: vi.fn(), }; + raceRegistrationRepository = { + getRegisteredDrivers: vi.fn(), + }; + penaltyRepository = { + findByRaceId: vi.fn(), + }; domainEventPublisher = { publish: vi.fn(), }; @@ -35,7 +48,9 @@ describe('CloseRaceEventStewardingUseCase', () => { useCase = new CloseRaceEventStewardingUseCase( logger as unknown as Logger, raceEventRepository as unknown as IRaceEventRepository, - domainEventPublisher as unknown as IDomainEventPublisher, + raceRegistrationRepository as unknown as IRaceRegistrationRepository, + penaltyRepository as unknown as IPenaltyRepository, + domainEventPublisher as unknown as DomainEventPublisher, ); }); @@ -61,6 +76,8 @@ describe('CloseRaceEventStewardingUseCase', () => { }); raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([raceEvent]); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); + penaltyRepository.findByRaceId.mockResolvedValue([]); domainEventPublisher.publish.mockResolvedValue(undefined); const result = await useCase.execute({}); @@ -89,6 +106,6 @@ describe('CloseRaceEventStewardingUseCase', () => { const result = await useCase.execute({}); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().code).toBe('FAILED_TO_CLOSE_STEWARDING'); }); }); \ 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 354ce5264..752e8a5ee 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts @@ -1,10 +1,12 @@ import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; -import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { DomainEventPublisher } from '@/shared/domain/DomainEvent'; import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CloseRaceEventStewardingCommand } from './CloseRaceEventStewardingCommand'; import type { RaceEvent } from '../../domain/entities/RaceEvent'; @@ -18,17 +20,19 @@ import type { RaceEvent } from '../../domain/entities/RaceEvent'; * to automatically close stewarding windows based on league configuration. */ export class CloseRaceEventStewardingUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly logger: Logger, private readonly raceEventRepository: IRaceEventRepository, - private readonly domainEventPublisher: IDomainEventPublisher, + private readonly raceRegistrationRepository: IRaceRegistrationRepository, + private readonly penaltyRepository: IPenaltyRepository, + private readonly domainEventPublisher: DomainEventPublisher, ) {} // eslint-disable-next-line @typescript-eslint/no-unused-vars - async execute(_command: CloseRaceEventStewardingCommand): Promise> { + async execute(_command: CloseRaceEventStewardingCommand): Promise>> { try { // Find all race events awaiting stewarding that have expired windows const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose(); @@ -40,7 +44,7 @@ export class CloseRaceEventStewardingUseCase 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(new RacingDomainValidationError('Failed to close stewarding for race events')); + return Result.err({ code: 'FAILED_TO_CLOSE_STEWARDING' }); } } @@ -50,11 +54,11 @@ export class CloseRaceEventStewardingUseCase const closedRaceEvent = raceEvent.closeStewarding(); await this.raceEventRepository.update(closedRaceEvent); - // Get list of participating drivers (would need to be implemented) - const driverIds = await this.getParticipatingDriverIds(); + // Get list of participating drivers + const driverIds = await this.getParticipatingDriverIds(raceEvent); // Check if any penalties were applied during stewarding - const hadPenaltiesApplied = await this.checkForAppliedPenalties(); + const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent); // Publish domain event to trigger final results notifications const event = new RaceEventStewardingClosedEvent({ @@ -70,19 +74,17 @@ export class CloseRaceEventStewardingUseCase } catch (error) { this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(error))); - // TODO: In production, this would trigger alerts/monitoring + // In production, this would trigger alerts/monitoring + throw error; } } - private async getParticipatingDriverIds(): Promise { - // TODO: Implement query for participating driver IDs from race event registrations - // This would typically involve querying race registrations for the event - return []; + private async getParticipatingDriverIds(raceEvent: RaceEvent): Promise { + return await this.raceRegistrationRepository.getRegisteredDrivers(raceEvent.id); } - private async checkForAppliedPenalties(): Promise { - // TODO: Implement check for applied penalties during stewarding window - // This would query the penalty repository for penalties related to this race event - return false; + private async checkForAppliedPenalties(raceEvent: RaceEvent): Promise { + const penalties = await this.penaltyRepository.findByRaceId(raceEvent.id); + return penalties.length > 0; } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts index 48a8c1580..1e0fdb524 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts @@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { CompleteDriverOnboardingUseCase } from './CompleteDriverOnboardingUseCase'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import { Driver } from '../../domain/entities/Driver'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand'; describe('CompleteDriverOnboardingUseCase', () => { @@ -78,8 +77,7 @@ describe('CompleteDriverOnboardingUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Driver already exists'); + expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS'); expect(driverRepository.create).not.toHaveBeenCalled(); }); @@ -98,8 +96,7 @@ describe('CompleteDriverOnboardingUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); }); it('should handle bio being undefined', async () => { diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts index 5e483f842..22000ad34 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -1,24 +1,24 @@ 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/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand'; /** * Use Case for completing driver onboarding. */ export class CompleteDriverOnboardingUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor(private readonly driverRepository: IDriverRepository) {} - async execute(command: CompleteDriverOnboardingCommand): Promise> { + async execute(command: CompleteDriverOnboardingCommand): Promise>> { try { // Check if driver already exists const existing = await this.driverRepository.findById(command.userId); if (existing) { - return Result.err(new RacingDomainValidationError('Driver already exists')); + return Result.err({ code: 'DRIVER_ALREADY_EXISTS' }); } // Create new driver @@ -33,8 +33,8 @@ export class CompleteDriverOnboardingUseCase await this.driverRepository.create(driver); return Result.ok({ driverId: driver.id }); - } catch (error) { - return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); + } catch { + return Result.err({ code: '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 6ce14cd81..7cf2d3af6 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCase.test.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCase.test.ts @@ -5,7 +5,6 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; describe('CompleteRaceUseCase', () => { @@ -97,8 +96,7 @@ describe('CompleteRaceUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Race not found'); + expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); }); it('should return error when no registered drivers', async () => { @@ -118,8 +116,7 @@ describe('CompleteRaceUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Cannot complete race with no registered drivers'); + expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS'); }); it('should return error when repository throws', async () => { @@ -141,7 +138,6 @@ describe('CompleteRaceUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); }); }); \ 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 2a02c3646..200368bf7 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCase.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCase.ts @@ -6,8 +6,8 @@ import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import { Result } from '../../domain/entities/Result'; import { Standing } from '../../domain/entities/Standing'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result as SharedResult } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; /** @@ -22,7 +22,7 @@ import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; * - persists all changes via repositories. */ export class CompleteRaceUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, @@ -31,19 +31,19 @@ export class CompleteRaceUseCase private readonly driverRatingProvider: DriverRatingProvider, ) {} - async execute(command: CompleteRaceCommandDTO): Promise> { + async execute(command: CompleteRaceCommandDTO): Promise>> { try { const { raceId } = command; const race = await this.raceRepository.findById(raceId); if (!race) { - return SharedResult.err(new RacingDomainValidationError('Race not found')); + return SharedResult.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(new RacingDomainValidationError('Cannot complete race with no registered drivers')); + return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' }); } // Get driver ratings @@ -65,8 +65,8 @@ export class CompleteRaceUseCase await this.raceRepository.update(completedRace); return SharedResult.ok({}); - } catch (error) { - return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); + } catch { + return SharedResult.err({ code: 'UNKNOWN_ERROR' }); } } diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts index 344e9c497..6c524d6ef 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts @@ -6,7 +6,6 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; describe('CompleteRaceUseCaseWithRatings', () => { @@ -107,8 +106,7 @@ describe('CompleteRaceUseCaseWithRatings', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Race not found'); + expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND'); }); it('should return error when no registered drivers', async () => { @@ -128,8 +126,7 @@ describe('CompleteRaceUseCaseWithRatings', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('Cannot complete race with no registered drivers'); + expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS'); }); it('should return error when repository throws', async () => { @@ -151,7 +148,6 @@ describe('CompleteRaceUseCaseWithRatings', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR'); }); }); \ 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 bbb6a075c..861bc2140 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts @@ -8,15 +8,15 @@ 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/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; /** * Enhanced CompleteRaceUseCase that includes rating updates */ export class CompleteRaceUseCaseWithRatings - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, @@ -26,19 +26,19 @@ export class CompleteRaceUseCaseWithRatings private readonly ratingUpdateService: RatingUpdateService, ) {} - async execute(command: CompleteRaceCommandDTO): Promise> { + async execute(command: CompleteRaceCommandDTO): Promise>> { try { const { raceId } = command; const race = await this.raceRepository.findById(raceId); if (!race) { - return SharedResult.err(new RacingDomainValidationError('Race not found')); + return SharedResult.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(new RacingDomainValidationError('Cannot complete race with no registered drivers')); + return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' }); } // Get driver ratings @@ -63,8 +63,8 @@ export class CompleteRaceUseCaseWithRatings await this.raceRepository.update(completedRace); return SharedResult.ok(undefined); - } catch (error) { - return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); + } catch { + return SharedResult.err({ code: 'UNKNOWN_ERROR' }); } } diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts index 8a57104fc..2adb6d654 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase'; -import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand'; +import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; @@ -114,7 +114,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('League name is required'); + expect(result.unwrapErr().details.message).toBe('League name is required'); }); it('should return error when ownerId is empty', async () => { @@ -133,7 +133,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('League ownerId is required'); + expect(result.unwrapErr().details.message).toBe('League ownerId is required'); }); it('should return error when gameId is empty', async () => { @@ -152,7 +152,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('gameId is required'); + expect(result.unwrapErr().details.message).toBe('gameId is required'); }); it('should return error when visibility is missing', async () => { @@ -166,10 +166,10 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { enableTrophyChampionship: false, }; - const result = await useCase.execute(command); + const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('visibility is required'); + expect(result.unwrapErr().details.message).toBe('visibility is required'); }); it('should return error when maxDrivers is invalid', async () => { @@ -189,7 +189,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('maxDrivers must be greater than 0 when provided'); + expect(result.unwrapErr().details.message).toBe('maxDrivers must be greater than 0 when provided'); }); it('should return error when ranked league has insufficient drivers', async () => { @@ -209,7 +209,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toContain('Ranked leagues require at least 10 drivers'); + expect(result.unwrapErr().details.message).toContain('Ranked leagues require at least 10 drivers'); }); it('should return error when scoring preset is unknown', async () => { @@ -231,7 +231,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Unknown scoring preset: unknown-preset'); + expect(result.unwrapErr().details.message).toBe('Unknown scoring preset: unknown-preset'); }); it('should return error when repository throws', async () => { @@ -259,6 +259,6 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().details.message).toBe('DB error'); }); }); \ 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 41e050ba5..a232ffa40 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -14,13 +14,33 @@ import { LeagueVisibility, MIN_RANKED_LEAGUE_DRIVERS, } from '../../domain/value-objects/LeagueVisibility'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { LeagueVisibilityInput } from './LeagueVisibilityInput'; import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO'; +export interface CreateLeagueWithSeasonAndScoringCommand { + 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: LeagueVisibilityInput; + ownerId: string; + gameId: string; + maxDrivers?: number; + maxTeams?: number; + enableDriverChampionship: boolean; + enableTeamChampionship: boolean; + enableNationsChampionship: boolean; + enableTrophyChampionship: boolean; + scoringPresetId?: string; +} + export class CreateLeagueWithSeasonAndScoringUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, @@ -31,7 +51,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase async execute( command: CreateLeagueWithSeasonAndScoringCommand, - ): Promise> { + ): Promise>> { this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command }); const validation = this.validate(command); if (validation.isErr()) { @@ -81,7 +101,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase if (!preset) { this.logger.error(`Unknown scoring preset: ${presetId}`); - return Result.err(new RacingDomainValidationError(`Unknown scoring preset: ${presetId}`)); + return Result.err({ code: 'UNKNOWN_PRESET', details: { message: `Unknown scoring preset: ${presetId}` } }); } this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); @@ -101,31 +121,31 @@ export class CreateLeagueWithSeasonAndScoringUseCase this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result }); return Result.ok(result); } catch (error) { - return Result.err(new RacingDomainValidationError(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 }); - return Result.err(new RacingDomainValidationError('League name is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'League name is required' } }); } if (!command.ownerId || command.ownerId.trim().length === 0) { this.logger.warn('Validation failed: League ownerId is required', { command }); - return Result.err(new RacingDomainValidationError('League ownerId is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'League ownerId is required' } }); } if (!command.gameId || command.gameId.trim().length === 0) { this.logger.warn('Validation failed: gameId is required', { command }); - return Result.err(new RacingDomainValidationError('gameId is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'gameId is required' } }); } if (!command.visibility) { this.logger.warn('Validation failed: visibility is required', { command }); - return Result.err(new RacingDomainValidationError('visibility is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'visibility is required' } }); } if (command.maxDrivers !== undefined && command.maxDrivers <= 0) { this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command }); - return Result.err(new RacingDomainValidationError('maxDrivers must be greater than 0 when provided')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'maxDrivers must be greater than 0 when provided' } }); } const visibility = LeagueVisibility.fromString(command.visibility); @@ -137,11 +157,11 @@ export class CreateLeagueWithSeasonAndScoringUseCase `Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`, { command } ); - return Result.err(new RacingDomainValidationError( + return Result.err({ code: 'VALIDATION_ERROR', details: { message: `Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` + `Current setting: ${driverCount}. ` + `For smaller groups, consider creating an Unranked (Friends) league instead.` - )); + } }); } } this.logger.debug('Validation successful.'); diff --git a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts new file mode 100644 index 000000000..f32f6dc1f --- /dev/null +++ b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from 'vitest'; + +import { + InMemorySeasonRepository, +} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; +import { Season } from '@core/racing/domain/entities/Season'; +import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { + CreateSeasonForLeagueUseCase, + type CreateSeasonForLeagueCommand, +} from '@core/racing/application/use-cases/CreateSeasonForLeagueUseCase'; +import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO'; +import type { Logger } from '@core/shared/application'; + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { + return { + findById: async (id: string) => seed.find((l) => l.id === id) ?? null, + findAll: async () => seed, + create: async (league: any) => league, + update: async (league: any) => league, + } as unknown as ILeagueRepository; +} + +function createLeagueConfigFormModel(overrides?: Partial): LeagueConfigFormModel { + return { + basics: { + name: 'Test League', + visibility: 'ranked', + gameId: 'iracing', + ...overrides?.basics, + }, + structure: { + mode: 'solo', + maxDrivers: 30, + ...overrides?.structure, + }, + championships: { + enableDriverChampionship: true, + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + ...overrides?.championships, + }, + scoring: { + patternId: 'sprint-main-driver', + customScoringEnabled: false, + ...overrides?.scoring, + }, + dropPolicy: { + strategy: 'bestNResults', + n: 3, + ...overrides?.dropPolicy, + }, + timings: { + qualifyingMinutes: 10, + mainRaceMinutes: 30, + sessionCount: 8, + seasonStartDate: '2025-01-01', + raceStartTime: '20:00', + timezoneId: 'UTC', + recurrenceStrategy: 'weekly', + weekdays: ['Mon'], + ...overrides?.timings, + }, + stewarding: { + decisionMode: 'steward_vote', + requiredVotes: 3, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + ...overrides?.stewarding, + }, + ...overrides, + }; +} + +describe('InMemorySeasonRepository', () => { + it('add and findById provide a roundtrip for Season', async () => { + const repo = new InMemorySeasonRepository(logger); + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Test Season', + status: 'planned', + }); + + await repo.add(season); + const loaded = await repo.findById(season.id); + + expect(loaded).not.toBeNull(); + expect(loaded!.id).toBe(season.id); + expect(loaded!.leagueId).toBe(season.leagueId); + expect(loaded!.status).toBe('planned'); + }); + + it('update persists changed Season state', async () => { + const repo = new InMemorySeasonRepository(logger); + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Initial Season', + status: 'planned', + }); + + await repo.add(season); + const activated = season.activate(); + + await repo.update(activated); + + const loaded = await repo.findById(season.id); + expect(loaded).not.toBeNull(); + expect(loaded!.status).toBe('active'); + }); + + it('listByLeague returns only seasons for that league', async () => { + const repo = new InMemorySeasonRepository(logger); + const s1 = Season.create({ + id: 's1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'L1 S1', + status: 'planned', + }); + const s2 = Season.create({ + id: 's2', + leagueId: 'league-1', + gameId: 'iracing', + name: 'L1 S2', + status: 'active', + }); + const s3 = Season.create({ + id: 's3', + leagueId: 'league-2', + gameId: 'iracing', + name: 'L2 S1', + status: 'planned', + }); + + await repo.add(s1); + await repo.add(s2); + await repo.add(s3); + + const league1Seasons = await repo.listByLeague('league-1'); + const league2Seasons = await repo.listByLeague('league-2'); + + expect(league1Seasons.map((s: Season) => s.id).sort()).toEqual(['s1', 's2']); + expect(league2Seasons.map((s: Season) => s.id)).toEqual(['s3']); + }); + + it('listActiveByLeague returns only active seasons for a league', async () => { + const repo = new InMemorySeasonRepository(logger); + const s1 = Season.create({ + id: 's1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Planned', + status: 'planned', + }); + const s2 = Season.create({ + id: 's2', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Active', + status: 'active', + }); + const s3 = Season.create({ + id: 's3', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Completed', + status: 'completed', + }); + const s4 = Season.create({ + id: 's4', + leagueId: 'league-2', + gameId: 'iracing', + name: 'Other League Active', + status: 'active', + }); + + await repo.add(s1); + await repo.add(s2); + await repo.add(s3); + await repo.add(s4); + + const activeInLeague1 = await repo.listActiveByLeague('league-1'); + + expect(activeInLeague1.map((s: Season) => s.id)).toEqual(['s2']); + }); +}); + +describe('CreateSeasonForLeagueUseCase', () => { + it('creates a planned Season for an existing league with config-derived props', async () => { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo = new InMemorySeasonRepository(logger); + + const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); + + const config = createLeagueConfigFormModel({ + basics: { + name: 'League With Config', + visibility: 'ranked', + gameId: 'iracing', + }, + scoring: { + patternId: 'club-default', + customScoringEnabled: true, + }, + dropPolicy: { + strategy: 'dropWorstN', + n: 2, + }, + // Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation, + // focusing this test on scoring/drop/stewarding/maxDrivers mapping. + timings: { + qualifyingMinutes: 10, + mainRaceMinutes: 30, + sessionCount: 8, + }, + }); + + const command: CreateSeasonForLeagueCommand = { + leagueId: 'league-1', + name: 'Season from Config', + gameId: 'iracing', + config, + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.value.seasonId).toBeDefined(); + + const created = await seasonRepo.findById(result.value.seasonId); + expect(created).not.toBeNull(); + const season = created!; + + expect(season.leagueId).toBe('league-1'); + expect(season.gameId).toBe('iracing'); + expect(season.name).toBe('Season from Config'); + expect(season.status).toBe('planned'); + + // Schedule is optional when timings lack seasonStartDate / raceStartTime. + expect(season.schedule).toBeUndefined(); + expect(season.scoringConfig).toBeDefined(); + expect(season.scoringConfig!.scoringPresetId).toBe('club-default'); + expect(season.scoringConfig!.customScoringEnabled).toBe(true); + + expect(season.dropPolicy).toBeDefined(); + expect(season.dropPolicy!.strategy).toBe('dropWorstN'); + expect(season.dropPolicy!.n).toBe(2); + + expect(season.stewardingConfig).toBeDefined(); + expect(season.maxDrivers).toBe(30); + }); + + it('clones configuration from a source season when sourceSeasonId is provided', async () => { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo = new InMemorySeasonRepository(logger); + + const sourceSeason = Season.create({ + id: 'source-season', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Source Season', + status: 'planned', + }).withMaxDrivers(40); + + await seasonRepo.add(sourceSeason); + + const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo); + + const command: CreateSeasonForLeagueCommand = { + leagueId: 'league-1', + name: 'Cloned Season', + gameId: 'iracing', + sourceSeasonId: 'source-season', + }; + + const result = await useCase.execute(command); + const created = await seasonRepo.findById(result.value.seasonId); + + expect(result.isOk()).toBe(true); + expect(created).not.toBeNull(); + const season = created!; + + expect(season.id).not.toBe(sourceSeason.id); + expect(season.leagueId).toBe(sourceSeason.leagueId); + expect(season.gameId).toBe(sourceSeason.gameId); + expect(season.status).toBe('planned'); + expect(season.maxDrivers).toBe(sourceSeason.maxDrivers); + expect(season.schedule).toBe(sourceSeason.schedule); + expect(season.scoringConfig).toBe(sourceSeason.scoringConfig); + expect(season.dropPolicy).toBe(sourceSeason.dropPolicy); + expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts new file mode 100644 index 000000000..c2bd5116c --- /dev/null +++ b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.ts @@ -0,0 +1,219 @@ +import { Season } from '../../domain/entities/Season'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO'; +import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule'; +import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig'; +import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy'; +import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig'; +import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay'; +import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone'; +import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy'; +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 { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface CreateSeasonForLeagueCommand { + leagueId: string; + name: string; + gameId: string; + sourceSeasonId?: string; + /** + * Optional high-level wizard config used to derive schedule/scoring/drop/stewarding. + * When omitted, the Season will be created with minimal metadata only. + */ + config?: LeagueConfigFormModel; +} + +export interface CreateSeasonForLeagueResultDTO { + seasonId: string; +} + +type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'SOURCE_SEASON_NOT_FOUND'; + +/** + * CreateSeasonForLeagueUseCase + * + * Creates a new Season for an existing League, optionally cloning or deriving + * configuration from a source Season or a league config form. + */ +export class CreateSeasonForLeagueUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly seasonRepository: ISeasonRepository, + ) {} + + 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) { + 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 } + : {}), + ...(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): { + schedule?: SeasonSchedule; + scoringConfig?: SeasonScoringConfig; + dropPolicy?: SeasonDropPolicy; + stewardingConfig?: SeasonStewardingConfig; + maxDrivers?: number; + } { + const schedule = this.buildScheduleFromTimings(config); + const scoringConfig = new SeasonScoringConfig({ + scoringPresetId: config.scoring.patternId ?? 'custom', + customScoringEnabled: config.scoring.customScoringEnabled ?? false, + }); + const dropPolicy = new SeasonDropPolicy({ + strategy: config.dropPolicy.strategy, + ...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}), + }); + const stewardingConfig = new SeasonStewardingConfig({ + decisionMode: config.stewarding.decisionMode, + ...(config.stewarding.requiredVotes !== undefined + ? { requiredVotes: config.stewarding.requiredVotes } + : {}), + requireDefense: config.stewarding.requireDefense, + defenseTimeLimit: config.stewarding.defenseTimeLimit, + voteTimeLimit: config.stewarding.voteTimeLimit, + protestDeadlineHours: config.stewarding.protestDeadlineHours, + stewardingClosesHours: config.stewarding.stewardingClosesHours, + notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest, + notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired, + }); + + const structure = config.structure; + const maxDrivers = + typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0 + ? structure.maxDrivers + : undefined; + + return { + ...(schedule !== undefined ? { schedule } : {}), + scoringConfig, + dropPolicy, + stewardingConfig, + ...(maxDrivers !== undefined ? { maxDrivers } : {}), + }; + } + + private buildScheduleFromTimings( + config: LeagueConfigFormModel, + ): SeasonSchedule | undefined { + const { timings } = config; + if (!timings.seasonStartDate || !timings.raceStartTime) { + return undefined; + } + + const startDate = new Date(timings.seasonStartDate); + const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime); + const timezoneId = timings.timezoneId ?? 'UTC'; + const timezone = new LeagueTimezone(timezoneId); + + const plannedRounds = + typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0 + ? timings.roundsPlanned + : timings.sessionCount; + + const recurrence = (() => { + const weekdays: WeekdaySet = + timings.weekdays && timings.weekdays.length > 0 + ? WeekdaySet.fromArray( + timings.weekdays as unknown as Weekday[], + ) + : WeekdaySet.fromArray(['Mon']); + switch (timings.recurrenceStrategy) { + case 'everyNWeeks': + return RecurrenceStrategyFactory.everyNWeeks( + timings.intervalWeeks ?? 2, + weekdays, + ); + case 'monthlyNthWeekday': { + const pattern = new MonthlyRecurrencePattern({ + ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4, + weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday, + }); + return RecurrenceStrategyFactory.monthlyNthWeekday(pattern); + } + case 'weekly': + default: + return RecurrenceStrategyFactory.weekly(weekdays); + } + })(); + + return new SeasonSchedule({ + startDate, + timeOfDay, + timezone, + recurrence, + 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 1bda5d94c..addc67a62 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.test.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CreateSponsorUseCase } from './CreateSponsorUseCase'; -import type { CreateSponsorCommand } from './CreateSponsorCommand'; +import { CreateSponsorUseCase, type CreateSponsorCommand } from './CreateSponsorUseCase'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { Logger } from '@core/shared/application'; @@ -80,7 +79,7 @@ describe('CreateSponsorUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Sponsor name is required'); + expect(result.unwrapErr().details.message).toBe('Sponsor name is required'); }); it('should return error when contactEmail is empty', async () => { @@ -92,7 +91,7 @@ describe('CreateSponsorUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Sponsor contact email is required'); + expect(result.unwrapErr().details.message).toBe('Sponsor contact email is required'); }); it('should return error when contactEmail is invalid', async () => { @@ -104,7 +103,7 @@ describe('CreateSponsorUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Invalid sponsor contact email format'); + expect(result.unwrapErr().details.message).toBe('Invalid sponsor contact email format'); }); it('should return error when websiteUrl is invalid', async () => { @@ -117,7 +116,7 @@ describe('CreateSponsorUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Invalid sponsor website URL'); + expect(result.unwrapErr().details.message).toBe('Invalid sponsor website URL'); }); it('should return error when repository throws', async () => { @@ -131,6 +130,6 @@ describe('CreateSponsorUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().details.message).toBe('DB error'); }); }); \ 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 115128590..f434d2225 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.ts @@ -8,13 +8,19 @@ import { Sponsor } from '../../domain/entities/Sponsor'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -import type { CreateSponsorCommand } from './CreateSponsorCommand'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { CreateSponsorResultDTO } from '../dto/CreateSponsorResultDTO'; +export interface CreateSponsorCommand { + name: string; + contactEmail: string; + websiteUrl?: string; + logoUrl?: string; +} + export class CreateSponsorUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, @@ -23,7 +29,7 @@ export class CreateSponsorUseCase async execute( command: CreateSponsorCommand, - ): Promise> { + ): Promise>> { this.logger.debug('Executing CreateSponsorUseCase', { command }); const validation = this.validate(command); if (validation.isErr()) { @@ -50,40 +56,40 @@ export class CreateSponsorUseCase id: sponsor.id, name: sponsor.name, contactEmail: sponsor.contactEmail, - websiteUrl: sponsor.websiteUrl, - logoUrl: sponsor.logoUrl, 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); } catch (error) { - return Result.err(new RacingDomainValidationError(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: CreateSponsorCommand): Result { + private validate(command: CreateSponsorCommand): Result> { this.logger.debug('Validating CreateSponsorCommand', { command }); if (!command.name || command.name.trim().length === 0) { this.logger.warn('Validation failed: Sponsor name is required', { command }); - return Result.err(new RacingDomainValidationError('Sponsor name is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor name is required' } }); } if (!command.contactEmail || command.contactEmail.trim().length === 0) { this.logger.warn('Validation failed: Sponsor contact email is required', { command }); - return Result.err(new RacingDomainValidationError('Sponsor contact email is required')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor contact email is required' } }); } // Basic email validation const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(command.contactEmail)) { this.logger.warn('Validation failed: Invalid sponsor contact email format', { command }); - return Result.err(new RacingDomainValidationError('Invalid sponsor contact email format')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor contact email format' } }); } if (command.websiteUrl && command.websiteUrl.trim().length > 0) { try { new URL(command.websiteUrl); } catch { this.logger.warn('Validation failed: Invalid sponsor website URL', { command }); - return Result.err(new RacingDomainValidationError('Invalid sponsor website URL')); + return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor website URL' } }); } } this.logger.debug('Validation successful.'); diff --git a/core/racing/application/use-cases/CreateTeamUseCase.test.ts b/core/racing/application/use-cases/CreateTeamUseCase.test.ts index b16e989ea..9f4cec89f 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.test.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.test.ts @@ -1,6 +1,5 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; -import { CreateTeamUseCase } from './CreateTeamUseCase'; -import type { CreateTeamCommandDTO } from '../dto/CreateTeamCommandDTO'; +import { CreateTeamUseCase, type CreateTeamCommandDTO } from './CreateTeamUseCase'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { Logger } from '@core/shared/application'; @@ -98,7 +97,7 @@ describe('CreateTeamUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Driver already belongs to a team'); + expect(result.unwrapErr().details.message).toBe('Driver already belongs to a team'); expect(teamRepository.create).not.toHaveBeenCalled(); expect(membershipRepository.saveMembership).not.toHaveBeenCalled(); }); @@ -118,6 +117,6 @@ describe('CreateTeamUseCase', () => { const result = await useCase.execute(command); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('DB error'); + expect(result.unwrapErr().details.message).toBe('DB error'); }); }); \ 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 b191f8f16..f757f9252 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.ts @@ -12,17 +12,25 @@ import type { TeamMembershipStatus, TeamRole, } from '../../domain/types/TeamMembership'; -import type { - CreateTeamCommandDTO, - CreateTeamResultDTO, -} from '../dto/CreateTeamCommandDTO'; import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface CreateTeamCommandDTO { + name: string; + tag: string; + description: string; + ownerId: string; + leagues: string[]; +} + +export interface CreateTeamResultDTO { + team: Team; +} export class CreateTeamUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly teamRepository: ITeamRepository, @@ -32,7 +40,7 @@ export class CreateTeamUseCase async execute( command: CreateTeamCommandDTO, - ): Promise> { + ): Promise>> { this.logger.debug('Executing CreateTeamUseCase', { command }); const { name, tag, description, ownerId, leagues } = command; @@ -41,7 +49,7 @@ export class CreateTeamUseCase ); if (existingMembership) { this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId }); - return Result.err(new RacingDomainValidationError('Driver already belongs to a team')); + return Result.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } }); } this.logger.info('Command validated successfully.'); @@ -76,7 +84,7 @@ export class CreateTeamUseCase this.logger.debug('CreateTeamUseCase completed successfully.', { result }); return Result.ok(result); } catch (error) { - return Result.err(new RacingDomainValidationError(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.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.ts index 9ac1d36cb..2ed5ebb14 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.ts @@ -8,15 +8,13 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac import type { IImageServicePort } from '../ports/IImageServicePort'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; 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 { DashboardOverviewParams } from './DashboardOverviewParams'; import type { DashboardOverviewViewModel, DashboardDriverSummaryViewModel, @@ -28,6 +26,10 @@ import type { DashboardFriendSummaryViewModel, } from '../presenters/IDashboardOverviewPresenter'; +interface DashboardOverviewParams { + driverId: string; +} + interface DashboardDriverStatsAdapter { rating: number | null; wins: number; @@ -52,7 +54,7 @@ export class DashboardOverviewUseCase { private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null, ) {} - async execute(params: DashboardOverviewParams): Promise> { + async execute(params: DashboardOverviewParams): Promise> { const { driverId } = params; const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([ @@ -285,13 +287,13 @@ export class DashboardOverviewUseCase { id: item.id, type: item.type, headline: item.headline, - body: item.body, timestamp: item.timestamp instanceof Date ? item.timestamp.toISOString() : new Date(item.timestamp).toISOString(), - ctaLabel: item.ctaLabel, - ctaHref: item.ctaHref, + ...(item.body !== undefined ? { body: item.body } : {}), + ...(item.ctaLabel !== undefined ? { ctaLabel: item.ctaLabel } : {}), + ...(item.ctaHref !== undefined ? { ctaHref: item.ctaHref } : {}), })); return { diff --git a/core/racing/application/use-cases/FileProtestUseCase.test.ts b/core/racing/application/use-cases/FileProtestUseCase.test.ts index 143aa766f..59caa2303 100644 --- a/core/racing/application/use-cases/FileProtestUseCase.test.ts +++ b/core/racing/application/use-cases/FileProtestUseCase.test.ts @@ -44,7 +44,7 @@ describe('FileProtestUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Race not found'); + expect(result.error!.details.message).toBe('Race not found'); }); it('should return error when protesting against self', async () => { @@ -64,7 +64,7 @@ describe('FileProtestUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Cannot file a protest against yourself'); + expect(result.error!.details.message).toBe('Cannot file a protest against yourself'); }); it('should return error when protesting driver is not an active member', async () => { @@ -87,7 +87,7 @@ describe('FileProtestUseCase', () => { }); expect(result.isOk()).toBe(false); - expect(result.error!.message).toBe('Protesting driver is not an active member of this league'); + expect(result.error!.details.message).toBe('Protesting driver is not an active member of this league'); }); it('should create protest and return protestId on success', async () => { diff --git a/core/racing/application/use-cases/FileProtestUseCase.ts b/core/racing/application/use-cases/FileProtestUseCase.ts index bbf5aaeae..977aabf3d 100644 --- a/core/racing/application/use-cases/FileProtestUseCase.ts +++ b/core/racing/application/use-cases/FileProtestUseCase.ts @@ -8,11 +8,20 @@ import { Protest } from '../../domain/entities/Protest'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -import type { FileProtestCommand } from './FileProtestCommand'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { ProtestIncident } from '../../domain/entities/Protest'; import { randomUUID } from 'crypto'; +export interface FileProtestCommand { + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: ProtestIncident; + comment?: string; + proofVideoUrl?: string; +} + export class FileProtestUseCase { constructor( private readonly protestRepository: IProtestRepository, @@ -20,16 +29,16 @@ export class FileProtestUseCase { private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(command: FileProtestCommand): Promise> { + async execute(command: FileProtestCommand): Promise>> { // Validate race exists const race = await this.raceRepository.findById(command.raceId); if (!race) { - return Result.err(new RacingDomainValidationError('Race not found')); + 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(new RacingDomainValidationError('Cannot file a protest against yourself')); + return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } }); } // Validate protesting driver is a member of the league @@ -39,7 +48,7 @@ export class FileProtestUseCase { ); if (!protestingDriverMembership) { - return Result.err(new RacingDomainValidationError('Protesting driver is not an active member of this league')); + return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } }); } // Create the protest diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts index a24cddf63..d95f4d28c 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts @@ -5,16 +5,13 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I import type { IGameRepository } from '../../domain/repositories/IGameRepository'; import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; import type { LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter'; -import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; /** * Use Case for retrieving all leagues with capacity and scoring information. * Orchestrates domain logic and delegates presentation to the presenter. */ export class GetAllLeaguesWithCapacityAndScoringUseCase - implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, @@ -25,7 +22,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase private readonly presetProvider: LeagueScoringPresetProvider, ) {} - async execute(): Promise> { + async execute(): Promise> { const leagues = await this.leagueRepository.findAll(); const enrichedLeagues: LeagueEnrichedData[] = []; diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts index c84f15c2b..f186507a8 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts @@ -2,22 +2,22 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { AllLeaguesWithCapacityResultDTO } from '../presenters/IAllLeaguesWithCapacityPresenter'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case for retrieving all leagues with capacity information. * Orchestrates domain logic and returns result. */ export class GetAllLeaguesWithCapacityUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(): Promise> { + async execute(): Promise>> { const leagues = await this.leagueRepository.findAll(); const memberCounts = new Map(); diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts index 52878d1f1..10d90b619 100644 --- a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts @@ -8,18 +8,18 @@ import type { AllRacesFilterOptionsViewModel, } from '../presenters/IAllRacesPagePresenter'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export class GetAllRacesPageDataUseCase - implements AsyncUseCase> { + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly logger: Logger, ) {} - async execute(): Promise> { + async execute(): Promise>> { this.logger.debug('Executing GetAllRacesPageDataUseCase'); try { const [allRaces, allLeagues] = await Promise.all([ diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.ts b/core/racing/application/use-cases/GetAllRacesUseCase.ts index 02389dde3..90b8afa5d 100644 --- a/core/racing/application/use-cases/GetAllRacesUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesUseCase.ts @@ -2,7 +2,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@/shared/application/Result'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; export class GetAllRacesUseCase implements AsyncUseCase> { diff --git a/core/racing/application/use-cases/GetAllTeamsUseCase.ts b/core/racing/application/use-cases/GetAllTeamsUseCase.ts index 780de88d8..b03a1101d 100644 --- a/core/racing/application/use-cases/GetAllTeamsUseCase.ts +++ b/core/racing/application/use-cases/GetAllTeamsUseCase.ts @@ -2,7 +2,7 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository' import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@/shared/application/Result'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; /** diff --git a/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts b/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts index e419341cf..c8358e2f7 100644 --- a/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts +++ b/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts @@ -5,13 +5,36 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamM import type { Logger } from '@core/shared/application'; describe('GetDriverTeamUseCase', () => { - let mockTeamRepo: { findById: Mock }; - let mockMembershipRepo: { getActiveMembershipForDriver: Mock }; + let mockTeamRepo: ITeamRepository; + let mockMembershipRepo: ITeamMembershipRepository; let mockLogger: Logger; + let mockFindById: Mock; + let mockGetActiveMembershipForDriver: Mock; beforeEach(() => { - mockTeamRepo = { findById: vi.fn() }; - mockMembershipRepo = { getActiveMembershipForDriver: vi.fn() }; + mockFindById = vi.fn(); + mockGetActiveMembershipForDriver = vi.fn(); + mockTeamRepo = { + findById: mockFindById, + findAll: vi.fn(), + findByLeagueId: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + } as ITeamRepository; + mockMembershipRepo = { + getActiveMembershipForDriver: mockGetActiveMembershipForDriver, + getMembership: vi.fn(), + getTeamMembers: vi.fn(), + saveMembership: vi.fn(), + removeMembership: vi.fn(), + getJoinRequests: vi.fn(), + getMembershipsForDriver: vi.fn(), + countByTeamId: vi.fn(), + saveJoinRequest: vi.fn(), + removeJoinRequest: vi.fn(), + } as ITeamMembershipRepository; mockLogger = { debug: vi.fn(), info: vi.fn(), @@ -22,8 +45,8 @@ describe('GetDriverTeamUseCase', () => { it('should return driver team data when membership and team exist', async () => { const useCase = new GetDriverTeamUseCase( - mockTeamRepo as unknown as ITeamRepository, - mockMembershipRepo as unknown as ITeamMembershipRepository, + mockTeamRepo, + mockMembershipRepo, mockLogger, ); @@ -31,8 +54,8 @@ describe('GetDriverTeamUseCase', () => { const membership = { id: 'membership1', driverId, teamId: 'team1' }; const team = { id: 'team1', name: 'Team One' }; - mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership); - mockTeamRepo.findById.mockResolvedValue(team); + mockGetActiveMembershipForDriver.mockResolvedValue(membership); + mockFindById.mockResolvedValue(team); const result = await useCase.execute({ driverId }); @@ -53,12 +76,13 @@ describe('GetDriverTeamUseCase', () => { const driverId = 'driver1'; - mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(null); + mockGetActiveMembershipForDriver.mockResolvedValue(null); const result = await useCase.execute({ driverId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('No active membership found for driver driver1'); + expect(result.unwrapErr().code).toBe('MEMBERSHIP_NOT_FOUND'); + expect(result.unwrapErr().details.message).toBe('No active membership found for driver driver1'); }); it('should return error when team not found', async () => { @@ -71,13 +95,14 @@ describe('GetDriverTeamUseCase', () => { const driverId = 'driver1'; const membership = { id: 'membership1', driverId, teamId: 'team1' }; - mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership); - mockTeamRepo.findById.mockResolvedValue(null); + mockGetActiveMembershipForDriver.mockResolvedValue(membership); + mockFindById.mockResolvedValue(null); const result = await useCase.execute({ driverId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Team not found for teamId team1'); + expect(result.unwrapErr().code).toBe('TEAM_NOT_FOUND'); + expect(result.unwrapErr().details.message).toBe('Team not found for teamId team1'); }); it('should return error when repository throws', async () => { @@ -90,11 +115,12 @@ describe('GetDriverTeamUseCase', () => { const driverId = 'driver1'; const error = new Error('Repository error'); - mockMembershipRepo.getActiveMembershipForDriver.mockRejectedValue(error); + mockGetActiveMembershipForDriver.mockRejectedValue(error); const result = await useCase.execute({ driverId }); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Repository error'); + expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); + expect(result.unwrapErr().details.message).toBe('Repository error'); }); }); \ 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 d42346e34..3e179ea4d 100644 --- a/core/racing/application/use-cases/GetDriverTeamUseCase.ts +++ b/core/racing/application/use-cases/GetDriverTeamUseCase.ts @@ -2,15 +2,15 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository' import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { DriverTeamResultDTO } from '../presenters/IDriverTeamPresenter'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case for retrieving a driver's team. * Orchestrates domain logic and returns result. */ export class GetDriverTeamUseCase - implements AsyncUseCase<{ driverId: string }, Result> + implements AsyncUseCase<{ driverId: string }, Result>> { constructor( private readonly teamRepository: ITeamRepository, @@ -18,20 +18,20 @@ export class GetDriverTeamUseCase private readonly logger: Logger, ) {} - async execute(input: { driverId: string }): Promise> { + async execute(input: { driverId: string }): Promise>> { this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`); try { const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId); if (!membership) { this.logger.warn(`No active membership found for driverId: ${input.driverId}`); - return Result.err(new RacingDomainValidationError(`No active membership found for driver ${input.driverId}`)); + return Result.err({ code: 'MEMBERSHIP_NOT_FOUND', details: { message: `No active membership found for driver ${input.driverId}` } }); } this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`); const team = await this.teamRepository.findById(membership.teamId); if (!team) { this.logger.error(`Team not found for teamId: ${membership.teamId}`); - return Result.err(new RacingDomainValidationError(`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}`); @@ -45,7 +45,7 @@ export class GetDriverTeamUseCase return Result.ok(dto); } catch (error) { this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error))); - return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } }); } } } \ 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 c79b757e7..ffeb22790 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -4,7 +4,7 @@ import type { IDriverStatsService } from '../../domain/services/IDriverStatsServ import type { IImageServicePort } from '../ports/IImageServicePort'; import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@/shared/application/Result'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; /** diff --git a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts index 0e8fff11e..af5510d06 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts @@ -6,15 +6,59 @@ import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISe import type { Logger } from '@core/shared/application'; describe('GetEntitySponsorshipPricingUseCase', () => { - let mockSponsorshipPricingRepo: { findByEntity: Mock }; - let mockSponsorshipRequestRepo: { findPendingByEntity: Mock }; - let mockSeasonSponsorshipRepo: { findBySeasonId: Mock }; + let mockSponsorshipPricingRepo: ISponsorshipPricingRepository; + let mockSponsorshipRequestRepo: ISponsorshipRequestRepository; + let mockSeasonSponsorshipRepo: ISeasonSponsorshipRepository; let mockLogger: Logger; + let mockFindByEntity: Mock; + let mockFindPendingByEntity: Mock; + let mockFindBySeasonId: Mock; beforeEach(() => { - mockSponsorshipPricingRepo = { findByEntity: vi.fn() }; - mockSponsorshipRequestRepo = { findPendingByEntity: vi.fn() }; - mockSeasonSponsorshipRepo = { findBySeasonId: vi.fn() }; + mockFindByEntity = vi.fn(); + mockFindPendingByEntity = vi.fn(); + mockFindBySeasonId = vi.fn(); + mockSponsorshipPricingRepo = { + findByEntity: mockFindByEntity, + findAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + save: vi.fn(), + exists: vi.fn(), + findAcceptingApplications: vi.fn(), + } as ISponsorshipPricingRepository; + mockSponsorshipRequestRepo = { + findPendingByEntity: mockFindPendingByEntity, + findByEntity: vi.fn(), + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findBySponsorId: vi.fn(), + findByStatus: vi.fn(), + findBySponsorIdAndStatus: vi.fn(), + hasPendingRequest: vi.fn(), + findPendingBySponsor: vi.fn(), + findApprovedByEntity: vi.fn(), + findRejectedByEntity: vi.fn(), + countPendingByEntity: vi.fn(), + create: vi.fn(), + exists: vi.fn(), + } as ISponsorshipRequestRepository; + mockSeasonSponsorshipRepo = { + findBySeasonId: mockFindBySeasonId, + findById: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + findByLeagueId: vi.fn(), + findBySponsorId: vi.fn(), + findBySeasonAndTier: vi.fn(), + create: vi.fn(), + exists: vi.fn(), + } as ISeasonSponsorshipRepository; mockLogger = { debug: vi.fn(), info: vi.fn(), @@ -33,7 +77,7 @@ describe('GetEntitySponsorshipPricingUseCase', () => { const dto = { entityType: 'season' as const, entityId: 'season1' }; - mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(null); + mockFindByEntity.mockResolvedValue(null); const result = await useCase.execute(dto); @@ -67,9 +111,9 @@ describe('GetEntitySponsorshipPricingUseCase', () => { }, }; - mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(pricing); - mockSponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([]); - mockSeasonSponsorshipRepo.findBySeasonId.mockResolvedValue([]); + mockFindByEntity.mockResolvedValue(pricing); + mockFindPendingByEntity.mockResolvedValue([]); + mockFindBySeasonId.mockResolvedValue([]); const result = await useCase.execute(dto); @@ -115,11 +159,12 @@ describe('GetEntitySponsorshipPricingUseCase', () => { const dto = { entityType: 'season' as const, entityId: 'season1' }; const error = new Error('Repository error'); - mockSponsorshipPricingRepo.findByEntity.mockRejectedValue(error); + mockFindByEntity.mockRejectedValue(error); const result = await useCase.execute(dto); expect(result.isErr()).toBe(true); - expect(result.unwrapErr().message).toBe('Repository error'); + expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR'); + expect(result.unwrapErr().details.message).toBe('Repository error'); }); }); \ 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 e2f70007c..e29212446 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts @@ -9,13 +9,13 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/IS import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { GetEntitySponsorshipPricingDTO } from '../dto/GetEntitySponsorshipPricingDTO'; import type { GetEntitySponsorshipPricingResultDTO } from '../dto/GetEntitySponsorshipPricingResultDTO'; export class GetEntitySponsorshipPricingUseCase - implements AsyncUseCase> + implements AsyncUseCase { constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, @@ -24,7 +24,7 @@ export class GetEntitySponsorshipPricingUseCase private readonly logger: Logger, ) {} - async execute(dto: GetEntitySponsorshipPricingDTO): Promise> { + async execute(dto: GetEntitySponsorshipPricingDTO): Promise>> { this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); try { const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); @@ -97,7 +97,7 @@ export class GetEntitySponsorshipPricingUseCase return Result.ok(result); } catch (error) { this.logger.error('Error executing GetEntitySponsorshipPricingUseCase', error instanceof Error ? error : new Error(String(error))); - return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); + return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } }); } } } \ 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 6005d64d7..16d2c7565 100644 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts @@ -2,29 +2,51 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { GetLeagueAdminPermissionsUseCase } from './GetLeagueAdminPermissionsUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams'; describe('GetLeagueAdminPermissionsUseCase', () => { - let mockLeagueRepo: { findById: Mock }; - let mockMembershipRepo: { getMembership: Mock }; + let mockLeagueRepo: ILeagueRepository; + let mockMembershipRepo: ILeagueMembershipRepository; + let mockFindById: Mock; + let mockGetMembership: Mock; beforeEach(() => { - mockLeagueRepo = { findById: vi.fn() }; - mockMembershipRepo = { getMembership: vi.fn() }; + mockFindById = vi.fn(); + mockGetMembership = vi.fn(); + mockLeagueRepo = { + findById: mockFindById, + findAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByOwnerId: vi.fn(), + searchByName: vi.fn(), + } as ILeagueRepository; + mockMembershipRepo = { + getMembership: mockGetMembership, + getMembershipsForDriver: vi.fn(), + saveMembership: vi.fn(), + removeMembership: vi.fn(), + getJoinRequests: vi.fn(), + saveJoinRequest: vi.fn(), + removeJoinRequest: vi.fn(), + countByLeagueId: vi.fn(), + getLeagueMembers: vi.fn(), + } as ILeagueMembershipRepository; }); const createUseCase = () => new GetLeagueAdminPermissionsUseCase( - mockLeagueRepo as unknown as ILeagueRepository, - mockMembershipRepo as unknown as ILeagueMembershipRepository, + mockLeagueRepo, + mockMembershipRepo, ); - const params: GetLeagueAdminPermissionsUseCaseParams = { + const params = { leagueId: 'league1', performerDriverId: 'driver1', }; it('should return no permissions when league not found', async () => { - mockLeagueRepo.findById.mockResolvedValue(null); + mockFindById.mockResolvedValue(null); const useCase = createUseCase(); const result = await useCase.execute(params); @@ -34,8 +56,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => { }); it('should return no permissions when membership not found', async () => { - mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); - mockMembershipRepo.getMembership.mockResolvedValue(null); + mockFindById.mockResolvedValue({ id: 'league1' }); + mockGetMembership.mockResolvedValue(null); const useCase = createUseCase(); const result = await useCase.execute(params); @@ -45,8 +67,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => { }); it('should return no permissions when membership not active', async () => { - mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); - mockMembershipRepo.getMembership.mockResolvedValue({ status: 'inactive', role: 'admin' }); + mockFindById.mockResolvedValue({ id: 'league1' }); + mockGetMembership.mockResolvedValue({ status: 'inactive', role: 'admin' }); const useCase = createUseCase(); const result = await useCase.execute(params); @@ -56,8 +78,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => { }); it('should return no permissions when role is member', async () => { - mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); - mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'member' }); + mockFindById.mockResolvedValue({ id: 'league1' }); + mockGetMembership.mockResolvedValue({ status: 'active', role: 'member' }); const useCase = createUseCase(); const result = await useCase.execute(params); @@ -67,8 +89,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => { }); it('should return permissions when role is admin', async () => { - mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); - mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'admin' }); + mockFindById.mockResolvedValue({ id: 'league1' }); + mockGetMembership.mockResolvedValue({ status: 'active', role: 'admin' }); const useCase = createUseCase(); const result = await useCase.execute(params); @@ -78,8 +100,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => { }); it('should return permissions when role is owner', async () => { - mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); - mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'owner' }); + mockFindById.mockResolvedValue({ id: 'league1' }); + mockGetMembership.mockResolvedValue({ status: 'active', role: 'owner' }); const useCase = createUseCase(); const result = await useCase.execute(params); diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts index ea1bc7206..9c8ee0dd3 100644 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts @@ -1,17 +1,16 @@ 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/result/Result'; -import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams'; +import { Result } from '@core/shared/application/Result'; import type { GetLeagueAdminPermissionsResultDTO } from '../dto/GetLeagueAdminPermissionsResultDTO'; -export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase> { +export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<{ leagueId: string; performerDriverId: string }, GetLeagueAdminPermissionsResultDTO, 'NO_ERROR'> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(params: GetLeagueAdminPermissionsUseCaseParams): Promise> { + 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 }); diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts deleted file mode 100644 index 3875d4c86..000000000 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface GetLeagueAdminPermissionsUseCaseParams { - leagueId: string; - performerDriverId: string; -} \ 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 6cc49cd89..4c373c6c5 100644 --- a/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts @@ -1,38 +1,47 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { GetLeagueAdminUseCase } from './GetLeagueAdminUseCase'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; describe('GetLeagueAdminUseCase', () => { - let mockLeagueRepo: { findById: Mock }; + let mockLeagueRepo: ILeagueRepository; + let mockFindById: Mock; beforeEach(() => { - mockLeagueRepo = { findById: vi.fn() }; + mockFindById = vi.fn(); + mockLeagueRepo = { + findById: mockFindById, + findAll: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + exists: vi.fn(), + findByOwnerId: vi.fn(), + searchByName: vi.fn(), + } as ILeagueRepository; }); const createUseCase = () => new GetLeagueAdminUseCase( - mockLeagueRepo as unknown as ILeagueRepository, + mockLeagueRepo, ); - const params: GetLeagueAdminUseCaseParams = { + const params = { leagueId: 'league1', }; it('should return error when league not found', async () => { - mockLeagueRepo.findById.mockResolvedValue(null); + mockFindById.mockResolvedValue(null); const useCase = createUseCase(); const result = await useCase.execute(params); expect(result.isErr()).toBe(true); - expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); - expect(result.unwrapErr().message).toBe('League not found'); + expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND'); + expect(result.unwrapErr().details.message).toBe('League not found'); }); it('should return league data when league found', async () => { const league = { id: 'league1', ownerId: 'owner1' }; - mockLeagueRepo.findById.mockResolvedValue(league); + mockFindById.mockResolvedValue(league); const useCase = createUseCase(); const result = await useCase.execute(params); diff --git a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts index 01d1babd1..ad9669f6f 100644 --- a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts @@ -1,19 +1,18 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { GetLeagueAdminResultDTO } from '../dto/GetLeagueAdminResultDTO'; -export class GetLeagueAdminUseCase implements AsyncUseCase> { +export class GetLeagueAdminUseCase implements AsyncUseCase<{ leagueId: string }, GetLeagueAdminResultDTO, 'LEAGUE_NOT_FOUND'> { constructor( private readonly leagueRepository: ILeagueRepository, ) {} - async execute(params: GetLeagueAdminUseCaseParams): Promise> { + async execute(params: { leagueId: string }): Promise>> { const league = await this.leagueRepository.findById(params.leagueId); if (!league) { - return Result.err(new RacingDomainValidationError('League not found')); + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } }); } const dto: GetLeagueAdminResultDTO = { diff --git a/core/racing/application/use-cases/GetLeagueAdminUseCaseParams.ts b/core/racing/application/use-cases/GetLeagueAdminUseCaseParams.ts deleted file mode 100644 index 2877f58b5..000000000 --- a/core/racing/application/use-cases/GetLeagueAdminUseCaseParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueAdminUseCaseParams { - leagueId: string; -} \ 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 595a8b834..d51f0c696 100644 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.test.ts @@ -4,8 +4,7 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { DriverRatingPort } from './DriverRatingPort'; -import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams'; +import type { DriverRatingPort } from '../ports/DriverRatingPort'; describe('GetLeagueDriverSeasonStatsUseCase', () => { let useCase: GetLeagueDriverSeasonStatsUseCase; @@ -42,7 +41,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { }); it('should return league driver season stats for given league id', async () => { - const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' }; + const params = { leagueId: 'league-1' }; const mockStandings = [ { driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }, @@ -82,7 +81,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => { }); it('should handle no penalties', async () => { - const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' }; + const params = { leagueId: 'league-1' }; const mockStandings = [{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }]; const mockRaces = [{ id: 'race-1' }]; diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts index 3d5538741..a51d4ba10 100644 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts @@ -4,16 +4,14 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { LeagueDriverSeasonStatsResultDTO } from '../presenters/ILeagueDriverSeasonStatsPresenter'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams'; -import type { DriverRatingPort } from './DriverRatingPort'; +import { Result } from '@core/shared/application/Result'; +import type { DriverRatingPort } from '../ports/DriverRatingPort'; /** * Use Case for retrieving league driver season statistics. * Orchestrates domain logic and returns the result. */ -export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase> { +export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsResultDTO, 'NO_ERROR'> { constructor( private readonly standingRepository: IStandingRepository, private readonly resultRepository: IResultRepository, @@ -22,7 +20,7 @@ export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase> { + async execute(params: { leagueId: string }): Promise> { const { leagueId } = params; // Get standings and races for the league diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts deleted file mode 100644 index c875c6fc0..000000000 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface GetLeagueDriverSeasonStatsUseCaseParams { - leagueId: string; -} \ 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 0bda1b56e..2f2a37a9b 100644 --- a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts @@ -144,7 +144,8 @@ describe('GetLeagueFullConfigUseCase', () => { expect(result.isErr()).toBe(true); const error = result.unwrapErr(); - expect(error.message).toBe('League with id league-1 not found'); + expect(error.code).toBe('LEAGUE_NOT_FOUND'); + expect(error.details.message).toBe('League with id league-1 not found'); }); it('should handle no active season', async () => { diff --git a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts index b8ec466e3..bc0c6cf05 100644 --- a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts @@ -8,14 +8,14 @@ import type { LeagueConfigFormViewModel, } from '../presenters/ILeagueFullConfigPresenter'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case for retrieving a league's full configuration. * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, Result> { +export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueConfigFormViewModel, 'LEAGUE_NOT_FOUND' | 'PRESENTATION_FAILED'> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, @@ -24,12 +24,12 @@ export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: stri private readonly presenter: ILeagueFullConfigPresenter, ) {} - async execute(params: { leagueId: string }): Promise> { + async execute(params: { leagueId: string }): Promise>> { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - return Result.err(new RacingDomainValidationError(`League with id ${leagueId} not found`)); + return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League with id ${leagueId} not found` } }); } const seasons = await this.seasonRepository.findByLeagueId(leagueId); @@ -58,7 +58,7 @@ export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: stri this.presenter.present(data); const viewModel = this.presenter.getViewModel(); if (!viewModel) { - return Result.err(new RacingDomainValidationError('Failed to present league config')); + return Result.err({ code: 'PRESENTATION_FAILED', details: { message: 'Failed to present league config' } }); } return Result.ok(viewModel); diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts index 785c56faa..e397b2892 100644 --- a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts @@ -1,25 +1,31 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; -import type { GetLeagueJoinRequestsUseCaseParams } from '../dto/GetLeagueJoinRequestsUseCaseParams'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { GetLeagueJoinRequestsResultDTO } from '../dto/GetLeagueJoinRequestsResultDTO'; -export class GetLeagueJoinRequestsUseCase implements AsyncUseCase> { +export interface GetLeagueJoinRequestsUseCaseParams { + leagueId: string; +} + +export class GetLeagueJoinRequestsUseCase implements AsyncUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise> { + 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.map(request => ({ - ...request, - driver: driverMap.get(request.driverId)!, - })); + const enrichedJoinRequests = joinRequests + .filter(request => driverMap.has(request.driverId)) + .map(request => ({ + ...request, + driver: driverMap.get(request.driverId)!, + })); return Result.ok({ joinRequests: enrichedJoinRequests, }); diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts index 8f7b6978e..0ef33de8f 100644 --- a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts @@ -1,20 +1,21 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { AsyncUseCase } from '@core/shared/application'; -import { Result } from '@core/shared/result/Result'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { GetLeagueMembershipsResultDTO } from '../dto/GetLeagueMembershipsResultDTO'; export interface GetLeagueMembershipsUseCaseParams { leagueId: string; } -export class GetLeagueMembershipsUseCase implements AsyncUseCase> { +export class GetLeagueMembershipsUseCase implements AsyncUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(params: GetLeagueMembershipsUseCaseParams): Promise> { + async execute(params: GetLeagueMembershipsUseCaseParams): Promise>> { const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const drivers: { id: string; name: string }[] = []; diff --git a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts new file mode 100644 index 000000000..c3873caef --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueOwnerSummaryUseCase } from './GetLeagueOwnerSummaryUseCase'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { Driver } from '../../domain/entities/Driver'; + +describe('GetLeagueOwnerSummaryUseCase', () => { + let useCase: GetLeagueOwnerSummaryUseCase; + let driverRepository: { + findById: Mock; + }; + + beforeEach(() => { + driverRepository = { + findById: vi.fn(), + }; + useCase = new GetLeagueOwnerSummaryUseCase( + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return owner summary when driver exists', async () => { + const ownerId = 'owner-1'; + const driver = Driver.create({ + id: ownerId, + iracingId: '123', + name: 'Owner Name', + country: 'US', + }); + + driverRepository.findById.mockResolvedValue(driver); + + const result = await useCase.execute({ ownerId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + summary: { + driver: { id: ownerId, name: 'Owner Name' }, + rating: 0, + rank: 0, + }, + }); + }); + + it('should return null summary when driver does not exist', async () => { + const ownerId = 'owner-1'; + + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ ownerId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + summary: null, + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts index c5df680e9..313226fd8 100644 --- a/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueOwnerSummaryUseCase.ts @@ -1,23 +1,19 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { IGetLeagueOwnerSummaryPresenter, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel } from '../presenters/IGetLeagueOwnerSummaryPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { GetLeagueOwnerSummaryResultDTO } from '../dto/GetLeagueOwnerSummaryResultDTO'; export interface GetLeagueOwnerSummaryUseCaseParams { ownerId: string; } -export interface GetLeagueOwnerSummaryResultDTO { - summary: { driver: { id: string; name: string }; rating: number; rank: number } | null; -} - -export class GetLeagueOwnerSummaryUseCase implements UseCase { +export class GetLeagueOwnerSummaryUseCase implements AsyncUseCase { constructor(private readonly driverRepository: IDriverRepository) {} - async execute(params: GetLeagueOwnerSummaryUseCaseParams, presenter: IGetLeagueOwnerSummaryPresenter): Promise { + async execute(params: GetLeagueOwnerSummaryUseCaseParams): Promise>> { const driver = await this.driverRepository.findById(params.ownerId); const summary = driver ? { driver: { id: driver.id, name: driver.name }, rating: 0, rank: 0 } : null; - const dto: GetLeagueOwnerSummaryResultDTO = { summary }; - presenter.reset(); - presenter.present(dto); + return Result.ok({ summary }); } } \ 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 new file mode 100644 index 000000000..0fe8e8b1c --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueProtestsUseCase.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueProtestsUseCase } from './GetLeagueProtestsUseCase'; +import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { Race } from '../../domain/entities/Race'; +import { Protest } from '../../domain/entities/Protest'; +import { Driver } from '../../domain/entities/Driver'; + +describe('GetLeagueProtestsUseCase', () => { + let useCase: GetLeagueProtestsUseCase; + let raceRepository: { + findByLeagueId: Mock; + }; + let protestRepository: { + findByRaceId: Mock; + }; + let driverRepository: { + findById: Mock; + }; + + beforeEach(() => { + raceRepository = { + findByLeagueId: vi.fn(), + }; + protestRepository = { + findByRaceId: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + useCase = new GetLeagueProtestsUseCase( + raceRepository as unknown as IRaceRepository, + protestRepository as unknown as IProtestRepository, + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return protests with races and drivers', async () => { + const leagueId = 'league-1'; + const race = Race.create({ + id: 'race-1', + leagueId, + scheduledAt: new Date(), + track: 'Track 1', + car: 'Car 1', + }); + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 1, description: 'Incident' }, + status: 'pending', + filedAt: 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', + name: 'Driver 2', + country: 'UK', + }); + + raceRepository.findByLeagueId.mockResolvedValue([race]); + 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); + return Promise.resolve(null); + }); + + const result = await useCase.execute({ leagueId }); + + 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' }, + ], + }); + }); + + it('should return empty when no races', async () => { + const leagueId = 'league-1'; + + raceRepository.findByLeagueId.mockResolvedValue([]); + protestRepository.findByRaceId.mockResolvedValue([]); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + protests: [], + races: [], + drivers: [], + }); + }); +}); \ 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 247a0d4e1..5a7e60f24 100644 --- a/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueProtestsUseCase.ts @@ -1,34 +1,30 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { IGetLeagueProtestsPresenter, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel } from '../presenters/IGetLeagueProtestsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { GetLeagueProtestsResultDTO, ProtestDTO } from '../dto/GetLeagueProtestsResultDTO'; export interface GetLeagueProtestsUseCaseParams { leagueId: string; } -export interface GetLeagueProtestsResultDTO { - protests: unknown[]; - races: unknown[]; - drivers: { id: string; name: string }[]; -} - -export class GetLeagueProtestsUseCase implements UseCase { +export class GetLeagueProtestsUseCase implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly protestRepository: IProtestRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(params: GetLeagueProtestsUseCaseParams, presenter: IGetLeagueProtestsPresenter): Promise { + async execute(params: GetLeagueProtestsUseCaseParams): Promise>> { const races = await this.raceRepository.findByLeagueId(params.leagueId); - const protests = []; + const protests: ProtestDTO[] = []; const raceMap = new Map(); const driverIds = new Set(); for (const race of races) { - raceMap.set(race.id, { id: race.id, name: race.name, date: race.scheduledAt.toISOString() }); + raceMap.set(race.id, { id: race.id, name: race.track, date: race.scheduledAt.toISOString() }); const raceProtests = await this.protestRepository.findByRaceId(race.id); for (const protest of raceProtests) { protests.push({ @@ -45,14 +41,17 @@ export class GetLeagueProtestsUseCase implements UseCase [d.id, { id: d.id, name: d.name }])); - const dto: GetLeagueProtestsResultDTO = { + const drivers: { id: string; name: string }[] = []; + for (const driverId of driverIds) { + const driver = await this.driverRepository.findById(driverId); + if (driver) { + drivers.push({ id: driver.id, name: driver.name }); + } + } + return Result.ok({ protests, races: Array.from(raceMap.values()), - drivers: Array.from(driverMap.values()), - }; - presenter.reset(); - presenter.present(dto); + drivers, + }); } } \ 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 new file mode 100644 index 000000000..63ce998a3 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueScheduleUseCase.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueScheduleUseCase } from './GetLeagueScheduleUseCase'; +import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import { Race } from '../../domain/entities/Race'; + +describe('GetLeagueScheduleUseCase', () => { + let useCase: GetLeagueScheduleUseCase; + let raceRepository: { + findByLeagueId: Mock; + }; + + beforeEach(() => { + raceRepository = { + findByLeagueId: vi.fn(), + }; + useCase = new GetLeagueScheduleUseCase( + raceRepository as unknown as IRaceRepository, + ); + }); + + it('should return league schedule', async () => { + const leagueId = 'league-1'; + const race = Race.create({ + id: 'race-1', + leagueId, + scheduledAt: new Date('2023-01-01T10:00:00Z'), + track: 'Track 1', + car: 'Car 1', + }); + + raceRepository.findByLeagueId.mockResolvedValue([race]); + + const result = await useCase.execute({ leagueId }); + + 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'), + }, + ], + }); + }); + + it('should return empty schedule when no races', async () => { + const leagueId = 'league-1'; + + raceRepository.findByLeagueId.mockResolvedValue([]); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + races: [], + }); + }); +}); \ 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 1f072021e..9181bef67 100644 --- a/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueScheduleUseCase.ts @@ -1,32 +1,24 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { IGetLeagueSchedulePresenter, GetLeagueScheduleResultDTO, GetLeagueScheduleViewModel } from '../presenters/IGetLeagueSchedulePresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { GetLeagueScheduleResultDTO } from '../dto/GetLeagueScheduleResultDTO'; export interface GetLeagueScheduleUseCaseParams { leagueId: string; } -export interface GetLeagueScheduleResultDTO { - races: Array<{ - id: string; - name: string; - scheduledAt: Date; - }>; -} - -export class GetLeagueScheduleUseCase implements UseCase { +export class GetLeagueScheduleUseCase implements AsyncUseCase { constructor(private readonly raceRepository: IRaceRepository) {} - async execute(params: GetLeagueScheduleUseCaseParams, presenter: IGetLeagueSchedulePresenter): Promise { + async execute(params: GetLeagueScheduleUseCaseParams): Promise>> { const races = await this.raceRepository.findByLeagueId(params.leagueId); - const dto: GetLeagueScheduleResultDTO = { + return Result.ok({ races: races.map(race => ({ id: race.id, name: `${race.track} - ${race.car}`, scheduledAt: race.scheduledAt, })), - }; - presenter.reset(); - presenter.present(dto); + }); } } \ 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 new file mode 100644 index 000000000..f986249cd --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueScoringConfigUseCase } 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 { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; + +describe('GetLeagueScoringConfigUseCase', () => { + let useCase: GetLeagueScoringConfigUseCase; + let leagueRepository: { findById: Mock }; + let seasonRepository: { findByLeagueId: Mock }; + let leagueScoringConfigRepository: { findBySeasonId: Mock }; + let gameRepository: { findById: Mock }; + let presetProvider: { getPresetById: Mock; listPresets: Mock; createScoringConfigFromPreset: Mock }; + + beforeEach(() => { + leagueRepository = { findById: vi.fn() }; + seasonRepository = { findByLeagueId: vi.fn() }; + leagueScoringConfigRepository = { findBySeasonId: vi.fn() }; + gameRepository = { findById: vi.fn() }; + presetProvider = { getPresetById: vi.fn(), listPresets: vi.fn(), createScoringConfigFromPreset: vi.fn() }; + useCase = new GetLeagueScoringConfigUseCase( + leagueRepository as unknown as ILeagueRepository, + seasonRepository as unknown as ISeasonRepository, + leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, + gameRepository as unknown as IGameRepository, + presetProvider as LeagueScoringPresetProvider, + ); + }); + + it('should return scoring config for active season', async () => { + const leagueId = 'league-1'; + const league = { id: 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' }; + const preset = { id: 'preset-1', name: 'Preset 1' }; + + leagueRepository.findById.mockResolvedValue(league); + seasonRepository.findByLeagueId.mockResolvedValue([season]); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig); + gameRepository.findById.mockResolvedValue(game); + presetProvider.getPresetById.mockReturnValue(preset); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + leagueId, + seasonId: 'season-1', + gameId: 'game-1', + gameName: 'Game 1', + scoringPresetId: 'preset-1', + preset, + championships: [], + }); + }); + + it('should return scoring config for first season if no active', async () => { + const leagueId = 'league-1'; + const league = { id: leagueId }; + const season = { id: 'season-1', status: 'inactive', gameId: 'game-1' }; + const scoringConfig = { scoringPresetId: undefined, championships: [] }; + const game = { id: 'game-1', name: 'Game 1' }; + + leagueRepository.findById.mockResolvedValue(league); + seasonRepository.findByLeagueId.mockResolvedValue([season]); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig); + gameRepository.findById.mockResolvedValue(game); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + leagueId, + seasonId: 'season-1', + gameId: 'game-1', + gameName: 'Game 1', + championships: [], + }); + }); + + it('should return error if league not found', async () => { + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toEqual({ code: 'LEAGUE_NOT_FOUND' }); + }); + + it('should return error if no seasons', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + seasonRepository.findByLeagueId.mockResolvedValue([]); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toEqual({ code: 'NO_SEASONS' }); + }); + + it('should return error if no seasons (null)', async () => { + leagueRepository.findById.mockResolvedValue({ id: 'league-1' }); + seasonRepository.findByLeagueId.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toEqual({ code: 'NO_SEASONS' }); + }); + + 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' }]); + 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' }); + }); + + 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: [] }); + 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' }); + }); +}); \ 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 2b4d48c1d..e05792b0a 100644 --- a/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueScoringConfigUseCase.ts @@ -3,19 +3,23 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository'; import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; -import type { - ILeagueScoringConfigPresenter, - LeagueScoringConfigData, - LeagueScoringConfigViewModel, -} from '../presenters/ILeagueScoringConfigPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +type GetLeagueScoringConfigErrorCode = + | 'LEAGUE_NOT_FOUND' + | 'NO_SEASONS' + | 'NO_ACTIVE_SEASON' + | 'NO_SCORING_CONFIG' + | 'GAME_NOT_FOUND'; /** * Use Case for retrieving a league's scoring configuration for its active season. - * Orchestrates domain logic and delegates presentation to the presenter. */ export class GetLeagueScoringConfigUseCase - implements UseCase<{ leagueId: string }, LeagueScoringConfigData, LeagueScoringConfigViewModel, ILeagueScoringConfigPresenter> + implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigData, GetLeagueScoringConfigErrorCode> { constructor( private readonly leagueRepository: ILeagueRepository, @@ -25,40 +29,40 @@ export class GetLeagueScoringConfigUseCase private readonly presetProvider: LeagueScoringPresetProvider, ) {} - async execute(params: { leagueId: string }, presenter: ILeagueScoringConfigPresenter): Promise { + async execute(params: { leagueId: string }): Promise>> { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - throw new Error(`League ${leagueId} not found`); + return Result.err({ code: 'LEAGUE_NOT_FOUND' }); } const seasons = await this.seasonRepository.findByLeagueId(leagueId); if (!seasons || seasons.length === 0) { - throw new Error(`No seasons found for league ${leagueId}`); + return Result.err({ code: 'NO_SEASONS' }); } - + const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0]; - + if (!activeSeason) { - throw new Error(`No active season could be determined for league ${leagueId}`); + return Result.err({ code: 'NO_ACTIVE_SEASON' }); } - + const scoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id); if (!scoringConfig) { - throw new Error(`No scoring config found for season ${activeSeason.id}`); + return Result.err({ code: 'NO_SCORING_CONFIG' }); } const game = await this.gameRepository.findById(activeSeason.gameId); if (!game) { - throw new Error(`Game ${activeSeason.gameId} not found`); + return Result.err({ code: 'GAME_NOT_FOUND' }); } const presetId = scoringConfig.scoringPresetId; const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined; - + const data: LeagueScoringConfigData = { leagueId: league.id, seasonId: activeSeason.id, @@ -69,7 +73,6 @@ export class GetLeagueScoringConfigUseCase championships: scoringConfig.championships, }; - presenter.reset(); - presenter.present(data); + return Result.ok(data); } } \ 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 new file mode 100644 index 000000000..cf50a8f03 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueSeasonsUseCase } from './GetLeagueSeasonsUseCase'; +import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import { Season } from '../../domain/entities/Season'; + +describe('GetLeagueSeasonsUseCase', () => { + let useCase: GetLeagueSeasonsUseCase; + let seasonRepository: { + findByLeagueId: Mock; + }; + + beforeEach(() => { + seasonRepository = { + findByLeagueId: vi.fn(), + }; + useCase = new GetLeagueSeasonsUseCase( + seasonRepository as unknown as ISeasonRepository, + ); + }); + + it('should return seasons mapped to view model', async () => { + const leagueId = 'league-1'; + const seasons = [ + Season.create({ + id: 'season-1', + leagueId, + gameId: 'game-1', + name: 'Season 1', + status: 'active', + startDate: new Date('2023-01-01'), + endDate: new Date('2023-12-31'), + }), + Season.create({ + id: 'season-2', + leagueId, + gameId: 'game-1', + name: 'Season 2', + status: 'planned', + }), + ]; + + seasonRepository.findByLeagueId.mockResolvedValue(seasons); + + const result = await useCase.execute({ leagueId }); + + 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, + }, + ], + }); + }); + + it('should set isParallelActive true for active seasons when multiple active', async () => { + const leagueId = 'league-1'; + const seasons = [ + Season.create({ + id: 'season-1', + leagueId, + gameId: 'game-1', + name: 'Season 1', + status: 'active', + }), + Season.create({ + id: 'season-2', + leagueId, + gameId: 'game-1', + name: 'Season 2', + status: 'active', + }), + ]; + + seasonRepository.findByLeagueId.mockResolvedValue(seasons); + + const result = await useCase.execute({ leagueId }); + + 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); + }); + + it('should return error when repository fails', async () => { + const leagueId = 'league-1'; + seasonRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute({ leagueId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch seasons', + }); + }); +}); \ 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 ae74f8eca..b449fad61 100644 --- a/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueSeasonsUseCase.ts @@ -1,22 +1,33 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; -import type { IGetLeagueSeasonsPresenter, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetLeagueSeasonsUseCaseParams { leagueId: string; } -export interface GetLeagueSeasonsResultDTO { - seasons: unknown[]; -} - -export class GetLeagueSeasonsUseCase implements UseCase { +export class GetLeagueSeasonsUseCase { constructor(private readonly seasonRepository: ISeasonRepository) {} - async execute(params: GetLeagueSeasonsUseCaseParams, presenter: IGetLeagueSeasonsPresenter): Promise { - const seasons = await this.seasonRepository.findByLeagueId(params.leagueId); - const dto: GetLeagueSeasonsResultDTO = { seasons }; - presenter.reset(); - presenter.present(dto); + async execute(params: GetLeagueSeasonsUseCaseParams): Promise>> { + try { + const seasons = await this.seasonRepository.findByLeagueId(params.leagueId); + const activeCount = seasons.filter(s => s.status === 'active').length; + const viewModel: GetLeagueSeasonsViewModel = { + seasons: seasons.map(s => ({ + seasonId: s.id, + name: s.name, + status: s.status, + startDate: s.startDate, + endDate: s.endDate, + isPrimary: false, + isParallelActive: s.status === 'active' && activeCount > 1 + })) + }; + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch 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 new file mode 100644 index 000000000..be75e272b --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueStandingsUseCase.test.ts @@ -0,0 +1,101 @@ +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 { Standing } from '../../domain/entities/Standing'; +import { Driver } from '../../domain/entities/Driver'; + +describe('GetLeagueStandingsUseCase', () => { + let useCase: GetLeagueStandingsUseCase; + let standingRepository: { + findByLeagueId: Mock; + }; + let driverRepository: { + findById: Mock; + }; + + beforeEach(() => { + standingRepository = { + findByLeagueId: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + useCase = new GetLeagueStandingsUseCase( + standingRepository as unknown as IStandingRepository, + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return standings with drivers mapped', async () => { + const leagueId = 'league-1'; + const standings = [ + Standing.create({ + id: 'standing-1', + leagueId, + driverId: 'driver-1', + points: 100, + position: 1, + }), + Standing.create({ + id: 'standing-2', + leagueId, + driverId: 'driver-2', + points: 80, + position: 2, + }), + ]; + const driver1 = Driver.create({ + id: 'driver-1', + iracingId: '123', + name: 'Driver One', + country: 'US', + }); + const driver2 = Driver.create({ + id: 'driver-2', + iracingId: '456', + name: 'Driver Two', + country: 'US', + }); + + standingRepository.findByLeagueId.mockResolvedValue(standings); + 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); + }); + + const result = await useCase.execute({ leagueId }); + + 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, + }, + ], + }); + }); + + it('should return error when repository fails', async () => { + const leagueId = 'league-1'; + standingRepository.findByLeagueId.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute({ leagueId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch league standings', + }); + }); +}); \ 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 69326d2c2..0c4ff7234 100644 --- a/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStandingsUseCase.ts @@ -1,11 +1,8 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { - ILeagueStandingsPresenter, - LeagueStandingsResultDTO, - LeagueStandingsViewModel, -} from '../presenters/ILeagueStandingsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { LeagueStandingsViewModel } from '../presenters/ILeagueStandingsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetLeagueStandingsUseCaseParams { leagueId: string; @@ -13,12 +10,8 @@ export interface GetLeagueStandingsUseCaseParams { /** * Use Case for retrieving league standings. - * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetLeagueStandingsUseCase - implements - UseCase -{ +export class GetLeagueStandingsUseCase { constructor( private readonly standingRepository: IStandingRepository, private readonly driverRepository: IDriverRepository, @@ -26,17 +19,25 @@ export class GetLeagueStandingsUseCase async execute( params: GetLeagueStandingsUseCaseParams, - presenter: ILeagueStandingsPresenter, - ): Promise { - const standings = await this.standingRepository.findByLeagueId(params.leagueId); - const driverIds = [...new Set(standings.map(s => s.driverId))]; - const drivers = await this.driverRepository.findByIds(driverIds); - const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); - const dto: LeagueStandingsResultDTO = { - standings, - drivers: Array.from(driverMap.values()), - }; - presenter.reset(); - presenter.present(dto); + ): Promise>> { + try { + const standings = await this.standingRepository.findByLeagueId(params.leagueId); + const driverIds = [...new Set(standings.map(s => s.driverId))]; + 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: LeagueStandingsViewModel = { + standings: standings.map(s => ({ + driverId: s.driverId, + driver: driverMap.get(s.driverId)!, + points: s.points, + rank: s.position, + })), + }; + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_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 new file mode 100644 index 000000000..5c9438096 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueStatsUseCase.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase'; +import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import { DriverRatingProvider } from '../ports/DriverRatingProvider'; + +describe('GetLeagueStatsUseCase', () => { + let useCase: GetLeagueStatsUseCase; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let raceRepository: { + findByLeagueId: Mock; + }; + let driverRatingProvider: { + getRatings: Mock; + }; + + beforeEach(() => { + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + raceRepository = { + findByLeagueId: vi.fn(), + }; + driverRatingProvider = { + getRatings: vi.fn(), + }; + useCase = new GetLeagueStatsUseCase( + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + raceRepository as unknown as IRaceRepository, + driverRatingProvider as unknown as DriverRatingProvider, + ); + }); + + it('should return league stats with average rating', async () => { + const leagueId = 'league-1'; + const memberships = [ + { driverId: 'driver-1' }, + { driverId: 'driver-2' }, + { driverId: 'driver-3' }, + ]; + const races = [{ id: 'race-1' }, { id: 'race-2' }]; + const ratings = new Map([ + ['driver-1', 1500], + ['driver-2', 1600], + ['driver-3', null], + ]); + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + raceRepository.findByLeagueId.mockResolvedValue(races); + driverRatingProvider.getRatings.mockReturnValue(ratings); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + totalMembers: 3, + totalRaces: 2, + averageRating: 1550, // (1500 + 1600) / 2 + }); + }); + + it('should return 0 average rating when no valid ratings', async () => { + const leagueId = 'league-1'; + const memberships = [{ driverId: 'driver-1' }]; + const races = [{ id: 'race-1' }]; + const ratings = new Map([['driver-1', null]]); + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + raceRepository.findByLeagueId.mockResolvedValue(races); + driverRatingProvider.getRatings.mockReturnValue(ratings); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + totalMembers: 1, + totalRaces: 1, + averageRating: 0, + }); + }); + + it('should return error when repository fails', async () => { + const leagueId = 'league-1'; + leagueMembershipRepository.getLeagueMembers.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute({ leagueId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch league stats', + }); + }); +}); \ 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 c400e758d..9f7adeec9 100644 --- a/core/racing/application/use-cases/GetLeagueStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueStatsUseCase.ts @@ -1,28 +1,37 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { ILeagueStatsPresenter, LeagueStatsResultDTO, LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter'; +import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetLeagueStatsUseCaseParams { leagueId: string; } -export class GetLeagueStatsUseCase implements UseCase { +export class GetLeagueStatsUseCase { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly raceRepository: IRaceRepository, + private readonly driverRatingProvider: DriverRatingProvider, ) {} - async execute(params: GetLeagueStatsUseCaseParams, presenter: ILeagueStatsPresenter): Promise { - const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); - const races = await this.raceRepository.findByLeagueId(params.leagueId); - // TODO: Implement average rating calculation from driver ratings - const dto: LeagueStatsResultDTO = { - totalMembers: memberships.length, - totalRaces: races.length, - averageRating: 0, - }; - presenter.reset(); - presenter.present(dto); + async execute(params: GetLeagueStatsUseCaseParams): 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); + const ratings = this.driverRatingProvider.getRatings(driverIds); + const validRatings = Array.from(ratings.values()).filter(r => r !== null) as number[]; + const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0; + const viewModel: LeagueStatsViewModel = { + totalMembers: memberships.length, + totalRaces: races.length, + averageRating, + }; + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league stats' }); + } } } \ 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 new file mode 100644 index 000000000..1a8d896d0 --- /dev/null +++ b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetPendingSponsorshipRequestsUseCase } 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'; + +describe('GetPendingSponsorshipRequestsUseCase', () => { + let useCase: GetPendingSponsorshipRequestsUseCase; + let sponsorshipRequestRepo: { + findPendingByEntity: Mock; + }; + let sponsorRepo: { + findById: Mock; + }; + + beforeEach(() => { + sponsorshipRequestRepo = { + findPendingByEntity: vi.fn(), + }; + sponsorRepo = { + findById: vi.fn(), + }; + useCase = new GetPendingSponsorshipRequestsUseCase( + sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + sponsorRepo as unknown as ISponsorRepository, + ); + }); + + it('should return pending sponsorship requests', async () => { + const dto = { entityType: 'season' as const, entityId: 'entity-1' }; + const request = SponsorshipRequest.create({ + id: 'req-1', + sponsorId: 'sponsor-1', + entityType: 'season', + entityId: 'entity-1', + tier: 'main', + offeredAmount: Money.create(10000, 'USD'), + message: 'Test message', + }); + const sponsor = Sponsor.create({ + id: 'sponsor-1', + name: 'Test Sponsor', + contactEmail: 'test@example.com', + logoUrl: 'logo.png', + }); + + sponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([request]); + sponsorRepo.findById.mockResolvedValue(sponsor); + + const result = await useCase.execute(dto); + + 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, + }); + }); + + 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 result = await useCase.execute(dto); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch pending sponsorship requests', + }); + }); +}); \ 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 a13ee3010..d6a9e19fb 100644 --- a/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase.ts @@ -8,11 +8,9 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; -import type { UseCase } from '@core/shared/application/UseCase'; -import type { - IPendingSponsorshipRequestsPresenter, - PendingSponsorshipRequestsViewModel, -} from '../presenters/IPendingSponsorshipRequestsPresenter'; +import type { PendingSponsorshipRequestsViewModel } from '../presenters/IPendingSponsorshipRequestsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetPendingSponsorshipRequestsDTO { entityType: SponsorableEntityType; @@ -41,13 +39,7 @@ export interface GetPendingSponsorshipRequestsResultDTO { totalCount: number; } -export class GetPendingSponsorshipRequestsUseCase - implements UseCase< - GetPendingSponsorshipRequestsDTO, - GetPendingSponsorshipRequestsResultDTO, - PendingSponsorshipRequestsViewModel, - IPendingSponsorshipRequestsPresenter - > { +export class GetPendingSponsorshipRequestsUseCase { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorRepo: ISponsorRepository, @@ -55,43 +47,46 @@ export class GetPendingSponsorshipRequestsUseCase async execute( dto: GetPendingSponsorshipRequestsDTO, - presenter: IPendingSponsorshipRequestsPresenter, - ): Promise { - presenter.reset(); - const requests = await this.sponsorshipRequestRepo.findPendingByEntity( - dto.entityType, - dto.entityId - ); + ): Promise>> { + try { + const requests = await this.sponsorshipRequestRepo.findPendingByEntity( + dto.entityType, + dto.entityId + ); - const requestDTOs: PendingSponsorshipRequestDTO[] = []; + const requestDTOs: PendingSponsorshipRequestDTO[] = []; - 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, - }); + 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, + }); + } + + // Sort by creation date (newest first) + requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const viewModel: PendingSponsorshipRequestsViewModel = { + entityType: dto.entityType, + entityId: dto.entityId, + requests: requestDTOs, + totalCount: requestDTOs.length, + }; + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch pending sponsorship requests' }); } - - // Sort by creation date (newest first) - requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - - presenter.present({ - entityType: dto.entityType, - entityId: dto.entityId, - requests: requestDTOs, - totalCount: requestDTOs.length, - }); } } \ 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 new file mode 100644 index 000000000..8e271996b --- /dev/null +++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetProfileOverviewUseCase } 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 { Driver } from '../../domain/entities/Driver'; +import { Team } from '../../domain/entities/Team'; + +describe('GetProfileOverviewUseCase', () => { + let useCase: GetProfileOverviewUseCase; + let driverRepository: { + findById: Mock; + }; + let teamRepository: { + findAll: Mock; + }; + let teamMembershipRepository: { + getMembership: Mock; + }; + let socialRepository: { + getFriends: Mock; + }; + let imageService: { + getDriverAvatar: Mock; + }; + let getDriverStats: Mock; + let getAllDriverRankings: Mock; + + beforeEach(() => { + driverRepository = { + findById: vi.fn(), + }; + teamRepository = { + findAll: vi.fn(), + }; + teamMembershipRepository = { + getMembership: vi.fn(), + }; + socialRepository = { + getFriends: vi.fn(), + }; + imageService = { + getDriverAvatar: vi.fn(), + }; + getDriverStats = vi.fn(); + getAllDriverRankings = vi.fn(); + 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, + getDriverStats, + getAllDriverRankings, + ); + }); + + it('should return profile overview for existing driver', async () => { + const driverId = 'driver-1'; + const driver = Driver.create({ + id: driverId, + iracingId: '123', + 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 statsAdapter = { + rating: 1500, + wins: 5, + totalRaces: 10, + avgFinish: 3.5, + }; + const rankings = [{ driverId, rating: 1500, overallRank: 1 }]; + + driverRepository.findById.mockResolvedValue(driver); + teamRepository.findAll.mockResolvedValue(teams); + teamMembershipRepository.getMembership.mockResolvedValue(null); + socialRepository.getFriends.mockResolvedValue(friends); + imageService.getDriverAvatar.mockReturnValue('avatar-url'); + getDriverStats.mockReturnValue(statsAdapter); + getAllDriverRankings.mockReturnValue(rankings); + + const result = await useCase.execute({ driverId }); + + expect(result.isOk()).toBe(true); + const viewModel = result.unwrap(); + expect(viewModel.currentDriver?.id).toBe(driverId); + expect(viewModel.extendedProfile).toBe(null); + }); + + it('should return error for non-existing driver', async () => { + const driverId = 'driver-1'; + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'DRIVER_NOT_FOUND', + message: 'Driver not found', + }); + }); + + 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 }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch profile overview', + }); + }); +}); \ 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 0395c5dcd..4eb7c281f 100644 --- a/core/racing/application/use-cases/GetProfileOverviewUseCase.ts +++ b/core/racing/application/use-cases/GetProfileOverviewUseCase.ts @@ -3,16 +3,18 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository' import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { IImageServicePort } from '../ports/IImageServicePort'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; +import type { Driver } from '../../domain/entities/Driver'; +import type { Team } from '../../domain/entities/Team'; import type { - IProfileOverviewPresenter, ProfileOverviewViewModel, ProfileOverviewDriverSummaryViewModel, ProfileOverviewStatsViewModel, ProfileOverviewFinishDistributionViewModel, ProfileOverviewTeamMembershipViewModel, ProfileOverviewSocialSummaryViewModel, - ProfileOverviewExtendedProfileViewModel, } from '../presenters/IProfileOverviewPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; interface ProfileDriverStatsAdapter { rating: number | null; @@ -47,59 +49,47 @@ export class GetProfileOverviewUseCase { private readonly imageService: IImageServicePort, private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null, private readonly getAllDriverRankings: () => DriverRankingEntry[], - public readonly presenter: IProfileOverviewPresenter, ) {} - async execute(params: GetProfileOverviewParams): Promise { - const { driverId } = params; + async execute(params: GetProfileOverviewParams): Promise>> { + try { + const { driverId } = params; - const driver = await this.driverRepository.findById(driverId); + const driver = await this.driverRepository.findById(driverId); - if (!driver) { - const emptyViewModel: ProfileOverviewViewModel = { - currentDriver: null, - stats: null, - finishDistribution: null, - teamMemberships: [], - socialSummary: { - friendsCount: 0, - friends: [], - }, + if (!driver) { + return Result.err({ code: 'DRIVER_NOT_FOUND', message: 'Driver not found' }); + } + + const [statsAdapter, teams, friends] = await Promise.all([ + Promise.resolve(this.getDriverStats(driverId)), + this.teamRepository.findAll(), + this.socialRepository.getFriends(driverId), + ]); + + const driverSummary = this.buildDriverSummary(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 viewModel: ProfileOverviewViewModel = { + currentDriver: driverSummary, + stats, + finishDistribution, + teamMemberships, + socialSummary, extendedProfile: null, }; - this.presenter.present(emptyViewModel); - return emptyViewModel; + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch profile overview' }); } - - const [statsAdapter, teams, friends] = await Promise.all([ - Promise.resolve(this.getDriverStats(driverId)), - this.teamRepository.findAll(), - this.socialRepository.getFriends(driverId), - ]); - - const driverSummary = this.buildDriverSummary(driver, statsAdapter); - const stats = this.buildStats(statsAdapter); - const finishDistribution = this.buildFinishDistribution(statsAdapter); - const teamMemberships = await this.buildTeamMemberships(driver.id, teams); - const socialSummary = this.buildSocialSummary(friends); - const extendedProfile = this.buildExtendedProfile(driver.id); - - const viewModel: ProfileOverviewViewModel = { - currentDriver: driverSummary, - stats, - finishDistribution, - teamMemberships, - socialSummary, - extendedProfile, - }; - - this.presenter.present(viewModel); - return viewModel; } private buildDriverSummary( - driver: any, + driver: Driver, stats: ProfileDriverStatsAdapter | null, ): ProfileOverviewDriverSummaryViewModel { const rankings = this.getAllDriverRankings(); @@ -202,7 +192,7 @@ export class GetProfileOverviewUseCase { private async buildTeamMemberships( driverId: string, - teams: unknown[], + teams: Team[], ): Promise { const memberships: ProfileOverviewTeamMembershipViewModel[] = []; @@ -231,7 +221,7 @@ export class GetProfileOverviewUseCase { return memberships; } - private buildSocialSummary(friends: unknown[]): ProfileOverviewSocialSummaryViewModel { + private buildSocialSummary(friends: Driver[]): ProfileOverviewSocialSummaryViewModel { return { friendsCount: friends.length, friends: friends.map(friend => ({ @@ -243,209 +233,4 @@ export class GetProfileOverviewUseCase { }; } - private buildExtendedProfile(driverId: string): ProfileOverviewExtendedProfileViewModel { - const hash = driverId - .split('') - .reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0); - - const socialOptions: Array< - Array<{ - platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; - handle: string; - url: string; - }> - > = [ - [ - { - platform: 'twitter', - handle: '@speedracer', - url: 'https://twitter.com/speedracer', - }, - { - platform: 'youtube', - handle: 'SpeedRacer Racing', - url: 'https://youtube.com/@speedracer', - }, - { - platform: 'twitch', - handle: 'speedracer_live', - url: 'https://twitch.tv/speedracer_live', - }, - ], - [ - { - platform: 'twitter', - handle: '@racingpro', - url: 'https://twitter.com/racingpro', - }, - { - platform: 'discord', - handle: 'RacingPro#1234', - url: '#', - }, - ], - [ - { - platform: 'twitch', - handle: 'simracer_elite', - url: 'https://twitch.tv/simracer_elite', - }, - { - platform: 'youtube', - handle: 'SimRacer Elite', - url: 'https://youtube.com/@simracerelite', - }, - ], - ]; - - const achievementSets: Array< - Array<{ - id: string; - title: string; - description: string; - icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; - rarity: 'common' | 'rare' | 'epic' | 'legendary'; - earnedAt: Date; - }> - > = [ - [ - { - id: '1', - title: 'First Victory', - description: 'Win your first race', - icon: 'trophy', - rarity: 'common', - earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), - }, - { - id: '2', - title: 'Clean Racer', - description: '10 races without incidents', - icon: 'star', - rarity: 'rare', - earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000), - }, - { - id: '3', - title: 'Podium Streak', - description: '5 consecutive podium finishes', - icon: 'medal', - rarity: 'epic', - earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), - }, - { - id: '4', - title: 'Championship Glory', - description: 'Win a league championship', - icon: 'crown', - rarity: 'legendary', - earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), - }, - ], - [ - { - id: '1', - title: 'Rookie No More', - description: 'Complete 25 races', - icon: 'target', - rarity: 'common', - earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), - }, - { - id: '2', - title: 'Consistent Performer', - description: 'Maintain 80%+ consistency rating', - icon: 'zap', - rarity: 'rare', - earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000), - }, - { - id: '3', - title: 'Endurance Master', - description: 'Complete a 24-hour race', - icon: 'star', - rarity: 'epic', - earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000), - }, - ], - [ - { - id: '1', - title: 'Welcome Racer', - description: 'Join GridPilot', - icon: 'star', - rarity: 'common', - earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000), - }, - { - id: '2', - title: 'Team Player', - description: 'Join a racing team', - icon: 'medal', - rarity: 'rare', - earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000), - }, - ], - ]; - - const tracks = [ - 'Spa-Francorchamps', - 'Nürburgring Nordschleife', - 'Suzuka', - 'Monza', - 'Interlagos', - 'Silverstone', - ]; - const cars = [ - 'Porsche 911 GT3 R', - 'Ferrari 488 GT3', - 'Mercedes-AMG GT3', - 'BMW M4 GT3', - 'Audi R8 LMS', - ]; - const styles = [ - 'Aggressive Overtaker', - 'Consistent Pacer', - 'Strategic Calculator', - 'Late Braker', - 'Smooth Operator', - ]; - const timezones = [ - 'EST (UTC-5)', - 'CET (UTC+1)', - 'PST (UTC-8)', - 'GMT (UTC+0)', - 'JST (UTC+9)', - ]; - const hours = [ - 'Evenings (18:00-23:00)', - 'Weekends only', - 'Late nights (22:00-02:00)', - 'Flexible schedule', - ]; - - const socialHandles = - socialOptions[hash % socialOptions.length] ?? []; - const achievementsSource = - achievementSets[hash % achievementSets.length] ?? []; - - return { - socialHandles, - achievements: achievementsSource.map(achievement => ({ - id: achievement.id, - title: achievement.title, - description: achievement.description, - icon: achievement.icon, - rarity: achievement.rarity, - earnedAt: achievement.earnedAt.toISOString(), - })), - racingStyle: styles[hash % styles.length] ?? 'Consistent Pacer', - favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track', - favoriteCar: cars[hash % cars.length] ?? 'Unknown Car', - timezone: timezones[hash % timezones.length] ?? 'UTC', - availableHours: hours[hash % hours.length] ?? 'Flexible schedule', - lookingForTeam: hash % 3 === 0, - openToRequests: hash % 2 === 0, - }; - } } \ 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 new file mode 100644 index 000000000..8f7047978 --- /dev/null +++ b/core/racing/application/use-cases/GetRaceDetailUseCase.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRaceDetailUseCase } 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'; + +describe('GetRaceDetailUseCase', () => { + let useCase: GetRaceDetailUseCase; + let raceRepository: { findById: Mock }; + let leagueRepository: { findById: Mock }; + let driverRepository: { findById: Mock }; + let raceRegistrationRepository: { getRegisteredDrivers: 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 }; + + beforeEach(() => { + raceRepository = { findById: vi.fn() }; + leagueRepository = { findById: vi.fn() }; + driverRepository = { findById: vi.fn() }; + raceRegistrationRepository = { getRegisteredDrivers: 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() }; + useCase = new GetRaceDetailUseCase( + raceRepository as unknown as IRaceRepository, + leagueRepository as unknown as ILeagueRepository, + driverRepository as unknown as IDriverRepository, + raceRegistrationRepository as unknown as IRaceRegistrationRepository, + resultRepository as unknown as IResultRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + driverRatingProvider as DriverRatingProvider, + imageService as IImageServicePort, + ); + }); + + it('should return race detail when race exists', async () => { + const raceId = 'race-1'; + const driverId = 'driver-1'; + const race = { + id: raceId, + leagueId: 'league-1', + track: 'Track 1', + car: 'Car 1', + scheduledAt: new Date('2023-01-01T10:00:00Z'), + sessionType: 'race' as const, + status: 'scheduled' as const, + strengthOfField: 1500, + registeredCount: 10, + maxParticipants: 20, + }; + const league = { + id: 'league-1', + name: 'League 1', + description: 'Description', + settings: { maxDrivers: 20, qualifyingFormat: 'ladder' }, + }; + const registeredDriverIds = ['driver-1', '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' }, + ]; + + raceRepository.findById.mockResolvedValue(race); + leagueRepository.findById.mockResolvedValue(league); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(registeredDriverIds); + 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}`); + + const result = await useCase.execute({ raceId, driverId }); + + 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(); + }); + + it('should return error when race not found', async () => { + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ raceId: 'race-1', driverId: 'driver-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' }); + }); + + it('should include user result when race is completed', async () => { + const raceId = 'race-1'; + const driverId = 'driver-1'; + const race = { + id: raceId, + leagueId: 'league-1', + track: 'Track 1', + car: 'Car 1', + scheduledAt: new Date('2023-01-01T10:00:00Z'), + 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, + }]; + + raceRepository.findById.mockResolvedValue(race); + leagueRepository.findById.mockResolvedValue(null); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]); + leagueMembershipRepository.getMembership.mockResolvedValue(null); + driverRatingProvider.getRatings.mockReturnValue(new Map()); + resultRepository.findByRaceId.mockResolvedValue(results); + + const result = await useCase.execute({ raceId, driverId }); + + 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 + }); + }); +}); \ 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 dedf6c5cf..64f0f2756 100644 --- a/core/racing/application/use-cases/GetRaceDetailUseCase.ts +++ b/core/racing/application/use-cases/GetRaceDetailUseCase.ts @@ -7,14 +7,15 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; import type { IImageServicePort } from '../ports/IImageServicePort'; import type { - IRaceDetailPresenter, RaceDetailViewModel, RaceDetailRaceViewModel, RaceDetailLeagueViewModel, RaceDetailEntryViewModel, RaceDetailUserResultViewModel, } from '../presenters/IRaceDetailPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case: GetRaceDetailUseCase @@ -31,8 +32,10 @@ export interface GetRaceDetailQueryParams { driverId: string; } +type GetRaceDetailErrorCode = 'RACE_NOT_FOUND'; + export class GetRaceDetailUseCase - implements UseCase + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, @@ -45,26 +48,12 @@ export class GetRaceDetailUseCase private readonly imageService: IImageServicePort, ) {} - async execute(params: GetRaceDetailQueryParams, presenter: IRaceDetailPresenter): Promise { - presenter.reset(); - + async execute(params: GetRaceDetailQueryParams): Promise>> { const { raceId, driverId } = params; const race = await this.raceRepository.findById(raceId); if (!race) { - const emptyViewModel: RaceDetailViewModel = { - race: null, - league: null, - entryList: [], - registration: { - isUserRegistered: false, - canRegister: false, - }, - userResult: null, - error: 'Race not found', - }; - presenter.present(emptyViewModel); - return; + return Result.err({ code: 'RACE_NOT_FOUND' }); } const [league, registeredDriverIds, membership] = await Promise.all([ @@ -156,7 +145,7 @@ export class GetRaceDetailUseCase userResult: userResultView, }; - presenter.present(viewModel); + return Result.ok(viewModel); } private calculateRatingChange(position: number): number { diff --git a/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts b/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts new file mode 100644 index 000000000..b29ab7109 --- /dev/null +++ b/core/racing/application/use-cases/GetRacePenaltiesUseCase.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRacePenaltiesUseCase } from './GetRacePenaltiesUseCase'; +import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; + +describe('GetRacePenaltiesUseCase', () => { + let useCase: GetRacePenaltiesUseCase; + let penaltyRepository: { findByRaceId: Mock }; + let driverRepository: { findById: Mock }; + + beforeEach(() => { + penaltyRepository = { findByRaceId: vi.fn() }; + driverRepository = { findById: vi.fn() }; + useCase = new GetRacePenaltiesUseCase( + penaltyRepository as unknown as IPenaltyRepository, + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return penalties with driver map', async () => { + const raceId = 'race-1'; + const penalties = [ + { + id: 'penalty-1', + raceId, + driverId: 'driver-1', + issuedBy: 'driver-2', + type: 'time' as const, + value: 10, + reason: 'Reason 1', + status: 'applied' as const, + issuedAt: new Date(), + }, + ]; + const drivers = [ + { id: 'driver-1', name: 'Driver 1' }, + { id: 'driver-2', name: 'Driver 2' }, + ]; + + penaltyRepository.findByRaceId.mockResolvedValue(penalties); + driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id))); + + const result = await useCase.execute({ raceId }); + + 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'); + }); + + it('should return empty when no penalties', async () => { + penaltyRepository.findByRaceId.mockResolvedValue([]); + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ raceId: 'race-1' }); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.penalties).toEqual([]); + expect(dto.driverMap.size).toBe(0); + }); +}); \ 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 61083cc6c..7f42e4f62 100644 --- a/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts +++ b/core/racing/application/use-cases/GetRacePenaltiesUseCase.ts @@ -7,27 +7,22 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { - IRacePenaltiesPresenter, - RacePenaltiesResultDTO, - RacePenaltiesViewModel, -} from '../presenters/IRacePenaltiesPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { RacePenaltiesResultDTO } from '../presenters/IRacePenaltiesPresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetRacePenaltiesInput { raceId: string; } -export class GetRacePenaltiesUseCase - implements - UseCase -{ +export class GetRacePenaltiesUseCase implements AsyncUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(input: GetRacePenaltiesInput, presenter: IRacePenaltiesPresenter): Promise { + async execute(input: GetRacePenaltiesInput): Promise>> { const penalties = await this.penaltyRepository.findByRaceId(input.raceId); const driverIds = new Set(); @@ -47,11 +42,10 @@ export class GetRacePenaltiesUseCase } }); - presenter.reset(); const dto: RacePenaltiesResultDTO = { penalties, driverMap, }; - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..6187fc547 --- /dev/null +++ b/core/racing/application/use-cases/GetRaceProtestsUseCase.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRaceProtestsUseCase } from './GetRaceProtestsUseCase'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; + +describe('GetRaceProtestsUseCase', () => { + let useCase: GetRaceProtestsUseCase; + let protestRepository: { findByRaceId: Mock }; + let driverRepository: { findById: Mock }; + + beforeEach(() => { + protestRepository = { findByRaceId: vi.fn() }; + driverRepository = { findById: vi.fn() }; + useCase = new GetRaceProtestsUseCase( + protestRepository as unknown as IProtestRepository, + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return protests with driver map', 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' }, + ]; + + protestRepository.findByRaceId.mockResolvedValue(protests); + driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id))); + + const result = await useCase.execute({ raceId }); + + 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'); + }); + + it('should return empty when no protests', async () => { + protestRepository.findByRaceId.mockResolvedValue([]); + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ raceId: 'race-1' }); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.protests).toEqual([]); + expect(dto.driverMap.size).toBe(0); + }); +}); \ 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 5a7354089..1562fc0ab 100644 --- a/core/racing/application/use-cases/GetRaceProtestsUseCase.ts +++ b/core/racing/application/use-cases/GetRaceProtestsUseCase.ts @@ -7,27 +7,22 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { - IRaceProtestsPresenter, - RaceProtestsResultDTO, - RaceProtestsViewModel, -} from '../presenters/IRaceProtestsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { RaceProtestsResultDTO } from '../presenters/IRaceProtestsPresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetRaceProtestsInput { raceId: string; } -export class GetRaceProtestsUseCase - implements - UseCase -{ +export class GetRaceProtestsUseCase implements AsyncUseCase { constructor( private readonly protestRepository: IProtestRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(input: GetRaceProtestsInput, presenter: IRaceProtestsPresenter): Promise { + async execute(input: GetRaceProtestsInput): Promise>> { const protests = await this.protestRepository.findByRaceId(input.raceId); const driverIds = new Set(); @@ -50,11 +45,10 @@ export class GetRaceProtestsUseCase } }); - presenter.reset(); const dto: RaceProtestsResultDTO = { protests, driverMap, }; - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..51e7e1163 --- /dev/null +++ b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRaceRegistrationsUseCase } from './GetRaceRegistrationsUseCase'; +import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; + +describe('GetRaceRegistrationsUseCase', () => { + let useCase: GetRaceRegistrationsUseCase; + let registrationRepository: { getRegisteredDrivers: Mock }; + + beforeEach(() => { + registrationRepository = { getRegisteredDrivers: vi.fn() }; + useCase = new GetRaceRegistrationsUseCase( + registrationRepository as unknown as IRaceRegistrationRepository, + ); + }); + + it('should return registered driver ids', async () => { + const raceId = 'race-1'; + const registeredDriverIds = ['driver-1', 'driver-2']; + + registrationRepository.getRegisteredDrivers.mockResolvedValue(registeredDriverIds); + + const result = await useCase.execute({ raceId }); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.registeredDriverIds).toEqual(registeredDriverIds); + }); + + it('should return empty array when no registrations', async () => { + registrationRepository.getRegisteredDrivers.mockResolvedValue([]); + + const result = await useCase.execute({ raceId: 'race-1' }); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.registeredDriverIds).toEqual([]); + }); +}); \ 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 683043a08..6c17140b6 100644 --- a/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts +++ b/core/racing/application/use-cases/GetRaceRegistrationsUseCase.ts @@ -1,11 +1,9 @@ import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository'; import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; -import type { - IRaceRegistrationsPresenter, - RaceRegistrationsResultDTO, - RaceRegistrationsViewModel, -} from '../presenters/IRaceRegistrationsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { RaceRegistrationsResultDTO } from '../presenters/IRaceRegistrationsPresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case: GetRaceRegistrationsUseCase @@ -13,19 +11,12 @@ import type { UseCase } from '@core/shared/application/UseCase'; * Returns registered driver IDs for a race. * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetRaceRegistrationsUseCase - implements UseCase -{ +export class GetRaceRegistrationsUseCase implements AsyncUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, ) {} - async execute( - params: GetRaceRegistrationsQueryParamsDTO, - presenter: IRaceRegistrationsPresenter, - ): Promise { - presenter.reset(); - + async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise>> { const { raceId } = params; const registeredDriverIds = await this.registrationRepository.getRegisteredDrivers(raceId); @@ -33,6 +24,6 @@ export class GetRaceRegistrationsUseCase registeredDriverIds, }; - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..4c91d1509 --- /dev/null +++ b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRaceResultsDetailUseCase } 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'; + +describe('GetRaceResultsDetailUseCase', () => { + let useCase: GetRaceResultsDetailUseCase; + let raceRepository: { findById: Mock }; + let leagueRepository: { findById: Mock }; + let resultRepository: { findByRaceId: Mock }; + let driverRepository: { findAll: Mock }; + let penaltyRepository: { findByRaceId: Mock }; + + beforeEach(() => { + raceRepository = { findById: vi.fn() }; + leagueRepository = { findById: vi.fn() }; + resultRepository = { findByRaceId: vi.fn() }; + driverRepository = { findAll: vi.fn() }; + penaltyRepository = { findByRaceId: vi.fn() }; + 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, + ); + }); + + it('should return race results detail when race exists', async () => { + const raceId = 'race-1'; + const race = { + id: raceId, + 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 }, + ]; + 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 }, + ]; + + raceRepository.findById.mockResolvedValue(race); + leagueRepository.findById.mockResolvedValue(league); + resultRepository.findByRaceId.mockResolvedValue(results); + driverRepository.findAll.mockResolvedValue(drivers); + penaltyRepository.findByRaceId.mockResolvedValue(penalties); + + const result = await useCase.execute({ raceId }); + + 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'); + }); + + it('should return error when race not found', async () => { + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ raceId: 'race-1' }); + + expect(result.isErr()).toBe(true); + expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts index 4e5095f6e..39a769367 100644 --- a/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts +++ b/core/racing/application/use-cases/GetRaceResultsDetailUseCase.ts @@ -3,15 +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 { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; -import type { - IRaceResultsDetailPresenter, - RaceResultsDetailViewModel, - RaceResultsPenaltySummaryViewModel, -} from '../presenters/IRaceResultsDetailPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { RaceResultsDetailViewModel, RaceResultsPenaltySummaryViewModel } from '../presenters/IRaceResultsDetailPresenter'; +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 { League } from '../../domain/entities/League'; -import type { Result } from '../../domain/entities/Result'; -import type { Driver } from '../../domain/entities/Driver'; +import type { Result as DomainResult } from '../../domain/entities/Result'; import type { Penalty } from '../../domain/entities/Penalty'; export interface GetRaceResultsDetailParams { @@ -19,76 +16,10 @@ export interface GetRaceResultsDetailParams { driverId?: string; } -function buildPointsSystem(league: League | null): Record | undefined { - if (!league) return undefined; - const pointsSystems: Record> = { - 'f1-2024': { - 1: 25, - 2: 18, - 3: 15, - 4: 12, - 5: 10, - 6: 8, - 7: 6, - 8: 4, - 9: 2, - 10: 1, - }, - indycar: { - 1: 50, - 2: 40, - 3: 35, - 4: 32, - 5: 30, - 6: 28, - 7: 26, - 8: 24, - 9: 22, - 10: 20, - 11: 19, - 12: 18, - 13: 17, - 14: 16, - 15: 15, - }, - }; +type GetRaceResultsDetailErrorCode = 'RACE_NOT_FOUND'; - const customPoints = league.settings.customPoints; - if (customPoints) { - return customPoints; - } - - const preset = pointsSystems[league.settings.pointsSystem]; - if (preset) { - return preset; - } - - return pointsSystems['f1-2024']; -} - -function getFastestLapTime(results: Result[]): number | undefined { - if (results.length === 0) return undefined; - return Math.min(...results.map((r) => r.fastestLap)); -} - -function mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] { - return penalties.map((p) => ({ - driverId: p.driverId, - type: p.type, - ...(p.value !== undefined ? { value: p.value } : {}), - })); -} - -export class GetRaceResultsDetailUseCase - implements - UseCase< - GetRaceResultsDetailParams, - RaceResultsDetailViewModel, - RaceResultsDetailViewModel, - IRaceResultsDetailPresenter - > -{ +export class GetRaceResultsDetailUseCase implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, @@ -97,27 +28,13 @@ export class GetRaceResultsDetailUseCase private readonly penaltyRepository: IPenaltyRepository, ) {} - async execute( - params: GetRaceResultsDetailParams, - presenter: IRaceResultsDetailPresenter, - ): Promise { - presenter.reset(); + async execute(params: GetRaceResultsDetailParams): Promise>> { const { raceId, driverId } = params; const race = await this.raceRepository.findById(raceId); if (!race) { - const errorViewModel: RaceResultsDetailViewModel = { - race: null, - league: null, - results: [], - drivers: [], - penalties: [], - ...(driverId ? { currentDriverId: driverId } : {}), - error: 'Race not found', - }; - presenter.present(errorViewModel); - return; + return Result.err({ code: 'RACE_NOT_FOUND' }); } const [league, results, drivers, penalties] = await Promise.all([ @@ -130,9 +47,9 @@ export class GetRaceResultsDetailUseCase const effectiveCurrentDriverId = driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined); - const pointsSystem = buildPointsSystem(league as League | null); - const fastestLapTime = getFastestLapTime(results); - const penaltySummary = mapPenaltySummary(penalties); + const pointsSystem = this.buildPointsSystem(league); + const fastestLapTime = this.getFastestLapTime(results); + const penaltySummary = this.mapPenaltySummary(penalties); const viewModel: RaceResultsDetailViewModel = { race: { @@ -156,6 +73,67 @@ export class GetRaceResultsDetailUseCase ...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}), }; - presenter.present(viewModel); + return Result.ok(viewModel); + } + + private buildPointsSystem(league: League | null): Record | undefined { + if (!league) return undefined; + + const pointsSystems: Record> = { + 'f1-2024': { + 1: 25, + 2: 18, + 3: 15, + 4: 12, + 5: 10, + 6: 8, + 7: 6, + 8: 4, + 9: 2, + 10: 1, + }, + indycar: { + 1: 50, + 2: 40, + 3: 35, + 4: 32, + 5: 30, + 6: 28, + 7: 26, + 8: 24, + 9: 22, + 10: 20, + 11: 19, + 12: 18, + 13: 17, + 14: 16, + 15: 15, + }, + }; + + const customPoints = league.settings.customPoints; + if (customPoints) { + return customPoints; + } + + const preset = pointsSystems[league.settings.pointsSystem]; + if (preset) { + return preset; + } + + return pointsSystems['f1-2024']; + } + + private getFastestLapTime(results: DomainResult[]): number | undefined { + if (results.length === 0) return undefined; + return Math.min(...results.map((r) => r.fastestLap)); + } + + private mapPenaltySummary(penalties: Penalty[]): RaceResultsPenaltySummaryViewModel[] { + return penalties.map((p) => ({ + driverId: p.driverId, + type: p.type, + ...(p.value !== undefined ? { value: p.value } : {}), + })); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts b/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts index d7cdd714d..8028a906b 100644 --- a/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts +++ b/core/racing/application/use-cases/GetRaceWithSOFUseCase.ts @@ -3,9 +3,11 @@ * * Returns race details enriched with calculated Strength of Field (SOF). * SOF is calculated from participant ratings if not already stored on the race. - * Orchestrates domain logic and delegates presentation to the presenter. */ +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'; @@ -14,16 +16,30 @@ import { AverageStrengthOfFieldCalculator, type StrengthOfFieldCalculator, } from '../../domain/services/StrengthOfFieldCalculator'; -import type { IRaceWithSOFPresenter, RaceWithSOFResultDTO } from '../presenters/IRaceWithSOFPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; export interface GetRaceWithSOFQueryParams { raceId: string; } -export class GetRaceWithSOFUseCase - implements UseCase -{ +export interface RaceWithSOFResultDTO { + raceId: string; + leagueId: string; + scheduledAt: Date; + track: string; + trackId: string; + car: string; + carId: string; + sessionType: string; + status: string; + strengthOfField: number | null; + registeredCount: number; + maxParticipants: number; + participantCount: number; +} + +type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND'; + +export class GetRaceWithSOFUseCase implements AsyncUseCase { private readonly sofCalculator: StrengthOfFieldCalculator; constructor( @@ -36,19 +52,17 @@ export class GetRaceWithSOFUseCase this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator(); } - async execute(params: GetRaceWithSOFQueryParams, presenter: IRaceWithSOFPresenter): Promise { - presenter.reset(); - + async execute(params: GetRaceWithSOFQueryParams): Promise>> { const { raceId } = params; const race = await this.raceRepository.findById(raceId); if (!race) { - return; + return Result.err({ code: 'RACE_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); @@ -70,8 +84,6 @@ export class GetRaceWithSOFUseCase strengthOfField = this.sofCalculator.calculate(driverRatings); } - presenter.reset(); - const dto: RaceWithSOFResultDTO = { raceId: race.id, leagueId: race.leagueId, @@ -80,14 +92,14 @@ export class GetRaceWithSOFUseCase trackId: race.trackId ?? '', car: race.car ?? '', carId: race.carId ?? '', - sessionType: race.sessionType, - status: race.status, + sessionType: race.sessionType as string, + status: race.status as string, strengthOfField, registeredCount: race.registeredCount ?? participantIds.length, maxParticipants: race.maxParticipants ?? participantIds.length, participantCount: participantIds.length, }; - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..4ead21dee --- /dev/null +++ b/core/racing/application/use-cases/GetRacesPageDataUseCase.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetRacesPageDataUseCase } from './GetRacesPageDataUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; + +describe('GetRacesPageDataUseCase', () => { + let useCase: GetRacesPageDataUseCase; + let raceRepository: { findAll: Mock }; + let leagueRepository: { findAll: Mock }; + + beforeEach(() => { + raceRepository = { findAll: vi.fn() }; + leagueRepository = { findAll: vi.fn() }; + useCase = new GetRacesPageDataUseCase( + raceRepository as unknown as IRaceRepository, + leagueRepository as unknown as ILeagueRepository, + ); + }); + + it('should return races page data', async () => { + const races = [ + { + id: 'race-1', + track: 'Track 1', + car: 'Car 1', + scheduledAt: new Date('2023-01-01T10:00:00Z'), + status: 'scheduled' as const, + leagueId: 'league-1', + strengthOfField: 1500, + isUpcoming: () => true, + isLive: () => false, + isPast: () => false, + }, + { + id: 'race-2', + track: 'Track 2', + car: 'Car 2', + scheduledAt: new Date('2023-01-02T10:00:00Z'), + status: 'completed' as const, + leagueId: 'league-1', + strengthOfField: 1600, + isUpcoming: () => false, + isLive: () => false, + isPast: () => true, + }, + ]; + const leagues = [ + { id: 'league-1', name: 'League 1' }, + ]; + + raceRepository.findAll.mockResolvedValue(races); + leagueRepository.findAll.mockResolvedValue(leagues); + + const result = await useCase.execute(); + + 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, + }); + }); + +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetRacesPageDataUseCase.ts index 3b39329b4..b133ae6a7 100644 --- a/core/racing/application/use-cases/GetRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetRacesPageDataUseCase.ts @@ -1,23 +1,17 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { - IRacesPagePresenter, - RacesPageResultDTO, - RacesPageViewModel, -} from '@core/racing/application/presenters/IRacesPagePresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { RacesPageResultDTO } from '@core/racing/application/presenters/IRacesPagePresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export class GetRacesPageDataUseCase - implements UseCase -{ +export class GetRacesPageDataUseCase implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, ) {} - async execute(_input: void, presenter: IRacesPagePresenter): Promise { - presenter.reset(); - + async execute(): Promise>> { const [allRaces, allLeagues] = await Promise.all([ this.raceRepository.findAll(), this.leagueRepository.findAll(), @@ -45,6 +39,6 @@ export class GetRacesPageDataUseCase races, }; - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..52a23ebee --- /dev/null +++ b/core/racing/application/use-cases/GetSeasonDetailsUseCase.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from 'vitest'; + +import { + InMemorySeasonRepository, +} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; +import { Season } from '@core/racing/domain/entities/Season'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { + GetSeasonDetailsUseCase, +} from '@core/racing/application/use-cases/GetSeasonDetailsUseCase'; +import type { Logger } from '@core/shared/application'; + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { + return { + findById: async (id: string) => seed.find((l) => l.id === id) ?? null, + findAll: async () => seed, + create: async (league: any) => league, + update: async (league: any) => league, + } as unknown as ILeagueRepository; +} + +describe('GetSeasonDetailsUseCase', () => { + it('returns full details for a season belonging to the league', async () => { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo = new InMemorySeasonRepository(logger); + + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Detailed Season', + status: 'planned', + }).withMaxDrivers(24); + + await seasonRepo.add(season); + + const useCase = new GetSeasonDetailsUseCase(leagueRepo, seasonRepo); + + const result = await useCase.execute({ + leagueId: 'league-1', + seasonId: 'season-1', + }); + + expect(result.isOk()).toBe(true); + const dto = result.value; + 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); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts b/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts new file mode 100644 index 000000000..42c8a4d28 --- /dev/null +++ b/core/racing/application/use-cases/GetSeasonDetailsUseCase.ts @@ -0,0 +1,132 @@ +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface GetSeasonDetailsQuery { + 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; + }; +} + +type GetSeasonDetailsErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND'; + +/** + * GetSeasonDetailsUseCase + */ +export class GetSeasonDetailsUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly seasonRepository: ISeasonRepository, + ) {} + + async execute(query: GetSeasonDetailsQuery): Promise>> { + 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) { + 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/GetSponsorDashboardUseCase.test.ts b/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts new file mode 100644 index 000000000..8377f440c --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorDashboardUseCase.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetSponsorDashboardUseCase } from './GetSponsorDashboardUseCase'; +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 { League } from '../../domain/entities/League'; +import { Money } from '../../domain/value-objects/Money'; + +describe('GetSponsorDashboardUseCase', () => { + let useCase: GetSponsorDashboardUseCase; + let sponsorRepository: { + findById: Mock; + }; + let seasonSponsorshipRepository: { + findBySponsorId: Mock; + }; + let seasonRepository: { + findById: Mock; + }; + let leagueRepository: { + findById: Mock; + }; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let raceRepository: { + findByLeagueId: Mock; + }; + + beforeEach(() => { + sponsorRepository = { + findById: vi.fn(), + }; + seasonSponsorshipRepository = { + findBySponsorId: vi.fn(), + }; + seasonRepository = { + findById: vi.fn(), + }; + leagueRepository = { + findById: vi.fn(), + }; + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + raceRepository = { + findByLeagueId: vi.fn(), + }; + useCase = new GetSponsorDashboardUseCase( + sponsorRepository as unknown as ISponsorRepository, + seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, + seasonRepository as unknown as ISeasonRepository, + leagueRepository as unknown as ILeagueRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + raceRepository as unknown as IRaceRepository, + ); + }); + + it('should return sponsor dashboard for existing sponsor', async () => { + const sponsorId = 'sponsor-1'; + const sponsor = Sponsor.create({ + id: sponsorId, + name: 'Test Sponsor', + contactEmail: 'test@example.com', + }); + const sponsorship = SeasonSponsorship.create({ + id: 'sponsorship-1', + sponsorId, + seasonId: 'season-1', + tier: 'main', + pricing: Money.create(10000, 'USD'), + }); + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'game-1', + name: 'Season 1', + status: 'active', + }); + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'Test', + ownerId: 'owner-1', + }); + const memberships = [{ driverId: 'driver-1' }]; + const races = [{ id: 'race-1', status: 'completed' }]; + + sponsorRepository.findById.mockResolvedValue(sponsor); + seasonSponsorshipRepository.findBySponsorId.mockResolvedValue([sponsorship]); + seasonRepository.findById.mockResolvedValue(season); + leagueRepository.findById.mockResolvedValue(league); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + raceRepository.findByLeagueId.mockResolvedValue(races); + + const result = await useCase.execute({ sponsorId }); + + 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 + }); + + it('should return null for non-existing sponsor', async () => { + const sponsorId = 'sponsor-1'; + sponsorRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ sponsorId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBe(null); + }); + + 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 }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch sponsor dashboard', + }); + }); +}); \ 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 a3862fe31..c3cf66b26 100644 --- a/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorDashboardUseCase.ts @@ -10,11 +10,9 @@ 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 { - ISponsorDashboardPresenter, - SponsorDashboardViewModel, -} from '../presenters/ISponsorDashboardPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { SponsorDashboardViewModel } from '../presenters/ISponsorDashboardPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetSponsorDashboardQueryParams { sponsorId: string; @@ -51,9 +49,7 @@ export interface SponsorDashboardDTO { }; } -export class GetSponsorDashboardUseCase - implements UseCase -{ +export class GetSponsorDashboardUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, @@ -65,115 +61,115 @@ export class GetSponsorDashboardUseCase async execute( params: GetSponsorDashboardQueryParams, - presenter: ISponsorDashboardPresenter, - ): Promise { - presenter.reset(); + ): Promise>> { + try { + const { sponsorId } = params; - const { sponsorId } = params; - - const sponsor = await this.sponsorRepository.findById(sponsorId); - if (!sponsor) { - presenter.present(null); - return; - } - - // Get all sponsorships for this sponsor - const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId); - - // Aggregate data across all sponsorships - let totalImpressions = 0; - let totalDrivers = 0; - let totalRaces = 0; - let totalInvestment = 0; - const sponsoredLeagues: SponsoredLeagueDTO[] = []; - const seenLeagues = new Set(); - - for (const sponsorship of sponsorships) { - // Get season to find league - const season = await this.seasonRepository.findById(sponsorship.seasonId); - if (!season) continue; - - // Only process each league once - if (seenLeagues.has(season.leagueId)) continue; - seenLeagues.add(season.leagueId); - - 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; - totalDrivers += driverCount; - - // Get races for this league - const races = await this.raceRepository.findByLeagueId(season.leagueId); - const raceCount = races.length; - 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'; - if (season.endDate && season.endDate < now) { - status = 'completed'; - } else if (season.startDate && season.startDate > now) { - status = 'upcoming'; + const sponsor = await this.sponsorRepository.findById(sponsorId); + if (!sponsor) { + return Result.ok(null); } - // Add investment - totalInvestment += sponsorship.pricing.amount; + // Get all sponsorships for this sponsor + const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId); - sponsoredLeagues.push({ - id: league.id, - name: league.name, - tier: sponsorship.tier, - drivers: driverCount, - races: raceCount, - impressions: leagueImpressions, - status, - }); + // Aggregate data across all sponsorships + let totalImpressions = 0; + let totalDrivers = 0; + let totalRaces = 0; + let totalInvestment = 0; + const sponsoredLeagues: SponsoredLeagueDTO[] = []; + const seenLeagues = new Set(); + + for (const sponsorship of sponsorships) { + // Get season to find league + const season = await this.seasonRepository.findById(sponsorship.seasonId); + if (!season) continue; + + // Only process each league once + if (seenLeagues.has(season.leagueId)) continue; + seenLeagues.add(season.leagueId); + + 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; + totalDrivers += driverCount; + + // Get races for this league + const races = await this.raceRepository.findByLeagueId(season.leagueId); + const raceCount = races.length; + 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'; + if (season.endDate && season.endDate < now) { + status = 'completed'; + } else if (season.startDate && season.startDate > now) { + status = 'upcoming'; + } + + // Add investment + totalInvestment += sponsorship.pricing.amount; + + sponsoredLeagues.push({ + id: league.id, + name: league.name, + tier: sponsorship.tier, + drivers: driverCount, + races: raceCount, + impressions: leagueImpressions, + status, + }); + } + + const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; + const costPerThousandViews = totalImpressions > 0 + ? (totalInvestment / (totalImpressions / 1000)) + : 0; + + // Calculate unique viewers (simplified: assume 70% of impressions are unique) + const uniqueViewers = Math.round(totalImpressions * 0.7); + + // Calculate exposure score (0-100 based on tier distribution) + const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length; + const exposure = sponsorships.length > 0 + ? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10)) + : 0; + + const dto: SponsorDashboardDTO = { + sponsorId, + sponsorName: sponsor.name, + metrics: { + impressions: totalImpressions, + impressionsChange: 0, + uniqueViewers, + viewersChange: 0, + races: totalRaces, + drivers: totalDrivers, + exposure, + exposureChange: 0, + }, + sponsoredLeagues, + investment: { + activeSponsorships, + totalInvestment, + costPerThousandViews: Math.round(costPerThousandViews * 100) / 100, + }, + }; + + return Result.ok(dto); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor dashboard' }); } - - const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; - const costPerThousandViews = totalImpressions > 0 - ? (totalInvestment / (totalImpressions / 1000)) - : 0; - - // Calculate unique viewers (simplified: assume 70% of impressions are unique) - const uniqueViewers = Math.round(totalImpressions * 0.7); - - // Calculate exposure score (0-100 based on tier distribution) - const mainSponsorships = sponsorships.filter(s => s.tier === 'main').length; - const exposure = sponsorships.length > 0 - ? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10)) - : 0; - - const dto: SponsorDashboardDTO = { - sponsorId, - sponsorName: sponsor.name, - metrics: { - impressions: totalImpressions, - impressionsChange: 12.5, // Would come from analytics comparison - uniqueViewers, - viewersChange: 8.3, // Would come from analytics comparison - races: totalRaces, - drivers: totalDrivers, - exposure, - exposureChange: 5.2, // Would come from analytics comparison - }, - sponsoredLeagues, - investment: { - activeSponsorships, - totalInvestment, - costPerThousandViews: Math.round(costPerThousandViews * 100) / 100, - }, - }; - - presenter.present(dto); } } \ 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 new file mode 100644 index 000000000..ef270ebee --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetSponsorSponsorshipsUseCase } 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 { League } from '../../domain/entities/League'; +import { Money } from '../../domain/value-objects/Money'; + +describe('GetSponsorSponsorshipsUseCase', () => { + let useCase: GetSponsorSponsorshipsUseCase; + let sponsorRepository: { + findById: Mock; + }; + let seasonSponsorshipRepository: { + findBySponsorId: Mock; + }; + let seasonRepository: { + findById: Mock; + }; + let leagueRepository: { + findById: Mock; + }; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let raceRepository: { + findByLeagueId: Mock; + }; + + beforeEach(() => { + sponsorRepository = { + findById: vi.fn(), + }; + seasonSponsorshipRepository = { + findBySponsorId: vi.fn(), + }; + seasonRepository = { + findById: vi.fn(), + }; + leagueRepository = { + findById: vi.fn(), + }; + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + raceRepository = { + findByLeagueId: vi.fn(), + }; + useCase = new GetSponsorSponsorshipsUseCase( + sponsorRepository as unknown as ISponsorRepository, + seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository, + seasonRepository as unknown as ISeasonRepository, + leagueRepository as unknown as ILeagueRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + raceRepository as unknown as IRaceRepository, + ); + }); + + it('should return sponsor sponsorships for existing sponsor', async () => { + const sponsorId = 'sponsor-1'; + const sponsor = Sponsor.create({ + id: sponsorId, + name: 'Test Sponsor', + contactEmail: 'test@example.com', + }); + const sponsorship = SeasonSponsorship.create({ + id: 'sponsorship-1', + sponsorId, + seasonId: 'season-1', + tier: 'main', + pricing: Money.create(10000, 'USD'), + }); + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'game-1', + name: 'Season 1', + status: 'active', + }); + const league = League.create({ + id: 'league-1', + name: 'Test League', + description: 'Test', + ownerId: 'owner-1', + }); + const memberships = [{ driverId: 'driver-1' }]; + const races = [{ id: 'race-1', status: 'completed' }]; + + sponsorRepository.findById.mockResolvedValue(sponsor); + seasonSponsorshipRepository.findBySponsorId.mockResolvedValue([sponsorship]); + seasonRepository.findById.mockResolvedValue(season); + leagueRepository.findById.mockResolvedValue(league); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + raceRepository.findByLeagueId.mockResolvedValue(races); + + const result = await useCase.execute({ sponsorId }); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data?.sponsorId).toBe(sponsorId); + expect(data?.sponsorships).toHaveLength(1); + }); + + it('should return null for non-existing sponsor', async () => { + const sponsorId = 'sponsor-1'; + sponsorRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ sponsorId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBe(null); + }); + + 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 }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch sponsor sponsorships', + }); + }); +}); \ 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 a6217dc5d..b489ff9c9 100644 --- a/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorSponsorshipsUseCase.ts @@ -11,11 +11,9 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { SponsorshipTier, SponsorshipStatus } from '../../domain/entities/SeasonSponsorship'; -import type { - ISponsorSponsorshipsPresenter, - SponsorSponsorshipsViewModel, -} from '../presenters/ISponsorSponsorshipsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { SponsorSponsorshipsViewModel } from '../presenters/ISponsorSponsorshipsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface GetSponsorSponsorshipsQueryParams { sponsorId: string; @@ -66,9 +64,7 @@ export interface SponsorSponsorshipsDTO { }; } -export class GetSponsorSponsorshipsUseCase - implements UseCase -{ +export class GetSponsorSponsorshipsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository, @@ -80,99 +76,99 @@ export class GetSponsorSponsorshipsUseCase async execute( params: GetSponsorSponsorshipsQueryParams, - presenter: ISponsorSponsorshipsPresenter, - ): Promise { - presenter.reset(); + ): Promise>> { + try { + const { sponsorId } = params; - const { sponsorId } = params; + const sponsor = await this.sponsorRepository.findById(sponsorId); + if (!sponsor) { + return Result.ok(null); + } - const sponsor = await this.sponsorRepository.findById(sponsorId); - if (!sponsor) { - presenter.present(null); - return; + // Get all sponsorships for this sponsor + const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId); + + const sponsorshipDetails: SponsorshipDetailDTO[] = []; + let totalInvestment = 0; + let totalPlatformFees = 0; + + 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(); + + totalInvestment += sponsorship.pricing.amount; + totalPlatformFees += platformFee.amount; + + 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, + }, + metrics: { + drivers: driverCount, + races: races.length, + completedRaces, + impressions, + }, + createdAt: sponsorship.createdAt, + ...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}), + }); + } + + const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; + + const dto: SponsorSponsorshipsDTO = { + sponsorId, + sponsorName: sponsor.name, + sponsorships: sponsorshipDetails, + summary: { + totalSponsorships: sponsorships.length, + activeSponsorships, + totalInvestment, + totalPlatformFees, + currency: 'USD', + }, + }; + + return Result.ok(dto); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor sponsorships' }); } - - // Get all sponsorships for this sponsor - const sponsorships = await this.seasonSponsorshipRepository.findBySponsorId(sponsorId); - - const sponsorshipDetails: SponsorshipDetailDTO[] = []; - let totalInvestment = 0; - let totalPlatformFees = 0; - - 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(); - - totalInvestment += sponsorship.pricing.amount; - totalPlatformFees += platformFee.amount; - - 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, - }, - metrics: { - drivers: driverCount, - races: races.length, - completedRaces, - impressions, - }, - createdAt: sponsorship.createdAt, - ...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}), - }); - } - - const activeSponsorships = sponsorships.filter(s => s.status === 'active').length; - - const dto: SponsorSponsorshipsDTO = { - sponsorId, - sponsorName: sponsor.name, - sponsorships: sponsorshipDetails, - summary: { - totalSponsorships: sponsorships.length, - activeSponsorships, - totalInvestment, - totalPlatformFees, - currency: 'USD', - }, - }; - - presenter.present(dto); } } \ 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 new file mode 100644 index 000000000..479175de1 --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorsUseCase.test.ts @@ -0,0 +1,74 @@ +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'; + +describe('GetSponsorsUseCase', () => { + let useCase: GetSponsorsUseCase; + let sponsorRepository: { + findAll: Mock; + }; + + beforeEach(() => { + sponsorRepository = { + findAll: vi.fn(), + }; + useCase = new GetSponsorsUseCase( + sponsorRepository as unknown as ISponsorRepository, + ); + }); + + it('should return all sponsors', async () => { + const sponsors = [ + Sponsor.create({ + id: 'sponsor-1', + name: 'Sponsor One', + contactEmail: 'one@example.com', + logoUrl: 'logo1.png', + }), + Sponsor.create({ + id: 'sponsor-2', + name: 'Sponsor Two', + contactEmail: 'two@example.com', + }), + ]; + + sponsorRepository.findAll.mockResolvedValue(sponsors); + + 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), + }, + ], + }); + }); + + it('should return error on repository failure', async () => { + sponsorRepository.findAll.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch sponsors', + }); + }); +}); \ 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 f1d5ec01f..391821a2c 100644 --- a/core/racing/application/use-cases/GetSponsorsUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorsUseCase.ts @@ -5,39 +5,33 @@ */ import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { - IGetSponsorsPresenter, - GetSponsorsResultDTO, - GetSponsorsViewModel, -} from '../presenters/IGetSponsorsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetSponsorsViewModel } from '../presenters/IGetSponsorsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export class GetSponsorsUseCase - implements UseCase -{ +export class GetSponsorsUseCase { constructor( private readonly sponsorRepository: ISponsorRepository, ) {} - async execute( - _input: void, - presenter: IGetSponsorsPresenter, - ): Promise { - presenter.reset(); + async execute(): Promise>> { + try { + const sponsors = await this.sponsorRepository.findAll(); - const sponsors = await this.sponsorRepository.findAll(); + const viewModel: GetSponsorsViewModel = { + sponsors: sponsors.map(sponsor => ({ + id: sponsor.id, + name: sponsor.name, + contactEmail: sponsor.contactEmail, + websiteUrl: sponsor.websiteUrl, + logoUrl: sponsor.logoUrl, + createdAt: sponsor.createdAt, + })), + }; - const dto: GetSponsorsResultDTO = { - sponsors: sponsors.map(sponsor => ({ - id: sponsor.id, - name: sponsor.name, - contactEmail: sponsor.contactEmail, - websiteUrl: sponsor.websiteUrl, - logoUrl: sponsor.logoUrl, - createdAt: sponsor.createdAt, - })), - }; - - presenter.present(dto); + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsors' }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts new file mode 100644 index 000000000..172394353 --- /dev/null +++ b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GetSponsorshipPricingUseCase } from './GetSponsorshipPricingUseCase'; + +describe('GetSponsorshipPricingUseCase', () => { + let useCase: GetSponsorshipPricingUseCase; + + beforeEach(() => { + useCase = new GetSponsorshipPricingUseCase(); + }); + + it('should return sponsorship pricing tiers', async () => { + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + 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' }, + ], + }); + }); +}); \ 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 4dd6975fa..69d3577a4 100644 --- a/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetSponsorshipPricingUseCase.ts @@ -4,25 +4,15 @@ * Retrieves general sponsorship pricing tiers. */ -import type { - IGetSponsorshipPricingPresenter, - GetSponsorshipPricingResultDTO, - GetSponsorshipPricingViewModel, -} from '../presenters/IGetSponsorshipPricingPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetSponsorshipPricingViewModel } from '../presenters/IGetSponsorshipPricingPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export class GetSponsorshipPricingUseCase - implements UseCase -{ +export class GetSponsorshipPricingUseCase { constructor() {} - async execute( - _input: void, - presenter: IGetSponsorshipPricingPresenter, - ): Promise { - presenter.reset(); - - const dto: GetSponsorshipPricingResultDTO = { + async execute(): Promise>> { + const viewModel: GetSponsorshipPricingViewModel = { pricing: [ { id: 'tier-bronze', level: 'Bronze', price: 100, currency: 'USD' }, { id: 'tier-silver', level: 'Silver', price: 250, currency: 'USD' }, @@ -30,6 +20,6 @@ export class GetSponsorshipPricingUseCase ], }; - presenter.present(dto); + return Result.ok(viewModel); } } \ 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 new file mode 100644 index 000000000..5b3755847 --- /dev/null +++ b/core/racing/application/use-cases/GetTeamDetailsUseCase.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetTeamDetailsUseCase } from './GetTeamDetailsUseCase'; +import { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import { Team } from '../../domain/entities/Team'; + +describe('GetTeamDetailsUseCase', () => { + let useCase: GetTeamDetailsUseCase; + let teamRepository: { + findById: Mock; + }; + let membershipRepository: { + getMembership: Mock; + }; + + beforeEach(() => { + teamRepository = { + findById: vi.fn(), + }; + membershipRepository = { + getMembership: vi.fn(), + }; + useCase = new GetTeamDetailsUseCase( + teamRepository as unknown as ITeamRepository, + membershipRepository as unknown as ITeamMembershipRepository, + ); + }); + + it('should return team details with membership', async () => { + const teamId = 'team-1'; + const driverId = 'driver-1'; + const team = Team.create({ + id: teamId, + name: 'Test Team', + tag: 'TT', + description: 'Test', + ownerId: 'owner-1', + leagues: [], + }); + const membership = { + driverId, + role: 'member' as const, + status: 'active' as const, + joinedAt: new Date(), + }; + + teamRepository.findById.mockResolvedValue(team); + membershipRepository.getMembership.mockResolvedValue(membership); + + const result = await useCase.execute({ teamId, driverId }); + + 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); + }); + + it('should return team details for owner', async () => { + const teamId = 'team-1'; + const driverId = 'driver-1'; + const team = Team.create({ + id: teamId, + name: 'Test Team', + tag: 'TT', + description: 'Test', + ownerId: driverId, + leagues: [], + }); + const membership = { + driverId, + role: 'owner' as const, + status: 'active' as const, + joinedAt: new Date(), + }; + + teamRepository.findById.mockResolvedValue(team); + membershipRepository.getMembership.mockResolvedValue(membership); + + const result = await useCase.execute({ teamId, driverId }); + + expect(result.isOk()).toBe(true); + const viewModel = result.unwrap(); + expect(viewModel.canManage).toBe(true); + }); + + it('should return error for non-existing team', async () => { + const teamId = 'team-1'; + const driverId = 'driver-1'; + teamRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ teamId, driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'TEAM_NOT_FOUND', + message: 'Team not found', + }); + }); + + it('should return error on repository failure', async () => { + const teamId = 'team-1'; + const driverId = 'driver-1'; + teamRepository.findById.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute({ teamId, driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + message: 'Failed to fetch team details', + }); + }); +}); \ 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 d22623e95..c90e84d3f 100644 --- a/core/racing/application/use-cases/GetTeamDetailsUseCase.ts +++ b/core/racing/application/use-cases/GetTeamDetailsUseCase.ts @@ -1,19 +1,13 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { - ITeamDetailsPresenter, - TeamDetailsResultDTO, - TeamDetailsViewModel, -} from '../presenters/ITeamDetailsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { TeamDetailsViewModel } from '../presenters/ITeamDetailsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case for retrieving team details. - * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetTeamDetailsUseCase - implements UseCase<{ teamId: string; driverId: string }, TeamDetailsResultDTO, TeamDetailsViewModel, ITeamDetailsPresenter> -{ +export class GetTeamDetailsUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, @@ -21,24 +15,37 @@ export class GetTeamDetailsUseCase async execute( params: { teamId: string; driverId: string }, - presenter: ITeamDetailsPresenter, - ): Promise { - presenter.reset(); + ): Promise>> { + try { + const { teamId, driverId } = params; + const team = await this.teamRepository.findById(teamId); + if (!team) { + return Result.err({ code: 'TEAM_NOT_FOUND', message: 'Team not found' }); + } - const { teamId, driverId } = params; - const team = await this.teamRepository.findById(teamId); - if (!team) { - throw new Error('Team not found'); + const membership = await this.membershipRepository.getMembership(teamId, driverId); + + const viewModel: TeamDetailsViewModel = { + team: { + id: team.id, + name: team.name, + tag: team.tag, + description: team.description, + ownerId: team.ownerId, + leagues: team.leagues, + createdAt: team.createdAt.toISOString(), + }, + membership: membership ? { + role: membership.role as 'owner' | 'manager' | 'member', + joinedAt: membership.joinedAt.toISOString(), + isActive: membership.status === 'active', + } : null, + canManage: membership ? membership.role === 'owner' || membership.role === 'manager' : false, + }; + + return Result.ok(viewModel); + } catch { + return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch team details' }); } - - const membership = await this.membershipRepository.getMembership(teamId, driverId); - - const dto: TeamDetailsResultDTO = { - team, - membership, - driverId, - }; - - presenter.present(dto); } } \ 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 new file mode 100644 index 000000000..eb1f0233a --- /dev/null +++ b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetTeamJoinRequestsUseCase } from './GetTeamJoinRequestsUseCase'; +import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { IImageServicePort } from '../ports/IImageServicePort'; +import { Driver } from '../../domain/entities/Driver'; +import type { Logger } from '@core/shared/application'; + +describe('GetTeamJoinRequestsUseCase', () => { + let useCase: GetTeamJoinRequestsUseCase; + let membershipRepository: { + getJoinRequests: Mock; + }; + let driverRepository: { + findById: Mock; + }; + let imageService: { + getDriverAvatar: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + membershipRepository = { + getJoinRequests: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + imageService = { + getDriverAvatar: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTeamJoinRequestsUseCase( + membershipRepository as unknown as ITeamMembershipRepository, + driverRepository as unknown as IDriverRepository, + imageService as unknown as IImageServicePort, + logger as unknown as Logger, + ); + }); + + it('should return join requests with driver names and avatar urls', async () => { + const teamId = 'team-1'; + 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({ + id: 'driver-1', + iracingId: '123', + name: 'Driver 1', + country: 'US', + }); + const driver2 = Driver.create({ + id: 'driver-2', + iracingId: '456', + name: 'Driver 2', + country: 'UK', + }); + + 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); + }); + imageService.getDriverAvatar.mockImplementation((id: string) => `avatar-${id}`); + + const result = await useCase.execute({ teamId }); + + 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', + }, + }); + }); + + it('should handle driver not found', async () => { + const teamId = 'team-1'; + const joinRequests = [ + { id: 'req-1', teamId, driverId: 'driver-1', requestedAt: new Date() }, + ]; + + membershipRepository.getJoinRequests.mockResolvedValue(joinRequests); + driverRepository.findById.mockResolvedValue(null); + imageService.getDriverAvatar.mockReturnValue('avatar-driver-1'); + + 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 }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve team join requests' }, + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts index 4272ee701..63a149af6 100644 --- a/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetTeamJoinRequestsUseCase.ts @@ -1,34 +1,26 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IImageServicePort } from '../ports/IImageServicePort'; -import type { - ITeamJoinRequestsPresenter, - TeamJoinRequestsResultDTO, - TeamJoinRequestsViewModel, -} from '../presenters/ITeamJoinRequestsPresenter'; -import type { UseCase } from '@core/shared/application'; +import type { TeamJoinRequestsResultDTO } from '../presenters/ITeamJoinRequestsPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; /** * Use Case for retrieving team join requests. - * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetTeamJoinRequestsUseCase - implements UseCase<{ teamId: string }, TeamJoinRequestsResultDTO, TeamJoinRequestsViewModel, ITeamJoinRequestsPresenter> +export class GetTeamJoinRequestsUseCase implements AsyncUseCase<{ teamId: string }, TeamJoinRequestsResultDTO, 'REPOSITORY_ERROR'> { constructor( private readonly membershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, private readonly imageService: IImageServicePort, private readonly logger: Logger, - // Kept for backward compatibility; callers must pass their own presenter. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public readonly presenter: ITeamJoinRequestsPresenter, ) {} - async execute(input: { teamId: string }, presenter: ITeamJoinRequestsPresenter): Promise { + async execute(input: { teamId: string }): Promise>> { this.logger.debug('Executing GetTeamJoinRequestsUseCase', { teamId: input.teamId }); - presenter.reset(); try { const requests = await this.membershipRepository.getJoinRequests(input.teamId); @@ -54,10 +46,10 @@ export class GetTeamJoinRequestsUseCase avatarUrls, }; - presenter.present(dto); + return Result.ok(dto); } catch (error) { - this.logger.error('Error retrieving team join requests', { teamId: input.teamId, error }); - throw 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' } }); } } } \ 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 new file mode 100644 index 000000000..a2e5a0075 --- /dev/null +++ b/core/racing/application/use-cases/GetTeamMembersUseCase.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetTeamMembersUseCase } from './GetTeamMembersUseCase'; +import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { IImageServicePort } from '../ports/IImageServicePort'; +import { Driver } from '../../domain/entities/Driver'; +import type { Logger } from '@core/shared/application'; + +describe('GetTeamMembersUseCase', () => { + let useCase: GetTeamMembersUseCase; + let membershipRepository: { + getTeamMembers: Mock; + }; + let driverRepository: { + findById: Mock; + }; + let imageService: { + getDriverAvatar: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + membershipRepository = { + getTeamMembers: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + imageService = { + getDriverAvatar: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTeamMembersUseCase( + membershipRepository as unknown as ITeamMembershipRepository, + driverRepository as unknown as IDriverRepository, + imageService as unknown as IImageServicePort, + logger as unknown as Logger, + ); + }); + + it('should return team members with driver names and avatar urls', async () => { + const teamId = 'team-1'; + 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() }, + ]; + const driver1 = 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', + }); + + 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); + }); + imageService.getDriverAvatar.mockImplementation((id: string) => `avatar-${id}`); + + const result = await useCase.execute({ teamId }); + + 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', + }, + }); + }); + + it('should handle driver not found', async () => { + const teamId = 'team-1'; + const memberships = [ + { teamId, driverId: 'driver-1', role: 'owner' as const, status: 'active' as const, joinedAt: new Date() }, + ]; + + membershipRepository.getTeamMembers.mockResolvedValue(memberships); + driverRepository.findById.mockResolvedValue(null); + imageService.getDriverAvatar.mockReturnValue('avatar-driver-1'); + + const result = await useCase.execute({ teamId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + memberships, + 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.getTeamMembers.mockRejectedValue(error); + + const result = await useCase.execute({ teamId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve team members' }, + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetTeamMembersUseCase.ts b/core/racing/application/use-cases/GetTeamMembersUseCase.ts index 15006e993..52b49d108 100644 --- a/core/racing/application/use-cases/GetTeamMembersUseCase.ts +++ b/core/racing/application/use-cases/GetTeamMembersUseCase.ts @@ -1,34 +1,26 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IImageServicePort } from '../ports/IImageServicePort'; -import type { - ITeamMembersPresenter, - TeamMembersResultDTO, - TeamMembersViewModel, -} from '../presenters/ITeamMembersPresenter'; -import type { UseCase } from '@core/shared/application'; +import type { TeamMembersResultDTO } from '../presenters/ITeamMembersPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; import type { Logger } from '@core/shared/application'; /** * Use Case for retrieving team members. - * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetTeamMembersUseCase - implements UseCase<{ teamId: string }, TeamMembersResultDTO, TeamMembersViewModel, ITeamMembersPresenter> +export class GetTeamMembersUseCase implements AsyncUseCase<{ teamId: string }, TeamMembersResultDTO, 'REPOSITORY_ERROR'> { constructor( private readonly membershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, private readonly imageService: IImageServicePort, private readonly logger: Logger, - // Kept for backward compatibility; callers must pass their own presenter. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - public readonly presenter: ITeamMembersPresenter, ) {} - async execute(input: { teamId: string }, presenter: ITeamMembersPresenter): Promise { + async execute(input: { teamId: string }): Promise>> { this.logger.debug(`Executing GetTeamMembersUseCase for teamId: ${input.teamId}`); - presenter.reset(); try { const memberships = await this.membershipRepository.getTeamMembers(input.teamId); @@ -54,11 +46,10 @@ export class GetTeamMembersUseCase avatarUrls, }; - presenter.present(dto); - this.logger.info(`Successfully presented team members for teamId: ${input.teamId}`); + return Result.ok(dto); } catch (error) { - this.logger.error(`Error in GetTeamMembersUseCase for teamId: ${input.teamId}, error: ${error instanceof Error ? error.message : String(error)}`); - throw 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' } }); } } } \ 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 new file mode 100644 index 000000000..d42b76023 --- /dev/null +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetTeamsLeaderboardUseCase } 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'; + +describe('GetTeamsLeaderboardUseCase', () => { + let useCase: GetTeamsLeaderboardUseCase; + let teamRepository: { + findAll: Mock; + }; + let teamMembershipRepository: { + getTeamMembers: Mock; + }; + let driverRepository: { + findById: Mock; + }; + let getDriverStats: Mock; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + teamRepository = { + findAll: vi.fn(), + }; + teamMembershipRepository = { + getTeamMembers: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + getDriverStats = vi.fn(); + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTeamsLeaderboardUseCase( + teamRepository as unknown as ITeamRepository, + teamMembershipRepository as unknown as ITeamMembershipRepository, + driverRepository as unknown as IDriverRepository, + getDriverStats, + logger as unknown as Logger, + ); + }); + + it('should return teams leaderboard with calculated stats', async () => { + const team1 = Team.create({ + id: 'team-1', + name: 'Team Alpha', + tag: 'TA', + description: 'Description 1', + ownerId: 'owner-1', + leagues: [], + }); + const team2 = Team.create({ + id: 'team-2', + name: 'Team Beta', + tag: 'TB', + description: 'Description 2', + ownerId: 'owner-2', + leagues: [], + }); + const memberships1 = [ + { teamId: 'team-1', driverId: 'driver-1', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, + { teamId: 'team-1', driverId: 'driver-2', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, + ]; + const memberships2 = [ + { teamId: 'team-2', driverId: 'driver-3', role: 'driver' as const, status: 'active' as const, joinedAt: new Date() }, + ]; + + teamRepository.findAll.mockResolvedValue([team1, team2]); + teamMembershipRepository.getTeamMembers.mockImplementation((teamId: string) => { + if (teamId === 'team-1') return Promise.resolve(memberships1); + if (teamId === 'team-2') return Promise.resolve(memberships2); + return Promise.resolve([]); + }); + getDriverStats.mockImplementation((driverId: string) => { + if (driverId === 'driver-1') return { rating: 1500, wins: 5, totalRaces: 10 }; + if (driverId === 'driver-2') return { rating: 1600, wins: 3, totalRaces: 8 }; + if (driverId === 'driver-3') return { rating: null, wins: 2, totalRaces: 5 }; + return null; + }); + + const result = await useCase.execute(); + + 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', + 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', + memberCount: 1, + rating: null, + totalWins: 2, + totalRaces: 5, + performanceLevel: expect.any(String), + isRecruiting: true, + description: 'Description 2', + }); + }); + + it('should return error on repository failure', async () => { + const error = new Error('Repository error'); + + teamRepository.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve teams leaderboard' }, + }); + }); +}); \ 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 825d72c9f..cfc22bed6 100644 --- a/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetTeamsLeaderboardUseCase.ts @@ -1,13 +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 { - ITeamsLeaderboardPresenter, - TeamsLeaderboardResultDTO, - TeamsLeaderboardViewModel, -} from '@core/racing/application/presenters/ITeamsLeaderboardPresenter'; +import type { TeamsLeaderboardResultDTO } from '@core/racing/application/presenters/ITeamsLeaderboardPresenter'; import { SkillLevelService } from '@core/racing/domain/services/SkillLevelService'; -import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; interface DriverStatsAdapter { rating: number | null; @@ -15,74 +14,89 @@ interface DriverStatsAdapter { totalRaces: number; } +interface TeamLeaderboardItem { + id: string; + name: string; + memberCount: number; + rating: number | null; + totalWins: number; + totalRaces: number; + performanceLevel: string; + isRecruiting: boolean; + createdAt: Date; + description: string; +} + /** * Use case: GetTeamsLeaderboardUseCase - * - * Plain constructor-injected dependencies (no decorators) to keep the - * application layer framework-agnostic and compatible with test tooling. */ -export class GetTeamsLeaderboardUseCase - implements UseCase { +export class GetTeamsLeaderboardUseCase implements AsyncUseCase +{ constructor( private readonly teamRepository: ITeamRepository, private readonly teamMembershipRepository: ITeamMembershipRepository, private readonly driverRepository: IDriverRepository, private readonly getDriverStats: (driverId: string) => DriverStatsAdapter | null, + private readonly logger: Logger, ) {} - async execute(_input: void, presenter: ITeamsLeaderboardPresenter): Promise { - const allTeams = await this.teamRepository.findAll(); - const teams: unknown[] = []; + async execute(): Promise>> { + try { + const allTeams = await this.teamRepository.findAll(); + const teams: TeamLeaderboardItem[] = []; - await Promise.all( - allTeams.map(async (team) => { - const memberships = await this.teamMembershipRepository.getTeamMembers(team.id); - const memberCount = memberships.length; + await Promise.all( + allTeams.map(async (team) => { + const memberships = await this.teamMembershipRepository.getTeamMembers(team.id); + const memberCount = memberships.length; - let ratingSum = 0; - let ratingCount = 0; - let totalWins = 0; - let totalRaces = 0; + let ratingSum = 0; + let ratingCount = 0; + let totalWins = 0; + let totalRaces = 0; - for (const membership of memberships) { - const stats = this.getDriverStats(membership.driverId); - if (!stats) continue; + for (const membership of memberships) { + const stats = this.getDriverStats(membership.driverId); + if (!stats) continue; - if (typeof stats.rating === 'number') { - ratingSum += stats.rating; - ratingCount += 1; + if (typeof stats.rating === 'number') { + ratingSum += stats.rating; + ratingCount += 1; + } + + totalWins += stats.wins ?? 0; + totalRaces += stats.totalRaces ?? 0; } - totalWins += stats.wins ?? 0; - totalRaces += stats.totalRaces ?? 0; - } + const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; + const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating); - const averageRating = ratingCount > 0 ? ratingSum / ratingCount : null; - const performanceLevel = SkillLevelService.getTeamPerformanceLevel(averageRating); + teams.push({ + id: team.id, + name: team.name, + memberCount, + rating: averageRating, + totalWins, + totalRaces, + performanceLevel, + isRecruiting: true, + createdAt: new Date(), + description: team.description, + }); + }) + ); - teams.push({ - id: team.id, - name: team.name, - memberCount, - rating: averageRating, - totalWins, - totalRaces, - performanceLevel, - isRecruiting: true, - createdAt: new Date(), - description: team.description, - }); - }) - ); + const recruitingCount = teams.filter((t) => t.isRecruiting).length; - const recruitingCount = teams.filter((t) => t.isRecruiting).length; + const result: TeamsLeaderboardResultDTO = { + teams, + recruitingCount, + }; - const result: TeamsLeaderboardResultDTO = { - teams, - recruitingCount, - }; - - presenter.reset(); - presenter.present(result); + return Result.ok(result); + } 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' } }); + } } } \ 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 new file mode 100644 index 000000000..0c8473489 --- /dev/null +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetTotalDriversUseCase } from './GetTotalDriversUseCase'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { Driver } from '../../domain/entities/Driver'; +import type { Logger } from '@core/shared/application'; + +describe('GetTotalDriversUseCase', () => { + let useCase: GetTotalDriversUseCase; + let driverRepository: { + findAll: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + driverRepository = { + findAll: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTotalDriversUseCase( + driverRepository as unknown as IDriverRepository, + logger as unknown as Logger, + ); + }); + + 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' }), + ]; + + driverRepository.findAll.mockResolvedValue(drivers); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + totalDrivers: 2, + }); + }); + + it('should return error on repository failure', async () => { + const error = new Error('Repository error'); + + driverRepository.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve total drivers' }, + }); + }); +}); \ 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 d6cd5eb2f..b00d34f15 100644 --- a/core/racing/application/use-cases/GetTotalDriversUseCase.ts +++ b/core/racing/application/use-cases/GetTotalDriversUseCase.ts @@ -1,23 +1,31 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { ITotalDriversPresenter, TotalDriversResultDTO } from '../presenters/ITotalDriversPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { TotalDriversResultDTO } from '../presenters/ITotalDriversPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; /** * Use Case for retrieving total number of drivers. */ -export class GetTotalDriversUseCase - implements UseCase +export class GetTotalDriversUseCase implements AsyncUseCase { - constructor(private readonly driverRepository: IDriverRepository) {} + constructor( + private readonly driverRepository: IDriverRepository, + private readonly logger: Logger, + ) {} - async execute(_input: void, presenter: ITotalDriversPresenter): Promise { - presenter.reset(); + async execute(): Promise>> { + try { + const drivers = await this.driverRepository.findAll(); + const dto: TotalDriversResultDTO = { + totalDrivers: drivers.length, + }; - const drivers = await this.driverRepository.findAll(); - const dto: TotalDriversResultDTO = { - totalDrivers: drivers.length, - }; - - presenter.present(dto); + return Result.ok(dto); + } 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' } }); + } } } \ 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 new file mode 100644 index 000000000..98419ad6a --- /dev/null +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.test.ts @@ -0,0 +1,64 @@ +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'; + +describe('GetTotalLeaguesUseCase', () => { + let useCase: GetTotalLeaguesUseCase; + let leagueRepository: { + findAll: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + leagueRepository = { + findAll: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTotalLeaguesUseCase( + leagueRepository as unknown as ILeagueRepository, + logger as unknown as Logger, + ); + }); + + it('should return total number of leagues', async () => { + const leagues = [ + { id: 'league-1', name: 'League 1' }, + { id: 'league-2', name: 'League 2' }, + { id: 'league-3', name: 'League 3' }, + ]; + + leagueRepository.findAll.mockResolvedValue(leagues); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + totalLeagues: 3, + }); + }); + + it('should return error on repository failure', async () => { + const error = new Error('Repository error'); + + leagueRepository.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve total leagues' }, + }); + }); +}); \ 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 19a34a78a..7602370a2 100644 --- a/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalLeaguesUseCase.ts @@ -1,20 +1,26 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { IGetTotalLeaguesPresenter, GetTotalLeaguesResultDTO, GetTotalLeaguesViewModel } from '../presenters/IGetTotalLeaguesPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetTotalLeaguesResultDTO } from '../presenters/IGetTotalLeaguesPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; -export interface GetTotalLeaguesUseCaseParams {} +export class GetTotalLeaguesUseCase implements AsyncUseCase +{ + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly logger: Logger, + ) {} -export interface GetTotalLeaguesResultDTO { - totalLeagues: number; -} + async execute(): Promise>> { + try { + const leagues = await this.leagueRepository.findAll(); + const dto: GetTotalLeaguesResultDTO = { totalLeagues: leagues.length }; -export class GetTotalLeaguesUseCase implements UseCase { - constructor(private readonly leagueRepository: ILeagueRepository) {} - - async execute(params: GetTotalLeaguesUseCaseParams, presenter: IGetTotalLeaguesPresenter): Promise { - const leagues = await this.leagueRepository.findAll(); - const dto: GetTotalLeaguesResultDTO = { totalLeagues: leagues.length }; - presenter.reset(); - presenter.present(dto); + return Result.ok(dto); + } 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' } }); + } } } \ 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 new file mode 100644 index 000000000..89a75276b --- /dev/null +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.test.ts @@ -0,0 +1,63 @@ +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'; + +describe('GetTotalRacesUseCase', () => { + let useCase: GetTotalRacesUseCase; + let raceRepository: { + findAll: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + raceRepository = { + findAll: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new GetTotalRacesUseCase( + raceRepository as unknown as IRaceRepository, + logger as unknown as Logger, + ); + }); + + it('should return total number of races', async () => { + const races = [ + { id: 'race-1', name: 'Race 1' }, + { id: 'race-2', name: 'Race 2' }, + ]; + + raceRepository.findAll.mockResolvedValue(races); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + totalRaces: 2, + }); + }); + + it('should return error on repository failure', async () => { + const error = new Error('Repository error'); + + raceRepository.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Failed to retrieve total races' }, + }); + }); +}); \ 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 e779c5c29..7de365bab 100644 --- a/core/racing/application/use-cases/GetTotalRacesUseCase.ts +++ b/core/racing/application/use-cases/GetTotalRacesUseCase.ts @@ -1,20 +1,26 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; -import type { IGetTotalRacesPresenter, GetTotalRacesResultDTO, GetTotalRacesViewModel } from '../presenters/IGetTotalRacesPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetTotalRacesResultDTO } from '../presenters/IGetTotalRacesPresenter'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { AsyncUseCase } from '@core/shared/application'; +import type { Logger } from '@core/shared/application'; -export interface GetTotalRacesUseCaseParams {} +export class GetTotalRacesUseCase implements AsyncUseCase +{ + constructor( + private readonly raceRepository: IRaceRepository, + private readonly logger: Logger, + ) {} -export interface GetTotalRacesResultDTO { - totalRaces: number; -} + async execute(): Promise>> { + try { + const races = await this.raceRepository.findAll(); + const dto: GetTotalRacesResultDTO = { totalRaces: races.length }; -export class GetTotalRacesUseCase implements UseCase { - constructor(private readonly raceRepository: IRaceRepository) {} - - async execute(params: GetTotalRacesUseCaseParams, presenter: IGetTotalRacesPresenter): Promise { - const races = await this.raceRepository.findAll(); - const dto: GetTotalRacesResultDTO = { totalRaces: races.length }; - presenter.reset(); - presenter.present(dto); + return Result.ok(dto); + } 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' } }); + } } } \ 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 new file mode 100644 index 000000000..c2b109df1 --- /dev/null +++ b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.test.ts @@ -0,0 +1,175 @@ +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 type { Logger } from '@core/shared/application'; + +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; + }; + + beforeEach(() => { + 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(), + }; + useCase = new ImportRaceResultsApiUseCase( + raceRepository as unknown as IRaceRepository, + leagueRepository as unknown as ILeagueRepository, + resultRepository as unknown as IResultRepository, + driverRepository as unknown as IDriverRepository, + standingRepository as unknown as IStandingRepository, + logger as unknown as Logger, + ); + }); + + it('should return parse error for invalid JSON', async () => { + const params = { raceId: 'race-1', resultsFileContent: 'invalid json' }; + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'PARSE_ERROR', + details: { message: 'Invalid JSON in results file content' }, + }); + }); + + it('should return race not found error', async () => { + const params = { raceId: 'race-1', resultsFileContent: '[]' }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race race-1 not found' }, + }); + }); + + it('should return league not found error', async () => { + const params = { raceId: 'race-1', resultsFileContent: '[]' }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League league-1 not found' }, + }); + }); + + it('should return results exist error', async () => { + const params = { 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); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'RESULTS_EXIST', + details: { message: 'Results already exist for this race' }, + }); + }); + + 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}]' }; + + 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); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'DRIVER_NOT_FOUND', + details: { message: 'Driver with iRacing ID 123 not found' }, + }); + }); + + 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}]' }; + + 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.mockResolvedValue(undefined); + standingRepository.recalculate.mockResolvedValue(undefined); + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + success: true, + raceId: 'race-1', + driversProcessed: 1, + resultsRecorded: 1, + errors: [], + }); + 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'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts index d780c98a3..037f474a1 100644 --- a/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts +++ b/core/racing/application/use-cases/ImportRaceResultsApiUseCase.ts @@ -1,25 +1,124 @@ -import type { IImportRaceResultsApiPresenter, ImportRaceResultsApiResultDTO, ImportRaceResultsSummaryViewModel } from '../presenters/IImportRaceResultsApiPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +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 { Result } from '../../domain/entities/Result'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; +import type { ImportRaceResultsApiResultDTO } from '../presenters/IImportRaceResultsApiPresenter'; -export interface ImportRaceResultsApiParams { +export interface ImportRaceResultDTO { + id: string; raceId: string; - resultsFileContent: string; + driverId: string; + position: number; + fastestLap: number; + incidents: number; + startPosition: number; } -export class ImportRaceResultsApiUseCase implements UseCase { - constructor() {} // No repositories for mock +type ImportRaceResultsApiErrorCode = + | 'PARSE_ERROR' + | 'RACE_NOT_FOUND' + | 'LEAGUE_NOT_FOUND' + | 'RESULTS_EXIST' + | 'DRIVER_NOT_FOUND' + | 'REPOSITORY_ERROR'; - async execute(params: ImportRaceResultsApiParams, presenter: IImportRaceResultsApiPresenter): Promise { - // Mock implementation - const dto: ImportRaceResultsApiResultDTO = { - success: true, - raceId: params.raceId, - driversProcessed: 10, - resultsRecorded: 10, - errors: [], - }; +type ImportRaceResultsApiApplicationError = ApplicationErrorCode; - presenter.reset(); - presenter.present(dto); +export class ImportRaceResultsApiUseCase implements AsyncUseCase<{ raceId: string; resultsFileContent: string }, ImportRaceResultsApiResultDTO, ImportRaceResultsApiErrorCode> { + constructor( + private readonly raceRepository: IRaceRepository, + private readonly leagueRepository: ILeagueRepository, + private readonly resultRepository: IResultRepository, + private readonly driverRepository: IDriverRepository, + private readonly standingRepository: IStandingRepository, + private readonly logger: Logger, + ) {} + + async execute(params: { raceId: string; resultsFileContent: string }): Promise>> { + this.logger.debug('ImportRaceResultsApiUseCase:execute', { raceId: params.raceId }); + const { raceId, resultsFileContent } = params; + + 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' } }); + } + + 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` } }); + } + 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.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.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 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` } }); + } + 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 ImportRaceResultsApiApplicationError).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('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 dto: ImportRaceResultsApiResultDTO = { + success: true, + raceId, + driversProcessed: results.length, + resultsRecorded: validEntities.length, + 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' } }); + } } } \ 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 new file mode 100644 index 000000000..415ad7c8c --- /dev/null +++ b/core/racing/application/use-cases/ImportRaceResultsUseCase.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ImportRaceResultsUseCase } 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'; + +describe('ImportRaceResultsUseCase', () => { + let useCase: ImportRaceResultsUseCase; + 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; + }; + + beforeEach(() => { + 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(), + }; + useCase = new ImportRaceResultsUseCase( + raceRepository as unknown as IRaceRepository, + leagueRepository as unknown as ILeagueRepository, + resultRepository as unknown as IResultRepository, + driverRepository as unknown as IDriverRepository, + standingRepository as unknown as IStandingRepository, + logger as unknown as Logger, + ); + }); + + it('should return race not found error', async () => { + const params = { raceId: 'race-1', results: [] }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race race-1 not found' }, + }); + }); + + it('should return league not found error', async () => { + const params = { raceId: 'race-1', results: [] }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'LEAGUE_NOT_FOUND', + details: { message: 'League league-1 not found' }, + }); + }); + + it('should return results exist error', async () => { + const params = { raceId: 'race-1', results: [] }; + + 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); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'RESULTS_EXIST', + details: { message: 'Results already exist for this race' }, + }); + }); + + 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 }] }; + + 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); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'DRIVER_NOT_FOUND', + details: { message: 'Driver with iRacing ID 123 not found' }, + }); + }); + + 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 }] }; + + 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.mockResolvedValue(undefined); + standingRepository.recalculate.mockResolvedValue(undefined); + + const result = await useCase.execute(params); + + 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'); + }); +}); \ 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 6e7f9a158..b4434596e 100644 --- a/core/racing/application/use-cases/ImportRaceResultsUseCase.ts +++ b/core/racing/application/use-cases/ImportRaceResultsUseCase.ts @@ -5,14 +5,8 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import { Result } from '../../domain/entities/Result'; import type { AsyncUseCase, Logger } from '@core/shared/application'; -import { - BusinessRuleViolationError, - EntityNotFoundError, -} from '../errors/RacingApplicationError'; -import type { - IImportRaceResultsPresenter, - ImportRaceResultsSummaryViewModel, -} from '../presenters/IImportRaceResultsPresenter'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface ImportRaceResultDTO { id: string; @@ -28,9 +22,18 @@ export interface ImportRaceResultsParams { raceId: string; results: ImportRaceResultDTO[]; } - + +type ImportRaceResultsErrorCode = + | 'RACE_NOT_FOUND' + | 'LEAGUE_NOT_FOUND' + | 'RESULTS_EXIST' + | 'DRIVER_NOT_FOUND' + | 'REPOSITORY_ERROR'; + +type ImportRaceResultsApplicationError = ApplicationErrorCode; + export class ImportRaceResultsUseCase - implements AsyncUseCase + implements AsyncUseCase { constructor( private readonly raceRepository: IRaceRepository, @@ -38,73 +41,73 @@ export class ImportRaceResultsUseCase private readonly resultRepository: IResultRepository, private readonly driverRepository: IDriverRepository, private readonly standingRepository: IStandingRepository, - public readonly presenter: IImportRaceResultsPresenter, private readonly logger: Logger, ) {} - async execute(params: ImportRaceResultsParams): Promise { + async execute(params: ImportRaceResultsParams): Promise> { this.logger.debug('ImportRaceResultsUseCase:execute', { params }); const { raceId, results } = params; + 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 }); + try { - const race = await this.raceRepository.findById(raceId); - if (!race) { - this.logger.warn(`ImportRaceResultsUseCase: Race with ID ${raceId} not found.`); - throw new EntityNotFoundError({ entity: 'race', id: raceId }); - } - 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}.`); - throw new EntityNotFoundError({ entity: 'league', id: race.leagueId }); - } - 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}.`); - throw new BusinessRuleViolationError('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 = 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}.`); - throw new BusinessRuleViolationError(`Driver with iRacing ID ${dto.driverId} not found`); - } - return Result.create({ - id: dto.id, - raceId: dto.raceId, - driverId: driver.id, - position: dto.position, - fastestLap: dto.fastestLap, - incidents: dto.incidents, - startPosition: dto.startPosition, - }); - }), - ); - this.logger.debug('ImportRaceResultsUseCase:entities created', { count: entities.length }); - - await this.resultRepository.createMany(entities); + 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 }); - const viewModel: ImportRaceResultsSummaryViewModel = { - importedCount: results.length, - standingsRecalculated: true, - }; - this.logger.debug('ImportRaceResultsUseCase:presenting view model', { viewModel }); - - this.presenter.present(viewModel); + return SharedResult.ok(undefined); } catch (error) { - this.logger.error('ImportRaceResultsUseCase:execution error', { error }); - throw 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' } }); } } } diff --git a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts new file mode 100644 index 000000000..7ec4d68af --- /dev/null +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { IsDriverRegisteredForRaceUseCase } from './IsDriverRegisteredForRaceUseCase'; +import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { Logger } from '@core/shared/application'; + +describe('IsDriverRegisteredForRaceUseCase', () => { + let useCase: IsDriverRegisteredForRaceUseCase; + let registrationRepository: { + isRegistered: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + registrationRepository = { + isRegistered: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new IsDriverRegisteredForRaceUseCase( + registrationRepository as unknown as IRaceRegistrationRepository, + logger as unknown as Logger, + ); + }); + + it('should return true when driver is registered', async () => { + const params = { 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); + }); + + it('should return false when driver is not registered', async () => { + const params = { 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); + }); + + it('should return error on repository failure', async () => { + const params = { raceId: 'race-1', driverId: 'driver-1' }; + const error = new Error('Repository error'); + + registrationRepository.isRegistered.mockRejectedValue(error); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Repository error' }, + }); + }); +}); \ 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 ed36f705f..ce9a0b2d7 100644 --- a/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts +++ b/core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase.ts @@ -1,23 +1,36 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; import type { IsDriverRegisteredForRaceQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO'; -import type { IDriverRegistrationStatusPresenter } from '../presenters/IDriverRegistrationStatusPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +type IsDriverRegisteredForRaceErrorCode = 'REPOSITORY_ERROR'; + +type IsDriverRegisteredForRaceApplicationError = ApplicationErrorCode; /** * Use Case: IsDriverRegisteredForRaceUseCase * * Checks if a driver is registered for a specific race. - * Orchestrates domain logic and delegates presentation to the presenter. */ export class IsDriverRegisteredForRaceUseCase - implements UseCase + implements AsyncUseCase { - constructor(private readonly registrationRepository: IRaceRegistrationRepository) {} + constructor( + private readonly registrationRepository: IRaceRegistrationRepository, + private readonly logger: Logger, + ) {} - async execute(params: IsDriverRegisteredForRaceQueryParamsDTO, presenter: IDriverRegistrationStatusPresenter): Promise { - presenter.reset(); + async execute(params: IsDriverRegisteredForRaceQueryParamsDTO): Promise> { + this.logger.debug('IsDriverRegisteredForRaceUseCase:execute', { params }); const { raceId, driverId } = params; - const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); - presenter.present(isRegistered, raceId, driverId); + + try { + const isRegistered = await this.registrationRepository.isRegistered(raceId, driverId); + return SharedResult.ok(isRegistered); + } 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' } }); + } } } \ 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 new file mode 100644 index 000000000..c59d72692 --- /dev/null +++ b/core/racing/application/use-cases/JoinLeagueUseCase.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { JoinLeagueUseCase } from './JoinLeagueUseCase'; +import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('JoinLeagueUseCase', () => { + let useCase: JoinLeagueUseCase; + let membershipRepository: { + getMembership: Mock; + saveMembership: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + membershipRepository = { + getMembership: vi.fn(), + saveMembership: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new JoinLeagueUseCase( + membershipRepository as unknown as ILeagueMembershipRepository, + logger as unknown as Logger, + ); + }); + + it('should join league successfully', async () => { + const command = { 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(), + }); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + id: 'membership-1', + leagueId: 'league-1', + driverId: 'driver-1', + role: 'member', + status: 'active', + joinedAt: expect.any(Date), + }); + }); + + it('should return error when already a member', async () => { + const command = { leagueId: 'league-1', driverId: 'driver-1' }; + + membershipRepository.getMembership.mockResolvedValue({ + id: 'membership-1', + leagueId: 'league-1', + driverId: 'driver-1', + role: 'member', + status: 'active', + joinedAt: new Date(), + }); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'ALREADY_MEMBER', + details: { message: 'Already a member or have a pending request' }, + }); + }); + + it('should return error on repository failure', async () => { + const command = { leagueId: 'league-1', driverId: 'driver-1' }; + const error = new Error('Repository error'); + + membershipRepository.getMembership.mockRejectedValue(error); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Repository error' }, + }); + }); +}); \ 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 b52481ecd..fb1db6c26 100644 --- a/core/racing/application/use-cases/JoinLeagueUseCase.ts +++ b/core/racing/application/use-cases/JoinLeagueUseCase.ts @@ -1,13 +1,17 @@ import type { Logger } from '@core/shared/application'; -import type { - ILeagueMembershipRepository, -} from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; +import { LeagueMembership, type MembershipRole, type MembershipStatus } from '../../domain/entities/LeagueMembership'; import type { AsyncUseCase } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { JoinLeagueCommandDTO } from '../dto/JoinLeagueCommandDTO'; -import { BusinessRuleViolationError } from '../errors/RacingApplicationError'; - -export class JoinLeagueUseCase implements AsyncUseCase { + +type JoinLeagueErrorCode = 'ALREADY_MEMBER' | 'REPOSITORY_ERROR'; + +type JoinLeagueApplicationError = ApplicationErrorCode; + +export class JoinLeagueUseCase implements AsyncUseCase { constructor( private readonly membershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, @@ -17,10 +21,10 @@ export class JoinLeagueUseCase implements AsyncUseCase { + async execute(command: JoinLeagueCommandDTO): Promise> { this.logger.debug('Attempting to join league', { command }); const { leagueId, driverId } = command; @@ -28,7 +32,7 @@ export class JoinLeagueUseCase implements AsyncUseCase { + let useCase: JoinTeamUseCase; + let teamRepository: { + findById: Mock; + }; + let membershipRepository: { + getActiveMembershipForDriver: Mock; + getMembership: Mock; + saveMembership: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + teamRepository = { + findById: vi.fn(), + }; + membershipRepository = { + getActiveMembershipForDriver: vi.fn(), + getMembership: vi.fn(), + saveMembership: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new JoinTeamUseCase( + teamRepository as unknown as ITeamRepository, + membershipRepository as unknown as ITeamMembershipRepository, + logger as unknown as Logger, + ); + }); + + it('should join team successfully', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + membershipRepository.getMembership.mockResolvedValue(null); + teamRepository.findById.mockResolvedValue({ id: 'team-1' }); + membershipRepository.saveMembership.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + }); + + it('should return error when driver already in a team', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ + teamId: 'team-2', + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: new Date(), + }); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'ALREADY_IN_TEAM', + details: { message: 'Driver already belongs to a team' }, + }); + }); + + it('should return error when already a member', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'driver-1', + role: 'driver', + status: 'pending', + joinedAt: new Date(), + }); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'ALREADY_MEMBER', + details: { message: 'Already a member or have a pending request' }, + }); + }); + + it('should return error when team not found', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + membershipRepository.getMembership.mockResolvedValue(null); + teamRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'TEAM_NOT_FOUND', + details: { message: 'Team team-1 not found' }, + }); + }); + + it('should return error on repository failure', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + const error = new Error('Repository error'); + + membershipRepository.getActiveMembershipForDriver.mockRejectedValue(error); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Repository error' }, + }); + }); +}); \ 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 81643d359..3d6e0598b 100644 --- a/core/racing/application/use-cases/JoinTeamUseCase.ts +++ b/core/racing/application/use-cases/JoinTeamUseCase.ts @@ -6,21 +6,22 @@ import type { TeamRole, } from '../../domain/types/TeamMembership'; import type { JoinTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO'; -import type { AsyncUseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; -import { - BusinessRuleViolationError, - EntityNotFoundError, -} from '../errors/RacingApplicationError'; - -export class JoinTeamUseCase implements AsyncUseCase { +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +type JoinTeamErrorCode = 'ALREADY_IN_TEAM' | 'ALREADY_MEMBER' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR'; + +type JoinTeamApplicationError = ApplicationErrorCode; + +export class JoinTeamUseCase implements AsyncUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, private readonly logger: Logger, ) {} - async execute(command: JoinTeamCommandDTO): Promise { + async execute(command: JoinTeamCommandDTO): Promise> { this.logger.debug('Attempting to join team', { command }); const { teamId, driverId } = command; @@ -30,19 +31,19 @@ export class JoinTeamUseCase implements AsyncUseCase { ); if (existingActive) { this.logger.warn('Driver already belongs to a team', { driverId, teamId }); - throw new BusinessRuleViolationError('Driver already belongs to a team'); + return SharedResult.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } }); } const existingMembership = await this.membershipRepository.getMembership(teamId, driverId); if (existingMembership) { this.logger.warn('Driver already has a pending or active membership request', { driverId, teamId }); - throw new BusinessRuleViolationError('Already a member or have a pending request'); + return SharedResult.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 }); - throw new EntityNotFoundError({ entity: 'team', id: teamId }); + return SharedResult.err({ code: 'TEAM_NOT_FOUND', details: { message: `Team ${teamId} not found` } }); } const membership: TeamMembership = { @@ -55,12 +56,10 @@ export class JoinTeamUseCase implements AsyncUseCase { await this.membershipRepository.saveMembership(membership); this.logger.info('Driver successfully joined team', { driverId, teamId }); + return SharedResult.ok(undefined); } catch (error) { - if (error instanceof BusinessRuleViolationError || error instanceof EntityNotFoundError) { - throw error; - } - this.logger.error('Failed to join team due to an unexpected error', { error, command }); - throw 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' } }); } } } \ 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 new file mode 100644 index 000000000..1c83d1ad4 --- /dev/null +++ b/core/racing/application/use-cases/LeaveTeamUseCase.test.ts @@ -0,0 +1,102 @@ +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'; + +describe('LeaveTeamUseCase', () => { + let useCase: LeaveTeamUseCase; + let membershipRepository: { + getMembership: Mock; + removeMembership: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + membershipRepository = { + getMembership: vi.fn(), + removeMembership: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new LeaveTeamUseCase( + membershipRepository as unknown as ITeamMembershipRepository, + logger as unknown as Logger, + ); + }); + + it('should leave team successfully', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: new Date(), + }); + membershipRepository.removeMembership.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + }); + + it('should return error when not a member', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'NOT_MEMBER', + details: { message: 'Not a member of this team' }, + }); + }); + + it('should return error when owner tries to leave', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + + membershipRepository.getMembership.mockResolvedValue({ + teamId: 'team-1', + driverId: 'driver-1', + role: 'owner', + status: 'active', + joinedAt: new Date(), + }); + + const result = await useCase.execute(command); + + 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.' }, + }); + }); + + it('should return error on repository failure', async () => { + const command = { teamId: 'team-1', driverId: 'driver-1' }; + const error = new Error('Repository error'); + + membershipRepository.getMembership.mockRejectedValue(error); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'REPOSITORY_ERROR', + details: { message: 'Repository error' }, + }); + }); +}); \ 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 9541d18c3..c7388da9d 100644 --- a/core/racing/application/use-cases/LeaveTeamUseCase.ts +++ b/core/racing/application/use-cases/LeaveTeamUseCase.ts @@ -1,25 +1,44 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { LeaveTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; -export class LeaveTeamUseCase { +type LeaveTeamErrorCode = 'NOT_MEMBER' | 'OWNER_CANNOT_LEAVE' | 'REPOSITORY_ERROR'; + +type LeaveTeamApplicationError = ApplicationErrorCode; + +export class LeaveTeamUseCase implements AsyncUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, + private readonly logger: Logger, ) {} - async execute(command: LeaveTeamCommandDTO): Promise { + async execute(command: LeaveTeamCommandDTO): Promise> { + this.logger.debug('Attempting to leave team', { command }); const { teamId, driverId } = command; - const membership = await this.membershipRepository.getMembership(teamId, driverId); - if (!membership) { - throw new Error('Not a member of this team'); - } + try { + 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' } }); + } - if (membership.role === 'owner') { - throw new Error( - 'Team owner cannot leave. Transfer ownership or disband team first.', - ); - } + if (membership.role === 'owner') { + this.logger.warn('Team owner cannot leave', { driverId, teamId }); + return SharedResult.err({ + code: 'OWNER_CANNOT_LEAVE', + details: { message: 'Team owner cannot leave. Transfer ownership or disband team first.' } + }); + } - await this.membershipRepository.removeMembership(teamId, driverId); + await this.membershipRepository.removeMembership(teamId, driverId); + this.logger.info('Driver successfully left team', { driverId, teamId }); + return SharedResult.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' } }); + } } } \ 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 new file mode 100644 index 000000000..24ffca774 --- /dev/null +++ b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ListLeagueScoringPresetsUseCase } from './ListLeagueScoringPresetsUseCase'; +import { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; + +describe('ListLeagueScoringPresetsUseCase', () => { + let useCase: ListLeagueScoringPresetsUseCase; + let presetProvider: { + listPresets: Mock; + }; + + beforeEach(() => { + presetProvider = { + listPresets: vi.fn(), + }; + useCase = new ListLeagueScoringPresetsUseCase( + presetProvider as unknown as LeagueScoringPresetProvider, + ); + }); + + it('should list presets successfully', async () => { + const mockPresets = [ + { id: 'preset-1', name: 'Preset 1' }, + { id: 'preset-2', name: 'Preset 2' }, + ]; + + presetProvider.listPresets.mockResolvedValue(mockPresets); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + presets: mockPresets, + }); + }); +}); \ 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 79294ee0b..629eb6ceb 100644 --- a/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts +++ b/core/racing/application/use-cases/ListLeagueScoringPresetsUseCase.ts @@ -1,28 +1,25 @@ import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; -import type { - ILeagueScoringPresetsPresenter, - LeagueScoringPresetsResultDTO, - LeagueScoringPresetsViewModel, -} from '../presenters/ILeagueScoringPresetsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { LeagueScoringPresetsResultDTO } from '../presenters/ILeagueScoringPresetsPresenter'; +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; /** * Use Case for listing league scoring presets. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and returns result. */ export class ListLeagueScoringPresetsUseCase - implements UseCase + implements AsyncUseCase { constructor(private readonly presetProvider: LeagueScoringPresetProvider) {} - async execute(_input: void, presenter: ILeagueScoringPresetsPresenter): Promise { + async execute(): Promise>> { const presets = await this.presetProvider.listPresets(); const dto: LeagueScoringPresetsResultDTO = { presets, }; - presenter.reset(); - presenter.present(dto); + return Result.ok(dto); } } \ 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 new file mode 100644 index 000000000..0990f4768 --- /dev/null +++ b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; + +import { + InMemorySeasonRepository, +} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; +import { Season } from '@core/racing/domain/entities/Season'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { + ListSeasonsForLeagueUseCase, +} from '@core/racing/application/use-cases/ListSeasonsForLeagueUseCase'; +import type { Logger } from '@core/shared/application'; + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { + return { + findById: async (id: string) => seed.find((l) => l.id === id) ?? null, + findAll: async () => seed, + create: async (league: any) => league, + update: async (league: any) => league, + } as unknown as ILeagueRepository; +} + +describe('ListSeasonsForLeagueUseCase', () => { + it('lists seasons for a league with summaries', async () => { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo = new InMemorySeasonRepository(logger); + + const s1 = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Season One', + status: 'planned', + }); + const s2 = Season.create({ + id: 'season-2', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Season Two', + status: 'active', + }); + const sOtherLeague = Season.create({ + id: 'season-3', + leagueId: 'league-2', + gameId: 'iracing', + name: 'Season Other', + status: 'planned', + }); + + await seasonRepo.add(s1); + await seasonRepo.add(s2); + await seasonRepo.add(sOtherLeague); + + const useCase = new ListSeasonsForLeagueUseCase(leagueRepo, seasonRepo); + + const result = await useCase.execute({ leagueId: 'league-1' }); + + expect(result.isOk()).toBe(true); + expect(result.value.items.map((i) => i.seasonId).sort()).toEqual([ + 'season-1', + 'season-2', + ]); + expect(result.value.items.every((i) => i.leagueId === 'league-1')).toBe(true); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts new file mode 100644 index 000000000..54376d447 --- /dev/null +++ b/core/racing/application/use-cases/ListSeasonsForLeagueUseCase.ts @@ -0,0 +1,60 @@ +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface SeasonSummaryDTO { + seasonId: string; + leagueId: string; + name: string; + status: import('../../domain/entities/Season').SeasonStatus; + startDate?: Date; + endDate?: Date; + isPrimary: boolean; +} + +export interface ListSeasonsForLeagueQuery { + leagueId: string; +} + +export interface ListSeasonsForLeagueResultDTO { + items: SeasonSummaryDTO[]; +} + +type ListSeasonsForLeagueErrorCode = 'LEAGUE_NOT_FOUND'; + +/** + * ListSeasonsForLeagueUseCase + */ +export class ListSeasonsForLeagueUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly seasonRepository: ISeasonRepository, + ) {} + + async execute( + query: ListSeasonsForLeagueQuery, + ): Promise>> { + 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); + 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 new file mode 100644 index 000000000..84adadfa1 --- /dev/null +++ b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; + +import { + InMemorySeasonRepository, +} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; +import { Season } from '@core/racing/domain/entities/Season'; +import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; +import { + ManageSeasonLifecycleUseCase, + type ManageSeasonLifecycleCommand, +} from '@core/racing/application/use-cases/ManageSeasonLifecycleUseCase'; +import type { Logger } from '@core/shared/application'; + +const logger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository { + return { + findById: async (id: string) => seed.find((l) => l.id === id) ?? null, + findAll: async () => seed, + create: async (league: any) => league, + update: async (league: any) => league, + } as unknown as ILeagueRepository; +} + +describe('ManageSeasonLifecycleUseCase', () => { + function setupLifecycleTest() { + const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]); + const seasonRepo = new InMemorySeasonRepository(logger); + + const season = Season.create({ + id: 'season-1', + leagueId: 'league-1', + gameId: 'iracing', + name: 'Lifecycle Season', + status: 'planned', + }); + + seasonRepo.seed(season); + + const useCase = new ManageSeasonLifecycleUseCase(leagueRepo, seasonRepo); + + return { leagueRepo, seasonRepo, useCase, season }; + } + + it('applies activate → complete → archive transitions and persists state', async () => { + const { useCase, seasonRepo, season } = setupLifecycleTest(); + + const activateCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: season.id, + transition: 'activate', + }; + + const activated = await useCase.execute(activateCommand); + expect(activated.isOk()).toBe(true); + expect(activated.value.status).toBe('active'); + + const completeCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: season.id, + transition: 'complete', + }; + + const completed = await useCase.execute(completeCommand); + expect(completed.isOk()).toBe(true); + expect(completed.value.status).toBe('completed'); + + const archiveCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: season.id, + transition: 'archive', + }; + + const archived = await useCase.execute(archiveCommand); + expect(archived.isOk()).toBe(true); + expect(archived.value.status).toBe('archived'); + + const persisted = await seasonRepo.findById(season.id); + expect(persisted!.status).toBe('archived'); + }); + + it('propagates domain invariant errors for invalid transitions', async () => { + const { useCase, seasonRepo, season } = setupLifecycleTest(); + + const completeCommand: ManageSeasonLifecycleCommand = { + leagueId: 'league-1', + seasonId: season.id, + transition: 'complete', + }; + + const result = await useCase.execute(completeCommand); + expect(result.isErr()).toBe(true); + expect(result.error.code).toBe('INVALID_TRANSITION'); + + 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/ManageSeasonLifecycleUseCase.ts b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts new file mode 100644 index 000000000..7ab08ed95 --- /dev/null +++ b/core/racing/application/use-cases/ManageSeasonLifecycleUseCase.ts @@ -0,0 +1,89 @@ +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export type SeasonLifecycleTransition = + | 'activate' + | 'complete' + | 'archive' + | 'cancel'; + +export interface ManageSeasonLifecycleCommand { + leagueId: string; + seasonId: string; + transition: SeasonLifecycleTransition; +} + +export interface ManageSeasonLifecycleResultDTO { + seasonId: string; + status: import('../../domain/entities/Season').SeasonStatus; + startDate?: Date; + endDate?: Date; +} + +type ManageSeasonLifecycleErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'INVALID_TRANSITION'; + +/** + * ManageSeasonLifecycleUseCase + */ +export class ManageSeasonLifecycleUseCase { + constructor( + private readonly leagueRepository: ILeagueRepository, + private readonly seasonRepository: ISeasonRepository, + ) {} + + 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; + 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`); + } + } catch (error) { + return Result.err({ + code: 'INVALID_TRANSITION', + details: { message: `Invalid transition: ${(error as Error).message}` }, + }); + } + + 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/MembershipUseCases.test.ts b/core/racing/application/use-cases/MembershipUseCases.test.ts index 69156bb8d..012164dfe 100644 --- a/core/racing/application/use-cases/MembershipUseCases.test.ts +++ b/core/racing/application/use-cases/MembershipUseCases.test.ts @@ -1,11 +1,14 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; import { JoinLeagueUseCase } from '@core/racing/application/use-cases/JoinLeagueUseCase'; import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; - +import type { Logger } from '@core/shared/application'; +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; +import type { JoinRequest } from '@core/racing/domain/entities/LeagueMembership'; class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository { private memberships: LeagueMembership[] = []; + private joinRequests: JoinRequest[] = []; async getMembership(leagueId: string, driverId: string): Promise { return ( @@ -15,23 +18,15 @@ class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository ); } - async getActiveMembershipForDriver(driverId: string): Promise { - return ( - this.memberships.find( - (m) => m.driverId === driverId && m.status === 'active', - ) || null - ); - } - async getLeagueMembers(leagueId: string): Promise { return this.memberships.filter( (m) => m.leagueId === leagueId && m.status === 'active', ); } - async getTeamMembers(leagueId: string): Promise { - return this.memberships.filter( - (m) => m.leagueId === leagueId && m.status === 'active', + async getJoinRequests(leagueId: string): Promise { + return this.joinRequests.filter( + (r) => r.leagueId === leagueId, ); } @@ -55,16 +50,15 @@ class InMemoryLeagueMembershipRepository implements ILeagueMembershipRepository ); } - async getJoinRequests(): Promise { - throw new Error('Not implemented for this test'); + async saveJoinRequest(request: JoinRequest): Promise { + this.joinRequests.push(request); + return request; } - async saveJoinRequest(): Promise { - throw new Error('Not implemented for this test'); - } - - async removeJoinRequest(): Promise { - throw new Error('Not implemented for this test'); + async removeJoinRequest(requestId: string): Promise { + this.joinRequests = this.joinRequests.filter( + (r) => r.id !== requestId, + ); } seedMembership(membership: LeagueMembership): void { @@ -80,28 +74,43 @@ describe('Membership use-cases', () => { describe('JoinLeagueUseCase', () => { let repository: InMemoryLeagueMembershipRepository; let useCase: JoinLeagueUseCase; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; beforeEach(() => { repository = new InMemoryLeagueMembershipRepository(); - useCase = new JoinLeagueUseCase(repository); + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new JoinLeagueUseCase( + repository as unknown as ILeagueMembershipRepository, + logger as unknown as Logger, + ); }); it('creates an active member when driver has no membership', async () => { const leagueId = 'league-1'; const driverId = 'driver-1'; - await useCase.execute({ leagueId, driverId }); + const result = await useCase.execute({ leagueId, driverId }); - const membership = await repository.getMembership(leagueId, driverId); - expect(membership).not.toBeNull(); - expect(membership?.leagueId).toBe(leagueId); - expect(membership?.driverId).toBe(driverId); - expect(membership?.role as MembershipRole).toBe('member'); - expect(membership?.status as MembershipStatus).toBe('active'); - expect(membership?.joinedAt).toBeInstanceOf(Date); + expect(result.isOk()).toBe(true); + const membership = result.unwrap(); + expect(membership.leagueId).toBe(leagueId); + expect(membership.driverId).toBe(driverId); + expect(membership.role).toBe('member'); + expect(membership.status).toBe('active'); + expect(membership.joinedAt).toBeInstanceOf(Date); }); - it('throws when driver already has membership for league', async () => { + it('returns error when driver already has membership for league', async () => { const leagueId = 'league-1'; const driverId = 'driver-1'; @@ -113,9 +122,13 @@ describe('Membership use-cases', () => { joinedAt: new Date('2024-01-01'), })); - await expect( - useCase.execute({ leagueId, driverId }), - ).rejects.toThrow('Already a member or have a pending request'); + const result = await useCase.execute({ leagueId, driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'ALREADY_MEMBER', + details: { message: 'Already a member or have a pending request' }, + }); }); }); }); \ 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 new file mode 100644 index 000000000..719b8da2a --- /dev/null +++ b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { PreviewLeagueScheduleUseCase } from './PreviewLeagueScheduleUseCase'; +import type { Logger } from '@core/shared/application'; + +describe('PreviewLeagueScheduleUseCase', () => { + let useCase: PreviewLeagueScheduleUseCase; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new PreviewLeagueScheduleUseCase( + undefined, + logger as unknown as Logger, + ); + }); + + it('should preview schedule successfully', async () => { + const params = { + schedule: { + seasonStartDate: '2024-01-01', + recurrenceStrategy: 'weekly' as const, + weekdays: ['Mon' as const], + 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'); + }); + + 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); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'INVALID_SCHEDULE', + details: { message: 'Invalid schedule data' }, + }); + }); +}); \ 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 5ef00501d..ead609ad8 100644 --- a/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts +++ b/core/racing/application/use-cases/PreviewLeagueScheduleUseCase.ts @@ -1,24 +1,36 @@ import { SeasonScheduleGenerator } from '../../domain/services/SeasonScheduleGenerator'; import type { LeagueSchedulePreviewDTO, LeagueScheduleDTO } from '../dto/LeagueScheduleDTO'; import { scheduleDTOToSeasonSchedule } from '../dto/LeagueScheduleDTO'; -import type { ILeagueSchedulePreviewPresenter } from '../presenters/ILeagueSchedulePreviewPresenter'; +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'; interface PreviewLeagueScheduleQueryParams { schedule: LeagueScheduleDTO; maxRounds?: number; } -export class PreviewLeagueScheduleUseCase { +type PreviewLeagueScheduleErrorCode = 'INVALID_SCHEDULE'; + +type PreviewLeagueScheduleApplicationError = ApplicationErrorCode; + +export class PreviewLeagueScheduleUseCase implements AsyncUseCase { constructor( private readonly scheduleGenerator: typeof SeasonScheduleGenerator = SeasonScheduleGenerator, - private readonly presenter: ILeagueSchedulePreviewPresenter, + private readonly logger: Logger, ) {} - execute(params: PreviewLeagueScheduleQueryParams): void { - const seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule); + async execute(params: PreviewLeagueScheduleQueryParams): Promise> { + this.logger.debug('Previewing league schedule', { params }); - if (!seasonSchedule) { - throw new Error('Invalid schedule data'); + let seasonSchedule: SeasonSchedule; + try { + seasonSchedule = scheduleDTOToSeasonSchedule(params.schedule); + } 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 = @@ -36,10 +48,13 @@ export class PreviewLeagueScheduleUseCase { const summary = this.buildSummary(params.schedule, rounds); - this.presenter.present({ + const result: LeagueSchedulePreviewDTO = { rounds, summary, - }); + }; + + this.logger.info('Successfully generated league schedule preview', { roundCount: rounds.length }); + return Result.ok(result); } private buildSummary( diff --git a/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts b/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts new file mode 100644 index 000000000..be21eb5c9 --- /dev/null +++ b/core/racing/application/use-cases/QuickPenaltyUseCase.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { QuickPenaltyUseCase } 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'; + +describe('QuickPenaltyUseCase', () => { + let useCase: QuickPenaltyUseCase; + let penaltyRepository: { + create: Mock; + }; + let raceRepository: { + findById: Mock; + }; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + penaltyRepository = { + create: vi.fn(), + }; + raceRepository = { + findById: vi.fn(), + }; + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new QuickPenaltyUseCase( + penaltyRepository as unknown as IPenaltyRepository, + raceRepository as unknown as IRaceRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + logger as unknown as Logger, + ); + }); + + it('should apply penalty successfully', async () => { + const command = { + raceId: 'race-1', + driverId: 'driver-1', + adminId: 'admin-1', + infractionType: 'track_limits' as const, + severity: 'minor' as const, + notes: 'Test penalty', + }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ + { driverId: 'admin-1', role: 'admin', status: 'active' }, + ]); + penaltyRepository.create.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toHaveProperty('penaltyId'); + }); + + it('should return error when race not found', async () => { + const command = { + raceId: 'race-1', + driverId: 'driver-1', + adminId: 'admin-1', + infractionType: 'track_limits' as const, + severity: 'minor' as const, + }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'RACE_NOT_FOUND', + details: { message: 'Race not found' }, + }); + }); + + it('should return error when admin unauthorized', async () => { + const command = { + raceId: 'race-1', + driverId: 'driver-1', + adminId: 'admin-1', + infractionType: 'track_limits' as const, + severity: 'minor' as const, + }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ + { driverId: 'admin-1', role: 'member', status: 'active' }, + ]); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'UNAUTHORIZED', + details: { message: 'Only league owners and admins can issue penalties' }, + }); + }); + + it('should handle other infraction type', async () => { + const command = { + raceId: 'race-1', + driverId: 'driver-1', + adminId: 'admin-1', + infractionType: 'other' as const, + severity: 'major' as const, + }; + + raceRepository.findById.mockResolvedValue({ id: 'race-1', leagueId: 'league-1' }); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([ + { driverId: 'admin-1', role: 'admin', status: 'active' }, + ]); + penaltyRepository.create.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + }); +}); \ 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 e5517e512..b4bd50848 100644 --- a/core/racing/application/use-cases/QuickPenaltyUseCase.ts +++ b/core/racing/application/use-cases/QuickPenaltyUseCase.ts @@ -12,6 +12,12 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea import { randomUUID } from 'crypto'; import type { AsyncUseCase } 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'; + +type QuickPenaltyErrorCode = 'RACE_NOT_FOUND' | 'UNAUTHORIZED' | 'UNKNOWN_INFRACTION' | 'REPOSITORY_ERROR'; + +type QuickPenaltyApplicationError = ApplicationErrorCode; export interface QuickPenaltyCommand { raceId: string; @@ -23,7 +29,7 @@ export interface QuickPenaltyCommand { } export class QuickPenaltyUseCase - implements AsyncUseCase { + implements AsyncUseCase { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly raceRepository: IRaceRepository, @@ -31,14 +37,15 @@ export class QuickPenaltyUseCase private readonly logger: Logger, ) {} - async execute(command: QuickPenaltyCommand): Promise<{ penaltyId: string }> { + async execute(command: QuickPenaltyCommand): Promise> { this.logger.debug('Executing QuickPenaltyUseCase', { command }); + try { // Validate race exists const race = await this.raceRepository.findById(command.raceId); if (!race) { this.logger.warn('Race not found', { raceId: command.raceId }); - throw new Error('Race not found'); + return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } }); } // Validate admin has authority @@ -49,15 +56,22 @@ export class QuickPenaltyUseCase if (!adminMembership || (adminMembership.role !== 'owner' && adminMembership.role !== 'admin')) { this.logger.warn('Unauthorized admin attempting to issue penalty', { adminId: command.adminId, leagueId: race.leagueId }); - throw new Error('Only league owners and admins can issue penalties'); + return Result.err({ code: 'UNAUTHORIZED', details: { message: 'Only league owners and admins can issue penalties' } }); } // Map infraction + severity to penalty type and value - const { type, value, reason } = this.mapInfractionToPenalty( + const penaltyMapping = this.mapInfractionToPenalty( command.infractionType, command.severity ); + if (!penaltyMapping) { + this.logger.error('Unknown infraction type', { infractionType: command.infractionType, severity: command.severity }); + return Result.err({ code: 'UNKNOWN_INFRACTION', details: { message: 'Unknown infraction type' } }); + } + + const { type, value, reason } = penaltyMapping; + // Create the penalty const penalty = Penalty.create({ id: randomUUID(), @@ -77,17 +91,17 @@ export class QuickPenaltyUseCase await this.penaltyRepository.create(penalty); this.logger.info('Quick penalty applied successfully', { penaltyId: penalty.id, raceId: command.raceId, driverId: command.driverId }); - return { penaltyId: penalty.id }; + return Result.ok({ penaltyId: penalty.id }); } catch (error) { - this.logger.error('Failed to apply quick penalty', { command, error: error.message }); - throw 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' } }); } } private mapInfractionToPenalty( infractionType: QuickPenaltyCommand['infractionType'], severity: QuickPenaltyCommand['severity'] - ): { type: PenaltyType; value?: number; reason: string } { + ): { type: PenaltyType; value?: number; reason: string } | null { const severityMultipliers = { warning: 1, minor: 2, @@ -143,8 +157,7 @@ export class QuickPenaltyUseCase }; default: - this.logger.error(`Unknown infraction type: ${infractionType}`); - throw new Error(`Unknown infraction type: ${infractionType}`); + return null; } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RaceDetailUseCases.test.ts b/core/racing/application/use-cases/RaceDetailUseCases.test.ts index b35b71c1b..18013cec8 100644 --- a/core/racing/application/use-cases/RaceDetailUseCases.test.ts +++ b/core/racing/application/use-cases/RaceDetailUseCases.test.ts @@ -8,15 +8,13 @@ import type { IResultRepository } from '@core/racing/domain/repositories/IResult import type { ILeagueMembershipRepository } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import type { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider'; import type { IImageServicePort } from '@core/racing/application/ports/IImageServicePort'; -import type { - IRaceDetailPresenter, - RaceDetailViewModel, -} from '@core/racing/application/presenters/IRaceDetailPresenter'; +import type { Logger } from '@core/shared/application'; import { Race } from '@core/racing/domain/entities/Race'; import { League } from '@core/racing/domain/entities/League'; import { Result } from '@core/racing/domain/entities/Result'; import { Driver } from '@core/racing/domain/entities/Driver'; +import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership'; import { GetRaceDetailUseCase } from '@core/racing/application/use-cases/GetRaceDetailUseCase'; import { CancelRaceUseCase } from '@core/racing/application/use-cases/CancelRaceUseCase'; @@ -154,11 +152,11 @@ class InMemoryDriverRepository implements IDriverRepository { .filter((d): d is Driver => !!d); } - async create(): Promise { + async create(): Promise { throw new Error('Not needed for these tests'); } - async update(): Promise { + async update(): Promise { throw new Error('Not needed for these tests'); } @@ -371,21 +369,11 @@ class TestImageService implements IImageServicePort { } } -class FakeRaceDetailPresenter implements IRaceDetailPresenter { - viewModel: RaceDetailViewModel | null = null; - - present(viewModel: RaceDetailViewModel): RaceDetailViewModel { - this.viewModel = viewModel; - return viewModel; - } - - getViewModel(): RaceDetailViewModel | null { - return this.viewModel; - } - - reset(): void { - this.viewModel = null; - } +class MockLogger implements Logger { + debug = vi.fn(); + info = vi.fn(); + warn = vi.fn(); + error = vi.fn(); } describe('GetRaceDetailUseCase', () => { @@ -404,7 +392,7 @@ describe('GetRaceDetailUseCase', () => { scheduledAt: new Date(Date.now() + 60 * 60 * 1000), track: 'Test Track', car: 'GT3', - sessionType: 'race', + sessionType: 'main', status: 'scheduled', }); @@ -439,7 +427,6 @@ describe('GetRaceDetailUseCase', () => { ratingProvider.seed(otherDriverId, 1600); const imageService = new TestImageService(); - const presenter = new FakeRaceDetailPresenter(); const useCase = new GetRaceDetailUseCase( raceRepo, @@ -453,10 +440,10 @@ describe('GetRaceDetailUseCase', () => { ); // When (execute the query for the current driver) - await useCase.execute({ raceId: race.id, driverId }, presenter); + const result = await useCase.execute({ raceId: race.id, driverId }); - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); + expect(result.isOk()).toBe(true); + const viewModel = result.unwrap(); // Then (verify race, league and registration flags) expect(viewModel!.race?.id).toBe(race.id); @@ -494,7 +481,7 @@ describe('GetRaceDetailUseCase', () => { scheduledAt: new Date(Date.now() - 2 * 60 * 60 * 1000), track: 'Historic Circuit', car: 'LMP2', - sessionType: 'race', + sessionType: 'main', status: 'completed', }); @@ -534,7 +521,6 @@ describe('GetRaceDetailUseCase', () => { ratingProvider.seed(driverId, 2000); const imageService = new TestImageService(); - const presenter = new FakeRaceDetailPresenter(); const useCase = new GetRaceDetailUseCase( raceRepo, @@ -548,11 +534,11 @@ describe('GetRaceDetailUseCase', () => { ); // When (executing the query for the completed race) - await useCase.execute({ raceId: race.id, driverId }, presenter); + const result = await useCase.execute({ raceId: race.id, driverId }); - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - expect(viewModel!.userResult).not.toBeNull(); + expect(result.isOk()).toBe(true); + const viewModel = result.unwrap(); + expect(viewModel.userResult).not.toBeNull(); // Then (rating change uses the same formula as the legacy UI) // For P1: baseChange = 25, positionBonus = (20 - 1) * 2 = 38, total = 63 @@ -574,7 +560,6 @@ describe('GetRaceDetailUseCase', () => { const membershipRepo = new InMemoryLeagueMembershipRepository(); const ratingProvider = new TestDriverRatingProvider(); const imageService = new TestImageService(); - const presenter = new FakeRaceDetailPresenter(); const useCase = new GetRaceDetailUseCase( raceRepo, @@ -588,13 +573,11 @@ describe('GetRaceDetailUseCase', () => { ); // When - await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' }, presenter); + const result = await useCase.execute({ raceId: 'missing-race', driverId: 'driver-x' }); - const viewModel = presenter.getViewModel(); // Then - expect(viewModel).not.toBeNull(); - expect(viewModel!.race).toBeNull(); - expect(viewModel!.error).toBe('Race not found'); + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); }); }); @@ -607,30 +590,35 @@ describe('CancelRaceUseCase', () => { scheduledAt: new Date(Date.now() + 60 * 60 * 1000), track: 'Cancel Circuit', car: 'GT4', - sessionType: 'race', + sessionType: 'main', status: 'scheduled', }); const raceRepo = new InMemoryRaceRepository([race]); - const useCase = new CancelRaceUseCase(raceRepo); + const logger = new MockLogger(); + const useCase = new CancelRaceUseCase(raceRepo, logger); // When - await useCase.execute({ raceId: race.id }); + const result = await useCase.execute({ raceId: race.id }); // Then (the stored race is now cancelled) + expect(result.isOk()).toBe(true); const updated = raceRepo.getStored(race.id); expect(updated).not.toBeNull(); expect(updated!.status).toBe('cancelled'); }); - it('throws when trying to cancel a non-existent race', async () => { + it('returns error when trying to cancel a non-existent race', async () => { // Given const raceRepo = new InMemoryRaceRepository([]); - const useCase = new CancelRaceUseCase(raceRepo); + const logger = new MockLogger(); + const useCase = new CancelRaceUseCase(raceRepo, logger); - // When / Then - await expect( - useCase.execute({ raceId: 'does-not-exist' }), - ).rejects.toThrow('Race not found'); + // When + const result = await useCase.execute({ raceId: 'does-not-exist' }); + + // Then + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/RaceResultsUseCases.test.ts b/core/racing/application/use-cases/RaceResultsUseCases.test.ts deleted file mode 100644 index 6fd5a367e..000000000 --- a/core/racing/application/use-cases/RaceResultsUseCases.test.ts +++ /dev/null @@ -1,716 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -import { Race } from '@core/racing/domain/entities/Race'; -import { League } from '@core/racing/domain/entities/League'; -import { Result } from '@core/racing/domain/entities/Result'; -import { Penalty } from '@core/racing/domain/entities/Penalty'; -import { Standing } from '@core/racing/domain/entities/Standing'; - - -import { GetRaceResultsDetailUseCase } from '@core/racing/application/use-cases/GetRaceResultsDetailUseCase'; -import { ImportRaceResultsUseCase } from '@core/racing/application/use-cases/ImportRaceResultsUseCase'; - -import type { - IRaceResultsDetailPresenter, - RaceResultsDetailViewModel, -} from '@core/racing/application/presenters/IRaceResultsDetailPresenter'; -import type { - IImportRaceResultsPresenter, - ImportRaceResultsSummaryViewModel, -} from '@core/racing/application/presenters/IImportRaceResultsPresenter'; - -class FakeRaceResultsDetailPresenter implements IRaceResultsDetailPresenter { - viewModel: RaceResultsDetailViewModel | null = null; - - reset(): void { - this.viewModel = null; - } - - present(viewModel: RaceResultsDetailViewModel): RaceResultsDetailViewModel { - this.viewModel = viewModel; - return viewModel; - } - - getViewModel(): RaceResultsDetailViewModel | null { - return this.viewModel; - } -} - -class FakeImportRaceResultsPresenter implements IImportRaceResultsPresenter { - viewModel: ImportRaceResultsSummaryViewModel | null = null; - - present(viewModel: ImportRaceResultsSummaryViewModel): ImportRaceResultsSummaryViewModel { - this.viewModel = viewModel; - return viewModel; - } - - getViewModel(): ImportRaceResultsSummaryViewModel | null { - return this.viewModel; - } -} - -describe('ImportRaceResultsUseCase', () => { - it('imports results and triggers standings recalculation for the league', async () => { - // Given a league, a race, empty results, and a standing repository - const league = League.create({ - id: 'league-1', - name: 'Import League', - description: 'League for import tests', - ownerId: 'owner-1', - }); - - const race = Race.create({ - id: 'race-1', - leagueId: league.id, - scheduledAt: new Date(), - track: 'Import Circuit', - car: 'GT3', - sessionType: 'race', - status: 'completed', - }); - - const races = new Map(); - races.set(race.id, race); - - const leagues = new Map(); - leagues.set(league.id, league); - - const storedResults: Result[] = []; - let existsByRaceIdCalled = false; - const recalcCalls: string[] = []; - - const raceRepository = { - findById: async (id: string): Promise => races.get(id) ?? 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 leagueRepository = { - findById: async (id: string): Promise => leagues.get(id) ?? 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 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 (results: Result[]): Promise => { - storedResults.push(...results); - return results; - }, - 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 (raceId: string): Promise => { - existsByRaceIdCalled = true; - return storedResults.some((r) => r.raceId === raceId); - }, - }; - - 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 (leagueId: string): Promise => { - recalcCalls.push(leagueId); - return []; - }, - }; - - const presenter = new FakeImportRaceResultsPresenter(); - - const useCase = new ImportRaceResultsUseCase( - raceRepository, - leagueRepository, - resultRepository, - standingRepository, - presenter, - ); - - const importedResults = [ - Result.create({ - id: 'result-1', - raceId: race.id, - driverId: 'driver-1', - position: 1, - fastestLap: 90.123, - incidents: 0, - startPosition: 3, - }), - Result.create({ - id: 'result-2', - raceId: race.id, - driverId: 'driver-2', - position: 2, - fastestLap: 91.456, - incidents: 2, - startPosition: 1, - }), - ]; - - // When executing the import - await useCase.execute({ - raceId: race.id, - results: importedResults, - }); - - // Then new Result entries are persisted - expect(existsByRaceIdCalled).toBe(true); - expect(storedResults.length).toBe(2); - expect(storedResults.map((r) => r.id)).toEqual(['result-1', 'result-2']); - - // And standings are recalculated exactly once for the correct league - expect(recalcCalls).toEqual([league.id]); - - // And the presenter receives a summary - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - expect(viewModel!.importedCount).toBe(2); - expect(viewModel!.standingsRecalculated).toBe(true); - }); - - it('rejects import when results already exist for the race', async () => { - const league = League.create({ - id: 'league-2', - name: 'Existing Results League', - description: 'League with existing results', - ownerId: 'owner-2', - }); - - const race = Race.create({ - id: 'race-2', - leagueId: league.id, - scheduledAt: new Date(), - track: 'Existing Circuit', - car: 'GT4', - sessionType: 'race', - status: 'completed', - }); - - const races = new Map([[race.id, race]]); - const leagues = new Map([[league.id, league]]); - - const storedResults: Result[] = [ - Result.create({ - id: 'existing', - raceId: race.id, - driverId: 'driver-x', - position: 1, - fastestLap: 90.0, - incidents: 1, - startPosition: 1, - }), - ]; - - const raceRepository = { - findById: async (id: string): Promise => races.get(id) ?? 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 leagueRepository = { - findById: async (id: string): Promise => leagues.get(id) ?? 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 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 (_results: Result[]): Promise => { - throw new Error('Should not be called when results already exist'); - }, - 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 (raceId: string): Promise => { - return storedResults.some((r) => r.raceId === raceId); - }, - }; - - 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 (_leagueId: string): Promise => { - throw new Error('Should not be called when results already exist'); - }, - }; - - const presenter = new FakeImportRaceResultsPresenter(); - - const driverRepository = { - findById: async (): Promise => null, - findByIRacingId: async (iracingId: string): Promise => { - // Mock finding driver by iracingId - if (iracingId === 'driver-1') { - return Driver.create({ id: 'driver-1', iracingId: 'driver-1', name: 'Driver One', country: 'US' }); - } - if (iracingId === 'driver-2') { - return Driver.create({ id: 'driver-2', iracingId: 'driver-2', name: 'Driver Two', country: 'GB' }); - } - return 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 useCase = new ImportRaceResultsUseCase( - raceRepository, - leagueRepository, - resultRepository, - driverRepository, - standingRepository, - presenter, - ); - - const importedResults = [ - Result.create({ - id: 'new-result', - raceId: race.id, - driverId: 'driver-1', - position: 2, - fastestLap: 91.0, - incidents: 0, - startPosition: 2, - }), - ]; - - await expect( - useCase.execute({ - raceId: race.id, - results: importedResults, - }), - ).rejects.toThrow('Results already exist for this race'); - }); -}); - -describe('GetRaceResultsDetailUseCase', () => { - it('computes points system from league settings and identifies fastest lap', async () => { - // Given a league with default scoring configuration and two results - const league = League.create({ - id: 'league-scoring', - name: 'Scoring League', - description: 'League with scoring settings', - ownerId: 'owner-scoring', - }); - - const race = Race.create({ - id: 'race-scoring', - leagueId: league.id, - scheduledAt: new Date(), - track: 'Scoring Circuit', - car: 'Prototype', - sessionType: 'race', - status: 'completed', - }); - - const driver1: { id: string; name: string; country: string } = { - id: 'driver-a', - name: 'Driver A', - country: 'US', - }; - const driver2: { id: string; name: string; country: string } = { - id: 'driver-b', - name: 'Driver B', - country: 'GB', - }; - - const result1 = Result.create({ - id: 'r1', - raceId: race.id, - driverId: driver1.id, - position: 1, - fastestLap: 90.123, - incidents: 0, - startPosition: 3, - }); - - const result2 = Result.create({ - id: 'r2', - raceId: race.id, - driverId: driver2.id, - position: 2, - fastestLap: 88.456, - incidents: 2, - startPosition: 1, - }); - - const races = new Map([[race.id, race]]); - const leagues = new Map([[league.id, league]]); - const results = [result1, result2]; - const drivers = [driver1, driver2]; - - const raceRepository = { - findById: async (id: string): Promise => races.get(id) ?? 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 leagueRepository = { - findById: async (id: string): Promise => leagues.get(id) ?? 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 resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => [], - findByRaceId: async (raceId: string): Promise => - results.filter((r) => r.raceId === raceId), - 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 driverRepository = { - findById: async (): Promise => null, - findByIRacingId: async (): Promise => null, - findAll: async (): Promise => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })), - 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 penaltyRepository = { - findById: async (): Promise => null, - findByRaceId: async (): Promise => [] as Penalty[], - findByDriverId: async (): Promise => [], - findByProtestId: async (): Promise => [], - findPending: async (): Promise => [], - findIssuedBy: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - exists: async (): Promise => false, - }; - - const presenter = new FakeRaceResultsDetailPresenter(); - - const useCase = new GetRaceResultsDetailUseCase( - raceRepository, - leagueRepository, - resultRepository, - driverRepository, - penaltyRepository, - ); - - // When executing the query - await useCase.execute({ raceId: race.id }, presenter); - - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - - // Then points system matches the default F1-style configuration - expect(viewModel!.pointsSystem?.[1]).toBe(25); - expect(viewModel!.pointsSystem?.[2]).toBe(18); - - // And fastest lap is identified correctly - expect(viewModel!.fastestLapTime).toBeCloseTo(88.456, 3); - }); - - it('builds race results view model including penalties', async () => { - // Given a race with one result and one applied penalty - const league = League.create({ - id: 'league-penalties', - name: 'Penalty League', - description: 'League with penalties', - ownerId: 'owner-penalties', - }); - - const race = Race.create({ - id: 'race-penalties', - leagueId: league.id, - scheduledAt: new Date(), - track: 'Penalty Circuit', - car: 'Touring', - sessionType: 'race', - status: 'completed', - }); - - const driver: { id: string; name: string; country: string } = { - id: 'driver-pen', - name: 'Penalty Driver', - country: 'DE', - }; - - const result = Result.create({ - id: 'res-pen', - raceId: race.id, - driverId: driver.id, - position: 3, - fastestLap: 95.0, - incidents: 4, - startPosition: 5, - }); - - const penalty = Penalty.create({ - id: 'pen-1', - leagueId: league.id, - raceId: race.id, - driverId: driver.id, - type: 'points_deduction', - value: 3, - reason: 'Track limits', - issuedBy: 'steward-1', - status: 'applied', - issuedAt: new Date(), - }); - - const races = new Map([[race.id, race]]); - const leagues = new Map([[league.id, league]]); - const results = [result]; - const drivers = [driver]; - const penalties = [penalty]; - - const raceRepository = { - findById: async (id: string): Promise => races.get(id) ?? 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 leagueRepository = { - findById: async (id: string): Promise => leagues.get(id) ?? 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 resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => [], - findByRaceId: async (raceId: string): Promise => - results.filter((r) => r.raceId === raceId), - 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 driverRepository = { - findById: async (): Promise => null, - findByIRacingId: async (): Promise => null, - findAll: async (): Promise => drivers.map(d => Driver.create({ id: d.id, iracingId: '123', name: d.name, country: d.country })), - 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 penaltyRepository = { - findById: async (): Promise => null, - findByRaceId: async (raceId: string): Promise => - penalties.filter((p) => p.raceId === raceId), - findByDriverId: async (): Promise => [], - findByProtestId: async (): Promise => [], - findPending: async (): Promise => [], - findIssuedBy: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - exists: async (): Promise => false, - }; - - const presenter = new FakeRaceResultsDetailPresenter(); - - const useCase = new GetRaceResultsDetailUseCase( - raceRepository, - leagueRepository, - resultRepository, - driverRepository, - penaltyRepository, - ); - - // When - await useCase.execute({ raceId: race.id }, presenter); - - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - - // Then header and league info are present - expect(viewModel!.race).not.toBeNull(); - expect(viewModel!.race!.id).toBe(race.id); - expect(viewModel!.league).not.toBeNull(); - expect(viewModel!.league!.id).toBe(league.id); - - // And classification and penalties match the underlying data - expect(viewModel!.results.length).toBe(1); - expect(viewModel!.results[0]!.id).toBe(result.id); - - expect(viewModel!.penalties.length).toBe(1); - expect(viewModel!.penalties[0]!.driverId).toBe(driver.id); - expect(viewModel!.penalties[0]!.type).toBe('points_deduction'); - expect(viewModel!.penalties[0]!.value).toBe(3); - }); - - it('presents an error when race does not exist', async () => { - // Given repositories without the requested race - 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 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 resultRepository = { - findById: async (): Promise => null, - findAll: async (): Promise => [], - findByRaceId: async (): Promise => [] as Result[], - 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 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 penaltyRepository = { - findById: async (): Promise => null, - findByRaceId: async (): Promise => [] as Penalty[], - findByDriverId: async (): Promise => [], - findByProtestId: async (): Promise => [], - findPending: async (): Promise => [], - findIssuedBy: async (): Promise => [], - create: async (): Promise => { throw new Error('Not implemented'); }, - update: async (): Promise => { throw new Error('Not implemented'); }, - exists: async (): Promise => false, - }; - - const presenter = new FakeRaceResultsDetailPresenter(); - - const useCase = new GetRaceResultsDetailUseCase( - raceRepository, - leagueRepository, - resultRepository, - driverRepository, - penaltyRepository, - ); - - // When - await useCase.execute({ raceId: 'missing-race' }, presenter); - - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - expect(viewModel!.race).toBeNull(); - expect(viewModel!.error).toBe('Race not found'); - }); -}); \ No newline at end of file diff --git a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts new file mode 100644 index 000000000..ee4181782 --- /dev/null +++ b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RecalculateChampionshipStandingsUseCase } 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'; + +describe('RecalculateChampionshipStandingsUseCase', () => { + let useCase: RecalculateChampionshipStandingsUseCase; + let seasonRepository: { findById: Mock }; + let leagueScoringConfigRepository: { findBySeasonId: Mock }; + let raceRepository: { findByLeagueId: Mock }; + let resultRepository: { findByRaceId: Mock }; + let penaltyRepository: { findByRaceId: Mock }; + let championshipStandingRepository: { saveAll: Mock }; + let eventScoringService: { scoreSession: Mock }; + let championshipAggregator: { aggregate: Mock }; + + beforeEach(() => { + seasonRepository = { findById: vi.fn() }; + leagueScoringConfigRepository = { findBySeasonId: vi.fn() }; + raceRepository = { findByLeagueId: vi.fn() }; + resultRepository = { findByRaceId: vi.fn() }; + penaltyRepository = { findByRaceId: vi.fn() }; + championshipStandingRepository = { saveAll: vi.fn() }; + eventScoringService = { scoreSession: vi.fn() }; + championshipAggregator = { aggregate: vi.fn() }; + useCase = new RecalculateChampionshipStandingsUseCase( + seasonRepository as unknown as ISeasonRepository, + leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, + raceRepository as unknown as IRaceRepository, + resultRepository as unknown as IResultRepository, + penaltyRepository as unknown as IPenaltyRepository, + championshipStandingRepository as unknown as IChampionshipStandingRepository, + eventScoringService as unknown as EventScoringService, + championshipAggregator as unknown as ChampionshipAggregator, + ); + }); + + it('should return season not found error', async () => { + seasonRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'SEASON_NOT_FOUND', + details: { message: 'Season not found: season-1' }, + }); + }); + + it('should return league scoring config not found error', async () => { + seasonRepository.findById.mockResolvedValue({ id: 'season-1', leagueId: 'league-1' }); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(null); + + const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + + 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' }, + }); + }); + + 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 () => { + const season = { id: 'season-1', leagueId: 'league-1' }; + 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 penalties: Penalty[] = []; + const standings = [{ participant: 'driver-1', position: 1, totalPoints: 25, resultsCounted: 1, resultsDropped: 0 }]; + + seasonRepository.findById.mockResolvedValue(season); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(leagueScoringConfig); + raceRepository.findByLeagueId.mockResolvedValue(races); + resultRepository.findByRaceId.mockResolvedValue(results); + penaltyRepository.findByRaceId.mockResolvedValue(penalties); + eventScoringService.scoreSession.mockReturnValue({}); + championshipAggregator.aggregate.mockReturnValue(standings); + championshipStandingRepository.saveAll.mockResolvedValue(undefined); + + const result = await useCase.execute({ seasonId: 'season-1', championshipId: 'champ-1' }); + + 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 }]); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts index cde456cac..d8a8968d6 100644 --- a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts +++ b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts @@ -16,7 +16,23 @@ import type { ChampionshipStandingsRowDTO, } from '../dto/ChampionshipStandingsDTO'; -export class RecalculateChampionshipStandingsUseCase { +import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; + +export interface RecalculateChampionshipStandingsParams { + seasonId: string; + championshipId: string; +} + +type RecalculateChampionshipStandingsErrorCode = + | 'SEASON_NOT_FOUND' + | 'LEAGUE_SCORING_CONFIG_NOT_FOUND' + | 'CHAMPIONSHIP_CONFIG_NOT_FOUND'; + +export class RecalculateChampionshipStandingsUseCase + implements AsyncUseCase +{ constructor( private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, @@ -28,27 +44,24 @@ export class RecalculateChampionshipStandingsUseCase { private readonly championshipAggregator: ChampionshipAggregator, ) {} - async execute(params: { - seasonId: string; - championshipId: string; - }): Promise { + async execute(params: RecalculateChampionshipStandingsParams): Promise>> { const { seasonId, championshipId } = params; const season = await this.seasonRepository.findById(seasonId); if (!season) { - throw new Error(`Season not found: ${seasonId}`); + return Result.err({ code: 'SEASON_NOT_FOUND', details: { message: `Season not found: ${seasonId}` } }); } const leagueScoringConfig = await this.leagueScoringConfigRepository.findBySeasonId(seasonId); if (!leagueScoringConfig) { - throw new Error(`League scoring config not found for season: ${seasonId}`); + return Result.err({ code: 'LEAGUE_SCORING_CONFIG_NOT_FOUND', details: { message: `League scoring config not found for season: ${seasonId}` } }); } - const championship = this.findChampionshipConfig( - leagueScoringConfig.championships, - championshipId, - ); + 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); @@ -101,19 +114,9 @@ export class RecalculateChampionshipStandingsUseCase { rows, }; - return dto; + return Result.ok(dto); } - private findChampionshipConfig( - configs: ChampionshipConfig[], - championshipId: string, - ): ChampionshipConfig { - const found = configs.find((c) => c.id === championshipId); - if (!found) { - throw new Error(`Championship config not found: ${championshipId}`); - } - return found; - } private mapRaceSessionType(sessionType: string): SessionType { if (sessionType === 'race') { diff --git a/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts b/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts new file mode 100644 index 000000000..753601acd --- /dev/null +++ b/core/racing/application/use-cases/RegisterForRaceUseCase.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RegisterForRaceUseCase } from './RegisterForRaceUseCase'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +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 }; + + 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() }; + useCase = new RegisterForRaceUseCase( + registrationRepository as unknown as IRaceRegistrationRepository, + membershipRepository as unknown as ILeagueMembershipRepository, + logger as unknown as Logger, + ); + }); + + 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' }, + }); + }); + + it('should return not active member error', async () => { + registrationRepository.isRegistered.mockResolvedValue(false); + membershipRepository.getMembership.mockResolvedValue(null); + + const result = await useCase.execute({ raceId: 'race-1', leagueId: 'league-1', driverId: 'driver-1' }); + + 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' }, + }); + }); + + it('should return 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' }); + + 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' }, + }); + }); + + it('should register successfully', 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' }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(registrationRepository.register).toHaveBeenCalledWith( + expect.objectContaining({ + raceId: 'race-1', + driverId: 'driver-1', + }), + ); + }); +}); \ 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 970458716..3379c5d4d 100644 --- a/core/racing/application/use-cases/RegisterForRaceUseCase.ts +++ b/core/racing/application/use-cases/RegisterForRaceUseCase.ts @@ -1,51 +1,58 @@ 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 { RegisterForRaceCommandDTO } from '../dto/RegisterForRaceCommandDTO'; import type { AsyncUseCase } 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'; -import { - BusinessRuleViolationError, - PermissionDeniedError, -} from '../errors/RacingApplicationError'; - + +export interface RegisterForRaceParams { + raceId: string; + leagueId: string; + driverId: string; +} + +type RegisterForRaceErrorCode = 'ALREADY_REGISTERED' | 'NOT_ACTIVE_MEMBER'; + export class RegisterForRaceUseCase - implements AsyncUseCase + implements AsyncUseCase { constructor( private readonly registrationRepository: IRaceRegistrationRepository, private readonly membershipRepository: ILeagueMembershipRepository, private readonly logger: Logger, ) {} - + /** * Mirrors legacy registerForRace behavior: - * - throws if already registered + * - returns error if already registered * - validates active league membership * - registers driver for race */ - async execute(command: RegisterForRaceCommandDTO): Promise { - const { raceId, leagueId, driverId } = command; - this.logger.debug('RegisterForRaceUseCase: executing command', { raceId, leagueId, driverId }); - + async execute(params: RegisterForRaceParams): Promise>> { + const { raceId, leagueId, driverId } = params; + 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}`); - throw new BusinessRuleViolationError('Already registered for this race'); + 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}`); - throw new PermissionDeniedError('NOT_ACTIVE_MEMBER', 'Must be an active league member to register for races'); + 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/RejectSponsorshipRequestUseCase.test.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts new file mode 100644 index 000000000..71a614c97 --- /dev/null +++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RejectSponsorshipRequestUseCase } from './RejectSponsorshipRequestUseCase'; +import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; + +describe('RejectSponsorshipRequestUseCase', () => { + let useCase: RejectSponsorshipRequestUseCase; + let sponsorshipRequestRepo: { findById: Mock; update: Mock }; + + beforeEach(() => { + sponsorshipRequestRepo = { findById: vi.fn(), update: vi.fn() }; + useCase = new RejectSponsorshipRequestUseCase( + sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + ); + }); + + it('should return not found error when request does not exist', async () => { + sponsorshipRequestRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + requestId: 'request-1', + respondedBy: 'driver-1', + reason: 'Not interested', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'SPONSORSHIP_REQUEST_NOT_FOUND', + }); + }); + + it('should return not pending error when request is not pending', async () => { + const mockRequest = { + id: 'request-1', + status: 'accepted', + isPending: vi.fn().mockReturnValue(false), + }; + sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); + + const result = await useCase.execute({ + requestId: 'request-1', + respondedBy: 'driver-1', + reason: 'Not interested', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ + code: 'SPONSORSHIP_REQUEST_NOT_PENDING', + }); + }); + + it('should reject the request successfully', async () => { + const mockRequest = { + id: 'request-1', + status: 'pending', + isPending: vi.fn().mockReturnValue(true), + reject: vi.fn().mockReturnValue({ + id: 'request-1', + respondedAt: new Date('2023-01-01T00:00:00Z'), + rejectionReason: 'Not interested', + }), + }; + sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); + sponsorshipRequestRepo.update.mockResolvedValue(undefined); + + const result = await useCase.execute({ + requestId: 'request-1', + respondedBy: 'driver-1', + reason: 'Not interested', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + requestId: 'request-1', + status: 'rejected', + rejectedAt: new Date('2023-01-01T00:00:00Z'), + reason: 'Not interested', + }); + expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject()); + }); + + it('should reject the request successfully without reason', async () => { + const mockRequest = { + id: 'request-1', + status: 'pending', + isPending: vi.fn().mockReturnValue(true), + reject: vi.fn().mockReturnValue({ + id: 'request-1', + respondedAt: new Date('2023-01-01T00:00:00Z'), + rejectionReason: undefined, + }), + }; + sponsorshipRequestRepo.findById.mockResolvedValue(mockRequest); + sponsorshipRequestRepo.update.mockResolvedValue(undefined); + + const result = await useCase.execute({ + requestId: 'request-1', + respondedBy: 'driver-1', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + requestId: 'request-1', + status: 'rejected', + rejectedAt: new Date('2023-01-01T00:00:00Z'), + }); + expect(sponsorshipRequestRepo.update).toHaveBeenCalledWith(mockRequest.reject()); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts index 35d8f15e9..c34cbd398 100644 --- a/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/RejectSponsorshipRequestUseCase.ts @@ -1,10 +1,12 @@ /** * Use Case: RejectSponsorshipRequestUseCase - * + * * Allows an entity owner to reject a sponsorship request. */ import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface RejectSponsorshipRequestDTO { requestId: string; @@ -24,30 +26,30 @@ export class RejectSponsorshipRequestUseCase { private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, ) {} - async execute(dto: RejectSponsorshipRequestDTO): Promise { + async execute(dto: RejectSponsorshipRequestDTO): Promise>> { // Find the request const request = await this.sponsorshipRequestRepo.findById(dto.requestId); if (!request) { - throw new Error('Sponsorship request not found'); + return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_FOUND' }); } if (!request.isPending()) { - throw new Error(`Cannot reject a ${request.status} sponsorship request`); + return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_PENDING' }); } // Reject the request const rejectedRequest = request.reject(dto.respondedBy, dto.reason); await this.sponsorshipRequestRepo.update(rejectedRequest); - + // TODO: In a real implementation, notify the sponsor - - return { + + return Result.ok({ requestId: rejectedRequest.id, status: 'rejected', rejectedAt: rejectedRequest.respondedAt!, ...(rejectedRequest.rejectionReason !== undefined ? { reason: rejectedRequest.rejectionReason } : {}), - }; + }); } } \ 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 new file mode 100644 index 000000000..b3e73a9db --- /dev/null +++ b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RejectTeamJoinRequestUseCase } from './RejectTeamJoinRequestUseCase'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; + +describe('RejectTeamJoinRequestUseCase', () => { + let useCase: RejectTeamJoinRequestUseCase; + let membershipRepository: { removeJoinRequest: Mock }; + + beforeEach(() => { + membershipRepository = { removeJoinRequest: vi.fn() }; + useCase = new RejectTeamJoinRequestUseCase( + membershipRepository as unknown as ITeamMembershipRepository, + ); + }); + + it('should reject the join request successfully', async () => { + membershipRepository.removeJoinRequest.mockResolvedValue(undefined); + + const result = await useCase.execute({ + requestId: 'request-1', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(membershipRepository.removeJoinRequest).toHaveBeenCalledWith('request-1'); + }); +}); \ 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 d057ab8a5..57685a187 100644 --- a/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/RejectTeamJoinRequestUseCase.ts @@ -1,13 +1,16 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { RejectTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export class RejectTeamJoinRequestUseCase { constructor( private readonly membershipRepository: ITeamMembershipRepository, ) {} - async execute(command: RejectTeamJoinRequestCommandDTO): Promise { + async execute(command: RejectTeamJoinRequestCommandDTO): Promise>> { const { requestId } = command; await this.membershipRepository.removeJoinRequest(requestId); + return Result.ok(undefined); } } \ 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 ad171adf6..df2bf0e0b 100644 --- a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts +++ b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.test.ts @@ -1,19 +1,19 @@ -import { RemoveLeagueMemberUseCase } from '@core/racing/application/use-cases/RemoveLeagueMemberUseCase'; -import { RemoveLeagueMemberPresenter } from '@apps/api/src/modules/league/presenters/RemoveLeagueMemberPresenter'; - +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RemoveLeagueMemberUseCase } from './RemoveLeagueMemberUseCase'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; describe('RemoveLeagueMemberUseCase', () => { let useCase: RemoveLeagueMemberUseCase; - let leagueMembershipRepository: jest.Mocked; - let presenter: RemoveLeagueMemberPresenter; + let leagueMembershipRepository: { getLeagueMembers: Mock; saveMembership: Mock }; beforeEach(() => { leagueMembershipRepository = { - getLeagueMembers: jest.fn(), - saveMembership: jest.fn(), - } as unknown; - presenter = new RemoveLeagueMemberPresenter(); - useCase = new RemoveLeagueMemberUseCase(leagueMembershipRepository); + getLeagueMembers: vi.fn(), + saveMembership: vi.fn(), + }; + useCase = new RemoveLeagueMemberUseCase( + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + ); }); it('should remove league member by setting status to inactive', async () => { @@ -23,8 +23,10 @@ describe('RemoveLeagueMemberUseCase', () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); - await useCase.execute({ leagueId, targetDriverId }, presenter); + const result = await useCase.execute({ leagueId, targetDriverId }); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ success: true }); expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ leagueId, driverId: targetDriverId, @@ -32,12 +34,14 @@ describe('RemoveLeagueMemberUseCase', () => { status: 'inactive', joinedAt: expect.any(Date), }); - expect(presenter.viewModel).toEqual({ success: true }); }); - it('should throw error if membership not found', async () => { + it('should return error if membership not found', async () => { leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); - await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }, presenter)).rejects.toThrow('Membership not found'); + const result = await useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1' }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'MEMBERSHIP_NOT_FOUND' }); }); }); \ 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 73f8a3b64..82b00fea4 100644 --- a/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts +++ b/core/racing/application/use-cases/RemoveLeagueMemberUseCase.ts @@ -1,6 +1,6 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { IRemoveLeagueMemberPresenter, RemoveLeagueMemberResultDTO, RemoveLeagueMemberViewModel } from '../presenters/IRemoveLeagueMemberPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface RemoveLeagueMemberUseCaseParams { leagueId: string; @@ -11,21 +11,19 @@ export interface RemoveLeagueMemberResultDTO { success: boolean; } -export class RemoveLeagueMemberUseCase implements UseCase { +export class RemoveLeagueMemberUseCase { constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} - async execute(params: RemoveLeagueMemberUseCaseParams, presenter: IRemoveLeagueMemberPresenter): Promise { + async execute(params: RemoveLeagueMemberUseCaseParams): Promise>> { const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const membership = memberships.find(m => m.driverId === params.targetDriverId); if (!membership) { - throw new Error('Membership not found'); + return Result.err({ code: 'MEMBERSHIP_NOT_FOUND' }); } await this.leagueMembershipRepository.saveMembership({ ...membership, status: 'inactive', }); - const dto: RemoveLeagueMemberResultDTO = { success: true }; - presenter.reset(); - presenter.present(dto); + return Result.ok({ success: true }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts b/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts new file mode 100644 index 000000000..bc9b904d7 --- /dev/null +++ b/core/racing/application/use-cases/RequestProtestDefenseUseCase.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { RequestProtestDefenseUseCase } from './RequestProtestDefenseUseCase'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; + +describe('RequestProtestDefenseUseCase', () => { + let useCase: RequestProtestDefenseUseCase; + let protestRepository: { findById: Mock; update: Mock }; + let raceRepository: { findById: Mock }; + let membershipRepository: { getMembership: Mock }; + + beforeEach(() => { + protestRepository = { findById: vi.fn(), update: vi.fn() }; + raceRepository = { findById: vi.fn() }; + membershipRepository = { getMembership: vi.fn() }; + useCase = new RequestProtestDefenseUseCase( + protestRepository as unknown as IProtestRepository, + raceRepository as unknown as IRaceRepository, + membershipRepository as unknown as ILeagueMembershipRepository, + ); + }); + + it('should return protest not found error', async () => { + protestRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + }); + + it('should return race not found error', async () => { + 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', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); + }); + + it('should return insufficient permissions error', async () => { + 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', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'INSUFFICIENT_PERMISSIONS' }); + }); + + 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 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', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'DEFENSE_CANNOT_BE_REQUESTED' }); + }); + + it('should request defense successfully', async () => { + const mockProtest = { + raceId: 'race-1', + accusedDriverId: 'driver-1', + id: 'protest-1', + canRequestDefense: vi.fn().mockReturnValue(true), + requestDefense: vi.fn().mockReturnValue({}), + }; + const mockRace = { leagueId: 'league-1' }; + const mockMembership = { role: 'steward' }; + protestRepository.findById.mockResolvedValue(mockProtest); + raceRepository.findById.mockResolvedValue(mockRace); + membershipRepository.getMembership.mockResolvedValue(mockMembership); + protestRepository.update.mockResolvedValue(undefined); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + success: true, + accusedDriverId: 'driver-1', + protestId: 'protest-1', + }); + expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.requestDefense()); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts b/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts index fbb68fd70..606b40f0b 100644 --- a/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts +++ b/core/racing/application/use-cases/RequestProtestDefenseUseCase.ts @@ -1,6 +1,6 @@ /** * Application Use Case: RequestProtestDefenseUseCase - * + * * Allows a steward to request defense from the accused driver before making a decision. * This will trigger a notification to the accused driver. */ @@ -9,6 +9,8 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos 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 type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface RequestProtestDefenseCommand { protestId: string; @@ -28,38 +30,38 @@ export class RequestProtestDefenseUseCase { private readonly membershipRepository: ILeagueMembershipRepository, ) {} - async execute(command: RequestProtestDefenseCommand): Promise { + async execute(command: RequestProtestDefenseCommand): Promise>> { // Get the protest const protest = await this.protestRepository.findById(command.protestId); if (!protest) { - throw new Error('Protest not found'); + return Result.err({ code: 'PROTEST_NOT_FOUND' }); } // Get the race to find the league const race = await this.raceRepository.findById(protest.raceId); if (!race) { - throw new Error('Race not found'); + 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)) { - throw new Error('Only stewards and admins can request defense'); + return Result.err({ code: 'INSUFFICIENT_PERMISSIONS' }); } // Check if defense can be requested if (!protest.canRequestDefense()) { - throw new Error('Defense cannot be requested for this protest'); + return Result.err({ code: 'DEFENSE_CANNOT_BE_REQUESTED' }); } // Request defense const updatedProtest = protest.requestDefense(command.stewardId); await this.protestRepository.update(updatedProtest); - return { + 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 new file mode 100644 index 000000000..ca023b52d --- /dev/null +++ b/core/racing/application/use-cases/ReviewProtestUseCase.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ReviewProtestUseCase } from './ReviewProtestUseCase'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; + +describe('ReviewProtestUseCase', () => { + let useCase: ReviewProtestUseCase; + let protestRepository: { findById: Mock; update: Mock }; + let raceRepository: { findById: Mock }; + let leagueMembershipRepository: { getLeagueMembers: Mock }; + + beforeEach(() => { + protestRepository = { findById: vi.fn(), update: vi.fn() }; + raceRepository = { findById: vi.fn() }; + leagueMembershipRepository = { getLeagueMembers: vi.fn() }; + useCase = new ReviewProtestUseCase( + protestRepository as unknown as IProtestRepository, + raceRepository as unknown as IRaceRepository, + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + ); + }); + + it('should return protest not found error', async () => { + protestRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'uphold', + decisionNotes: 'Notes', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + }); + + it('should return race not found error', async () => { + const mockProtest = { raceId: 'race-1' }; + protestRepository.findById.mockResolvedValue(mockProtest); + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'uphold', + decisionNotes: 'Notes', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' }); + }); + + it('should return not league admin error', async () => { + const mockProtest = { raceId: 'race-1', uphold: vi.fn(), dismiss: vi.fn() }; + const mockRace = { leagueId: 'league-1' }; + protestRepository.findById.mockResolvedValue(mockProtest); + raceRepository.findById.mockResolvedValue(mockRace); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'uphold', + decisionNotes: 'Notes', + }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'NOT_LEAGUE_ADMIN' }); + }); + + it('should uphold protest successfully', async () => { + const mockProtest = { 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.mockResolvedValue(undefined); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'uphold', + decisionNotes: 'Notes', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.uphold()); + }); + + it('should dismiss protest successfully', async () => { + const mockProtest = { 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); + raceRepository.findById.mockResolvedValue(mockRace); + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + protestRepository.update.mockResolvedValue(undefined); + + const result = await useCase.execute({ + protestId: 'protest-1', + stewardId: 'steward-1', + decision: 'dismiss', + decisionNotes: 'Notes', + }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(protestRepository.update).toHaveBeenCalledWith(mockProtest.dismiss()); + }); +}); \ 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 417456ea6..cdefe4226 100644 --- a/core/racing/application/use-cases/ReviewProtestUseCase.ts +++ b/core/racing/application/use-cases/ReviewProtestUseCase.ts @@ -1,16 +1,14 @@ /** * Application Use Case: ReviewProtestUseCase - * + * * Allows a steward to review a protest and make a decision (uphold or dismiss). */ import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import { - EntityNotFoundError, - PermissionDeniedError, -} from '../errors/RacingApplicationError'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; export interface ReviewProtestCommand { protestId: string; @@ -26,17 +24,17 @@ export class ReviewProtestUseCase { private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(command: ReviewProtestCommand): Promise { + async execute(command: ReviewProtestCommand): Promise>> { // Load the protest const protest = await this.protestRepository.findById(command.protestId); if (!protest) { - throw new EntityNotFoundError({ entity: 'protest', id: command.protestId }); + return Result.err({ code: 'PROTEST_NOT_FOUND' }); } // Load the race to get league ID const race = await this.raceRepository.findById(protest.raceId); if (!race) { - throw new EntityNotFoundError({ entity: 'race', id: protest.raceId }); + return Result.err({ code: 'RACE_NOT_FOUND' }); } // Validate steward has authority (owner or admin of the league) @@ -44,12 +42,9 @@ export class ReviewProtestUseCase { const stewardMembership = memberships.find( m => m.driverId === command.stewardId && m.status === 'active' ); - + if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { - throw new PermissionDeniedError( - 'NOT_LEAGUE_ADMIN', - 'Only league owners and admins can review protests', - ); + return Result.err({ code: 'NOT_LEAGUE_ADMIN' }); } // Apply the decision @@ -61,5 +56,6 @@ export class ReviewProtestUseCase { } await this.protestRepository.update(updatedProtest); + return Result.ok(undefined); } } \ 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 new file mode 100644 index 000000000..fb56c90f9 --- /dev/null +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SendFinalResultsUseCase } from './SendFinalResultsUseCase'; +import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; +import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; +import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; + +describe('SendFinalResultsUseCase', () => { + it('sends final results notifications to all participating drivers', async () => { + const mockNotificationService = { + sendNotification: vi.fn(), + } as unknown as INotificationService; + + const mockRaceEvent = { + id: 'race-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1' }), + }; + + const mockRaceEventRepository = { + findById: vi.fn().mockResolvedValue(mockRaceEvent), + } as unknown as IRaceEventRepository; + + const mockResults = [ + { + driverId: 'driver-1', + position: 1, + 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; + + 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', 'driver-2'], + hadPenaltiesApplied: false, + closedAt: new Date(), + }, + }; + + const result = await useCase.execute(event); + + expect(result.isOk()).toBe(true); + expect(mockRaceEventRepository.findById).toHaveBeenCalledWith('race-1'); + expect(mockResultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); + expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2); + + // 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, + }), + }), + ); + + // 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, + }), + }), + ); + }); + + it('skips sending notifications if race event not found', async () => { + const mockNotificationService = { + sendNotification: vi.fn(), + } as unknown as INotificationService; + + const mockRaceEventRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as IRaceEventRepository; + + 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(); + }); + + it('skips sending notifications if no main race session', async () => { + const mockNotificationService = { + sendNotification: vi.fn(), + } as unknown as INotificationService; + + const mockRaceEvent = { + id: 'race-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue(null), + }; + + const mockRaceEventRepository = { + findById: vi.fn().mockResolvedValue(mockRaceEvent), + } as unknown as IRaceEventRepository; + + 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(); + }); +}); \ 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 6e68ebeb6..3567ac508 100644 --- a/core/racing/application/use-cases/SendFinalResultsUseCase.ts +++ b/core/racing/application/use-cases/SendFinalResultsUseCase.ts @@ -1,9 +1,12 @@ -import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; +import type { RaceEvent } from '../../domain/entities/RaceEvent'; +import type { Result as RaceResult } from '../../domain/entities/Result'; /** * Use Case: SendFinalResultsUseCase @@ -12,28 +15,28 @@ import type { NotificationType } from '../../../notifications/domain/types/Notif * Sends final results modal notifications to all drivers who participated, * including any penalty adjustments applied during stewarding. */ -export class SendFinalResultsUseCase implements UseCase { +export class SendFinalResultsUseCase { constructor( private readonly notificationService: INotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, ) {} - async execute(event: RaceEventStewardingClosedEvent): Promise { + async execute(event: RaceEventStewardingClosedEvent): Promise>> { const { raceEventId, leagueId, driverIds, hadPenaltiesApplied } = event.eventData; // Get race event to include context const raceEvent = await this.raceEventRepository.findById(raceEventId); if (!raceEvent) { - console.warn(`RaceEvent ${raceEventId} not found, skipping final results notifications`); - return; + // RaceEvent not found, skip + return Result.ok(undefined); } // Get final results for the main race session const mainRaceSession = raceEvent.getMainRaceSession(); if (!mainRaceSession) { - console.warn(`No main race session found for RaceEvent ${raceEventId}`); - return; + // No main race session, skip + return Result.ok(undefined); } const results = await this.resultRepository.findByRaceId(mainRaceSession.id); @@ -50,12 +53,14 @@ export class SendFinalResultsUseCase implements UseCase { @@ -88,7 +93,7 @@ export class SendFinalResultsUseCase implements UseCase { + it('sends performance summary notifications to all participating drivers', async () => { + const mockNotificationService = { + sendNotification: vi.fn(), + } as unknown as INotificationService; + + const mockRaceEvent = { + id: 'race-1', + name: 'Test Race', + getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1' }), + }; + + const mockRaceEventRepository = { + findById: vi.fn().mockResolvedValue(mockRaceEvent), + } as unknown as IRaceEventRepository; + + const mockResults = [ + { + driverId: 'driver-1', + position: 1, + 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; + + 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', 'driver-2'], + completedAt: new Date(), + }, + }; + + const result = await useCase.execute(event); + + expect(result.isOk()).toBe(true); + expect(mockRaceEventRepository.findById).toHaveBeenCalledWith('race-1'); + expect(mockResultRepository.findByRaceId).toHaveBeenCalledWith('session-1'); + expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2); + + // Check first notification + expect(mockNotificationService.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, + }), + }), + ); + }); + + it('skips sending notifications if race event not found', async () => { + const mockNotificationService = { + sendNotification: vi.fn(), + } as unknown as INotificationService; + + const mockRaceEventRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as IRaceEventRepository; + + 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(); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts index 8dbf4ed0c..2ebb5d4af 100644 --- a/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts +++ b/core/racing/application/use-cases/SendPerformanceSummaryUseCase.ts @@ -1,9 +1,12 @@ -import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { INotificationService } from '../../../notifications/application/ports/INotificationService'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted'; import type { NotificationType } from '../../../notifications/domain/types/NotificationTypes'; +import type { RaceEvent } from '../../domain/entities/RaceEvent'; +import type { Result as RaceResult } from '../../domain/entities/Result'; /** * Use Case: SendPerformanceSummaryUseCase @@ -11,21 +14,21 @@ import type { NotificationType } from '../../../notifications/domain/types/Notif * Triggered by MainRaceCompleted domain event. * Sends immediate performance summary modal notifications to all drivers who participated in the main race. */ -export class SendPerformanceSummaryUseCase implements UseCase { +export class SendPerformanceSummaryUseCase { constructor( private readonly notificationService: INotificationService, private readonly raceEventRepository: IRaceEventRepository, private readonly resultRepository: IResultRepository, ) {} - async execute(event: MainRaceCompletedEvent): Promise { + async execute(event: MainRaceCompletedEvent): Promise>> { const { raceEventId, sessionId, leagueId, driverIds } = event.eventData; // Get race event to include context const raceEvent = await this.raceEventRepository.findById(raceEventId); if (!raceEvent) { - console.warn(`RaceEvent ${raceEventId} not found, skipping performance summary notifications`); - return; + // RaceEvent not found, skip + return Result.ok(undefined); } // Get results for the main race session to calculate performance data @@ -42,12 +45,14 @@ export class SendPerformanceSummaryUseCase implements UseCase { const position = driverResult?.position ?? 'DNF'; @@ -77,7 +82,7 @@ export class SendPerformanceSummaryUseCase implements UseCase { + it('submits defense successfully', async () => { + const mockProtest = { + id: 'protest-1', + accusedDriverId: 'driver-1', + canSubmitDefense: vi.fn().mockReturnValue(true), + submitDefense: vi.fn().mockReturnValue({}), + }; + + const mockProtestRepository = { + findById: vi.fn().mockResolvedValue(mockProtest), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as IProtestRepository; + + const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + + const command = { + protestId: 'protest-1', + driverId: 'driver-1', + statement: 'My defense', + videoUrl: 'http://video.com', + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ protestId: 'protest-1' }); + expect(mockProtestRepository.findById).toHaveBeenCalledWith('protest-1'); + expect(mockProtest.canSubmitDefense).toHaveBeenCalled(); + expect(mockProtest.submitDefense).toHaveBeenCalledWith('My defense', 'http://video.com'); + expect(mockProtestRepository.update).toHaveBeenCalledWith({}); + }); + + it('returns error when protest not found', async () => { + const mockProtestRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as IProtestRepository; + + const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + + const command = { + protestId: 'protest-1', + driverId: 'driver-1', + statement: 'My defense', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'PROTEST_NOT_FOUND' }); + }); + + it('returns error when driver is not the accused', async () => { + const mockProtest = { + id: 'protest-1', + accusedDriverId: 'driver-2', + }; + + const mockProtestRepository = { + findById: vi.fn().mockResolvedValue(mockProtest), + } as unknown as IProtestRepository; + + const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + + const command = { + protestId: 'protest-1', + driverId: 'driver-1', + statement: 'My defense', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'NOT_ACCUSED_DRIVER' }); + }); + + it('returns error when defense cannot be submitted', async () => { + const mockProtest = { + id: 'protest-1', + accusedDriverId: 'driver-1', + canSubmitDefense: vi.fn().mockReturnValue(false), + }; + + const mockProtestRepository = { + findById: vi.fn().mockResolvedValue(mockProtest), + } as unknown as IProtestRepository; + + const useCase = new SubmitProtestDefenseUseCase(mockProtestRepository); + + const command = { + protestId: 'protest-1', + driverId: 'driver-1', + statement: 'My defense', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'DEFENSE_CANNOT_BE_SUBMITTED' }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts index b919a1244..07153dfd7 100644 --- a/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts +++ b/core/racing/application/use-cases/SubmitProtestDefenseUseCase.ts @@ -1,9 +1,11 @@ /** * Application Use Case: SubmitProtestDefenseUseCase - * + * * Allows the accused driver to submit their defense statement for a protest. */ +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; export interface SubmitProtestDefenseCommand { @@ -13,40 +15,34 @@ export interface SubmitProtestDefenseCommand { videoUrl?: string; } -export interface SubmitProtestDefenseResult { - success: boolean; - protestId: string; -} +type SubmitProtestDefenseErrorCode = 'PROTEST_NOT_FOUND' | 'NOT_ACCUSED_DRIVER' | 'DEFENSE_CANNOT_BE_SUBMITTED'; export class SubmitProtestDefenseUseCase { constructor( private readonly protestRepository: IProtestRepository, ) {} - async execute(command: SubmitProtestDefenseCommand): Promise { + async execute(command: SubmitProtestDefenseCommand): Promise>> { // Get the protest const protest = await this.protestRepository.findById(command.protestId); if (!protest) { - throw new Error('Protest not found'); + return Result.err({ code: 'PROTEST_NOT_FOUND' }); } // Verify the submitter is the accused driver if (protest.accusedDriverId !== command.driverId) { - throw new Error('Only the accused driver can submit a defense'); + return Result.err({ code: 'NOT_ACCUSED_DRIVER' }); } // Check if defense can be submitted if (!protest.canSubmitDefense()) { - throw new Error('Defense cannot be submitted for this protest'); + return Result.err({ code: 'DEFENSE_CANNOT_BE_SUBMITTED' }); } // Submit defense const updatedProtest = protest.submitDefense(command.statement, command.videoUrl); await this.protestRepository.update(updatedProtest); - return { - success: true, - protestId: protest.id, - }; + 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 new file mode 100644 index 000000000..c501f50f9 --- /dev/null +++ b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi } from 'vitest'; +import { TransferLeagueOwnershipUseCase } from './TransferLeagueOwnershipUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; + +describe('TransferLeagueOwnershipUseCase', () => { + it('transfers ownership successfully', async () => { + const mockLeague = { + id: 'league-1', + ownerId: 'owner-1', + update: vi.fn().mockReturnValue({}), + }; + + const mockNewOwnerMembership = { + leagueId: 'league-1', + driverId: 'owner-2', + status: 'active', + role: 'member', + }; + + const mockCurrentOwnerMembership = { + leagueId: 'league-1', + driverId: 'owner-1', + status: 'active', + role: 'owner', + }; + + const mockLeagueRepository = { + findById: vi.fn().mockResolvedValue(mockLeague), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ILeagueRepository; + + const mockMembershipRepository = { + getMembership: vi.fn() + .mockResolvedValueOnce(mockNewOwnerMembership) + .mockResolvedValueOnce(mockCurrentOwnerMembership), + saveMembership: vi.fn().mockResolvedValue(undefined), + } as unknown as ILeagueMembershipRepository; + + const useCase = new TransferLeagueOwnershipUseCase( + mockLeagueRepository, + mockMembershipRepository, + ); + + const command = { + leagueId: 'league-1', + currentOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }; + + const result = await useCase.execute(command); + + 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(mockLeague.update).toHaveBeenCalledWith({ ownerId: 'owner-2' }); + expect(mockLeagueRepository.update).toHaveBeenCalledWith({}); + }); + + it('returns error when league not found', async () => { + const mockLeagueRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as ILeagueRepository; + + const mockMembershipRepository = {} as unknown as ILeagueMembershipRepository; + + const useCase = new TransferLeagueOwnershipUseCase( + mockLeagueRepository, + mockMembershipRepository, + ); + + const command = { + leagueId: 'league-1', + currentOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'LEAGUE_NOT_FOUND' }); + }); + + it('returns error when not current owner', async () => { + const mockLeague = { + id: 'league-1', + ownerId: 'owner-2', + }; + + const mockLeagueRepository = { + findById: vi.fn().mockResolvedValue(mockLeague), + } as unknown as ILeagueRepository; + + const mockMembershipRepository = {} as unknown as ILeagueMembershipRepository; + + const useCase = new TransferLeagueOwnershipUseCase( + mockLeagueRepository, + mockMembershipRepository, + ); + + const command = { + leagueId: 'league-1', + currentOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'NOT_CURRENT_OWNER' }); + }); + + it('returns error when new owner is not active member', async () => { + const mockLeague = { + id: 'league-1', + ownerId: 'owner-1', + }; + + const mockLeagueRepository = { + findById: vi.fn().mockResolvedValue(mockLeague), + } as unknown as ILeagueRepository; + + const mockMembershipRepository = { + getMembership: vi.fn().mockResolvedValue(null), + } as unknown as ILeagueMembershipRepository; + + const useCase = new TransferLeagueOwnershipUseCase( + mockLeagueRepository, + mockMembershipRepository, + ); + + const command = { + leagueId: 'league-1', + currentOwnerId: 'owner-1', + newOwnerId: 'owner-2', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'NEW_OWNER_NOT_ACTIVE_MEMBER' }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts index 5d94e3866..517a5c443 100644 --- a/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts +++ b/core/racing/application/use-cases/TransferLeagueOwnershipUseCase.ts @@ -1,9 +1,10 @@ +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ILeagueMembershipRepository, } from '@core/racing/domain/repositories/ILeagueMembershipRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import type { - LeagueMembership, MembershipRole, } from '@core/racing/domain/entities/LeagueMembership'; @@ -13,31 +14,33 @@ export interface TransferLeagueOwnershipCommandDTO { newOwnerId: string; } +type TransferLeagueOwnershipErrorCode = 'LEAGUE_NOT_FOUND' | 'NOT_CURRENT_OWNER' | 'NEW_OWNER_NOT_ACTIVE_MEMBER'; + export class TransferLeagueOwnershipUseCase { constructor( private readonly leagueRepository: ILeagueRepository, private readonly membershipRepository: ILeagueMembershipRepository ) {} - async execute(command: TransferLeagueOwnershipCommandDTO): Promise { + async execute(command: TransferLeagueOwnershipCommandDTO): Promise>> { const { leagueId, currentOwnerId, newOwnerId } = command; const league = await this.leagueRepository.findById(leagueId); if (!league) { - throw new Error('League not found'); + return Result.err({ code: 'LEAGUE_NOT_FOUND' }); } if (league.ownerId !== currentOwnerId) { - throw new Error('Only the current owner can transfer ownership'); + return Result.err({ code: 'NOT_CURRENT_OWNER' }); } const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId); if (!newOwnerMembership || newOwnerMembership.status !== 'active') { - throw new Error('New owner must be an active member of the league'); + return Result.err({ code: 'NEW_OWNER_NOT_ACTIVE_MEMBER' }); } const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId); - + await this.membershipRepository.saveMembership({ ...newOwnerMembership, role: 'owner' as MembershipRole, @@ -52,5 +55,7 @@ export class TransferLeagueOwnershipUseCase { const updatedLeague = league.update({ ownerId: newOwnerId }); await this.leagueRepository.update(updatedLeague); + + return Result.ok(undefined); } } \ 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 new file mode 100644 index 000000000..acc1a0e89 --- /dev/null +++ b/core/racing/application/use-cases/UpdateDriverProfileUseCase.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; +import { UpdateDriverProfileUseCase } from './UpdateDriverProfileUseCase'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { EntityMappers } from '../mappers/EntityMappers'; + +vi.mock('../mappers/EntityMappers', () => ({ + EntityMappers: { + toDriverDTO: vi.fn(), + }, +})); + +describe('UpdateDriverProfileUseCase', () => { + it('updates driver profile successfully', async () => { + const mockDriver = { + id: 'driver-1', + update: vi.fn().mockReturnValue({}), + }; + + const mockUpdatedDriver = {}; + + const mockDTO = { id: 'driver-1', bio: 'New bio' }; + + const mockDriverRepository = { + findById: vi.fn().mockResolvedValue(mockDriver), + update: vi.fn().mockResolvedValue(mockUpdatedDriver), + } as unknown as IDriverRepository; + + (EntityMappers.toDriverDTO as any).mockReturnValue(mockDTO); + + const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); + + const input = { + driverId: 'driver-1', + bio: 'New bio', + country: 'US', + }; + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual(mockDTO); + expect(mockDriverRepository.findById).toHaveBeenCalledWith('driver-1'); + expect(mockDriver.update).toHaveBeenCalledWith({ bio: 'New bio', country: 'US' }); + expect(mockDriverRepository.update).toHaveBeenCalledWith({}); + expect(EntityMappers.toDriverDTO).toHaveBeenCalledWith(mockUpdatedDriver); + }); + + it('returns error when driver not found', async () => { + const mockDriverRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as IDriverRepository; + + const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); + + const input = { + driverId: 'driver-1', + bio: 'New bio', + }; + + const result = await useCase.execute(input); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'DRIVER_NOT_FOUND' }); + }); + + it('updates only provided fields', async () => { + const mockDriver = { + id: 'driver-1', + update: vi.fn().mockReturnValue({}), + }; + + const mockUpdatedDriver = {}; + + const mockDTO = { id: 'driver-1', country: 'US' }; + + const mockDriverRepository = { + findById: vi.fn().mockResolvedValue(mockDriver), + update: vi.fn().mockResolvedValue(mockUpdatedDriver), + } as unknown as IDriverRepository; + + (EntityMappers.toDriverDTO as any).mockReturnValue(mockDTO); + + const useCase = new UpdateDriverProfileUseCase(mockDriverRepository); + + const input = { + driverId: 'driver-1', + country: 'US', + }; + + const result = await useCase.execute(input); + + expect(result.isOk()).toBe(true); + expect(mockDriver.update).toHaveBeenCalledWith({ country: 'US' }); + }); +}); \ 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 0c9366d4a..adae24ce1 100644 --- a/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts +++ b/core/racing/application/use-cases/UpdateDriverProfileUseCase.ts @@ -1,3 +1,5 @@ +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { DriverDTO } from '../dto/DriverDTO'; import { EntityMappers } from '../mappers/EntityMappers'; @@ -15,12 +17,12 @@ export interface UpdateDriverProfileInput { export class UpdateDriverProfileUseCase { constructor(private readonly driverRepository: IDriverRepository) {} - 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 null; + return Result.err({ code: 'DRIVER_NOT_FOUND' }); } const updated = existing.update({ @@ -30,6 +32,6 @@ export class UpdateDriverProfileUseCase { const persisted = await this.driverRepository.update(updated); const dto = EntityMappers.toDriverDTO(persisted); - return dto ?? null; + return Result.ok(dto!); } } \ 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 a353e9949..fde1a53b3 100644 --- a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts +++ b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.test.ts @@ -1,44 +1,56 @@ -import { UpdateLeagueMemberRoleUseCase } from '@core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase'; -import { UpdateLeagueMemberRolePresenter } from '@apps/api/src/modules/league/presenters/UpdateLeagueMemberRolePresenter'; - +import { describe, it, expect, vi } from 'vitest'; +import { UpdateLeagueMemberRoleUseCase } from './UpdateLeagueMemberRoleUseCase'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; describe('UpdateLeagueMemberRoleUseCase', () => { - let useCase: UpdateLeagueMemberRoleUseCase; - let leagueMembershipRepository: jest.Mocked; - let presenter: UpdateLeagueMemberRolePresenter; - - beforeEach(() => { - leagueMembershipRepository = { - getLeagueMembers: jest.fn(), - saveMembership: jest.fn(), - } as unknown; - presenter = new UpdateLeagueMemberRolePresenter(); - useCase = new UpdateLeagueMemberRoleUseCase(leagueMembershipRepository); - }); - - it('should update league member role', async () => { - const leagueId = 'league-1'; - const targetDriverId = 'driver-1'; - const newRole = 'admin'; - const memberships = [{ leagueId, driverId: targetDriverId, role: 'member', status: 'active', joinedAt: new Date() }]; - - leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); - - await useCase.execute({ leagueId, targetDriverId, newRole }, presenter); - - expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ - leagueId, - driverId: targetDriverId, - role: 'admin', + it('updates league member role successfully', async () => { + const mockMembership = { + leagueId: 'league-1', + driverId: 'driver-1', + role: 'member', status: 'active', - joinedAt: expect.any(Date), + joinedAt: new Date(), + }; + + const mockLeagueMembershipRepository = { + getLeagueMembers: vi.fn().mockResolvedValue([mockMembership]), + saveMembership: vi.fn().mockResolvedValue(undefined), + } as unknown as ILeagueMembershipRepository; + + const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository); + + const params = { + leagueId: 'league-1', + targetDriverId: 'driver-1', + newRole: 'admin', + }; + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(mockLeagueMembershipRepository.getLeagueMembers).toHaveBeenCalledWith('league-1'); + expect(mockLeagueMembershipRepository.saveMembership).toHaveBeenCalledWith({ + ...mockMembership, + role: 'admin', }); - expect(presenter.viewModel).toEqual({ success: true }); }); - it('should throw error if membership not found', async () => { - leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]); + it('returns error if membership not found', async () => { + const mockLeagueMembershipRepository = { + getLeagueMembers: vi.fn().mockResolvedValue([]), + } as unknown as ILeagueMembershipRepository; - await expect(useCase.execute({ leagueId: 'league-1', targetDriverId: 'driver-1', newRole: 'admin' }, presenter)).rejects.toThrow('Membership not found'); + const useCase = new UpdateLeagueMemberRoleUseCase(mockLeagueMembershipRepository); + + const params = { + leagueId: 'league-1', + targetDriverId: 'driver-1', + newRole: 'admin', + }; + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'MEMBERSHIP_NOT_FOUND' }); }); }); \ 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 44e35ff57..a856dd27c 100644 --- a/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts +++ b/core/racing/application/use-cases/UpdateLeagueMemberRoleUseCase.ts @@ -1,6 +1,6 @@ +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { IUpdateLeagueMemberRolePresenter, UpdateLeagueMemberRoleResultDTO, UpdateLeagueMemberRoleViewModel } from '../presenters/IUpdateLeagueMemberRolePresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; export interface UpdateLeagueMemberRoleUseCaseParams { leagueId: string; @@ -8,25 +8,19 @@ export interface UpdateLeagueMemberRoleUseCaseParams { newRole: string; } -export interface UpdateLeagueMemberRoleResultDTO { - success: boolean; -} - -export class UpdateLeagueMemberRoleUseCase implements UseCase { +export class UpdateLeagueMemberRoleUseCase { constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} - async execute(params: UpdateLeagueMemberRoleUseCaseParams, presenter: IUpdateLeagueMemberRolePresenter): Promise { + async execute(params: UpdateLeagueMemberRoleUseCaseParams): Promise>> { const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const membership = memberships.find(m => m.driverId === params.targetDriverId); if (!membership) { - throw new Error('Membership not found'); + return Result.err({ code: 'MEMBERSHIP_NOT_FOUND' }); } await this.leagueMembershipRepository.saveMembership({ ...membership, role: params.newRole, }); - const dto: UpdateLeagueMemberRoleResultDTO = { success: true }; - presenter.reset(); - presenter.present(dto); + return Result.ok(undefined); } } \ 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 new file mode 100644 index 000000000..536044991 --- /dev/null +++ b/core/racing/application/use-cases/UpdateTeamUseCase.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from 'vitest'; +import { UpdateTeamUseCase } from './UpdateTeamUseCase'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; + +describe('UpdateTeamUseCase', () => { + it('updates team successfully', async () => { + const mockMembership = { + role: 'owner', + }; + + const mockTeam = { + id: 'team-1', + update: vi.fn().mockReturnValue({}), + }; + + const mockTeamRepository = { + findById: vi.fn().mockResolvedValue(mockTeam), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as ITeamRepository; + + const mockMembershipRepository = { + getMembership: vi.fn().mockResolvedValue(mockMembership), + } as unknown as ITeamMembershipRepository; + + const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository); + + const command = { + teamId: 'team-1', + updates: { name: 'New Name', tag: 'NEW' }, + updatedBy: 'user-1', + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + 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({}); + }); + + it('returns error if insufficient permissions', async () => { + const mockMembership = { + role: 'member', + }; + + const mockMembershipRepository = { + getMembership: vi.fn().mockResolvedValue(mockMembership), + } as unknown as ITeamMembershipRepository; + + const useCase = new UpdateTeamUseCase({} as unknown as ITeamRepository, mockMembershipRepository); + + const command = { + teamId: 'team-1', + updates: { name: 'New Name' }, + updatedBy: 'user-1', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'INSUFFICIENT_PERMISSIONS' }); + }); + + it('returns error if team not found', async () => { + const mockMembership = { + role: 'owner', + }; + + const mockTeamRepository = { + findById: vi.fn().mockResolvedValue(null), + } as unknown as ITeamRepository; + + const mockMembershipRepository = { + getMembership: vi.fn().mockResolvedValue(mockMembership), + } as unknown as ITeamMembershipRepository; + + const useCase = new UpdateTeamUseCase(mockTeamRepository, mockMembershipRepository); + + const command = { + teamId: 'team-1', + updates: { name: 'New Name' }, + updatedBy: 'user-1', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'TEAM_NOT_FOUND' }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/UpdateTeamUseCase.ts b/core/racing/application/use-cases/UpdateTeamUseCase.ts index 092b10c1b..538351d5a 100644 --- a/core/racing/application/use-cases/UpdateTeamUseCase.ts +++ b/core/racing/application/use-cases/UpdateTeamUseCase.ts @@ -1,25 +1,29 @@ +import { Result } from '@core/shared/application/Result'; +import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { UpdateTeamCommandDTO } from '../dto/TeamCommandAndQueryDTO'; +type UpdateTeamErrorCode = 'INSUFFICIENT_PERMISSIONS' | 'TEAM_NOT_FOUND'; + export class UpdateTeamUseCase { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, ) {} - async execute(command: UpdateTeamCommandDTO): Promise { + async execute(command: UpdateTeamCommandDTO): Promise>> { const { teamId, updates, updatedBy } = command; const updaterMembership = await this.membershipRepository.getMembership(teamId, updatedBy); if (!updaterMembership || (updaterMembership.role !== 'owner' && updaterMembership.role !== 'manager')) { - throw new Error('Only owners and managers can update team info'); + return Result.err({ code: 'INSUFFICIENT_PERMISSIONS' }); } const existing = await this.teamRepository.findById(teamId); if (!existing) { - throw new Error('Team not found'); + return Result.err({ code: 'TEAM_NOT_FOUND' }); } const updated = existing.update({ @@ -30,5 +34,7 @@ export class UpdateTeamUseCase { }); await this.teamRepository.update(updated); + + return Result.ok(undefined); } } \ 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 new file mode 100644 index 000000000..d07b99c7f --- /dev/null +++ b/core/racing/application/use-cases/WithdrawFromRaceUseCase.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest'; +import { WithdrawFromRaceUseCase } from './WithdrawFromRaceUseCase'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; + +describe('WithdrawFromRaceUseCase', () => { + it('withdraws from race successfully', async () => { + const mockRegistrationRepository = { + withdraw: vi.fn().mockResolvedValue(undefined), + } as unknown as IRaceRegistrationRepository; + + const useCase = new WithdrawFromRaceUseCase(mockRegistrationRepository); + + const command = { + raceId: 'race-1', + driverId: 'driver-1', + }; + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(mockRegistrationRepository.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; + + const useCase = new WithdrawFromRaceUseCase(mockRegistrationRepository); + + const command = { + raceId: 'race-1', + driverId: 'driver-1', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toEqual({ code: 'NOT_REGISTERED' }); + }); +}); \ 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 712d9b71b..53064bcb5 100644 --- a/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts +++ b/core/racing/application/use-cases/WithdrawFromRaceUseCase.ts @@ -1,9 +1,11 @@ +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'; /** * Mirrors legacy withdrawFromRace behavior: - * - throws when driver is not registered + * - returns error when driver is not registered * - removes registration and cleans up empty race sets * * The repository encapsulates the in-memory or persistent details. @@ -13,10 +15,14 @@ export class WithdrawFromRaceUseCase { private readonly registrationRepository: IRaceRegistrationRepository, ) {} - async execute(command: WithdrawFromRaceCommandDTO): Promise { + async execute(command: WithdrawFromRaceCommandDTO): Promise>> { const { raceId, driverId } = command; - // Let repository enforce "not registered" error behavior to match legacy logic. - await this.registrationRepository.withdraw(raceId, driverId); + try { + await this.registrationRepository.withdraw(raceId, driverId); + return Result.ok(undefined); + } catch { + return Result.err({ code: 'NOT_REGISTERED' }); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/index.ts b/core/racing/application/use-cases/index.ts index ee4d4f8de..c7dd888a0 100644 --- a/core/racing/application/use-cases/index.ts +++ b/core/racing/application/use-cases/index.ts @@ -1,2 +1,2 @@ -// Use cases will be added as needed -// Example: CreateDriverUseCase, CreateLeagueUseCase, etc. \ No newline at end of file +export { ImportRaceResultsApiUseCase } from './ImportRaceResultsApiUseCase'; +export { ImportRaceResultsUseCase } from './ImportRaceResultsUseCase'; \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueMembership.ts b/core/racing/domain/entities/LeagueMembership.ts index 2bf287a0b..e21f76163 100644 --- a/core/racing/domain/entities/LeagueMembership.ts +++ b/core/racing/domain/entities/LeagueMembership.ts @@ -8,7 +8,7 @@ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member'; -export type MembershipStatus = 'active' | 'pending' | 'none'; +export type MembershipStatus = 'active' | 'inactive' | 'pending'; export interface LeagueMembershipProps { id?: string; diff --git a/core/shared/application/AsyncUseCase.ts b/core/shared/application/AsyncUseCase.ts index 2d7824ba6..a20dbe023 100644 --- a/core/shared/application/AsyncUseCase.ts +++ b/core/shared/application/AsyncUseCase.ts @@ -1,13 +1,16 @@ +import type { Result } from './Result'; +import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; + /** * Async Use Case interface for queries. * * Queries do not change system state and return data directly. - * The output is most often a Result where T is the data and E is a domain error code, + * The output must be a Result> where T is the data and E is an application error code, * to handle business rejections explicitly. Use cases do not throw errors; they use error codes in Result. * * Example: * ```typescript - * export type YourUseCaseError = + * export type YourUseCaseErrorCode = * | 'SPONSOR_NOT_FOUND' * | 'PRICING_NOT_CONFIGURED' * | 'APPLICATIONS_CLOSED' @@ -15,16 +18,17 @@ * | 'DUPLICATE_PENDING_REQUEST' * | 'OFFER_BELOW_MINIMUM'; * - * export class ApplyForSponsorshipUseCase implements AsyncUseCase> { - * async execute(input: Input): Promise> { + * export class ApplyForSponsorshipUseCase implements AsyncUseCase { + * async execute(input: Input): Promise>> { * // implementation * } * } * ``` * * @template Input - The input type for the use case - * @template Output - The output type returned by the use case, often Result + * @template Success - The success type returned in the Result + * @template ErrorCode - The error code type for ApplicationErrorCode */ -export interface AsyncUseCase { - execute(input: Input): Promise; +export interface AsyncUseCase { + execute(input: Input): Promise>>; } \ No newline at end of file diff --git a/core/shared/application/Logger.ts b/core/shared/application/Logger.ts index 00525c99d..f5777d454 100644 --- a/core/shared/application/Logger.ts +++ b/core/shared/application/Logger.ts @@ -1,6 +1,6 @@ export interface Logger { - debug(message: string, context?: Record): void; - info(message: string, context?: Record): void; - warn(message: string, context?: Record): void; - error(message: string, error?: Error, context?: Record): void; + debug(message: string, context?: unknown): void; + info(message: string, context?: unknown): void; + warn(message: string, context?: unknown): void; + error(message: string, error?: Error, context?: unknown): void; } \ No newline at end of file diff --git a/core/shared/result/Result.ts b/core/shared/application/Result.ts similarity index 74% rename from core/shared/result/Result.ts rename to core/shared/application/Result.ts index 0b3d837ee..553d6318b 100644 --- a/core/shared/result/Result.ts +++ b/core/shared/application/Result.ts @@ -1,3 +1,14 @@ +/** + * Result type for handling success and error cases in a type-safe way. + * + * This class MUST ONLY be used within use cases (AsyncUseCase and UseCase implementations). + * It is NOT allowed to be used in domain entities, services, repositories, presenters, or any other layer. + * Use cases are the only place where business logic decisions that can fail should be made, + * and Result provides a way to handle those failures without throwing exceptions. + * + * @template T - The type of the success value + * @template E - The type of the error value, typically ApplicationErrorCode + */ export class Result { private constructor( private readonly _value?: T, diff --git a/core/shared/application/Service.ts b/core/shared/application/Service.ts index 54c491fc4..fb9f85fa8 100644 --- a/core/shared/application/Service.ts +++ b/core/shared/application/Service.ts @@ -1,5 +1,5 @@ -import type { Result } from '../result/Result'; -import type { IApplicationError } from '../errors/ApplicationError'; +import type { Result } from './Result'; +import type { ApplicationError } from '../errors/ApplicationError'; export interface IApplicationService { readonly serviceName?: string; @@ -12,7 +12,7 @@ export interface IAsyncApplicationService extends IApplicationSer export interface IAsyncResultApplicationService< Input, Output, - Error = IApplicationError + Error = ApplicationError > extends IApplicationService { execute(input: Input): Promise>; } \ No newline at end of file diff --git a/core/shared/application/UseCase.ts b/core/shared/application/UseCase.ts index ce397b1fc..942f09858 100644 --- a/core/shared/application/UseCase.ts +++ b/core/shared/application/UseCase.ts @@ -1,4 +1,6 @@ import type { Presenter } from '../presentation'; +import type { Result } from './Result'; +import type { ApplicationErrorCode } from '../errors/ApplicationErrorCode'; /** * Use Case interface for commands. @@ -7,28 +9,29 @@ import type { Presenter } from '../presentation'; * but contain no infrastructure or framework concerns. * * Commands change system state and return nothing on success. They use a presenter to handle the output. - * If a business rejection is possible, the output may be a Result handled by the presenter. + * If a business rejection is possible, the output must be a Result> handled by the presenter. * Use cases do not throw errors; they use error codes in Result. * * Example: * ```typescript - * export type CreateRaceError = + * export type CreateRaceErrorCode = * | 'INSUFFICIENT_PERMISSIONS' * | 'RACE_ALREADY_EXISTS' * | 'INVALID_RACE_CONFIG'; * - * export class CreateRaceUseCase implements UseCase, ViewModel, Presenter, ViewModel>> { - * execute(input: CreateRaceInput, presenter: Presenter, ViewModel>): Promise { + * export class CreateRaceUseCase implements UseCase>, ViewModel>> { + * execute(input: CreateRaceInput, presenter: Presenter>, ViewModel>): Promise { * // implementation * } * } * ``` * * @template Input - The input type for the use case - * @template OutputDTO - The output DTO type, often Result + * @template Success - The success type in the Result passed to the presenter + * @template ErrorCode - The error code type for ApplicationErrorCode * @template ViewModel - The view model type - * @template P - The presenter type, extending Presenter + * @template P - The presenter type, extending Presenter>, ViewModel> */ -export interface UseCase> { +export interface UseCase>, ViewModel>> { execute(input: Input, presenter: P): Promise | void; } \ No newline at end of file diff --git a/core/shared/domain/DomainEvent.ts b/core/shared/domain/DomainEvent.ts new file mode 100644 index 000000000..34ffea9f5 --- /dev/null +++ b/core/shared/domain/DomainEvent.ts @@ -0,0 +1,10 @@ +export interface DomainEvent { + readonly eventType: string; + readonly aggregateId: string; + readonly eventData: T; + readonly occurredAt: Date; +} + +export interface DomainEventPublisher { + publish(event: DomainEvent): Promise; +} \ No newline at end of file diff --git a/core/shared/domain/IDomainEvent.ts b/core/shared/domain/IDomainEvent.ts deleted file mode 100644 index 22043ca42..000000000 --- a/core/shared/domain/IDomainEvent.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface IDomainEvent { - readonly eventType: string; - readonly aggregateId: string; - readonly eventData: T; - readonly occurredAt: Date; -} - -export interface IDomainEventPublisher { - publish(event: IDomainEvent): Promise; -} \ No newline at end of file diff --git a/core/shared/domain/Service.ts b/core/shared/domain/Service.ts index 7d75e1967..f2159794b 100644 --- a/core/shared/domain/Service.ts +++ b/core/shared/domain/Service.ts @@ -1,4 +1,4 @@ -import type { Result } from '../result/Result'; +import type { Result } from '../application/Result'; import type { IDomainError } from '../errors/DomainError'; export interface IDomainService { diff --git a/core/shared/errors/ApplicationError.ts b/core/shared/errors/ApplicationError.ts index 0a2de0c2a..f28031fe4 100644 --- a/core/shared/errors/ApplicationError.ts +++ b/core/shared/errors/ApplicationError.ts @@ -12,7 +12,7 @@ export type CommonApplicationErrorKind = /** * @deprecated Use ApplicationErrorCode in Result instead of throwing ApplicationError. */ -export interface IApplicationError extends Error { +export interface ApplicationError extends Error { readonly type: 'application'; readonly context: string; readonly kind: K; diff --git a/core/shared/errors/ApplicationErrorCode.ts b/core/shared/errors/ApplicationErrorCode.ts index ba652fb7b..69980ece7 100644 --- a/core/shared/errors/ApplicationErrorCode.ts +++ b/core/shared/errors/ApplicationErrorCode.ts @@ -1,6 +1,4 @@ -export type ApplicationErrorCode< - Code extends string, - Details = undefined -> = Details extends undefined - ? { code: Code } - : { code: Code; details: Details }; \ No newline at end of file +export type ApplicationErrorCode = + Details extends undefined + ? { code: Code } + : { code: Code; details: Details }; \ No newline at end of file diff --git a/core/shared/index.ts b/core/shared/index.ts index e8ecc8cb7..f0dc72742 100644 --- a/core/shared/index.ts +++ b/core/shared/index.ts @@ -1,4 +1,4 @@ -export * from './result/Result'; +export * from './application/Result'; export * as application from './application'; export * as domain from './domain'; export * as errors from './errors'; diff --git a/core/racing/application/use-cases/RegistrationAndTeamUseCases.test.ts b/tests/RegistrationAndTeamUseCases.test.ts similarity index 100% rename from core/racing/application/use-cases/RegistrationAndTeamUseCases.test.ts rename to tests/RegistrationAndTeamUseCases.test.ts diff --git a/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts b/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts index 12264c18b..f2586d22b 100644 --- a/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts +++ b/tests/integration/infrastructure/CheckoutPriceExtractor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { Result } from '../../../core/shared/result/Result'; +import { Result } from '@gridpilot/shared/application/Result'; import { CheckoutPriceExtractor } from '../../../apps/companion/main/automation/infrastructure/automation/CheckoutPriceExtractor'; import { CheckoutStateEnum } from 'apps/companion/main/automation/domain/value-objects/CheckoutState';