diff --git a/core/racing/application/dto/AcceptSponsorshipRequestDTO.ts b/core/racing/application/dto/AcceptSponsorshipRequestDTO.ts new file mode 100644 index 000000000..1f4e9095f --- /dev/null +++ b/core/racing/application/dto/AcceptSponsorshipRequestDTO.ts @@ -0,0 +1,4 @@ +export interface AcceptSponsorshipRequestDTO { + requestId: string; + respondedBy: string; // driverId of the person accepting +} \ No newline at end of file diff --git a/core/racing/application/dto/AcceptSponsorshipRequestResultDTO.ts b/core/racing/application/dto/AcceptSponsorshipRequestResultDTO.ts new file mode 100644 index 000000000..9f18b315b --- /dev/null +++ b/core/racing/application/dto/AcceptSponsorshipRequestResultDTO.ts @@ -0,0 +1,8 @@ +export interface AcceptSponsorshipRequestResultDTO { + requestId: string; + sponsorshipId: string; + status: 'accepted'; + acceptedAt: Date; + platformFee: number; + netAmount: number; +} \ No newline at end of file diff --git a/core/racing/application/dto/ApplyForSponsorshipDTO.ts b/core/racing/application/dto/ApplyForSponsorshipDTO.ts new file mode 100644 index 000000000..c2db72f12 --- /dev/null +++ b/core/racing/application/dto/ApplyForSponsorshipDTO.ts @@ -0,0 +1,13 @@ +import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; +import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; +import type { Currency } from '../../domain/value-objects/Money'; + +export interface ApplyForSponsorshipDTO { + sponsorId: string; + entityType: SponsorableEntityType; + entityId: string; + tier: SponsorshipTier; + offeredAmount: number; // in cents + currency?: Currency; + message?: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/ApplyForSponsorshipResultDTO.ts b/core/racing/application/dto/ApplyForSponsorshipResultDTO.ts new file mode 100644 index 000000000..8e26c3fe2 --- /dev/null +++ b/core/racing/application/dto/ApplyForSponsorshipResultDTO.ts @@ -0,0 +1,5 @@ +export interface ApplyForSponsorshipResultDTO { + requestId: string; + status: 'pending'; + createdAt: Date; +} \ No newline at end of file diff --git a/core/racing/application/dto/ApproveLeagueJoinRequestResultDTO.ts b/core/racing/application/dto/ApproveLeagueJoinRequestResultDTO.ts new file mode 100644 index 000000000..8c30d5f37 --- /dev/null +++ b/core/racing/application/dto/ApproveLeagueJoinRequestResultDTO.ts @@ -0,0 +1,4 @@ +export interface ApproveLeagueJoinRequestResultDTO { + success: boolean; + message: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/CancelRaceCommandDTO.ts b/core/racing/application/dto/CancelRaceCommandDTO.ts new file mode 100644 index 000000000..b6e36d0dc --- /dev/null +++ b/core/racing/application/dto/CancelRaceCommandDTO.ts @@ -0,0 +1,3 @@ +export interface CancelRaceCommandDTO { + raceId: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/CompleteRaceCommandDTO.ts b/core/racing/application/dto/CompleteRaceCommandDTO.ts new file mode 100644 index 000000000..befa17f45 --- /dev/null +++ b/core/racing/application/dto/CompleteRaceCommandDTO.ts @@ -0,0 +1,3 @@ +export interface CompleteRaceCommandDTO { + raceId: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/CreateLeagueWithSeasonAndScoringResultDTO.ts b/core/racing/application/dto/CreateLeagueWithSeasonAndScoringResultDTO.ts new file mode 100644 index 000000000..34c1aebd6 --- /dev/null +++ b/core/racing/application/dto/CreateLeagueWithSeasonAndScoringResultDTO.ts @@ -0,0 +1,6 @@ +export interface CreateLeagueWithSeasonAndScoringResultDTO { + leagueId: string; + seasonId: string; + scoringPresetId?: string; + scoringPresetName?: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/CreateSponsorResultDTO.ts b/core/racing/application/dto/CreateSponsorResultDTO.ts new file mode 100644 index 000000000..04e31cf60 --- /dev/null +++ b/core/racing/application/dto/CreateSponsorResultDTO.ts @@ -0,0 +1,10 @@ +export interface CreateSponsorResultDTO { + sponsor: { + id: string; + name: string; + contactEmail: string; + websiteUrl?: string; + logoUrl?: string; + createdAt: Date; + }; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetEntitySponsorshipPricingDTO.ts b/core/racing/application/dto/GetEntitySponsorshipPricingDTO.ts new file mode 100644 index 000000000..b9278c8f6 --- /dev/null +++ b/core/racing/application/dto/GetEntitySponsorshipPricingDTO.ts @@ -0,0 +1,6 @@ +import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; + +export interface GetEntitySponsorshipPricingDTO { + entityType: SponsorableEntityType; + entityId: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetEntitySponsorshipPricingResultDTO.ts b/core/racing/application/dto/GetEntitySponsorshipPricingResultDTO.ts new file mode 100644 index 000000000..452051879 --- /dev/null +++ b/core/racing/application/dto/GetEntitySponsorshipPricingResultDTO.ts @@ -0,0 +1,11 @@ +import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; +import type { SponsorshipSlotDTO } from '../use-cases/SponsorshipSlotDTO'; + +export interface GetEntitySponsorshipPricingResultDTO { + entityType: SponsorableEntityType; + entityId: string; + acceptingApplications: boolean; + customRequirements?: string; + mainSlot?: SponsorshipSlotDTO; + secondarySlot?: SponsorshipSlotDTO; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueAdminPermissionsResultDTO.ts b/core/racing/application/dto/GetLeagueAdminPermissionsResultDTO.ts new file mode 100644 index 000000000..a712d8d6a --- /dev/null +++ b/core/racing/application/dto/GetLeagueAdminPermissionsResultDTO.ts @@ -0,0 +1,4 @@ +export interface GetLeagueAdminPermissionsResultDTO { + canRemoveMember: boolean; + canUpdateRoles: boolean; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueAdminResultDTO.ts b/core/racing/application/dto/GetLeagueAdminResultDTO.ts new file mode 100644 index 000000000..23010d5c3 --- /dev/null +++ b/core/racing/application/dto/GetLeagueAdminResultDTO.ts @@ -0,0 +1,7 @@ +export interface GetLeagueAdminResultDTO { + league: { + id: string; + ownerId: string; + }; + // Additional data would be populated by combining multiple use cases +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueJoinRequestsResultDTO.ts b/core/racing/application/dto/GetLeagueJoinRequestsResultDTO.ts new file mode 100644 index 000000000..2c9143c61 --- /dev/null +++ b/core/racing/application/dto/GetLeagueJoinRequestsResultDTO.ts @@ -0,0 +1,13 @@ +export interface GetLeagueJoinRequestsResultDTO { + joinRequests: Array<{ + id: string; + leagueId: string; + driverId: string; + requestedAt: Date; + message?: string; + driver: { + id: string; + name: string; + }; + }>; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueJoinRequestsUseCaseParams.ts b/core/racing/application/dto/GetLeagueJoinRequestsUseCaseParams.ts new file mode 100644 index 000000000..c93aa9679 --- /dev/null +++ b/core/racing/application/dto/GetLeagueJoinRequestsUseCaseParams.ts @@ -0,0 +1,3 @@ +export interface GetLeagueJoinRequestsUseCaseParams { + leagueId: string; +} \ No newline at end of file diff --git a/core/racing/application/dto/GetLeagueMembershipsResultDTO.ts b/core/racing/application/dto/GetLeagueMembershipsResultDTO.ts new file mode 100644 index 000000000..5992e33bf --- /dev/null +++ b/core/racing/application/dto/GetLeagueMembershipsResultDTO.ts @@ -0,0 +1,6 @@ +import type { LeagueMembership } from '../../domain/entities/LeagueMembership'; + +export interface GetLeagueMembershipsResultDTO { + memberships: LeagueMembership[]; + drivers: { id: string; name: string }[]; +} \ No newline at end of file diff --git a/core/racing/application/dto/SponsorshipSlotDTO.ts b/core/racing/application/dto/SponsorshipSlotDTO.ts new file mode 100644 index 000000000..3bfeacd70 --- /dev/null +++ b/core/racing/application/dto/SponsorshipSlotDTO.ts @@ -0,0 +1,13 @@ +import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; + +export interface SponsorshipSlotDTO { + tier: SponsorshipTier; + price: number; + currency: string; + formattedPrice: string; + benefits: string[]; + available: boolean; + maxSlots: number; + filledSlots: number; + pendingRequests: number; +} \ No newline at end of file diff --git a/core/racing/application/dto/TeamCommandAndQueryDTO.ts b/core/racing/application/dto/TeamCommandAndQueryDTO.ts index 863f11717..e8a861073 100644 --- a/core/racing/application/dto/TeamCommandAndQueryDTO.ts +++ b/core/racing/application/dto/TeamCommandAndQueryDTO.ts @@ -1,8 +1,5 @@ import type { Team } from '../../domain/entities/Team'; -import type { - TeamJoinRequest, - TeamMembership, -} from '../../domain/types/TeamMembership'; +import type { TeamMembership } from '../../domain/types/TeamMembership'; export interface JoinTeamCommandDTO { teamId: string; @@ -15,6 +12,7 @@ export interface LeaveTeamCommandDTO { } export interface ApproveTeamJoinRequestCommandDTO { + teamId: string; requestId: string; } diff --git a/core/racing/application/presenters/IDashboardOverviewPresenter.ts b/core/racing/application/presenters/IDashboardOverviewPresenter.ts index b9142050e..1c5c9f8a2 100644 --- a/core/racing/application/presenters/IDashboardOverviewPresenter.ts +++ b/core/racing/application/presenters/IDashboardOverviewPresenter.ts @@ -1,4 +1,5 @@ import type { Presenter } from '@core/shared/presentation'; +import type { FeedItemType } from '@core/social/domain/types/FeedItemType'; export interface DashboardDriverSummaryViewModel { id: string; @@ -44,7 +45,7 @@ export interface DashboardLeagueStandingSummaryViewModel { export interface DashboardFeedItemSummaryViewModel { id: string; - type: string; + type: FeedItemType; headline: string; body?: string; timestamp: string; diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts index 8e0675f78..65131ae24 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.test.ts @@ -139,7 +139,9 @@ describe('AcceptSponsorshipRequestUseCase', () => { respondedBy: 'driver1', }); - expect(result).toBeDefined(); + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto).toBeDefined(); expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({ recipientId: 'sponsor1', type: 'sponsorship_request_accepted', @@ -149,7 +151,7 @@ describe('AcceptSponsorshipRequestUseCase', () => { urgency: 'toast', data: { requestId: 'req1', - sponsorshipId: expect.any(String), + sponsorshipId: dto.sponsorshipId, }, }); expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith( diff --git a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts index 6253a0901..520cfc6eb 100644 --- a/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts +++ b/core/racing/application/use-cases/AcceptSponsorshipRequestUseCase.ts @@ -1,6 +1,6 @@ /** * Use Case: AcceptSponsorshipRequestUseCase - * + * * Allows an entity owner to accept a sponsorship request. * This creates an active sponsorship and notifies the sponsor. */ @@ -15,23 +15,16 @@ 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'; - -export interface AcceptSponsorshipRequestDTO { - requestId: string; - respondedBy: string; // driverId of the person accepting -} - -export interface AcceptSponsorshipRequestResultDTO { - requestId: string; - sponsorshipId: string; - status: 'accepted'; - acceptedAt: Date; - platformFee: number; - netAmount: number; -} +import { Result } from '@core/shared/result/Result'; +import { + RacingDomainValidationError, + RacingDomainInvariantError, +} from '../../domain/errors/RacingDomainError'; +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, @@ -43,118 +36,113 @@ 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 }); - try { - // 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 }); - throw new Error('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 }); - throw new Error(`Cannot accept a ${request.status} sponsorship request`); - } - - this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId }); - - // Accept the request - const acceptedRequest = request.accept(dto.respondedBy); - await this.sponsorshipRequestRepo.update(acceptedRequest); - this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId }); - - // If this is a season sponsorship, create the SeasonSponsorship record - let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - if (request.entityType === 'season') { - this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType }); - 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 }); - throw new Error('Season not found for sponsorship request'); - } - - const sponsorship = SeasonSponsorship.create({ - id: sponsorshipId, - seasonId: season.id, - leagueId: season.leagueId, - sponsorId: request.sponsorId, - tier: request.tier, - pricing: request.offeredAmount, - status: 'active', - }); - await this.seasonSponsorshipRepo.create(sponsorship); - this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId }); - - // Notify the sponsor - await this.notificationService.sendNotification({ - recipientId: request.sponsorId, - type: 'sponsorship_request_accepted', - title: 'Sponsorship Accepted', - body: `Your sponsorship request for ${season.name} has been accepted.`, - channel: 'in_app', - urgency: 'toast', - data: { - requestId: request.id, - sponsorshipId, - }, - }); - - // Process payment - const paymentResult = await this.paymentGateway.processPayment( - request.offeredAmount, - request.sponsorId, - `Sponsorship payment for ${request.entityType} ${request.entityId}`, - { requestId: request.id } - ); - if (!paymentResult.success) { - this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id }); - throw new Error('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 }); - throw new Error('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 }); - throw new Error('League wallet not found'); - } - - const netAmount = acceptedRequest.getNetAmount(); - - // Deduct from sponsor wallet - const updatedSponsorWallet = { - ...sponsorWallet, - balance: sponsorWallet.balance - request.offeredAmount.amount, - }; - await this.walletRepository.update(updatedSponsorWallet); - - // Add to league wallet - const updatedLeagueWallet = leagueWallet.addFunds(netAmount, paymentResult.transactionId!); - await this.leagueWalletRepository.update(updatedLeagueWallet); - } - - this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId }); - - return { - requestId: acceptedRequest.id, - sponsorshipId, - status: 'accepted', - acceptedAt: acceptedRequest.respondedAt!, - platformFee: acceptedRequest.getPlatformFee().amount, - netAmount: acceptedRequest.getNetAmount().amount, - }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${err.message}`, err, { requestId: dto.requestId }); - throw err; + // 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')); } + + 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`)); + } + + this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId }); + + // Accept the request + const acceptedRequest = request.accept(dto.respondedBy); + await this.sponsorshipRequestRepo.update(acceptedRequest); + this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId }); + + // If this is a season sponsorship, create the SeasonSponsorship record + let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + if (request.entityType === 'season') { + this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType }); + 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')); + } + + const sponsorship = SeasonSponsorship.create({ + id: sponsorshipId, + seasonId: season.id, + leagueId: season.leagueId, + sponsorId: request.sponsorId, + tier: request.tier, + pricing: request.offeredAmount, + status: 'active', + }); + await this.seasonSponsorshipRepo.create(sponsorship); + this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId }); + + // Notify the sponsor + await this.notificationService.sendNotification({ + recipientId: request.sponsorId, + type: 'sponsorship_request_accepted', + title: 'Sponsorship Accepted', + body: `Your sponsorship request for ${season.name} has been accepted.`, + channel: 'in_app', + urgency: 'toast', + data: { + requestId: request.id, + sponsorshipId, + }, + }); + + // Process payment + const paymentResult = await this.paymentGateway.processPayment( + request.offeredAmount, + request.sponsorId, + `Sponsorship payment for ${request.entityType} ${request.entityId}`, + { requestId: request.id } + ); + 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')); + } + + // 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')); + } + + 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')); + } + + const netAmount = acceptedRequest.getNetAmount(); + + // Deduct from sponsor wallet + const updatedSponsorWallet = { + ...sponsorWallet, + balance: sponsorWallet.balance - request.offeredAmount.amount, + }; + await this.walletRepository.update(updatedSponsorWallet); + + // Add to league wallet + const updatedLeagueWallet = leagueWallet.addFunds(netAmount, paymentResult.transactionId!); + await this.leagueWalletRepository.update(updatedLeagueWallet); + } + + this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId }); + + return Result.ok({ + requestId: acceptedRequest.id, + sponsorshipId, + status: 'accepted', + acceptedAt: acceptedRequest.respondedAt!, + platformFee: acceptedRequest.getPlatformFee().amount, + netAmount: acceptedRequest.getNetAmount().amount, + }); } } diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts new file mode 100644 index 000000000..b36f2c708 --- /dev/null +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ApplyForSponsorshipUseCase } from './ApplyForSponsorshipUseCase'; +import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; +import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import type { Logger } from '@core/shared/application'; +import { Money } from '../../domain/value-objects/Money'; + +describe('ApplyForSponsorshipUseCase', () => { + let mockSponsorshipRequestRepo: { + create: Mock; + hasPendingRequest: Mock; + }; + let mockSponsorshipPricingRepo: { + findByEntity: Mock; + }; + let mockSponsorRepo: { + findById: Mock; + }; + let mockLogger: { + debug: Mock; + error: Mock; + warn: Mock; + }; + + beforeEach(() => { + mockSponsorshipRequestRepo = { + create: vi.fn(), + hasPendingRequest: vi.fn(), + }; + mockSponsorshipPricingRepo = { + findByEntity: vi.fn(), + }; + mockSponsorRepo = { + findById: vi.fn(), + }; + mockLogger = { + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }; + }); + + it('should return error when sponsor does not exist', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + sponsorId: 'nonexistent', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Sponsor not found'); + }); + + it('should return error when sponsorship pricing is not set up', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(null); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('This entity has not set up sponsorship pricing'); + }); + + it('should return error when entity is not accepting applications', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({ + acceptingApplications: false, + isSlotAvailable: vi.fn().mockReturnValue(true), + getPrice: vi.fn().mockReturnValue(Money.create(500)), + }); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('This entity is not currently accepting sponsorship applications'); + }); + + it('should return error when no slots are available', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({ + acceptingApplications: true, + isSlotAvailable: vi.fn().mockReturnValue(false), + getPrice: vi.fn().mockReturnValue(Money.create(500)), + }); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('No main sponsorship slots are available'); + }); + + it('should return error when sponsor has pending request', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({ + acceptingApplications: true, + isSlotAvailable: vi.fn().mockReturnValue(true), + getPrice: vi.fn().mockReturnValue(Money.create(500)), + }); + mockSponsorshipRequestRepo.hasPendingRequest.mockResolvedValue(true); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('You already have a pending sponsorship request for this entity'); + }); + + it('should return error when offered amount is less than minimum', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({ + acceptingApplications: true, + isSlotAvailable: vi.fn().mockReturnValue(true), + getPrice: vi.fn().mockReturnValue(Money.create(1500)), + }); + mockSponsorshipRequestRepo.hasPendingRequest.mockResolvedValue(false); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Offered amount must be at least $15.00'); + }); + + it('should create sponsorship request and return result on success', async () => { + const useCase = new ApplyForSponsorshipUseCase( + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorRepo as unknown as ISponsorRepository, + mockLogger as unknown as Logger, + ); + + mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' }); + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue({ + acceptingApplications: true, + isSlotAvailable: vi.fn().mockReturnValue(true), + getPrice: vi.fn().mockReturnValue(Money.create(500)), + }); + mockSponsorshipRequestRepo.hasPendingRequest.mockResolvedValue(false); + mockSponsorshipRequestRepo.create.mockResolvedValue(undefined); + + const result = await useCase.execute({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: 1000, + message: 'Test message', + }); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + requestId: expect.any(String), + status: 'pending', + createdAt: expect.any(Date), + }); + expect(mockSponsorshipRequestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + sponsorId: 'sponsor1', + entityType: 'season', + entityId: 'season1', + tier: 'main', + offeredAmount: expect.objectContaining({ amount: 1000 }), + message: 'Test message', + }) + ); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts index 135c832b8..e11052b6d 100644 --- a/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts +++ b/core/racing/application/use-cases/ApplyForSponsorshipUseCase.ts @@ -1,41 +1,24 @@ /** * Use Case: ApplyForSponsorshipUseCase - * + * * Allows a sponsor to apply for a sponsorship slot on any entity * (driver, team, race, or season/league). */ -import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; -import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; +import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import { Money, type Currency } from '../../domain/value-objects/Money'; +import { Money } from '../../domain/value-objects/Money'; import type { AsyncUseCase } from '@core/shared/application'; -import { - EntityNotFoundError, - BusinessRuleViolationError, -} from '../errors/RacingApplicationError'; +import { Result } from '@core/shared/result/Result'; import type { Logger } from '@core/shared/application'; - -export interface ApplyForSponsorshipDTO { - sponsorId: string; - entityType: SponsorableEntityType; - entityId: string; - tier: SponsorshipTier; - offeredAmount: number; // in cents - currency?: Currency; - message?: string; -} - -export interface ApplyForSponsorshipResultDTO { - requestId: string; - status: 'pending'; - createdAt: Date; -} +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { ApplyForSponsorshipDTO } from '../dto/ApplyForSponsorshipDTO'; +import type { ApplyForSponsorshipResultDTO } from '../dto/ApplyForSponsorshipResultDTO'; export class ApplyForSponsorshipUseCase - implements AsyncUseCase + implements AsyncUseCase> { constructor( private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, @@ -44,37 +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 }); - throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId }); + return Result.err(new RacingDomainValidationError('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 }); - throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing'); + return Result.err(new RacingDomainValidationError('This entity has not set up sponsorship pricing')); } if (!pricing.acceptingApplications) { this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId }); - throw new BusinessRuleViolationError( - 'This entity is not currently accepting sponsorship applications', - ); + return Result.err(new RacingDomainValidationError('This entity is not currently accepting sponsorship 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}`); - throw new BusinessRuleViolationError( - `No ${dto.tier} sponsorship slots are available`, - ); + return Result.err(new RacingDomainValidationError(`No ${dto.tier} sponsorship slots are available`)); } // Check if sponsor already has a pending request for this entity @@ -85,18 +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 }); - throw new BusinessRuleViolationError( - 'You already have a pending sponsorship request for this entity', - ); + return Result.err(new RacingDomainValidationError('You already have a pending sponsorship request for this entity')); } // 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}`); - throw new BusinessRuleViolationError( - `Offered amount must be at least ${minPrice.format()}`, - ); + return Result.err(new RacingDomainValidationError(`Offered amount must be at least ${minPrice.format()}`)); } // Create the sponsorship request @@ -115,10 +90,10 @@ export class ApplyForSponsorshipUseCase await this.sponsorshipRequestRepo.create(request); - return { + return Result.ok({ requestId: request.id, status: 'pending', createdAt: request.createdAt, - }; + }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyPenaltyCommand.ts b/core/racing/application/use-cases/ApplyPenaltyCommand.ts new file mode 100644 index 000000000..f276a2e66 --- /dev/null +++ b/core/racing/application/use-cases/ApplyPenaltyCommand.ts @@ -0,0 +1,12 @@ +import type { PenaltyType } from '../../domain/entities/Penalty'; + +export interface ApplyPenaltyCommand { + raceId: string; + driverId: string; + stewardId: string; + type: PenaltyType; + value?: number; + reason: string; + protestId?: string; + notes?: string; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts new file mode 100644 index 000000000..c00e237ac --- /dev/null +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { ApplyPenaltyUseCase } from './ApplyPenaltyUseCase'; +import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('ApplyPenaltyUseCase', () => { + let mockPenaltyRepo: { + create: Mock; + }; + let mockProtestRepo: { + findById: Mock; + }; + let mockRaceRepo: { + findById: Mock; + }; + let mockLeagueMembershipRepo: { + getLeagueMembers: Mock; + }; + let mockLogger: { + debug: Mock; + warn: Mock; + info: Mock; + error: Mock; + }; + + beforeEach(() => { + mockPenaltyRepo = { + create: vi.fn(), + }; + mockProtestRepo = { + findById: vi.fn(), + }; + mockRaceRepo = { + findById: vi.fn(), + }; + mockLeagueMembershipRepo = { + getLeagueMembers: vi.fn(), + }; + mockLogger = { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return error when race does not exist', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + raceId: 'nonexistent', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Race not found'); + }); + + it('should return error when steward does not have authority', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'steward1', role: 'member', status: 'active' }, + ]); + + const result = await useCase.execute({ + raceId: 'race1', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Only league owners and admins can apply penalties'); + }); + + it('should return error when protest does not exist', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'steward1', role: 'owner', status: 'active' }, + ]); + mockProtestRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + raceId: 'race1', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + protestId: 'protest1', + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Protest not found'); + }); + + it('should return error when protest is not upheld', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'steward1', role: 'owner', status: 'active' }, + ]); + mockProtestRepo.findById.mockResolvedValue({ id: 'protest1', status: 'pending', raceId: 'race1' }); + + const result = await useCase.execute({ + raceId: 'race1', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + protestId: 'protest1', + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Can only create penalties for upheld protests'); + }); + + it('should return error when protest is not for this race', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'steward1', role: 'owner', status: 'active' }, + ]); + mockProtestRepo.findById.mockResolvedValue({ id: 'protest1', status: 'upheld', raceId: 'race2' }); + + const result = await useCase.execute({ + raceId: 'race1', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + protestId: 'protest1', + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Protest is not for this race'); + }); + + it('should create penalty and return result on success', async () => { + const useCase = new ApplyPenaltyUseCase( + mockPenaltyRepo as unknown as IPenaltyRepository, + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + mockLogger as unknown as Logger, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'steward1', role: 'admin', status: 'active' }, + ]); + mockPenaltyRepo.create.mockResolvedValue(undefined); + + const result = await useCase.execute({ + raceId: 'race1', + driverId: 'driver1', + stewardId: 'steward1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + notes: 'Test notes', + }); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + penaltyId: expect.any(String), + }); + expect(mockPenaltyRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + leagueId: 'league1', + raceId: 'race1', + driverId: 'driver1', + type: 'time_penalty', + value: 5, + reason: 'Test penalty', + issuedBy: 'steward1', + status: 'pending', + notes: 'Test notes', + }) + ); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts index 82dd12fa0..e28e37c0d 100644 --- a/core/racing/application/use-cases/ApplyPenaltyUseCase.ts +++ b/core/racing/application/use-cases/ApplyPenaltyUseCase.ts @@ -1,32 +1,24 @@ /** * Application Use Case: ApplyPenaltyUseCase - * + * * Allows a steward to apply a penalty to a driver for an incident during a race. * The penalty can be standalone or linked to an upheld protest. */ -import { Penalty, type PenaltyType } from '../../domain/entities/Penalty'; +import { Penalty } from '../../domain/entities/Penalty'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import { randomUUID } from 'crypto'; import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; import type { Logger } from '@core/shared/application'; - -export interface ApplyPenaltyCommand { - raceId: string; - driverId: string; - stewardId: string; - type: PenaltyType; - value?: number; - reason: string; - protestId?: string; - notes?: string; -} +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand'; export class ApplyPenaltyUseCase - implements AsyncUseCase { + implements AsyncUseCase> { constructor( private readonly penaltyRepository: IPenaltyRepository, private readonly protestRepository: IProtestRepository, @@ -35,70 +27,66 @@ export class ApplyPenaltyUseCase private readonly logger: Logger, ) {} - async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> { + async execute(command: ApplyPenaltyCommand): Promise> { this.logger.debug('ApplyPenaltyUseCase: Executing with command', command); - try { - // Validate race exists - const race = await this.raceRepository.findById(command.raceId); - if (!race) { - this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`); - throw new Error('Race not found'); - } - this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`); - // Validate steward has authority (owner or admin of the league) - const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); - const stewardMembership = memberships.find( - m => m.driverId === command.stewardId && m.status === 'active' - ); - - if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { - this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`); - throw new Error('Only league owners and admins can apply penalties'); - } - this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`); - - // If linked to a protest, validate the protest exists and is upheld - if (command.protestId) { - const protest = await this.protestRepository.findById(command.protestId); - if (!protest) { - this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`); - throw new Error('Protest not found'); - } - if (protest.status !== 'upheld') { - this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`); - throw new Error('Can only create penalties for upheld protests'); - } - if (protest.raceId !== command.raceId) { - this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`); - throw new Error('Protest is not for this race'); - } - this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`); - } - - // Create the penalty - const penalty = Penalty.create({ - id: randomUUID(), - leagueId: race.leagueId, - raceId: command.raceId, - driverId: command.driverId, - type: command.type, - ...(command.value !== undefined ? { value: command.value } : {}), - reason: command.reason, - ...(command.protestId !== undefined ? { protestId: command.protestId } : {}), - issuedBy: command.stewardId, - status: 'pending', - issuedAt: new Date(), - ...(command.notes !== undefined ? { notes: command.notes } : {}), - }); - - await this.penaltyRepository.create(penalty); - this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`); - - return { penaltyId: penalty.id }; - } catch (error) { - this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', error, { command }); - throw error; + // 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')); } + this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`); + + // Validate steward has authority (owner or admin of the league) + const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); + const stewardMembership = memberships.find( + m => m.driverId === command.stewardId && m.status === 'active' + ); + + if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) { + 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')); + } + this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`); + + // If linked to a protest, validate the protest exists and is upheld + if (command.protestId) { + 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')); + } + 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')); + } + 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')); + } + this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`); + } + + // Create the penalty + const penalty = Penalty.create({ + id: randomUUID(), + leagueId: race.leagueId, + raceId: command.raceId, + driverId: command.driverId, + type: command.type, + ...(command.value !== undefined ? { value: command.value } : {}), + reason: command.reason, + ...(command.protestId !== undefined ? { protestId: command.protestId } : {}), + issuedBy: command.stewardId, + status: 'pending', + issuedAt: new Date(), + ...(command.notes !== undefined ? { notes: command.notes } : {}), + }); + + await this.penaltyRepository.create(penalty); + this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`); + + return Result.ok({ penaltyId: penalty.id }); } } \ 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 66acdb2d3..c7b378abe 100644 --- a/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase.ts @@ -1,28 +1,23 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '../presenters/IApproveLeagueJoinRequestPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { AsyncUseCase } from '@core/shared/application'; +import { randomUUID } from 'crypto'; +import type { ApproveLeagueJoinRequestUseCaseParams } from './ApproveLeagueJoinRequestUseCaseParams'; +import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO'; -export interface ApproveLeagueJoinRequestUseCaseParams { - leagueId: string; - requestId: string; -} - -export interface ApproveLeagueJoinRequestResultDTO { - success: boolean; - message: string; -} - -export class ApproveLeagueJoinRequestUseCase implements UseCase { +export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase> { constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} - async execute(params: ApproveLeagueJoinRequestUseCaseParams, presenter: IApproveLeagueJoinRequestPresenter): 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) { - throw new Error('Join request not found'); + return Result.err(new RacingDomainValidationError('Join request not found')); } await this.leagueMembershipRepository.removeJoinRequest(params.requestId); await this.leagueMembershipRepository.saveMembership({ + id: randomUUID(), leagueId: params.leagueId, driverId: request.driverId, role: 'member', @@ -30,7 +25,6 @@ export class ApproveLeagueJoinRequestUseCase implements UseCase { + let useCase: ApproveTeamJoinRequestUseCase; + let membershipRepository: { + getJoinRequests: Mock; + removeJoinRequest: Mock; + saveMembership: Mock; + }; + + beforeEach(() => { + membershipRepository = { + getJoinRequests: vi.fn(), + removeJoinRequest: vi.fn(), + saveMembership: vi.fn(), + }; + useCase = new ApproveTeamJoinRequestUseCase(membershipRepository as unknown as ITeamMembershipRepository); + }); + + it('should approve join request and save membership', async () => { + const teamId = 'team-1'; + const requestId = 'req-1'; + const joinRequests = [{ id: requestId, teamId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }]; + + membershipRepository.getJoinRequests.mockResolvedValue(joinRequests); + + const result = await useCase.execute({ teamId, requestId }); + + expect(result.isOk()).toBe(true); + expect(membershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId); + expect(membershipRepository.saveMembership).toHaveBeenCalledWith({ + teamId, + driverId: 'driver-1', + role: 'driver', + status: 'active', + joinedAt: expect.any(Date), + }); + }); + + it('should return error if request not found', async () => { + membershipRepository.getJoinRequests.mockResolvedValue([]); + + 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'); + }); +}); \ 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 31f2da143..e36022fb2 100644 --- a/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts +++ b/core/racing/application/use-cases/ApproveTeamJoinRequestUseCase.ts @@ -1,4 +1,3 @@ -import type { Logger } from '@core/shared/application'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { TeamMembership, @@ -8,36 +7,24 @@ import type { } 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'; export class ApproveTeamJoinRequestUseCase - implements AsyncUseCase { + implements AsyncUseCase> { constructor( private readonly membershipRepository: ITeamMembershipRepository, - private readonly logger: Logger, ) {} - async execute(command: ApproveTeamJoinRequestCommandDTO): Promise { - const { requestId } = command; - this.logger.debug( - `Attempting to approve team join request with ID: ${requestId}`, - ); + async execute(command: ApproveTeamJoinRequestCommandDTO): Promise> { + const { teamId, requestId } = command; - // There is no repository method to look up a single request by ID, - try { - // There is no repository method to look up a single request by ID, - // so we rely on the repository implementation to surface all relevant - // requests via getJoinRequests and search by ID here. - const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests( - // For the in-memory fake used in tests, the teamId argument is ignored - // and all requests are returned.' - '' as string, - ); - const request = allRequests.find((r) => r.id === requestId); + const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId); + const request = allRequests.find((r) => r.id === requestId); - if (!request) { - this.logger.warn(`Team join request with ID ${requestId} not found`); - throw new Error('Join request not found'); - } + if (!request) { + return Result.err(new RacingDomainValidationError('Join request not found')); + } const membership: TeamMembership = { teamId: request.teamId, @@ -48,14 +35,7 @@ export class ApproveTeamJoinRequestUseCase }; await this.membershipRepository.saveMembership(membership); - this.logger.info( - `Team membership created for driver ${request.driverId} in team ${request.teamId} from request ${requestId}`, - ); await this.membershipRepository.removeJoinRequest(requestId); - this.logger.info(`Team join request with ID ${requestId} removed`); - } catch (error) { - this.logger.error(`Failed to approve team join request ${requestId}`, error instanceof Error ? error : new Error(String(error))); - throw error; - } + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CancelRaceUseCase.test.ts b/core/racing/application/use-cases/CancelRaceUseCase.test.ts new file mode 100644 index 000000000..b685764c5 --- /dev/null +++ b/core/racing/application/use-cases/CancelRaceUseCase.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CancelRaceUseCase } from './CancelRaceUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { Logger } from '@core/shared/application'; +import { Race } from '../../domain/entities/Race'; +import { SessionType } from '../../domain/value-objects/SessionType'; +import { RacingDomainInvariantError, RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; + +describe('CancelRaceUseCase', () => { + let useCase: CancelRaceUseCase; + let raceRepository: { + findById: Mock; + update: Mock; + }; + let logger: { + debug: Mock; + warn: Mock; + info: Mock; + error: Mock; + }; + + beforeEach(() => { + raceRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + logger = { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + }; + useCase = new CancelRaceUseCase(raceRepository as unknown as IRaceRepository, logger as unknown as Logger); + }); + + it('should cancel race successfully', async () => { + const raceId = 'race-1'; + const race = Race.create({ + id: raceId, + leagueId: 'league-1', + scheduledAt: new Date(), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'scheduled', + }); + + raceRepository.findById.mockResolvedValue(race); + + const result = await useCase.execute({ raceId }); + + 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 () => { + const raceId = 'race-1'; + raceRepository.findById.mockResolvedValue(null); + + 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.`); + }); + + it('should return domain error if race is already cancelled', async () => { + const raceId = 'race-1'; + const race = Race.create({ + id: raceId, + leagueId: 'league-1', + scheduledAt: new Date(), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'cancelled', + }); + + raceRepository.findById.mockResolvedValue(race); + + 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`); + }); + + it('should return domain error if race is completed', async () => { + const raceId = 'race-1'; + const race = Race.create({ + id: raceId, + leagueId: 'league-1', + scheduledAt: new Date(), + track: 'Track 1', + car: 'Car 1', + sessionType: SessionType.main(), + status: 'completed', + }); + + raceRepository.findById.mockResolvedValue(race); + + 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`); + }); +}); \ 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 3e26abca3..b71994af5 100644 --- a/core/racing/application/use-cases/CancelRaceUseCase.ts +++ b/core/racing/application/use-cases/CancelRaceUseCase.ts @@ -1,28 +1,27 @@ 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 type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO'; /** * Use Case: CancelRaceUseCase * * Encapsulates the workflow for cancelling a race: * - loads the race by id - * - throws if the race does not exist + * - returns error if the race does not exist * - delegates cancellation rules to the Race domain entity * - persists the updated race via the repository. */ -export interface CancelRaceCommandDTO { - raceId: string; -} - 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}`); @@ -30,14 +29,19 @@ export class CancelRaceUseCase const race = await this.raceRepository.findById(raceId); if (!race) { this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`); - throw new Error('Race not found'); + return Result.err(new RacingDomainValidationError('Race not found')); } const cancelledRace = race.cancel(); await this.raceRepository.update(cancelledRace); this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`); + return Result.ok(undefined); } catch (error) { - this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error))); + if (error instanceof RacingDomainInvariantError || error instanceof RacingDomainValidationError) { + this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`); + return Result.err(error); + } + this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error))); throw error; } } diff --git a/core/racing/application/use-cases/CloseRaceEventStewardingCommand.ts b/core/racing/application/use-cases/CloseRaceEventStewardingCommand.ts new file mode 100644 index 000000000..e0ea67e48 --- /dev/null +++ b/core/racing/application/use-cases/CloseRaceEventStewardingCommand.ts @@ -0,0 +1,9 @@ +/** + * Command for closing race event stewarding. + * + * Scheduled job that checks for race events with expired stewarding windows + * and closes them, triggering final results notifications. + */ +export interface CloseRaceEventStewardingCommand { + // No parameters needed - finds all expired events automatically +} \ 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 new file mode 100644 index 000000000..29b130f87 --- /dev/null +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.test.ts @@ -0,0 +1,94 @@ +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 { 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; + let raceEventRepository: { + findAwaitingStewardingClose: Mock; + update: Mock; + }; + let domainEventPublisher: { + publish: Mock; + }; + let logger: { + error: Mock; + }; + + beforeEach(() => { + raceEventRepository = { + findAwaitingStewardingClose: vi.fn(), + update: vi.fn(), + }; + domainEventPublisher = { + publish: vi.fn(), + }; + logger = { + error: vi.fn(), + }; + useCase = new CloseRaceEventStewardingUseCase( + logger as unknown as Logger, + raceEventRepository as unknown as IRaceEventRepository, + domainEventPublisher as unknown as IDomainEventPublisher, + ); + }); + + it('should close stewarding for expired events successfully', async () => { + const raceEvent = RaceEvent.create({ + id: 'event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Test Event', + sessions: [ + Session.create({ + id: 'session-1', + raceEventId: 'event-1', + sessionType: SessionType.main(), + scheduledAt: new Date(), + track: 'Test Track', + car: 'Test Car', + status: 'completed', + }), + ], + status: 'awaiting_stewarding', + stewardingClosesAt: new Date(Date.now() - 1000), // expired + }); + + raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([raceEvent]); + domainEventPublisher.publish.mockResolvedValue(undefined); + + const result = await useCase.execute({}); + + expect(result.isOk()).toBe(true); + expect(raceEventRepository.findAwaitingStewardingClose).toHaveBeenCalled(); + expect(raceEventRepository.update).toHaveBeenCalledWith( + expect.objectContaining({ id: 'event-1', status: 'closed' }) + ); + expect(domainEventPublisher.publish).toHaveBeenCalled(); + }); + + it('should handle no expired events', async () => { + raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([]); + + const result = await useCase.execute({}); + + expect(result.isOk()).toBe(true); + expect(raceEventRepository.update).not.toHaveBeenCalled(); + expect(domainEventPublisher.publish).not.toHaveBeenCalled(); + }); + + it('should return error when repository throws', async () => { + raceEventRepository.findAwaitingStewardingClose.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute({}); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + }); +}); \ 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 50994d41b..354ce5264 100644 --- a/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts +++ b/core/racing/application/use-cases/CloseRaceEventStewardingUseCase.ts @@ -1,8 +1,12 @@ -import type { UseCase } from '@core/shared/application/UseCase'; +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'; -import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; +import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent'; +import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CloseRaceEventStewardingCommand } from './CloseRaceEventStewardingCommand'; +import type { RaceEvent } from '../../domain/entities/RaceEvent'; /** * Use Case: CloseRaceEventStewardingUseCase @@ -13,12 +17,8 @@ import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEve * This would typically be run by a scheduled job (e.g., every 5 minutes) * to automatically close stewarding windows based on league configuration. */ -export interface CloseRaceEventStewardingCommand { - // No parameters needed - finds all expired events automatically -} - export class CloseRaceEventStewardingUseCase - implements UseCase + implements AsyncUseCase> { constructor( private readonly logger: Logger, @@ -27,26 +27,34 @@ export class CloseRaceEventStewardingUseCase private readonly domainEventPublisher: IDomainEventPublisher, ) {} - async execute(command: CloseRaceEventStewardingCommand): Promise { - // Find all race events awaiting stewarding that have expired windows - const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async execute(_command: CloseRaceEventStewardingCommand): Promise> { + try { + // Find all race events awaiting stewarding that have expired windows + const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose(); - for (const raceEvent of expiredEvents) { - await this.closeStewardingForRaceEvent(raceEvent); + for (const raceEvent of expiredEvents) { + await this.closeStewardingForRaceEvent(raceEvent); + } + + 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')); } } - private async closeStewardingForRaceEvent(raceEvent: any): Promise { + private async closeStewardingForRaceEvent(raceEvent: RaceEvent): Promise { try { // Close the stewarding window const closedRaceEvent = raceEvent.closeStewarding(); await this.raceEventRepository.update(closedRaceEvent); // Get list of participating drivers (would need to be implemented) - const driverIds = await this.getParticipatingDriverIds(raceEvent); + const driverIds = await this.getParticipatingDriverIds(); // Check if any penalties were applied during stewarding - const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent); + const hadPenaltiesApplied = await this.checkForAppliedPenalties(); // Publish domain event to trigger final results notifications const event = new RaceEventStewardingClosedEvent({ @@ -62,28 +70,19 @@ 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))); - // In production, this would trigger alerts/monitoring + // TODO: In production, this would trigger alerts/monitoring } } - private async getParticipatingDriverIds(raceEvent: any): Promise { - // In a real implementation, this would query race registrations - // For the prototype, we'll return a mock list - // This would typically involve: - // 1. Get all sessions in the race event - // 2. For each session, get registered drivers - // 3. Return unique driver IDs across all sessions - - // Mock implementation for prototype - return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation + 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 checkForAppliedPenalties(raceEvent: any): Promise { - // In a real implementation, this would check if any penalties were issued - // during the stewarding window for this race event + 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 - - // Mock implementation for prototype - randomly simulate penalties - return Math.random() > 0.7; // 30% chance of penalties being applied + return false; } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingCommand.ts b/core/racing/application/use-cases/CompleteDriverOnboardingCommand.ts new file mode 100644 index 000000000..c8ea76294 --- /dev/null +++ b/core/racing/application/use-cases/CompleteDriverOnboardingCommand.ts @@ -0,0 +1,9 @@ +export interface CompleteDriverOnboardingCommand { + userId: string; + firstName: string; + lastName: string; + displayName: string; + country: string; + timezone?: string; + bio?: string; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts new file mode 100644 index 000000000..48a8c1580 --- /dev/null +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.test.ts @@ -0,0 +1,136 @@ +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', () => { + let useCase: CompleteDriverOnboardingUseCase; + let driverRepository: { + findById: Mock; + create: Mock; + }; + + beforeEach(() => { + driverRepository = { + findById: vi.fn(), + create: vi.fn(), + }; + useCase = new CompleteDriverOnboardingUseCase( + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should create driver successfully when driver does not exist', async () => { + const command: CompleteDriverOnboardingCommand = { + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + bio: 'Test bio', + }; + + driverRepository.findById.mockResolvedValue(null); + const createdDriver = Driver.create({ + id: 'user-1', + iracingId: 'user-1', + name: 'John Doe', + country: 'US', + bio: 'Test bio', + }); + driverRepository.create.mockResolvedValue(createdDriver); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ driverId: 'user-1' }); + expect(driverRepository.findById).toHaveBeenCalledWith('user-1'); + expect(driverRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'user-1', + iracingId: 'user-1', + name: 'John Doe', + country: 'US', + bio: 'Test bio', + }) + ); + }); + + it('should return error when driver already exists', async () => { + const command: CompleteDriverOnboardingCommand = { + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + }; + + const existingDriver = Driver.create({ + id: 'user-1', + iracingId: 'user-1', + name: 'John Doe', + country: 'US', + }); + driverRepository.findById.mockResolvedValue(existingDriver); + + 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(driverRepository.create).not.toHaveBeenCalled(); + }); + + it('should return error when repository create throws', async () => { + const command: CompleteDriverOnboardingCommand = { + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + }; + + driverRepository.findById.mockResolvedValue(null); + driverRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().message).toBe('DB error'); + }); + + it('should handle bio being undefined', async () => { + const command: CompleteDriverOnboardingCommand = { + userId: 'user-1', + firstName: 'John', + lastName: 'Doe', + displayName: 'John Doe', + country: 'US', + }; + + driverRepository.findById.mockResolvedValue(null); + const createdDriver = Driver.create({ + id: 'user-1', + iracingId: 'user-1', + name: 'John Doe', + country: 'US', + }); + driverRepository.create.mockResolvedValue(createdDriver); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(driverRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'user-1', + iracingId: 'user-1', + name: 'John Doe', + country: 'US', + bio: undefined, + }) + ); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts index 4a55ff2e2..5e483f842 100644 --- a/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts +++ b/core/racing/application/use-cases/CompleteDriverOnboardingUseCase.ts @@ -1,60 +1,40 @@ +import type { AsyncUseCase } from '@core/shared/application'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../presenters/ICompleteDriverOnboardingPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; import { Driver } from '../../domain/entities/Driver'; - -export interface CompleteDriverOnboardingInput { - userId: string; - firstName: string; - lastName: string; - displayName: string; - country: string; - timezone?: string; - bio?: string; -} +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand'; /** * Use Case for completing driver onboarding. */ export class CompleteDriverOnboardingUseCase - implements UseCase + implements AsyncUseCase> { constructor(private readonly driverRepository: IDriverRepository) {} - async execute(input: CompleteDriverOnboardingInput, presenter: ICompleteDriverOnboardingPresenter): Promise { - presenter.reset(); - + async execute(command: CompleteDriverOnboardingCommand): Promise> { try { // Check if driver already exists - const existing = await this.driverRepository.findById(input.userId); + const existing = await this.driverRepository.findById(command.userId); if (existing) { - presenter.present({ - success: false, - errorMessage: 'Driver already exists', - }); - return; + return Result.err(new RacingDomainValidationError('Driver already exists')); } // Create new driver const driver = Driver.create({ - id: input.userId, - iracingId: input.userId, // Assuming userId is iracingId for now - name: input.displayName, - country: input.country, - bio: input.bio, + id: command.userId, + iracingId: command.userId, // Assuming userId is iracingId for now + name: command.displayName, + country: command.country, + ...(command.bio !== undefined ? { bio: command.bio } : {}), }); - await this.driverRepository.save(driver); + await this.driverRepository.create(driver); - presenter.present({ - success: true, - driverId: driver.id, - }); + return Result.ok({ driverId: driver.id }); } catch (error) { - presenter.present({ - success: false, - errorMessage: error instanceof Error ? error.message : 'Unknown error', - }); + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteRaceUseCase.test.ts b/core/racing/application/use-cases/CompleteRaceUseCase.test.ts new file mode 100644 index 000000000..6ce14cd81 --- /dev/null +++ b/core/racing/application/use-cases/CompleteRaceUseCase.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CompleteRaceUseCase } from './CompleteRaceUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; + +describe('CompleteRaceUseCase', () => { + let useCase: CompleteRaceUseCase; + let raceRepository: { + findById: Mock; + update: Mock; + }; + let raceRegistrationRepository: { + getRegisteredDrivers: Mock; + }; + let resultRepository: { + create: Mock; + }; + let standingRepository: { + findByDriverIdAndLeagueId: Mock; + save: Mock; + }; + let driverRatingProvider: { + getRatings: Mock; + }; + + beforeEach(() => { + raceRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + raceRegistrationRepository = { + getRegisteredDrivers: vi.fn(), + }; + resultRepository = { + create: vi.fn(), + }; + standingRepository = { + findByDriverIdAndLeagueId: vi.fn(), + save: vi.fn(), + }; + driverRatingProvider = { + getRatings: vi.fn(), + }; + useCase = new CompleteRaceUseCase( + raceRepository as unknown as IRaceRepository, + raceRegistrationRepository as unknown as IRaceRegistrationRepository, + resultRepository as unknown as IResultRepository, + standingRepository as unknown as IStandingRepository, + driverRatingProvider as unknown as DriverRatingProvider, + ); + }); + + it('should complete race successfully when race exists and has registered drivers', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); + driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600], ['driver-2', 1500]])); + resultRepository.create.mockResolvedValue(undefined); + standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null); + standingRepository.save.mockResolvedValue(undefined); + raceRepository.update.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({}); + expect(raceRepository.findById).toHaveBeenCalledWith('race-1'); + expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1'); + expect(driverRatingProvider.getRatings).toHaveBeenCalledWith(['driver-1', 'driver-2']); + expect(resultRepository.create).toHaveBeenCalledTimes(2); + expect(standingRepository.save).toHaveBeenCalledTimes(2); + expect(mockRace.complete).toHaveBeenCalled(); + expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' }); + }); + + it('should return error when race does not exist', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().message).toBe('Race not found'); + }); + + it('should return error when no registered drivers', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn(), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]); + + 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'); + }); + + it('should return error when repository throws', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']); + driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]])); + resultRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().message).toBe('DB 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 45b58b3b3..2a02c3646 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCase.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCase.ts @@ -6,79 +6,67 @@ 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 type { Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; /** * Use Case: CompleteRaceUseCase * * Encapsulates the workflow for completing a race: * - loads the race by id - * - throws if the race does not exist + * - returns error if the race does not exist * - delegates completion rules to the Race domain entity * - automatically generates realistic results for registered drivers * - updates league standings * - persists all changes via repositories. */ -export interface CompleteRaceCommandDTO { - raceId: string; -} - export class CompleteRaceUseCase - implements AsyncUseCase { + implements AsyncUseCase> { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly resultRepository: IResultRepository, private readonly standingRepository: IStandingRepository, private readonly driverRatingProvider: DriverRatingProvider, - private readonly logger: Logger, ) {} - async execute(command: CompleteRaceCommandDTO): Promise { - this.logger.debug(`Executing CompleteRaceUseCase for raceId: ${command.raceId}`); - const { raceId } = command; - + async execute(command: CompleteRaceCommandDTO): Promise> { try { + const { raceId } = command; + const race = await this.raceRepository.findById(raceId); if (!race) { - this.logger.error(`Race with id ${raceId} not found.`); - throw new Error('Race not found'); + return SharedResult.err(new RacingDomainValidationError('Race not found')); } - this.logger.debug(`Race ${raceId} found. Status: ${race.status}`); // Get registered drivers for this race const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); if (registeredDriverIds.length === 0) { - this.logger.warn(`No registered drivers found for race ${raceId}.`); - throw new Error('Cannot complete race with no registered drivers'); + return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers')); } - this.logger.info(`${registeredDriverIds.length} drivers registered for race ${raceId}. Generating results.`); // Get driver ratings const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); - this.logger.debug(`Driver ratings fetched for ${registeredDriverIds.length} drivers.`); // Generate realistic race results const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings); - this.logger.debug(`Generated ${results.length} race results for race ${raceId}.`); // Save results for (const result of results) { await this.resultRepository.create(result); } - this.logger.info(`Persisted ${results.length} race results for race ${raceId}.`); // Update standings await this.updateStandings(race.leagueId, results); - this.logger.info(`Standings updated for league ${race.leagueId}.`); // Complete the race const completedRace = race.complete(); await this.raceRepository.update(completedRace); - this.logger.info(`Race ${raceId} successfully completed and updated.`); + + return SharedResult.ok({}); } catch (error) { - this.logger.error(`Failed to complete race ${raceId}: ${error.message}`, error as Error); - throw error; + return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); } } @@ -87,7 +75,6 @@ export class CompleteRaceUseCase driverIds: string[], driverRatings: Map ): Result[] { - this.logger.debug(`Generating race results for race ${raceId} with ${driverIds.length} drivers.`); // Create driver performance data const driverPerformances = driverIds.map(driverId => ({ driverId, @@ -101,7 +88,6 @@ export class CompleteRaceUseCase const perfB = b.rating + (b.randomFactor * 200); return perfB - perfA; // Higher performance first }); - this.logger.debug(`Driver performances sorted for race ${raceId}.`); // Generate qualifying results for start positions (similar but different from race results) const qualiPerformances = driverPerformances.map(p => ({ @@ -113,12 +99,11 @@ export class CompleteRaceUseCase const perfB = b.rating + (b.randomFactor * 150); return perfB - perfA; }); - this.logger.debug(`Qualifying performances generated for race ${raceId}.`); // Generate results const results: Result[] = []; for (let i = 0; i < driverPerformances.length; i++) { - const { driverId } = driverPerformances[i]; + const { driverId } = driverPerformances[i]!; const position = i + 1; const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1; @@ -143,13 +128,11 @@ export class CompleteRaceUseCase }) ); } - this.logger.debug(`Individual results created for race ${raceId}.`); return results; } private async updateStandings(leagueId: string, results: Result[]): Promise { - this.logger.debug(`Updating standings for league ${leagueId} with ${results.length} results.`); // Group results by driver const resultsByDriver = new Map(); for (const result of results) { @@ -157,7 +140,6 @@ export class CompleteRaceUseCase existing.push(result); resultsByDriver.set(result.driverId, existing); } - this.logger.debug(`Results grouped by driver for league ${leagueId}.`); // Update or create standings for each driver for (const [driverId, driverResults] of resultsByDriver) { @@ -168,9 +150,6 @@ export class CompleteRaceUseCase leagueId, driverId, }); - this.logger.debug(`Created new standing for driver ${driverId} in league ${leagueId}.`); - } else { - this.logger.debug(`Found existing standing for driver ${driverId} in league ${leagueId}.`); } // Add all results for this driver (should be just one for this race) @@ -181,8 +160,6 @@ export class CompleteRaceUseCase } await this.standingRepository.save(standing); - this.logger.debug(`Standing saved for driver ${driverId} in league ${leagueId}.`); } - this.logger.info(`Standings update complete for league ${leagueId}.`); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts new file mode 100644 index 000000000..344e9c497 --- /dev/null +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CompleteRaceUseCaseWithRatings } from './CompleteRaceUseCaseWithRatings'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository'; +import type { IResultRepository } from '../../domain/repositories/IResultRepository'; +import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; +import type { DriverRatingProvider } from '../ports/DriverRatingProvider'; +import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; + +describe('CompleteRaceUseCaseWithRatings', () => { + let useCase: CompleteRaceUseCaseWithRatings; + let raceRepository: { + findById: Mock; + update: Mock; + }; + let raceRegistrationRepository: { + getRegisteredDrivers: Mock; + }; + let resultRepository: { + create: Mock; + }; + let standingRepository: { + findByDriverIdAndLeagueId: Mock; + save: Mock; + }; + let driverRatingProvider: { + getRatings: Mock; + }; + let ratingUpdateService: { + updateDriverRatingsAfterRace: Mock; + }; + + beforeEach(() => { + raceRepository = { + findById: vi.fn(), + update: vi.fn(), + }; + raceRegistrationRepository = { + getRegisteredDrivers: vi.fn(), + }; + resultRepository = { + create: vi.fn(), + }; + standingRepository = { + findByDriverIdAndLeagueId: vi.fn(), + save: vi.fn(), + }; + driverRatingProvider = { + getRatings: vi.fn(), + }; + ratingUpdateService = { + updateDriverRatingsAfterRace: vi.fn(), + }; + useCase = new CompleteRaceUseCaseWithRatings( + raceRepository as unknown as IRaceRepository, + raceRegistrationRepository as unknown as IRaceRegistrationRepository, + resultRepository as unknown as IResultRepository, + standingRepository as unknown as IStandingRepository, + driverRatingProvider as unknown as DriverRatingProvider, + ratingUpdateService as unknown as RatingUpdateService, + ); + }); + + it('should complete race successfully when race exists and has registered drivers', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']); + driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600], ['driver-2', 1500]])); + resultRepository.create.mockResolvedValue(undefined); + standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null); + standingRepository.save.mockResolvedValue(undefined); + ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined); + raceRepository.update.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toBeUndefined(); + expect(raceRepository.findById).toHaveBeenCalledWith('race-1'); + expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1'); + expect(driverRatingProvider.getRatings).toHaveBeenCalledWith(['driver-1', 'driver-2']); + expect(resultRepository.create).toHaveBeenCalledTimes(2); + expect(standingRepository.save).toHaveBeenCalledTimes(2); + expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled(); + expect(mockRace.complete).toHaveBeenCalled(); + expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' }); + }); + + it('should return error when race does not exist', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + raceRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().message).toBe('Race not found'); + }); + + it('should return error when no registered drivers', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn(), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]); + + 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'); + }); + + it('should return error when repository throws', async () => { + const command: CompleteRaceCommandDTO = { + raceId: 'race-1', + }; + + const mockRace = { + id: 'race-1', + leagueId: 'league-1', + status: 'scheduled', + complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }), + }; + raceRepository.findById.mockResolvedValue(mockRace); + raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']); + driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]])); + resultRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError); + expect(result.unwrapErr().message).toBe('DB 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 4d1cc5d51..bbb6a075c 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts @@ -8,17 +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 type { Logger } from '@core/shared/application'; +import { Result as SharedResult } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO'; /** * Enhanced CompleteRaceUseCase that includes rating updates */ -export interface CompleteRaceCommandDTO { - raceId: string; -} - export class CompleteRaceUseCaseWithRatings - implements AsyncUseCase { + implements AsyncUseCase> { constructor( private readonly raceRepository: IRaceRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository, @@ -26,64 +24,47 @@ export class CompleteRaceUseCaseWithRatings private readonly standingRepository: IStandingRepository, private readonly driverRatingProvider: DriverRatingProvider, private readonly ratingUpdateService: RatingUpdateService, - private readonly logger: Logger, ) {} - async execute(command: CompleteRaceCommandDTO): Promise { - const { raceId } = command; - this.logger.debug(`Attempting to complete race with ID: ${raceId}`); - + async execute(command: CompleteRaceCommandDTO): Promise> { try { + const { raceId } = command; + const race = await this.raceRepository.findById(raceId); if (!race) { - this.logger.error(`Race not found for ID: ${raceId}`); - throw new Error('Race not found'); + return SharedResult.err(new RacingDomainValidationError('Race not found')); } - this.logger.debug(`Found race: ${race.id}`); // Get registered drivers for this race const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); if (registeredDriverIds.length === 0) { - this.logger.warn(`No registered drivers for race ID: ${raceId}. Cannot complete race.`); - throw new Error('Cannot complete race with no registered drivers'); + return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers')); } - this.logger.debug(`Found ${registeredDriverIds.length} registered drivers for race ID: ${raceId}`); // Get driver ratings - this.logger.debug('Fetching driver ratings...'); const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); - this.logger.debug('Driver ratings fetched.'); // Generate realistic race results - this.logger.debug('Generating race results...'); const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings); - this.logger.info(`Generated ${results.length} race results for race ID: ${raceId}`); // Save results - this.logger.debug('Saving race results...'); for (const result of results) { await this.resultRepository.create(result); } - this.logger.info('Race results saved successfully.'); // Update standings - this.logger.debug(`Updating standings for league ID: ${race.leagueId}`); await this.updateStandings(race.leagueId, results); - this.logger.info('Standings updated successfully.'); // Update driver ratings based on performance - this.logger.debug('Updating driver ratings...'); await this.updateDriverRatings(results, registeredDriverIds.length); - this.logger.info('Driver ratings updated successfully.'); // Complete the race - this.logger.debug(`Marking race ID: ${raceId} as complete...`); const completedRace = race.complete(); await this.raceRepository.update(completedRace); - this.logger.info(`Race ID: ${raceId} completed successfully.`); + + return SharedResult.ok(undefined); } catch (error) { - this.logger.error(`Error completing race ${raceId}`, error instanceof Error ? error : new Error(String(error))); - throw error; + return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); } } diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringCommand.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringCommand.ts new file mode 100644 index 000000000..1d3a9cf8b --- /dev/null +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringCommand.ts @@ -0,0 +1,21 @@ +import type { LeagueVisibilityInput } from './LeagueVisibilityInput'; + +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; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts new file mode 100644 index 000000000..8a57104fc --- /dev/null +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase'; +import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; +import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; +import type { Logger } from '@core/shared/application'; + +describe('CreateLeagueWithSeasonAndScoringUseCase', () => { + let useCase: CreateLeagueWithSeasonAndScoringUseCase; + let leagueRepository: { + create: Mock; + }; + let seasonRepository: { + create: Mock; + }; + let leagueScoringConfigRepository: { + save: Mock; + }; + let presetProvider: { + getPresetById: Mock; + createScoringConfigFromPreset: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + leagueRepository = { + create: vi.fn(), + }; + seasonRepository = { + create: vi.fn(), + }; + leagueScoringConfigRepository = { + save: vi.fn(), + }; + presetProvider = { + getPresetById: vi.fn(), + createScoringConfigFromPreset: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new CreateLeagueWithSeasonAndScoringUseCase( + leagueRepository as unknown as ILeagueRepository, + seasonRepository as unknown as ISeasonRepository, + leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository, + presetProvider as unknown as LeagueScoringPresetProvider, + logger as unknown as Logger, + ); + }); + + it('should create league, season, and scoring successfully', async () => { + const command = { + name: 'Test League', + description: 'A test league', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + maxDrivers: 20, + maxTeams: 5, + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + scoringPresetId: 'club-default', + }; + + const mockPreset = { + id: 'club-default', + name: 'Club Default', + }; + + presetProvider.getPresetById.mockReturnValue(mockPreset); + presetProvider.createScoringConfigFromPreset.mockReturnValue({ id: 'config-1' }); + leagueRepository.create.mockResolvedValue(undefined); + seasonRepository.create.mockResolvedValue(undefined); + leagueScoringConfigRepository.save.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.leagueId).toBeDefined(); + expect(data.seasonId).toBeDefined(); + expect(data.scoringPresetId).toBe('club-default'); + expect(data.scoringPresetName).toBe('Club Default'); + expect(leagueRepository.create).toHaveBeenCalledTimes(1); + expect(seasonRepository.create).toHaveBeenCalledTimes(1); + expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1); + }); + + it('should return error when league name is empty', async () => { + const command = { + name: '', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('League name is required'); + }); + + it('should return error when ownerId is empty', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: '', + gameId: 'game-1', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('League ownerId is required'); + }); + + it('should return error when gameId is empty', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: '', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('gameId is required'); + }); + + it('should return error when visibility is missing', async () => { + const command: Partial = { + name: 'Test League', + ownerId: 'owner-1', + gameId: 'game-1', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('visibility is required'); + }); + + it('should return error when maxDrivers is invalid', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + maxDrivers: 0, + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('maxDrivers must be greater than 0 when provided'); + }); + + it('should return error when ranked league has insufficient drivers', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'ranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + maxDrivers: 5, + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toContain('Ranked leagues require at least 10 drivers'); + }); + + it('should return error when scoring preset is unknown', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + scoringPresetId: 'unknown-preset', + }; + + presetProvider.getPresetById.mockReturnValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Unknown scoring preset: unknown-preset'); + }); + + it('should return error when repository throws', async () => { + const command = { + name: 'Test League', + description: 'Test description', + visibility: 'unranked' as const, + ownerId: 'owner-1', + gameId: 'game-1', + enableDriverChampionship: true, + enableTeamChampionship: true, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }; + + const mockPreset = { + id: 'club-default', + name: 'Club Default', + }; + + presetProvider.getPresetById.mockReturnValue(mockPreset); + presetProvider.createScoringConfigFromPreset.mockReturnValue({ id: 'config-1' }); + leagueRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().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 32c9345a0..41e050ba5 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -14,43 +14,13 @@ import { LeagueVisibility, MIN_RANKED_LEAGUE_DRIVERS, } from '../../domain/value-objects/LeagueVisibility'; - -/** - * League visibility/ranking mode. - * - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers. - * - 'unranked' (or legacy 'private'): Casual with friends, no rating impact. - */ -export type LeagueVisibilityInput = 'ranked' | 'unranked' | 'public' | 'private'; - -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 interface CreateLeagueWithSeasonAndScoringResultDTO { - leagueId: string; - seasonId: string; - scoringPresetId?: string; - scoringPresetName?: string; -} +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand'; +import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO'; export class CreateLeagueWithSeasonAndScoringUseCase - implements AsyncUseCase { + implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, @@ -61,11 +31,14 @@ export class CreateLeagueWithSeasonAndScoringUseCase async execute( command: CreateLeagueWithSeasonAndScoringCommand, - ): Promise { + ): Promise> { this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command }); + const validation = this.validate(command); + if (validation.isErr()) { + return Result.err(validation.unwrapErr()); + } + this.logger.info('Command validated successfully.'); try { - this.validate(command); - this.logger.info('Command validated successfully.'); const leagueId = uuidv4(); this.logger.debug(`Generated leagueId: ${leagueId}`); @@ -108,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase if (!preset) { this.logger.error(`Unknown scoring preset: ${presetId}`); - throw new Error(`Unknown scoring preset: ${presetId}`); + return Result.err(new RacingDomainValidationError(`Unknown scoring preset: ${presetId}`)); } this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); @@ -119,45 +92,44 @@ export class CreateLeagueWithSeasonAndScoringUseCase await this.leagueScoringConfigRepository.save(finalConfig); this.logger.info(`Scoring configuration saved for season ${seasonId}.`); - const result = { + const result: CreateLeagueWithSeasonAndScoringResultDTO = { leagueId: league.id, seasonId, scoringPresetId: preset.id, scoringPresetName: preset.name, }; this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result }); - return result; + return Result.ok(result); } catch (error) { - this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', error, { command }); - throw error; + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error')); } } - private validate(command: CreateLeagueWithSeasonAndScoringCommand): void { + 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 }); - throw new Error('League name is required'); + return Result.err(new RacingDomainValidationError('League name is required')); } if (!command.ownerId || command.ownerId.trim().length === 0) { this.logger.warn('Validation failed: League ownerId is required', { command }); - throw new Error('League ownerId is required'); + return Result.err(new RacingDomainValidationError('League ownerId is required')); } if (!command.gameId || command.gameId.trim().length === 0) { this.logger.warn('Validation failed: gameId is required', { command }); - throw new Error('gameId is required'); + return Result.err(new RacingDomainValidationError('gameId is required')); } if (!command.visibility) { this.logger.warn('Validation failed: visibility is required', { command }); - throw new Error('visibility is required'); + return Result.err(new RacingDomainValidationError('visibility is required')); } if (command.maxDrivers !== undefined && command.maxDrivers <= 0) { this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command }); - throw new Error('maxDrivers must be greater than 0 when provided'); + return Result.err(new RacingDomainValidationError('maxDrivers must be greater than 0 when provided')); } const visibility = LeagueVisibility.fromString(command.visibility); - + if (visibility.isRanked()) { const driverCount = command.maxDrivers ?? 0; if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) { @@ -165,13 +137,14 @@ export class CreateLeagueWithSeasonAndScoringUseCase `Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`, { command } ); - throw new Error( + return Result.err(new RacingDomainValidationError( `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.'); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateSponsorCommand.ts b/core/racing/application/use-cases/CreateSponsorCommand.ts new file mode 100644 index 000000000..01d9cc52c --- /dev/null +++ b/core/racing/application/use-cases/CreateSponsorCommand.ts @@ -0,0 +1,6 @@ +export interface CreateSponsorCommand { + name: string; + contactEmail: string; + websiteUrl?: string; + logoUrl?: string; +} \ 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 new file mode 100644 index 000000000..1bda5d94c --- /dev/null +++ b/core/racing/application/use-cases/CreateSponsorUseCase.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CreateSponsorUseCase } from './CreateSponsorUseCase'; +import type { CreateSponsorCommand } from './CreateSponsorCommand'; +import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; +import type { Logger } from '@core/shared/application'; + +describe('CreateSponsorUseCase', () => { + let useCase: CreateSponsorUseCase; + let sponsorRepository: { + create: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + sponsorRepository = { + create: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new CreateSponsorUseCase( + sponsorRepository as unknown as ISponsorRepository, + logger as unknown as Logger, + ); + }); + + it('should create sponsor successfully', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: 'test@example.com', + websiteUrl: 'https://example.com', + logoUrl: 'https://example.com/logo.png', + }; + + sponsorRepository.create.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.sponsor.id).toBeDefined(); + expect(data.sponsor.name).toBe('Test Sponsor'); + expect(data.sponsor.contactEmail).toBe('test@example.com'); + expect(data.sponsor.websiteUrl).toBe('https://example.com'); + expect(data.sponsor.logoUrl).toBe('https://example.com/logo.png'); + expect(data.sponsor.createdAt).toBeInstanceOf(Date); + expect(sponsorRepository.create).toHaveBeenCalledTimes(1); + }); + + it('should create sponsor without optional fields', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: 'test@example.com', + }; + + sponsorRepository.create.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.sponsor.websiteUrl).toBeUndefined(); + expect(data.sponsor.logoUrl).toBeUndefined(); + }); + + it('should return error when name is empty', async () => { + const command: CreateSponsorCommand = { + name: '', + contactEmail: 'test@example.com', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Sponsor name is required'); + }); + + it('should return error when contactEmail is empty', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: '', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Sponsor contact email is required'); + }); + + it('should return error when contactEmail is invalid', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: 'invalid-email', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Invalid sponsor contact email format'); + }); + + it('should return error when websiteUrl is invalid', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: 'test@example.com', + websiteUrl: 'invalid-url', + }; + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Invalid sponsor website URL'); + }); + + it('should return error when repository throws', async () => { + const command: CreateSponsorCommand = { + name: 'Test Sponsor', + contactEmail: 'test@example.com', + }; + + sponsorRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().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 732602f0b..115128590 100644 --- a/core/racing/application/use-cases/CreateSponsorUseCase.ts +++ b/core/racing/application/use-cases/CreateSponsorUseCase.ts @@ -3,59 +3,90 @@ * * Creates a new sponsor. */ - - +import { v4 as uuidv4 } from 'uuid'; +import { Sponsor } from '../../domain/entities/Sponsor'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; -import type { - ICreateSponsorPresenter, - CreateSponsorResultDTO, - CreateSponsorViewModel, -} from '../presenters/ICreateSponsorPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; - -export interface CreateSponsorInput { - name: string; - contactEmail: string; - websiteUrl?: string; - logoUrl?: string; -} +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 type { CreateSponsorResultDTO } from '../dto/CreateSponsorResultDTO'; export class CreateSponsorUseCase - implements UseCase + implements AsyncUseCase> { constructor( private readonly sponsorRepository: ISponsorRepository, + private readonly logger: Logger, ) {} async execute( - input: CreateSponsorInput, - presenter: ICreateSponsorPresenter, - ): Promise { - presenter.reset(); + command: CreateSponsorCommand, + ): Promise> { + this.logger.debug('Executing CreateSponsorUseCase', { command }); + const validation = this.validate(command); + if (validation.isErr()) { + return Result.err(validation.unwrapErr()); + } + this.logger.info('Command validated successfully.'); + try { + const sponsorId = uuidv4(); + this.logger.debug(`Generated sponsorId: ${sponsorId}`); - const id = `sponsor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const sponsor = Sponsor.create({ + id: sponsorId, + name: command.name, + contactEmail: command.contactEmail, + ...(command.websiteUrl !== undefined ? { websiteUrl: command.websiteUrl } : {}), + ...(command.logoUrl !== undefined ? { logoUrl: command.logoUrl } : {}), + }); - const sponsor = Sponsor.create({ - id, - name: input.name, - contactEmail: input.contactEmail, - ...(input.websiteUrl !== undefined ? { websiteUrl: input.websiteUrl } : {}), - ...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl } : {}), - } as unknown); + await this.sponsorRepository.create(sponsor); + this.logger.info(`Sponsor ${sponsor.name} (${sponsor.id}) created successfully.`); - await this.sponsorRepository.create(sponsor); + const result: CreateSponsorResultDTO = { + sponsor: { + id: sponsor.id, + name: sponsor.name, + contactEmail: sponsor.contactEmail, + websiteUrl: sponsor.websiteUrl, + logoUrl: sponsor.logoUrl, + createdAt: sponsor.createdAt, + }, + }; + 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')); + } + } - const dto: CreateSponsorResultDTO = { - sponsor: { - id: sponsor.id, - name: sponsor.name, - contactEmail: sponsor.contactEmail, - websiteUrl: sponsor.websiteUrl, - logoUrl: sponsor.logoUrl, - createdAt: sponsor.createdAt, - }, - }; - - presenter.present(dto); + 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')); + } + 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')); + } + // 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')); + } + 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')); + } + } + this.logger.debug('Validation successful.'); + return Result.ok(undefined); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/CreateTeamUseCase.test.ts b/core/racing/application/use-cases/CreateTeamUseCase.test.ts new file mode 100644 index 000000000..b16e989ea --- /dev/null +++ b/core/racing/application/use-cases/CreateTeamUseCase.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { CreateTeamUseCase } from './CreateTeamUseCase'; +import type { CreateTeamCommandDTO } from '../dto/CreateTeamCommandDTO'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('CreateTeamUseCase', () => { + let useCase: CreateTeamUseCase; + let teamRepository: { + create: Mock; + }; + let membershipRepository: { + getActiveMembershipForDriver: Mock; + saveMembership: Mock; + }; + let logger: { + debug: Mock; + info: Mock; + warn: Mock; + error: Mock; + }; + + beforeEach(() => { + teamRepository = { + create: vi.fn(), + }; + membershipRepository = { + getActiveMembershipForDriver: vi.fn(), + saveMembership: vi.fn(), + }; + logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + useCase = new CreateTeamUseCase( + teamRepository as unknown as ITeamRepository, + membershipRepository as unknown as ITeamMembershipRepository, + logger as unknown as Logger, + ); + }); + + it('should create team successfully', async () => { + const command: CreateTeamCommandDTO = { + name: 'Test Team', + tag: 'TT', + description: 'A test team', + ownerId: 'owner-123', + leagues: ['league-1'], + }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + const mockTeam = { + id: 'team-uuid', + name: 'Test Team', + tag: 'TT', + description: 'A test team', + ownerId: 'owner-123', + leagues: ['league-1'], + createdAt: new Date(), + }; + teamRepository.create.mockResolvedValue(mockTeam); + membershipRepository.saveMembership.mockResolvedValue(undefined); + + const result = await useCase.execute(command); + + expect(result.isOk()).toBe(true); + const data = result.unwrap(); + expect(data.team.id).toBeDefined(); + expect(data.team.name).toBe('Test Team'); + expect(data.team.tag).toBe('TT'); + expect(data.team.description).toBe('A test team'); + expect(data.team.ownerId).toBe('owner-123'); + expect(data.team.leagues).toEqual(['league-1']); + expect(teamRepository.create).toHaveBeenCalledTimes(1); + expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(1); + }); + + it('should return error when driver already belongs to a team', async () => { + const command: CreateTeamCommandDTO = { + name: 'Test Team', + tag: 'TT', + description: 'A test team', + ownerId: 'owner-123', + leagues: ['league-1'], + }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue({ + teamId: 'existing-team', + driverId: 'owner-123', + role: 'member', + status: 'active', + joinedAt: new Date(), + }); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Driver already belongs to a team'); + expect(teamRepository.create).not.toHaveBeenCalled(); + expect(membershipRepository.saveMembership).not.toHaveBeenCalled(); + }); + + it('should return error when repository throws', async () => { + const command: CreateTeamCommandDTO = { + name: 'Test Team', + tag: 'TT', + description: 'A test team', + ownerId: 'owner-123', + leagues: ['league-1'], + }; + + membershipRepository.getActiveMembershipForDriver.mockResolvedValue(null); + teamRepository.create.mockRejectedValue(new Error('DB error')); + + const result = await useCase.execute(command); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().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 0d6d4d451..b191f8f16 100644 --- a/core/racing/application/use-cases/CreateTeamUseCase.ts +++ b/core/racing/application/use-cases/CreateTeamUseCase.ts @@ -1,3 +1,9 @@ +/** + * Application Use Case: CreateTeamUseCase + * + * Creates a new team. + */ +import { v4 as uuidv4 } from 'uuid'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import { Team } from '../../domain/entities/Team'; @@ -10,44 +16,67 @@ 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'; -export class CreateTeamUseCase { +export class CreateTeamUseCase + implements AsyncUseCase> +{ constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, + private readonly logger: Logger, ) {} - async execute(command: CreateTeamCommandDTO): Promise { + async execute( + command: CreateTeamCommandDTO, + ): Promise> { + this.logger.debug('Executing CreateTeamUseCase', { command }); const { name, tag, description, ownerId, leagues } = command; const existingMembership = await this.membershipRepository.getActiveMembershipForDriver( ownerId, ); if (existingMembership) { - throw new Error('Driver already belongs to a team'); + this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId }); + return Result.err(new RacingDomainValidationError('Driver already belongs to a team')); } - const team = Team.create({ - id: `team-${Date.now()}`, - name, - tag, - description, - ownerId, - leagues, - }); + this.logger.info('Command validated successfully.'); + try { + const teamId = uuidv4(); + this.logger.debug(`Generated teamId: ${teamId}`); - const createdTeam = await this.teamRepository.create(team); + const team = Team.create({ + id: teamId, + name, + tag, + description, + ownerId, + leagues, + }); - const membership: TeamMembership = { - teamId: createdTeam.id, - driverId: ownerId, - role: 'owner' as TeamRole, - status: 'active' as TeamMembershipStatus, - joinedAt: new Date(), - }; + const createdTeam = await this.teamRepository.create(team); + this.logger.info(`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`); - await this.membershipRepository.saveMembership(membership); + const membership: TeamMembership = { + teamId: createdTeam.id, + driverId: ownerId, + role: 'owner' as TeamRole, + status: 'active' as TeamMembershipStatus, + joinedAt: new Date(), + }; - return { team: createdTeam }; + await this.membershipRepository.saveMembership(membership); + this.logger.debug('Team membership created successfully.'); + + const result: CreateTeamResultDTO = { team: createdTeam }; + 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')); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/DashboardOverviewParams.ts b/core/racing/application/use-cases/DashboardOverviewParams.ts new file mode 100644 index 000000000..ea0ce703f --- /dev/null +++ b/core/racing/application/use-cases/DashboardOverviewParams.ts @@ -0,0 +1,3 @@ +export interface DashboardOverviewParams { + driverId: string; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts index df5403f86..1888eb954 100644 --- a/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.test.ts @@ -1,34 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { GetDashboardOverviewUseCase } from '@core/racing/application/use-cases/GetDashboardOverviewUseCase'; +import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase'; import { Driver } from '@core/racing/domain/entities/Driver'; import { Race } from '@core/racing/domain/entities/Race'; import { Result } from '@core/racing/domain/entities/Result'; import { League } from '@core/racing/domain/entities/League'; import { Standing } from '@core/racing/domain/entities/Standing'; +import { LeagueMembership, JoinRequest } from '@core/racing/domain/entities/LeagueMembership'; import type { FeedItem } from '@core/social/domain/types/FeedItem'; -import type { - IDashboardOverviewPresenter, - DashboardOverviewViewModel, - DashboardFeedItemSummaryViewModel, -} from '@core/racing/application/presenters/IDashboardOverviewPresenter'; - -class FakeDashboardOverviewPresenter implements IDashboardOverviewPresenter { - viewModel: DashboardOverviewViewModel | null = null; - - reset(): void { - this.viewModel = null; - } - - present(viewModel: DashboardOverviewViewModel): void { - this.viewModel = viewModel; - } - - getViewModel(): DashboardOverviewViewModel | null { - return this.viewModel; - } -} interface TestImageService { getDriverAvatar(driverId: string): string; @@ -46,7 +26,7 @@ function createTestImageService(): TestImageService { }; } -describe('GetDashboardOverviewUseCase', () => { +describe('DashboardOverviewUseCase', () => { it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => { // Given a driver with memberships in two leagues and future races with mixed registration const driverId = 'driver-1'; @@ -189,10 +169,10 @@ describe('GetDashboardOverviewUseCase', () => { ); }, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], + getJoinRequests: async (): Promise => [], saveMembership: async (): Promise => { throw new Error('Not implemented'); }, removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, }; @@ -234,9 +214,7 @@ describe('GetDashboardOverviewUseCase', () => { } : null; - const presenter = new FakeDashboardOverviewPresenter(); - - const useCase = new GetDashboardOverviewUseCase( + const useCase = new DashboardOverviewUseCase( driverRepository, raceRepository, resultRepository, @@ -251,12 +229,10 @@ describe('GetDashboardOverviewUseCase', () => { ); // When - await useCase.execute({ driverId }, presenter); + const result = await useCase.execute({ driverId }); + expect(result.isOk()).toBe(true); - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - - const vm = viewModel!; + const vm = result.unwrap(); // Then myUpcomingRaces only contains registered races from the driver's leagues expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']); @@ -422,10 +398,10 @@ describe('GetDashboardOverviewUseCase', () => { ); }, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], + getJoinRequests: async (): Promise => [], saveMembership: async (): Promise => { throw new Error('Not implemented'); }, removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, }; @@ -464,9 +440,7 @@ describe('GetDashboardOverviewUseCase', () => { } : null; - const presenter = new FakeDashboardOverviewPresenter(); - - const useCase = new GetDashboardOverviewUseCase( + const useCase = new DashboardOverviewUseCase( driverRepository, raceRepository, resultRepository, @@ -481,12 +455,10 @@ describe('GetDashboardOverviewUseCase', () => { ); // When - await useCase.execute({ driverId }, presenter); + const result = await useCase.execute({ driverId }); + expect(result.isOk()).toBe(true); - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - - const vm = viewModel!; + const vm = result.unwrap(); // Then recentResults are sorted by finishedAt descending (newest first) expect(vm.recentResults.length).toBe(2); @@ -584,10 +556,10 @@ describe('GetDashboardOverviewUseCase', () => { const leagueMembershipRepository = { getMembership: async (): Promise => null, getLeagueMembers: async (): Promise => [], - getJoinRequests: async (): Promise => [], + getJoinRequests: async (): Promise => [], saveMembership: async (): Promise => { throw new Error('Not implemented'); }, removeMembership: async (): Promise => { throw new Error('Not implemented'); }, - saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, + saveJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise => { throw new Error('Not implemented'); }, }; @@ -616,9 +588,7 @@ describe('GetDashboardOverviewUseCase', () => { const getDriverStats = () => null; - const presenter = new FakeDashboardOverviewPresenter(); - - const useCase = new GetDashboardOverviewUseCase( + const useCase = new DashboardOverviewUseCase( driverRepository, raceRepository, resultRepository, @@ -633,12 +603,10 @@ describe('GetDashboardOverviewUseCase', () => { ); // When - await useCase.execute({ driverId }, presenter); + const result = await useCase.execute({ driverId }); + expect(result.isOk()).toBe(true); - const viewModel = presenter.getViewModel(); - expect(viewModel).not.toBeNull(); - - const vm = viewModel!; + const vm = result.unwrap(); // Then collections are empty and no errors are thrown expect(vm.myUpcomingRaces).toEqual([]); diff --git a/core/racing/application/use-cases/GetDashboardOverviewUseCase.ts b/core/racing/application/use-cases/DashboardOverviewUseCase.ts similarity index 88% rename from core/racing/application/use-cases/GetDashboardOverviewUseCase.ts rename to core/racing/application/use-cases/DashboardOverviewUseCase.ts index 87e84b81b..9ac1d36cb 100644 --- a/core/racing/application/use-cases/GetDashboardOverviewUseCase.ts +++ b/core/racing/application/use-cases/DashboardOverviewUseCase.ts @@ -8,8 +8,16 @@ 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 { 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 { - IDashboardOverviewPresenter, DashboardOverviewViewModel, DashboardDriverSummaryViewModel, DashboardRaceSummaryViewModel, @@ -29,11 +37,7 @@ interface DashboardDriverStatsAdapter { consistency: number | null; } -export interface GetDashboardOverviewParams { - driverId: string; -} - -export class GetDashboardOverviewUseCase { +export class DashboardOverviewUseCase { constructor( private readonly driverRepository: IDriverRepository, private readonly raceRepository: IRaceRepository, @@ -48,7 +52,7 @@ export class GetDashboardOverviewUseCase { private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null, ) {} - async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise { + async execute(params: DashboardOverviewParams): Promise> { const { driverId } = params; const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([ @@ -134,12 +138,11 @@ export class GetDashboardOverviewUseCase { friends: friendsSummary, }; - presenter.reset(); - presenter.present(viewModel); + return Result.ok(viewModel); } - private async getDriverLeagues(allLeagues: unknown[], driverId: string): Promise { - const driverLeagues: unknown[] = []; + private async getDriverLeagues(allLeagues: League[], driverId: string): Promise { + const driverLeagues: League[] = []; for (const league of allLeagues) { const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId); @@ -152,7 +155,7 @@ export class GetDashboardOverviewUseCase { } private async partitionUpcomingRacesByRegistration( - upcomingRaces: unknown[], + upcomingRaces: Race[], driverId: string, leagueMap: Map, ): Promise<{ @@ -177,7 +180,7 @@ export class GetDashboardOverviewUseCase { } private mapRaceToSummary( - race: any, + race: Race, leagueMap: Map, isMyLeague: boolean, ): DashboardRaceSummaryViewModel { @@ -194,9 +197,9 @@ export class GetDashboardOverviewUseCase { } private buildRecentResults( - allResults: unknown[], - allRaces: unknown[], - allLeagues: unknown[], + allResults: RaceResult[], + allRaces: Race[], + allLeagues: League[], driverId: string, ): DashboardRecentResultViewModel[] { const raceById = new Map(allRaces.map(race => [race.id, race])); @@ -237,7 +240,7 @@ export class GetDashboardOverviewUseCase { } private async buildLeagueStandingsSummaries( - driverLeagues: unknown[], + driverLeagues: League[], driverId: string, ): Promise { const summaries: DashboardLeagueStandingSummaryViewModel[] = []; @@ -245,7 +248,7 @@ export class GetDashboardOverviewUseCase { for (const league of driverLeagues.slice(0, 3)) { const standings = await this.standingRepository.findByLeagueId(league.id); const driverStanding = standings.find( - (standing: any) => standing.driverId === driverId, + (standing: Standing) => standing.driverId === driverId, ); summaries.push({ @@ -277,7 +280,7 @@ export class GetDashboardOverviewUseCase { return activeLeagueIds.size; } - private buildFeedSummary(feedItems: unknown[]): DashboardFeedSummaryViewModel { + private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryViewModel { const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({ id: item.id, type: item.type, @@ -297,7 +300,7 @@ export class GetDashboardOverviewUseCase { }; } - private buildFriendsSummary(friends: unknown[]): DashboardFriendSummaryViewModel[] { + private buildFriendsSummary(friends: Driver[]): DashboardFriendSummaryViewModel[] { return friends.map(friend => ({ id: friend.id, name: friend.name, diff --git a/core/racing/application/use-cases/DriverRatingPort.ts b/core/racing/application/use-cases/DriverRatingPort.ts new file mode 100644 index 000000000..fe47290a5 --- /dev/null +++ b/core/racing/application/use-cases/DriverRatingPort.ts @@ -0,0 +1,3 @@ +export interface DriverRatingPort { + getRating(driverId: string): { rating: number | null; ratingChange: number | null }; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/FileProtestCommand.ts b/core/racing/application/use-cases/FileProtestCommand.ts new file mode 100644 index 000000000..7f7616961 --- /dev/null +++ b/core/racing/application/use-cases/FileProtestCommand.ts @@ -0,0 +1,10 @@ +import type { ProtestIncident } from '../../domain/entities/Protest'; + +export interface FileProtestCommand { + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: ProtestIncident; + comment?: string; + proofVideoUrl?: string; +} \ No newline at end of file diff --git a/core/racing/application/use-cases/FileProtestUseCase.test.ts b/core/racing/application/use-cases/FileProtestUseCase.test.ts new file mode 100644 index 000000000..143aa766f --- /dev/null +++ b/core/racing/application/use-cases/FileProtestUseCase.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { FileProtestUseCase } from './FileProtestUseCase'; +import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; + +describe('FileProtestUseCase', () => { + let mockProtestRepo: { + create: Mock; + }; + let mockRaceRepo: { + findById: Mock; + }; + let mockLeagueMembershipRepo: { + getLeagueMembers: Mock; + }; + + beforeEach(() => { + mockProtestRepo = { + create: vi.fn(), + }; + mockRaceRepo = { + findById: vi.fn(), + }; + mockLeagueMembershipRepo = { + getLeagueMembers: vi.fn(), + }; + }); + + it('should return error when race does not exist', async () => { + const useCase = new FileProtestUseCase( + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + mockRaceRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ + raceId: 'nonexistent', + protestingDriverId: 'driver1', + accusedDriverId: 'driver2', + incident: { lap: 5, description: 'Collision' }, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Race not found'); + }); + + it('should return error when protesting against self', async () => { + const useCase = new FileProtestUseCase( + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + + const result = await useCase.execute({ + raceId: 'race1', + protestingDriverId: 'driver1', + accusedDriverId: 'driver1', + incident: { lap: 5, description: 'Collision' }, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Cannot file a protest against yourself'); + }); + + it('should return error when protesting driver is not an active member', async () => { + const useCase = new FileProtestUseCase( + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'driver2', status: 'active' }, + ]); + + const result = await useCase.execute({ + raceId: 'race1', + protestingDriverId: 'driver1', + accusedDriverId: 'driver2', + incident: { lap: 5, description: 'Collision' }, + }); + + expect(result.isOk()).toBe(false); + expect(result.error!.message).toBe('Protesting driver is not an active member of this league'); + }); + + it('should create protest and return protestId on success', async () => { + const useCase = new FileProtestUseCase( + mockProtestRepo as unknown as IProtestRepository, + mockRaceRepo as unknown as IRaceRepository, + mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' }); + mockLeagueMembershipRepo.getLeagueMembers.mockResolvedValue([ + { driverId: 'driver1', status: 'active' }, + ]); + mockProtestRepo.create.mockResolvedValue(undefined); + + const result = await useCase.execute({ + raceId: 'race1', + protestingDriverId: 'driver1', + accusedDriverId: 'driver2', + incident: { lap: 5, description: 'Collision' }, + comment: 'Test comment', + proofVideoUrl: 'http://example.com/video', + }); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + protestId: expect.any(String), + }); + expect(mockProtestRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + raceId: 'race1', + protestingDriverId: 'driver1', + accusedDriverId: 'driver2', + incident: { lap: 5, description: 'Collision' }, + comment: 'Test comment', + proofVideoUrl: 'http://example.com/video', + status: 'pending', + }) + ); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/FileProtestUseCase.ts b/core/racing/application/use-cases/FileProtestUseCase.ts index 71ae3f467..bbf5aaeae 100644 --- a/core/racing/application/use-cases/FileProtestUseCase.ts +++ b/core/racing/application/use-cases/FileProtestUseCase.ts @@ -1,24 +1,18 @@ /** * Application Use Case: FileProtestUseCase - * + * * Allows a driver to file a protest against another driver for an incident during a race. */ -import { Protest, type ProtestIncident } from '../../domain/entities/Protest'; +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 { 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, @@ -26,16 +20,16 @@ export class FileProtestUseCase { private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(command: FileProtestCommand): Promise<{ protestId: string }> { + async execute(command: FileProtestCommand): Promise> { // Validate race exists const race = await this.raceRepository.findById(command.raceId); if (!race) { - throw new Error('Race not found'); + return Result.err(new RacingDomainValidationError('Race not found')); } // Validate drivers are not the same if (command.protestingDriverId === command.accusedDriverId) { - throw new Error('Cannot file a protest against yourself'); + return Result.err(new RacingDomainValidationError('Cannot file a protest against yourself')); } // Validate protesting driver is a member of the league @@ -43,9 +37,9 @@ export class FileProtestUseCase { const protestingDriverMembership = memberships.find( m => m.driverId === command.protestingDriverId && m.status === 'active' ); - + if (!protestingDriverMembership) { - throw new Error('Protesting driver is not an active member of this league'); + return Result.err(new RacingDomainValidationError('Protesting driver is not an active member of this league')); } // Create the protest @@ -63,6 +57,6 @@ export class FileProtestUseCase { await this.protestRepository.create(protest); - return { protestId: protest.id }; + return Result.ok({ protestId: protest.id }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts new file mode 100644 index 000000000..aa8740931 --- /dev/null +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetAllLeaguesWithCapacityAndScoringUseCase } from './GetAllLeaguesWithCapacityAndScoringUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; +import type { IGameRepository } from '../../domain/repositories/IGameRepository'; +import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; + +describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => { + let mockLeagueRepo: { findAll: Mock }; + let mockMembershipRepo: { getLeagueMembers: Mock }; + let mockSeasonRepo: { findByLeagueId: Mock }; + let mockScoringConfigRepo: { findBySeasonId: Mock }; + let mockGameRepo: { findById: Mock }; + let mockPresetProvider: { getPresetById: Mock }; + + beforeEach(() => { + mockLeagueRepo = { findAll: vi.fn() }; + mockMembershipRepo = { getLeagueMembers: vi.fn() }; + mockSeasonRepo = { findByLeagueId: vi.fn() }; + mockScoringConfigRepo = { findBySeasonId: vi.fn() }; + mockGameRepo = { findById: vi.fn() }; + mockPresetProvider = { getPresetById: vi.fn() }; + }); + + it('should return enriched leagues with capacity and scoring', async () => { + const useCase = new GetAllLeaguesWithCapacityAndScoringUseCase( + mockLeagueRepo as unknown as ILeagueRepository, + mockMembershipRepo as unknown as ILeagueMembershipRepository, + mockSeasonRepo as unknown as ISeasonRepository, + mockScoringConfigRepo as unknown as ILeagueScoringConfigRepository, + mockGameRepo as unknown as IGameRepository, + mockPresetProvider as unknown as LeagueScoringPresetProvider, + ); + + const league = { id: 'league1', name: 'Test League' }; + const members = [ + { status: 'active', role: 'member' }, + { status: 'active', role: 'owner' }, + ]; + const season = { id: 'season1', status: 'active', gameId: 'game1' }; + const scoringConfig = { scoringPresetId: 'preset1' }; + const game = { id: 'game1', name: 'iRacing' }; + const preset = { id: 'preset1', name: 'Default' }; + + mockLeagueRepo.findAll.mockResolvedValue([league]); + mockMembershipRepo.getLeagueMembers.mockResolvedValue(members); + mockSeasonRepo.findByLeagueId.mockResolvedValue([season]); + mockScoringConfigRepo.findBySeasonId.mockResolvedValue(scoringConfig); + mockGameRepo.findById.mockResolvedValue(game); + mockPresetProvider.getPresetById.mockReturnValue(preset); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual([ + { + league, + usedDriverSlots: 2, + season, + scoringConfig, + game, + preset, + }, + ]); + }); +}); diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts index a7d4833b5..a24cddf63 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityAndScoringUseCase.ts @@ -4,25 +4,17 @@ 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 { - AllLeaguesWithCapacityAndScoringViewModel, - IAllLeaguesWithCapacityAndScoringPresenter, - LeagueEnrichedData, -} from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +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'; /** * Use Case for retrieving all leagues with capacity and scoring information. * Orchestrates domain logic and delegates presentation to the presenter. */ export class GetAllLeaguesWithCapacityAndScoringUseCase - implements - UseCase< - void, - LeagueEnrichedData[], - AllLeaguesWithCapacityAndScoringViewModel, - IAllLeaguesWithCapacityAndScoringPresenter - > + implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, @@ -33,12 +25,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase private readonly presetProvider: LeagueScoringPresetProvider, ) {} - async execute( - _input: void, - presenter: IAllLeaguesWithCapacityAndScoringPresenter, - ): Promise { - presenter.reset(); - + async execute(): Promise> { const leagues = await this.leagueRepository.findAll(); const enrichedLeagues: LeagueEnrichedData[] = []; @@ -88,7 +75,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase ...(preset ? { preset } : {}), }); } +return Result.ok(enrichedLeagues); - presenter.present(enrichedLeagues); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts new file mode 100644 index 000000000..2377441e7 --- /dev/null +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetAllLeaguesWithCapacityUseCase } from './GetAllLeaguesWithCapacityUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; + +describe('GetAllLeaguesWithCapacityUseCase', () => { + let mockLeagueRepo: { findAll: Mock }; + let mockMembershipRepo: { getLeagueMembers: Mock }; + + beforeEach(() => { + mockLeagueRepo = { findAll: vi.fn() }; + mockMembershipRepo = { getLeagueMembers: vi.fn() }; + }); + + it('should return leagues with capacity information', async () => { + const useCase = new GetAllLeaguesWithCapacityUseCase( + mockLeagueRepo as unknown as ILeagueRepository, + mockMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + const league1 = { id: 'league1', name: 'Test League 1' }; + const league2 = { id: 'league2', name: 'Test League 2' }; + const members1 = [ + { status: 'active', role: 'member' }, + { status: 'active', role: 'owner' }, + { status: 'inactive', role: 'member' }, + ]; + const members2 = [ + { status: 'active', role: 'admin' }, + ]; + + mockLeagueRepo.findAll.mockResolvedValue([league1, league2]); + mockMembershipRepo.getLeagueMembers + .mockResolvedValueOnce(members1) + .mockResolvedValueOnce(members2); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + leagues: [league1, league2], + memberCounts: new Map([ + ['league1', 2], + ['league2', 1], + ]), + }); + }); + + it('should return empty result when no leagues', async () => { + const useCase = new GetAllLeaguesWithCapacityUseCase( + mockLeagueRepo as unknown as ILeagueRepository, + mockMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + mockLeagueRepo.findAll.mockResolvedValue([]); + mockMembershipRepo.getLeagueMembers.mockResolvedValue([]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + leagues: [], + memberCounts: new Map(), + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts index 0c7ddacac..c84f15c2b 100644 --- a/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts +++ b/core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts @@ -1,30 +1,23 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { - IAllLeaguesWithCapacityPresenter, - AllLeaguesWithCapacityResultDTO, - AllLeaguesWithCapacityViewModel, -} from '../presenters/IAllLeaguesWithCapacityPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +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'; /** * Use Case for retrieving all leagues with capacity information. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and returns result. */ export class GetAllLeaguesWithCapacityUseCase - implements UseCase + implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute( - _input: void, - presenter: IAllLeaguesWithCapacityPresenter, - ): Promise { - presenter.reset(); - + async execute(): Promise> { const leagues = await this.leagueRepository.findAll(); const memberCounts = new Map(); @@ -49,6 +42,6 @@ export class GetAllLeaguesWithCapacityUseCase memberCounts, }; - presenter.present(dto); + return Result.ok(dto); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts new file mode 100644 index 000000000..fc068d5ab --- /dev/null +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetAllRacesPageDataUseCase } from './GetAllRacesPageDataUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetAllRacesPageDataUseCase', () => { + let mockRaceRepo: { findAll: Mock }; + let mockLeagueRepo: { findAll: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockRaceRepo = { findAll: vi.fn() }; + mockLeagueRepo = { findAll: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return races and filters data', async () => { + const useCase = new GetAllRacesPageDataUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + const race1 = { + id: 'race1', + track: 'Track A', + car: 'Car A', + scheduledAt: new Date('2023-01-01T10:00:00Z'), + status: 'scheduled' as const, + leagueId: 'league1', + strengthOfField: 5, + }; + const race2 = { + id: 'race2', + track: 'Track B', + car: 'Car B', + scheduledAt: new Date('2023-01-02T10:00:00Z'), + status: 'completed' as const, + leagueId: 'league2', + strengthOfField: null, + }; + const league1 = { id: 'league1', name: 'League One' }; + const league2 = { id: 'league2', name: 'League Two' }; + + mockRaceRepo.findAll.mockResolvedValue([race1, race2]); + mockLeagueRepo.findAll.mockResolvedValue([league1, league2]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + races: [ + { + id: 'race2', + track: 'Track B', + car: 'Car B', + scheduledAt: '2023-01-02T10:00:00.000Z', + status: 'completed', + leagueId: 'league2', + leagueName: 'League Two', + strengthOfField: null, + }, + { + id: 'race1', + track: 'Track A', + car: 'Car A', + scheduledAt: '2023-01-01T10:00:00.000Z', + status: 'scheduled', + leagueId: 'league1', + leagueName: 'League One', + strengthOfField: 5, + }, + ], + filters: { + statuses: [ + { value: 'all', label: 'All Statuses' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'running', label: 'Live' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + ], + leagues: [ + { id: 'league1', name: 'League One' }, + { id: 'league2', name: 'League Two' }, + ], + }, + }); + }); + + it('should return empty result when no races or leagues', async () => { + const useCase = new GetAllRacesPageDataUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + mockRaceRepo.findAll.mockResolvedValue([]); + mockLeagueRepo.findAll.mockResolvedValue([]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + races: [], + filters: { + statuses: [ + { value: 'all', label: 'All Statuses' }, + { value: 'scheduled', label: 'Scheduled' }, + { value: 'running', label: 'Live' }, + { value: 'completed', label: 'Completed' }, + { value: 'cancelled', label: 'Cancelled' }, + ], + leagues: [], + }, + }); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetAllRacesPageDataUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + const error = new Error('Repository error'); + mockRaceRepo.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Repository error'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts index 449b59489..52878d1f1 100644 --- a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts @@ -2,23 +2,24 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository' import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { Logger } from '@core/shared/application'; import type { - IAllRacesPagePresenter, AllRacesPageResultDTO, AllRacesPageViewModel, AllRacesListItemViewModel, AllRacesFilterOptionsViewModel, } from '../presenters/IAllRacesPagePresenter'; -import type { UseCase } from '@core/shared/application'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; export class GetAllRacesPageDataUseCase - implements UseCase { + implements AsyncUseCase> { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, private readonly logger: Logger, ) {} - async execute(_input: void, presenter: IAllRacesPagePresenter): Promise { + async execute(): Promise> { this.logger.debug('Executing GetAllRacesPageDataUseCase'); try { const [allRaces, allLeagues] = await Promise.all([ @@ -64,12 +65,11 @@ export class GetAllRacesPageDataUseCase filters, }; - presenter.reset(); - presenter.present(viewModel); - this.logger.debug('Successfully presented all races page data.'); + this.logger.debug('Successfully retrieved all races page data.'); + return Result.ok(viewModel); } catch (error) { - this.logger.error('Error executing GetAllRacesPageDataUseCase', { error }); - throw error; + this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error))); + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.test.ts b/core/racing/application/use-cases/GetAllRacesUseCase.test.ts new file mode 100644 index 000000000..acc2d8512 --- /dev/null +++ b/core/racing/application/use-cases/GetAllRacesUseCase.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetAllRacesUseCase } from './GetAllRacesUseCase'; +import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetAllRacesUseCase', () => { + let mockRaceRepo: { findAll: Mock }; + let mockLeagueRepo: { findAll: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockRaceRepo = { findAll: vi.fn() }; + mockLeagueRepo = { findAll: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return races data', async () => { + const useCase = new GetAllRacesUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + const race1 = { + id: 'race1', + track: 'Track A', + car: 'Car A', + scheduledAt: new Date('2023-01-01T10:00:00Z'), + leagueId: 'league1', + }; + const race2 = { + id: 'race2', + track: 'Track B', + car: 'Car B', + scheduledAt: new Date('2023-01-02T10:00:00Z'), + leagueId: 'league2', + }; + const league1 = { id: 'league1', name: 'League One' }; + const league2 = { id: 'league2', name: 'League Two' }; + + mockRaceRepo.findAll.mockResolvedValue([race1, race2]); + mockLeagueRepo.findAll.mockResolvedValue([league1, league2]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + races: [ + { + id: 'race1', + name: 'Track A - Car A', + date: '2023-01-01T10:00:00.000Z', + leagueName: 'League One', + }, + { + id: 'race2', + name: 'Track B - Car B', + date: '2023-01-02T10:00:00.000Z', + leagueName: 'League Two', + }, + ], + totalCount: 2, + }); + }); + + it('should return empty result when no races or leagues', async () => { + const useCase = new GetAllRacesUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + mockRaceRepo.findAll.mockResolvedValue([]); + mockLeagueRepo.findAll.mockResolvedValue([]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + races: [], + totalCount: 0, + }); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetAllRacesUseCase( + mockRaceRepo as unknown as IRaceRepository, + mockLeagueRepo as unknown as ILeagueRepository, + mockLogger, + ); + + const error = new Error('Repository error'); + mockRaceRepo.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Repository error'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllRacesUseCase.ts b/core/racing/application/use-cases/GetAllRacesUseCase.ts index 74d119b04..02389dde3 100644 --- a/core/racing/application/use-cases/GetAllRacesUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesUseCase.ts @@ -1,34 +1,41 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import type { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '../presenters/IGetAllRacesPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; -export interface GetAllRacesUseCaseParams {} - -export class GetAllRacesUseCase implements UseCase { +export class GetAllRacesUseCase implements AsyncUseCase> { constructor( private readonly raceRepository: IRaceRepository, private readonly leagueRepository: ILeagueRepository, + private readonly logger: Logger, ) {} - async execute(params: GetAllRacesUseCaseParams, presenter: IGetAllRacesPresenter): Promise { - const races = await this.raceRepository.findAll(); - const leagues = await this.leagueRepository.findAll(); - const leagueMap = new Map(leagues.map(league => [league.id, league.name])); + async execute(): Promise> { + this.logger.debug('Executing GetAllRacesUseCase'); + try { + const races = await this.raceRepository.findAll(); + const leagues = await this.leagueRepository.findAll(); + const leagueMap = new Map(leagues.map(league => [league.id, league.name])); - const raceViewModels = races.map(race => ({ - id: race.id, - name: `Race ${race.id}`, // Placeholder, adjust based on domain - date: race.scheduledAt.toISOString(), - leagueName: leagueMap.get(race.leagueId) || 'Unknown League', - })); + const raceViewModels = races.map(race => ({ + id: race.id, + name: `${race.track} - ${race.car}`, + date: race.scheduledAt.toISOString(), + leagueName: leagueMap.get(race.leagueId) || 'Unknown League', + })); - const dto: GetAllRacesResultDTO = { - races: raceViewModels, - totalCount: races.length, - }; + const dto: GetAllRacesResultDTO = { + races: raceViewModels, + totalCount: races.length, + }; - presenter.reset(); - presenter.present(dto); + this.logger.debug('Successfully retrieved all races.'); + return Result.ok(dto); + } catch (error) { + this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error))); + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); + } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts b/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts new file mode 100644 index 000000000..c3cff8d58 --- /dev/null +++ b/core/racing/application/use-cases/GetAllTeamsUseCase.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetAllTeamsUseCase } from './GetAllTeamsUseCase'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetAllTeamsUseCase', () => { + let mockTeamRepo: { findAll: Mock }; + let mockTeamMembershipRepo: { countByTeamId: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockTeamRepo = { findAll: vi.fn() }; + mockTeamMembershipRepo = { countByTeamId: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return teams data', async () => { + const useCase = new GetAllTeamsUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockTeamMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + const team1 = { + id: 'team1', + name: 'Team One', + tag: 'TO', + description: 'Description One', + ownerId: 'owner1', + leagues: ['league1'], + createdAt: new Date('2023-01-01T00:00:00Z'), + }; + const team2 = { + id: 'team2', + name: 'Team Two', + tag: 'TT', + description: 'Description Two', + ownerId: 'owner2', + leagues: ['league2'], + createdAt: new Date('2023-01-02T00:00:00Z'), + }; + + mockTeamRepo.findAll.mockResolvedValue([team1, team2]); + mockTeamMembershipRepo.countByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3)); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + teams: [ + { + id: 'team1', + name: 'Team One', + tag: 'TO', + description: 'Description One', + ownerId: 'owner1', + leagues: ['league1'], + createdAt: new Date('2023-01-01T00:00:00Z'), + memberCount: 5, + }, + { + id: 'team2', + name: 'Team Two', + tag: 'TT', + description: 'Description Two', + ownerId: 'owner2', + leagues: ['league2'], + createdAt: new Date('2023-01-02T00:00:00Z'), + memberCount: 3, + }, + ], + }); + }); + + it('should return empty result when no teams', async () => { + const useCase = new GetAllTeamsUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockTeamMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + mockTeamRepo.findAll.mockResolvedValue([]); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + teams: [], + }); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetAllTeamsUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockTeamMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + const error = new Error('Repository error'); + mockTeamRepo.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Repository error'); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetAllTeamsUseCase.ts b/core/racing/application/use-cases/GetAllTeamsUseCase.ts index 298afa7aa..780de88d8 100644 --- a/core/racing/application/use-cases/GetAllTeamsUseCase.ts +++ b/core/racing/application/use-cases/GetAllTeamsUseCase.ts @@ -1,34 +1,25 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { - IAllTeamsPresenter, - AllTeamsResultDTO, -} from '../presenters/IAllTeamsPresenter'; -import type { UseCase } from '@core/shared/application'; -import { Logger } from "@core/shared/application"; +import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; /** * Use Case for retrieving all teams. - * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetAllTeamsUseCase - implements UseCase -{ +export class GetAllTeamsUseCase implements AsyncUseCase> { constructor( private readonly teamRepository: ITeamRepository, private readonly teamMembershipRepository: ITeamMembershipRepository, private readonly logger: Logger, ) {} - async execute(_input: void, presenter: IAllTeamsPresenter): Promise { + async execute(): Promise> { this.logger.debug('Executing GetAllTeamsUseCase'); - presenter.reset(); try { const teams = await this.teamRepository.findAll(); - if (teams.length === 0) { - this.logger.warn('No teams found.'); - } const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all( teams.map(async (team) => { @@ -40,7 +31,7 @@ export class GetAllTeamsUseCase description: team.description, ownerId: team.ownerId, leagues: [...team.leagues], - createdAt: team.createdAt, + createdAt: team.createdAt, memberCount, }; }), @@ -50,11 +41,11 @@ export class GetAllTeamsUseCase teams: enrichedTeams, }; - presenter.present(dto); - this.logger.info('Successfully retrieved all teams.'); + this.logger.debug('Successfully retrieved all teams.'); + return Result.ok(dto); } catch (error) { this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error))); - throw error; // Re-throw the error after logging + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); } } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts b/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts new file mode 100644 index 000000000..e419341cf --- /dev/null +++ b/core/racing/application/use-cases/GetDriverTeamUseCase.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetDriverTeamUseCase } from './GetDriverTeamUseCase'; +import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; +import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetDriverTeamUseCase', () => { + let mockTeamRepo: { findById: Mock }; + let mockMembershipRepo: { getActiveMembershipForDriver: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockTeamRepo = { findById: vi.fn() }; + mockMembershipRepo = { getActiveMembershipForDriver: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + 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, + mockLogger, + ); + + const driverId = 'driver1'; + const membership = { id: 'membership1', driverId, teamId: 'team1' }; + const team = { id: 'team1', name: 'Team One' }; + + mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership); + mockTeamRepo.findById.mockResolvedValue(team); + + const result = await useCase.execute({ driverId }); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + team, + membership, + driverId, + }); + }); + + it('should return error when no active membership found', async () => { + const useCase = new GetDriverTeamUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + const driverId = 'driver1'; + + mockMembershipRepo.getActiveMembershipForDriver.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'); + }); + + it('should return error when team not found', async () => { + const useCase = new GetDriverTeamUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + const driverId = 'driver1'; + const membership = { id: 'membership1', driverId, teamId: 'team1' }; + + mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership); + mockTeamRepo.findById.mockResolvedValue(null); + + const result = await useCase.execute({ driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Team not found for teamId team1'); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetDriverTeamUseCase( + mockTeamRepo as unknown as ITeamRepository, + mockMembershipRepo as unknown as ITeamMembershipRepository, + mockLogger, + ); + + const driverId = 'driver1'; + const error = new Error('Repository error'); + + mockMembershipRepo.getActiveMembershipForDriver.mockRejectedValue(error); + + const result = await useCase.execute({ driverId }); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().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 3e70ab929..d42346e34 100644 --- a/core/racing/application/use-cases/GetDriverTeamUseCase.ts +++ b/core/racing/application/use-cases/GetDriverTeamUseCase.ts @@ -1,54 +1,51 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; -import type { - IDriverTeamPresenter, - DriverTeamResultDTO, - DriverTeamViewModel, -} from '../presenters/IDriverTeamPresenter'; -import type { UseCase } from '@core/shared/application'; -import type { Logger } from '@core/shared/application'; +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'; /** * Use Case for retrieving a driver's team. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and returns result. */ export class GetDriverTeamUseCase - implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter> + implements AsyncUseCase<{ driverId: string }, Result> { constructor( private readonly teamRepository: ITeamRepository, private readonly membershipRepository: ITeamMembershipRepository, 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: IDriverTeamPresenter, ) {} - async execute(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise { + async execute(input: { driverId: string }): Promise> { this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`); - presenter.reset(); + 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}`)); + } + this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`); - const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId); - if (!membership) { - this.logger.warn(`No active membership found for driverId: ${input.driverId}`); - return; + 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}`)); + } + this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`); + + const dto: DriverTeamResultDTO = { + team, + membership, + driverId: input.driverId, + }; + + this.logger.info(`Successfully retrieved driver team for driverId: ${input.driverId}`); + 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')); } - 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; - } - this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`); - - const dto: DriverTeamResultDTO = { - team, - membership, - driverId: input.driverId, - }; - - presenter.present(dto); - this.logger.info(`Successfully presented driver team for driverId: ${input.driverId}`); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts new file mode 100644 index 000000000..c425a341d --- /dev/null +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetDriversLeaderboardUseCase } from './GetDriversLeaderboardUseCase'; +import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import type { IRankingService } from '../../domain/services/IRankingService'; +import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; +import type { IImageServicePort } from '../ports/IImageServicePort'; +import type { Logger } from '@core/shared/application'; + +describe('GetDriversLeaderboardUseCase', () => { + let mockDriverRepo: { findAll: Mock }; + let mockRankingService: { getAllDriverRankings: Mock }; + let mockDriverStatsService: { getDriverStats: Mock }; + let mockImageService: { getDriverAvatar: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockDriverRepo = { findAll: vi.fn() }; + mockRankingService = { getAllDriverRankings: vi.fn() }; + mockDriverStatsService = { getDriverStats: vi.fn() }; + mockImageService = { getDriverAvatar: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return drivers leaderboard data', async () => { + const useCase = new GetDriversLeaderboardUseCase( + mockDriverRepo as unknown as IDriverRepository, + mockRankingService as unknown as IRankingService, + mockDriverStatsService as unknown as IDriverStatsService, + mockImageService as unknown as IImageServicePort, + mockLogger, + ); + + const driver1 = { id: 'driver1', name: 'Driver One' }; + const driver2 = { id: 'driver2', name: 'Driver Two' }; + const rankings = { driver1: 1, driver2: 2 }; + const stats1 = { wins: 5, losses: 2 }; + const stats2 = { wins: 3, losses: 1 }; + + mockDriverRepo.findAll.mockResolvedValue([driver1, driver2]); + mockRankingService.getAllDriverRankings.mockReturnValue(rankings); + mockDriverStatsService.getDriverStats.mockImplementation((id) => { + if (id === 'driver1') return stats1; + if (id === 'driver2') return stats2; + return null; + }); + mockImageService.getDriverAvatar.mockImplementation((id) => `avatar-${id}`); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + drivers: [driver1, driver2], + rankings, + stats: { driver1: stats1, driver2: stats2 }, + avatarUrls: { driver1: 'avatar-driver1', driver2: 'avatar-driver2' }, + }); + expect(mockLogger.debug).toHaveBeenCalledWith('Executing GetDriversLeaderboardUseCase'); + expect(mockLogger.debug).toHaveBeenCalledWith('Successfully retrieved drivers leaderboard.'); + }); + + it('should return empty result when no drivers', async () => { + const useCase = new GetDriversLeaderboardUseCase( + mockDriverRepo as unknown as IDriverRepository, + mockRankingService as unknown as IRankingService, + mockDriverStatsService as unknown as IDriverStatsService, + mockImageService as unknown as IImageServicePort, + mockLogger, + ); + + mockDriverRepo.findAll.mockResolvedValue([]); + mockRankingService.getAllDriverRankings.mockReturnValue({}); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + drivers: [], + rankings: {}, + stats: {}, + avatarUrls: {}, + }); + }); + + it('should handle drivers without stats', async () => { + const useCase = new GetDriversLeaderboardUseCase( + mockDriverRepo as unknown as IDriverRepository, + mockRankingService as unknown as IRankingService, + mockDriverStatsService as unknown as IDriverStatsService, + mockImageService as unknown as IImageServicePort, + mockLogger, + ); + + const driver1 = { id: 'driver1', name: 'Driver One' }; + const rankings = { driver1: 1 }; + + mockDriverRepo.findAll.mockResolvedValue([driver1]); + mockRankingService.getAllDriverRankings.mockReturnValue(rankings); + mockDriverStatsService.getDriverStats.mockReturnValue(null); + mockImageService.getDriverAvatar.mockReturnValue('avatar-driver1'); + + const result = await useCase.execute(); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + drivers: [driver1], + rankings, + stats: {}, + avatarUrls: { driver1: 'avatar-driver1' }, + }); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetDriversLeaderboardUseCase( + mockDriverRepo as unknown as IDriverRepository, + mockRankingService as unknown as IRankingService, + mockDriverStatsService as unknown as IDriverStatsService, + mockImageService as unknown as IImageServicePort, + mockLogger, + ); + + const error = new Error('Repository error'); + mockDriverRepo.findAll.mockRejectedValue(error); + + const result = await useCase.execute(); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().message).toBe('Repository error'); + expect(mockLogger.error).toHaveBeenCalledWith('Error executing GetDriversLeaderboardUseCase', error); + }); +}); \ 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 7f542644e..c79b757e7 100644 --- a/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts +++ b/core/racing/application/use-cases/GetDriversLeaderboardUseCase.ts @@ -2,51 +2,55 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit import type { IRankingService } from '../../domain/services/IRankingService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; import type { IImageServicePort } from '../ports/IImageServicePort'; -import type { - IDriversLeaderboardPresenter, - DriversLeaderboardResultDTO, - DriversLeaderboardViewModel, -} from '../presenters/IDriversLeaderboardPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter'; +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; /** * Use Case for retrieving driver leaderboard data. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and returns result. */ export class GetDriversLeaderboardUseCase - implements UseCase + implements AsyncUseCase> { constructor( private readonly driverRepository: IDriverRepository, private readonly rankingService: IRankingService, private readonly driverStatsService: IDriverStatsService, private readonly imageService: IImageServicePort, + private readonly logger: Logger, ) {} - async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise { - presenter.reset(); + async execute(): Promise> { + this.logger.debug('Executing GetDriversLeaderboardUseCase'); + try { + const drivers = await this.driverRepository.findAll(); + const rankings = this.rankingService.getAllDriverRankings(); - const drivers = await this.driverRepository.findAll(); - const rankings = this.rankingService.getAllDriverRankings(); + const stats: DriversLeaderboardResultDTO['stats'] = {}; + const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {}; - const stats: DriversLeaderboardResultDTO['stats'] = {}; - const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {}; - - for (const driver of drivers) { - const driverStats = this.driverStatsService.getDriverStats(driver.id); - if (driverStats) { - stats[driver.id] = driverStats; + for (const driver of drivers) { + const driverStats = this.driverStatsService.getDriverStats(driver.id); + if (driverStats) { + stats[driver.id] = driverStats; + } + avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id); } - avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id); + + const dto: DriversLeaderboardResultDTO = { + drivers, + rankings, + stats, + avatarUrls, + }; + + this.logger.debug('Successfully retrieved drivers leaderboard.'); + return Result.ok(dto); + } catch (error) { + this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error))); + return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred')); } - - const dto: DriversLeaderboardResultDTO = { - drivers, - rankings, - stats, - avatarUrls, - }; - - presenter.present(dto); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts new file mode 100644 index 000000000..0e8fff11e --- /dev/null +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetEntitySponsorshipPricingUseCase } from './GetEntitySponsorshipPricingUseCase'; +import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; +import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; +import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; +import type { Logger } from '@core/shared/application'; + +describe('GetEntitySponsorshipPricingUseCase', () => { + let mockSponsorshipPricingRepo: { findByEntity: Mock }; + let mockSponsorshipRequestRepo: { findPendingByEntity: Mock }; + let mockSeasonSponsorshipRepo: { findBySeasonId: Mock }; + let mockLogger: Logger; + + beforeEach(() => { + mockSponsorshipPricingRepo = { findByEntity: vi.fn() }; + mockSponsorshipRequestRepo = { findPendingByEntity: vi.fn() }; + mockSeasonSponsorshipRepo = { findBySeasonId: vi.fn() }; + mockLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + }); + + it('should return null when no pricing found', async () => { + const useCase = new GetEntitySponsorshipPricingUseCase( + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, + mockLogger, + ); + + const dto = { entityType: 'season' as const, entityId: 'season1' }; + + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(null); + + const result = await useCase.execute(dto); + + expect(result.isOk()).toBe(true); + expect(result.value).toBe(null); + }); + + it('should return pricing data when found', async () => { + const useCase = new GetEntitySponsorshipPricingUseCase( + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, + mockLogger, + ); + + const dto = { entityType: 'season' as const, entityId: 'season1' }; + const pricing = { + acceptingApplications: true, + customRequirements: 'Some requirements', + mainSlot: { + price: { amount: 100, currency: 'USD', format: () => '$100' }, + benefits: ['Benefit 1'], + available: true, + maxSlots: 5, + }, + secondarySlots: { + price: { amount: 50, currency: 'USD', format: () => '$50' }, + benefits: ['Benefit 2'], + available: true, + maxSlots: 10, + }, + }; + + mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(pricing); + mockSponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([]); + mockSeasonSponsorshipRepo.findBySeasonId.mockResolvedValue([]); + + const result = await useCase.execute(dto); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + entityType: 'season', + entityId: 'season1', + acceptingApplications: true, + customRequirements: 'Some requirements', + mainSlot: { + tier: 'main', + price: 100, + currency: 'USD', + formattedPrice: '$100', + benefits: ['Benefit 1'], + available: true, + maxSlots: 5, + filledSlots: 0, + pendingRequests: 0, + }, + secondarySlot: { + tier: 'secondary', + price: 50, + currency: 'USD', + formattedPrice: '$50', + benefits: ['Benefit 2'], + available: true, + maxSlots: 10, + filledSlots: 0, + pendingRequests: 0, + }, + }); + }); + + it('should return error when repository throws', async () => { + const useCase = new GetEntitySponsorshipPricingUseCase( + mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository, + mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository, + mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository, + mockLogger, + ); + + const dto = { entityType: 'season' as const, entityId: 'season1' }; + const error = new Error('Repository error'); + + mockSponsorshipPricingRepo.findByEntity.mockRejectedValue(error); + + const result = await useCase.execute(dto); + + expect(result.isErr()).toBe(true); + expect(result.unwrapErr().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 0b0358520..e2f70007c 100644 --- a/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts +++ b/core/racing/application/use-cases/GetEntitySponsorshipPricingUseCase.ts @@ -8,58 +8,30 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; -import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; -import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; -import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; - -export interface GetEntitySponsorshipPricingDTO { - entityType: SponsorableEntityType; - entityId: string; -} - -export interface SponsorshipSlotDTO { - tier: SponsorshipTier; - price: number; - currency: string; - formattedPrice: string; - benefits: string[]; - available: boolean; - maxSlots: number; - filledSlots: number; - pendingRequests: number; -} - -export interface GetEntitySponsorshipPricingResultDTO { - entityType: SponsorableEntityType; - entityId: string; - acceptingApplications: boolean; - customRequirements?: string; - mainSlot?: SponsorshipSlotDTO; - secondarySlot?: SponsorshipSlotDTO; -} +import type { AsyncUseCase, Logger } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; +import type { GetEntitySponsorshipPricingDTO } from '../dto/GetEntitySponsorshipPricingDTO'; +import type { GetEntitySponsorshipPricingResultDTO } from '../dto/GetEntitySponsorshipPricingResultDTO'; export class GetEntitySponsorshipPricingUseCase - implements UseCase + implements AsyncUseCase> { constructor( private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, + private readonly logger: Logger, ) {} - async execute( - dto: GetEntitySponsorshipPricingDTO, - presenter: IEntitySponsorshipPricingPresenter, - ): Promise { - presenter.reset(); - + 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); if (!pricing) { - presenter.present(null); - return; + this.logger.info(`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); + return Result.ok(null); } // Count pending requests by tier @@ -121,9 +93,11 @@ export class GetEntitySponsorshipPricingUseCase }; } - presenter.present(result); - } catch (error: unknown) { - throw error; + this.logger.info(`Successfully retrieved sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`); + 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')); } } } \ 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 new file mode 100644 index 000000000..6005d64d7 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.test.ts @@ -0,0 +1,90 @@ +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 }; + + beforeEach(() => { + mockLeagueRepo = { findById: vi.fn() }; + mockMembershipRepo = { getMembership: vi.fn() }; + }); + + const createUseCase = () => new GetLeagueAdminPermissionsUseCase( + mockLeagueRepo as unknown as ILeagueRepository, + mockMembershipRepo as unknown as ILeagueMembershipRepository, + ); + + const params: GetLeagueAdminPermissionsUseCaseParams = { + leagueId: 'league1', + performerDriverId: 'driver1', + }; + + it('should return no permissions when league not found', async () => { + mockLeagueRepo.findById.mockResolvedValue(null); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + }); + + it('should return no permissions when membership not found', async () => { + mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); + mockMembershipRepo.getMembership.mockResolvedValue(null); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + }); + + it('should return no permissions when membership not active', async () => { + mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); + mockMembershipRepo.getMembership.mockResolvedValue({ status: 'inactive', role: 'admin' }); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + }); + + it('should return no permissions when role is member', async () => { + mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); + mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'member' }); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false }); + }); + + it('should return permissions when role is admin', async () => { + mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); + mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'admin' }); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true }); + }); + + it('should return permissions when role is owner', async () => { + mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' }); + mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'owner' }); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts index 3a27f3b51..ea1bc7206 100644 --- a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCase.ts @@ -1,42 +1,31 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; -import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams'; +import type { GetLeagueAdminPermissionsResultDTO } from '../dto/GetLeagueAdminPermissionsResultDTO'; -export interface GetLeagueAdminPermissionsUseCaseParams { - leagueId: string; - performerDriverId: string; -} - -export interface GetLeagueAdminPermissionsResultDTO { - canRemoveMember: boolean; - canUpdateRoles: boolean; -} - -export class GetLeagueAdminPermissionsUseCase implements UseCase { +export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository, ) {} - async execute(params: GetLeagueAdminPermissionsUseCaseParams, presenter: IGetLeagueAdminPermissionsPresenter): Promise { + async execute(params: GetLeagueAdminPermissionsUseCaseParams): Promise> { const league = await this.leagueRepository.findById(params.leagueId); if (!league) { - presenter.present({ canRemoveMember: false, canUpdateRoles: false }); - return; + return Result.ok({ canRemoveMember: false, canUpdateRoles: false }); } const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId); if (!membership || membership.status !== 'active') { - presenter.present({ canRemoveMember: false, canUpdateRoles: false }); - return; + return Result.ok({ canRemoveMember: false, canUpdateRoles: false }); } // Business logic: owners and admins can remove members and update roles const canRemoveMember = membership.role === 'owner' || membership.role === 'admin'; const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin'; - presenter.reset(); - presenter.present({ canRemoveMember, canUpdateRoles }); + return Result.ok({ canRemoveMember, canUpdateRoles }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts new file mode 100644 index 000000000..3875d4c86 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueAdminPermissionsUseCaseParams.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000..6cc49cd89 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.test.ts @@ -0,0 +1,48 @@ +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 }; + + beforeEach(() => { + mockLeagueRepo = { findById: vi.fn() }; + }); + + const createUseCase = () => new GetLeagueAdminUseCase( + mockLeagueRepo as unknown as ILeagueRepository, + ); + + const params: GetLeagueAdminUseCaseParams = { + leagueId: 'league1', + }; + + it('should return error when league not found', async () => { + mockLeagueRepo.findById.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'); + }); + + it('should return league data when league found', async () => { + const league = { id: 'league1', ownerId: 'owner1' }; + mockLeagueRepo.findById.mockResolvedValue(league); + + const useCase = createUseCase(); + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(result.value).toEqual({ + league: { + id: 'league1', + ownerId: 'owner1', + }, + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts index 0a2ebc0d3..01d1babd1 100644 --- a/core/racing/application/use-cases/GetLeagueAdminUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueAdminUseCase.ts @@ -1,29 +1,19 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; -import { GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter'; -import type { IGetLeagueAdminPresenter } from '../presenters/IGetLeagueAdminPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +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 type { GetLeagueAdminResultDTO } from '../dto/GetLeagueAdminResultDTO'; -export interface GetLeagueAdminUseCaseParams { - leagueId: string; -} - -export interface GetLeagueAdminResultDTO { - league: { - id: string; - ownerId: string; - }; - // Additional data would be populated by combining multiple use cases -} - -export class GetLeagueAdminUseCase implements UseCase { +export class GetLeagueAdminUseCase implements AsyncUseCase> { constructor( private readonly leagueRepository: ILeagueRepository, ) {} - async execute(params: GetLeagueAdminUseCaseParams, presenter: IGetLeagueAdminPresenter): Promise { + async execute(params: GetLeagueAdminUseCaseParams): Promise> { const league = await this.leagueRepository.findById(params.leagueId); if (!league) { - throw new Error('League not found'); + return Result.err(new RacingDomainValidationError('League not found')); } const dto: GetLeagueAdminResultDTO = { @@ -32,7 +22,6 @@ export class GetLeagueAdminUseCase implements UseCase { + let useCase: GetLeagueDriverSeasonStatsUseCase; + let standingRepository: IStandingRepository; + let resultRepository: IResultRepository; + let penaltyRepository: IPenaltyRepository; + let raceRepository: IRaceRepository; + let driverRatingPort: DriverRatingPort; + + beforeEach(() => { + standingRepository = { + findByLeagueId: vi.fn(), + } as any; + resultRepository = { + findByDriverIdAndLeagueId: vi.fn(), + } as any; + penaltyRepository = { + findByRaceId: vi.fn(), + } as any; + raceRepository = { + findByLeagueId: vi.fn(), + } as any; + driverRatingPort = { + getRating: vi.fn(), + } as any; + + useCase = new GetLeagueDriverSeasonStatsUseCase( + standingRepository, + resultRepository, + penaltyRepository, + raceRepository, + driverRatingPort, + ); + }); + + it('should return league driver season stats for given league id', async () => { + const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' }; + + const mockStandings = [ + { driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }, + { driverId: 'driver-2', position: 2, points: 80, racesCompleted: 5 }, + ]; + const mockRaces = [ + { id: 'race-1' }, + { id: 'race-2' }, + ]; + const mockPenalties = [ + { driverId: 'driver-1', status: 'applied', type: 'points_deduction', value: 10 }, + ]; + const mockResults = [{ position: 1 }]; + const mockRating = { rating: 1500, ratingChange: 50 }; + + standingRepository.findByLeagueId.mockResolvedValue(mockStandings); + raceRepository.findByLeagueId.mockResolvedValue(mockRaces); + penaltyRepository.findByRaceId.mockImplementation((raceId) => { + if (raceId === 'race-1') return Promise.resolve(mockPenalties); + return Promise.resolve([]); + }); + driverRatingPort.getRating.mockReturnValue(mockRating); + resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults); + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.leagueId).toBe('league-1'); + expect(dto.standings).toEqual([ + { driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }, + { driverId: 'driver-2', position: 2, points: 80, racesCompleted: 5 }, + ]); + expect(dto.penalties.get('driver-1')).toEqual({ baseDelta: -10, bonusDelta: 0 }); + expect(dto.driverRatings.get('driver-1')).toEqual(mockRating); + expect(dto.driverResults.get('driver-1')).toEqual(mockResults); + }); + + it('should handle no penalties', async () => { + const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' }; + + const mockStandings = [{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }]; + const mockRaces = [{ id: 'race-1' }]; + const mockResults = [{ position: 1 }]; + const mockRating = { rating: null, ratingChange: null }; + + standingRepository.findByLeagueId.mockResolvedValue(mockStandings); + raceRepository.findByLeagueId.mockResolvedValue(mockRaces); + penaltyRepository.findByRaceId.mockResolvedValue([]); + driverRatingPort.getRating.mockReturnValue(mockRating); + resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults); + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + const dto = result.unwrap(); + expect(dto.penalties.size).toBe(0); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts index 581385c1b..3d5538741 100644 --- a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCase.ts @@ -2,34 +2,18 @@ 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 { - ILeagueDriverSeasonStatsPresenter, - LeagueDriverSeasonStatsResultDTO, - LeagueDriverSeasonStatsViewModel, -} from '../presenters/ILeagueDriverSeasonStatsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; - -export interface DriverRatingPort { - getRating(driverId: string): { rating: number | null; ratingChange: number | null }; -} - -export interface GetLeagueDriverSeasonStatsUseCaseParams { - leagueId: string; -} +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'; /** * Use Case for retrieving league driver season statistics. - * Orchestrates domain logic and delegates presentation to the presenter. + * Orchestrates domain logic and returns the result. */ -export class GetLeagueDriverSeasonStatsUseCase - implements - UseCase< - GetLeagueDriverSeasonStatsUseCaseParams, - LeagueDriverSeasonStatsResultDTO, - LeagueDriverSeasonStatsViewModel, - ILeagueDriverSeasonStatsPresenter - > -{ +export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase> { constructor( private readonly standingRepository: IStandingRepository, private readonly resultRepository: IResultRepository, @@ -38,11 +22,7 @@ export class GetLeagueDriverSeasonStatsUseCase private readonly driverRatingPort: DriverRatingPort, ) {} - async execute( - params: GetLeagueDriverSeasonStatsUseCaseParams, - presenter: ILeagueDriverSeasonStatsPresenter, - ): Promise { - presenter.reset(); + async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise> { const { leagueId } = params; // Get standings and races for the league @@ -62,15 +42,15 @@ export class GetLeagueDriverSeasonStatsUseCase for (const p of penaltiesForLeague) { // Only count applied penalties if (p.status !== 'applied') continue; - + const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; - + // Convert penalty to points delta based on type if (p.type === 'points_deduction' && p.value) { // Points deductions are negative current.baseDelta -= p.value; } - + penaltiesByDriver.set(p.driverId, current); } @@ -104,6 +84,6 @@ export class GetLeagueDriverSeasonStatsUseCase driverRatings, }; - presenter.present(dto); + return Result.ok(dto); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts new file mode 100644 index 000000000..c875c6fc0 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueDriverSeasonStatsUseCaseParams.ts @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000..0bda1b56e --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GetLeagueFullConfigUseCase } from './GetLeagueFullConfigUseCase'; +import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; +import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository'; +import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; +import type { IGameRepository } from '../../domain/repositories/IGameRepository'; +import type { ILeagueFullConfigPresenter, LeagueConfigFormViewModel } from '../presenters/ILeagueFullConfigPresenter'; + +describe('GetLeagueFullConfigUseCase', () => { + let useCase: GetLeagueFullConfigUseCase; + let leagueRepository: ILeagueRepository; + let seasonRepository: ISeasonRepository; + let leagueScoringConfigRepository: ILeagueScoringConfigRepository; + let gameRepository: IGameRepository; + let presenter: ILeagueFullConfigPresenter; + + beforeEach(() => { + leagueRepository = { + findById: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + seasonRepository = { + findByLeagueId: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + leagueScoringConfigRepository = { + findBySeasonId: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + gameRepository = { + findById: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + presenter = { + reset: vi.fn(), + present: vi.fn(), + getViewModel: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + useCase = new GetLeagueFullConfigUseCase( + leagueRepository, + seasonRepository, + leagueScoringConfigRepository, + gameRepository, + presenter, + ); + }); + + it('should return league config when league exists', async () => { + const params = { leagueId: 'league-1' }; + + const mockLeague = { + id: 'league-1', + name: 'Test League', + description: 'A test league', + settings: { + maxDrivers: 32, + stewarding: { + decisionMode: 'admin_only', + requireDefense: false, + defenseTimeLimit: 48, + voteTimeLimit: 72, + protestDeadlineHours: 48, + stewardingClosesHours: 168, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }, + }, + }; + const mockSeasons = [{ id: 'season-1', status: 'active', gameId: 'game-1' }]; + const mockScoringConfig = { id: 'config-1' }; + const mockGame = { id: 'game-1' }; + const mockViewModel: LeagueConfigFormViewModel = { + leagueId: 'league-1', + basics: { + name: 'Test League', + description: 'A test league', + visibility: 'public', + gameId: 'iracing', + }, + structure: { + mode: 'solo', + maxDrivers: 32, + multiClassEnabled: false, + }, + championships: { + enableDriverChampionship: true, + enableTeamChampionship: false, + enableNationsChampionship: false, + enableTrophyChampionship: false, + }, + scoring: { + customScoringEnabled: false, + }, + dropPolicy: { + strategy: 'none', + }, + timings: { + practiceMinutes: 30, + qualifyingMinutes: 15, + mainRaceMinutes: 60, + sessionCount: 1, + roundsPlanned: 10, + }, + stewarding: { + decisionMode: 'admin_only', + requireDefense: false, + defenseTimeLimit: 48, + voteTimeLimit: 72, + protestDeadlineHours: 48, + stewardingClosesHours: 168, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }, + }; + + leagueRepository.findById.mockResolvedValue(mockLeague); + seasonRepository.findByLeagueId.mockResolvedValue(mockSeasons); + leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(mockScoringConfig); + gameRepository.findById.mockResolvedValue(mockGame); + presenter.getViewModel.mockReturnValue(mockViewModel); + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + const viewModel = result.unwrap(); + expect(viewModel).toEqual(mockViewModel); + expect(presenter.reset).toHaveBeenCalled(); + expect(presenter.present).toHaveBeenCalledWith({ + league: mockLeague, + activeSeason: mockSeasons[0], + scoringConfig: mockScoringConfig, + game: mockGame, + }); + }); + + it('should return error when league not found', async () => { + const params = { leagueId: 'league-1' }; + + leagueRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute(params); + + expect(result.isErr()).toBe(true); + const error = result.unwrapErr(); + expect(error.message).toBe('League with id league-1 not found'); + }); + + it('should handle no active season', async () => { + const params = { leagueId: 'league-1' }; + + const mockLeague = { + id: 'league-1', + name: 'Test League', + description: 'A test league', + settings: { maxDrivers: 32 }, + }; + const mockViewModel: LeagueConfigFormViewModel = { + leagueId: 'league-1', + basics: { name: 'Test League', description: 'A test league', visibility: 'public', gameId: 'iracing' }, + structure: { mode: 'solo', maxDrivers: 32, multiClassEnabled: false }, + championships: { enableDriverChampionship: true, enableTeamChampionship: false, enableNationsChampionship: false, enableTrophyChampionship: false }, + scoring: { customScoringEnabled: false }, + dropPolicy: { strategy: 'none' }, + timings: { practiceMinutes: 30, qualifyingMinutes: 15, mainRaceMinutes: 60, sessionCount: 1, roundsPlanned: 10 }, + stewarding: { decisionMode: 'admin_only', requireDefense: false, defenseTimeLimit: 48, voteTimeLimit: 72, protestDeadlineHours: 48, stewardingClosesHours: 168, notifyAccusedOnProtest: true, notifyOnVoteRequired: true }, + }; + + leagueRepository.findById.mockResolvedValue(mockLeague); + seasonRepository.findByLeagueId.mockResolvedValue([]); + presenter.getViewModel.mockReturnValue(mockViewModel); + + const result = await useCase.execute(params); + + expect(result.isOk()).toBe(true); + expect(presenter.present).toHaveBeenCalledWith({ + league: mockLeague, + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts index 62b1600df..b8ec466e3 100644 --- a/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueFullConfigUseCase.ts @@ -7,29 +7,29 @@ import type { LeagueFullConfigData, LeagueConfigFormViewModel, } from '../presenters/ILeagueFullConfigPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; -import { EntityNotFoundError } from '../errors/RacingApplicationError'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError'; /** * Use Case for retrieving a league's full configuration. * Orchestrates domain logic and delegates presentation to the presenter. */ -export class GetLeagueFullConfigUseCase - implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter> -{ +export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, Result> { constructor( private readonly leagueRepository: ILeagueRepository, private readonly seasonRepository: ISeasonRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly gameRepository: IGameRepository, + private readonly presenter: ILeagueFullConfigPresenter, ) {} - async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise { + async execute(params: { leagueId: string }): Promise> { const { leagueId } = params; const league = await this.leagueRepository.findById(leagueId); if (!league) { - throw new EntityNotFoundError({ entity: 'league', id: leagueId }); + return Result.err(new RacingDomainValidationError(`League with id ${leagueId} not found`)); } const seasons = await this.seasonRepository.findByLeagueId(leagueId); @@ -54,7 +54,13 @@ export class GetLeagueFullConfigUseCase ...(game ? { game } : {}), }; - presenter.reset(); - presenter.present(data); + this.presenter.reset(); + this.presenter.present(data); + const viewModel = this.presenter.getViewModel(); + if (!viewModel) { + return Result.err(new RacingDomainValidationError('Failed to present league config')); + } + + return Result.ok(viewModel); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts index 65f380317..9ec01ac08 100644 --- a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.test.ts @@ -1,23 +1,29 @@ -import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; -import { LeagueJoinRequestsPresenter } from '@apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter'; - -import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueJoinRequestsUseCase } from './GetLeagueJoinRequestsUseCase'; +import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { Driver } from '../../domain/entities/Driver'; describe('GetLeagueJoinRequestsUseCase', () => { let useCase: GetLeagueJoinRequestsUseCase; - let leagueMembershipRepository: jest.Mocked; - let driverRepository: jest.Mocked; - let presenter: LeagueJoinRequestsPresenter; + let leagueMembershipRepository: { + getJoinRequests: Mock; + }; + let driverRepository: { + findById: Mock; + }; beforeEach(() => { leagueMembershipRepository = { - getJoinRequests: jest.fn(), - } as unknown; + getJoinRequests: vi.fn(), + }; driverRepository = { - findByIds: jest.fn(), - } as unknown; - presenter = new LeagueJoinRequestsPresenter(); - useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository); + findById: vi.fn(), + }; + useCase = new GetLeagueJoinRequestsUseCase( + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + driverRepository as unknown as IDriverRepository, + ); }); it('should return join requests with drivers', async () => { @@ -25,22 +31,30 @@ describe('GetLeagueJoinRequestsUseCase', () => { const joinRequests = [ { id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }, ]; - const drivers = [{ id: 'driver-1', name: 'Driver 1' }]; + const driver = Driver.create({ + id: 'driver-1', + iracingId: '123', + name: 'Driver 1', + country: 'US', + }); leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); - driverRepository.findByIds.mockResolvedValue(drivers); + driverRepository.findById.mockResolvedValue(driver); - await useCase.execute({ leagueId }, presenter); + const result = await useCase.execute({ leagueId }); - expect(presenter.viewModel.joinRequests).toEqual([ - { - id: 'req-1', - leagueId, - driverId: 'driver-1', - requestedAt: expect.any(Date), - message: 'msg', - driver: { id: 'driver-1', name: 'Driver 1' }, - }, - ]); + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + joinRequests: [ + { + id: 'req-1', + leagueId, + driverId: 'driver-1', + requestedAt: expect.any(Date), + message: 'msg', + driver: { id: 'driver-1', name: 'Driver 1' }, + }, + ], + }); }); }); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts index e21d613bc..785c56faa 100644 --- a/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueJoinRequestsUseCase.ts @@ -1,33 +1,27 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '../presenters/IGetLeagueJoinRequestsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import type { GetLeagueJoinRequestsUseCaseParams } from '../dto/GetLeagueJoinRequestsUseCaseParams'; +import type { GetLeagueJoinRequestsResultDTO } from '../dto/GetLeagueJoinRequestsResultDTO'; -export interface GetLeagueJoinRequestsUseCaseParams { - leagueId: string; -} - -export interface GetLeagueJoinRequestsResultDTO { - joinRequests: unknown[]; - drivers: { id: string; name: string }[]; -} - -export class GetLeagueJoinRequestsUseCase implements UseCase { +export class GetLeagueJoinRequestsUseCase implements AsyncUseCase> { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(params: GetLeagueJoinRequestsUseCaseParams, presenter: IGetLeagueJoinRequestsPresenter): Promise { + async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise> { const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); - const driverIds = joinRequests.map(r => r.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: GetLeagueJoinRequestsResultDTO = { - joinRequests, - drivers: Array.from(driverMap.values()), - }; - presenter.reset(); - presenter.present(dto); + 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)!, + })); + return Result.ok({ + joinRequests: enrichedJoinRequests, + }); } } \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts new file mode 100644 index 000000000..e74bf1b34 --- /dev/null +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach, vi, Mock } from 'vitest'; +import { GetLeagueMembershipsUseCase } from './GetLeagueMembershipsUseCase'; +import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; +import { IDriverRepository } from '../../domain/repositories/IDriverRepository'; +import { LeagueMembership } from '../../domain/entities/LeagueMembership'; +import { Driver } from '../../domain/entities/Driver'; + +describe('GetLeagueMembershipsUseCase', () => { + let useCase: GetLeagueMembershipsUseCase; + let leagueMembershipRepository: { + getLeagueMembers: Mock; + }; + let driverRepository: { + findById: Mock; + }; + + beforeEach(() => { + leagueMembershipRepository = { + getLeagueMembers: vi.fn(), + }; + driverRepository = { + findById: vi.fn(), + }; + useCase = new GetLeagueMembershipsUseCase( + leagueMembershipRepository as unknown as ILeagueMembershipRepository, + driverRepository as unknown as IDriverRepository, + ); + }); + + it('should return league memberships with drivers', async () => { + const leagueId = 'league-1'; + const memberships = [ + LeagueMembership.create({ + id: 'membership-1', + leagueId, + driverId: 'driver-1', + role: 'member', + joinedAt: new Date(), + }), + LeagueMembership.create({ + id: 'membership-2', + leagueId, + driverId: 'driver-2', + role: 'admin', + 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', + }); + + leagueMembershipRepository.getLeagueMembers.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); + }); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + memberships, + drivers: [ + { id: 'driver-1', name: 'Driver 1' }, + { id: 'driver-2', name: 'Driver 2' }, + ], + }); + }); + + it('should handle drivers not found', async () => { + const leagueId = 'league-1'; + const memberships = [ + LeagueMembership.create({ + id: 'membership-1', + leagueId, + driverId: 'driver-1', + role: 'member', + joinedAt: new Date(), + }), + ]; + + leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships); + driverRepository.findById.mockResolvedValue(null); + + const result = await useCase.execute({ leagueId }); + + expect(result.isOk()).toBe(true); + expect(result.unwrap()).toEqual({ + memberships, + drivers: [], + }); + }); +}); \ No newline at end of file diff --git a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts index 7a538e083..8f7b6978e 100644 --- a/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts +++ b/core/racing/application/use-cases/GetLeagueMembershipsUseCase.ts @@ -1,25 +1,20 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; -import type { LeagueMembership } from '../../domain/entities/LeagueMembership'; -import type { IGetLeagueMembershipsPresenter, GetLeagueMembershipsViewModel } from '../presenters/IGetLeagueMembershipsPresenter'; -import type { UseCase } from '@core/shared/application/UseCase'; +import type { AsyncUseCase } from '@core/shared/application'; +import { Result } from '@core/shared/result/Result'; +import type { GetLeagueMembershipsResultDTO } from '../dto/GetLeagueMembershipsResultDTO'; export interface GetLeagueMembershipsUseCaseParams { leagueId: string; } -export interface GetLeagueMembershipsResultDTO { - memberships: LeagueMembership[]; - drivers: { id: string; name: string }[]; -} - -export class GetLeagueMembershipsUseCase implements UseCase { +export class GetLeagueMembershipsUseCase implements AsyncUseCase> { constructor( private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly driverRepository: IDriverRepository, ) {} - async execute(params: GetLeagueMembershipsUseCaseParams, presenter: IGetLeagueMembershipsPresenter): Promise { + async execute(params: GetLeagueMembershipsUseCaseParams): Promise> { const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const drivers: { id: string; name: string }[] = []; @@ -35,7 +30,6 @@ export class GetLeagueMembershipsUseCase implements UseCase { diff --git a/core/racing/domain/value-objects/Money.ts b/core/racing/domain/value-objects/Money.ts index 18140f53e..e322d22d9 100644 --- a/core/racing/domain/value-objects/Money.ts +++ b/core/racing/domain/value-objects/Money.ts @@ -110,6 +110,6 @@ export class Money implements IValueObject { minimumFractionDigits: 2, maximumFractionDigits: 2, }); - return formatter.format(this.amount); + return formatter.format(this.amount / 100); } } \ No newline at end of file diff --git a/core/shared/application/AsyncUseCase.ts b/core/shared/application/AsyncUseCase.ts index 1345b5a28..2d7824ba6 100644 --- a/core/shared/application/AsyncUseCase.ts +++ b/core/shared/application/AsyncUseCase.ts @@ -1,3 +1,30 @@ +/** + * 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, + * to handle business rejections explicitly. Use cases do not throw errors; they use error codes in Result. + * + * Example: + * ```typescript + * export type YourUseCaseError = + * | 'SPONSOR_NOT_FOUND' + * | 'PRICING_NOT_CONFIGURED' + * | 'APPLICATIONS_CLOSED' + * | 'NO_SLOTS_AVAILABLE' + * | 'DUPLICATE_PENDING_REQUEST' + * | 'OFFER_BELOW_MINIMUM'; + * + * 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 + */ export interface AsyncUseCase { 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 a453fc41f..ce397b1fc 100644 --- a/core/shared/application/UseCase.ts +++ b/core/shared/application/UseCase.ts @@ -1,5 +1,34 @@ import type { Presenter } from '../presentation'; +/** + * Use Case interface for commands. + * + * Use cases represent application-level business logic. They coordinate domain objects and repositories, + * 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. + * Use cases do not throw errors; they use error codes in Result. + * + * Example: + * ```typescript + * export type CreateRaceError = + * | 'INSUFFICIENT_PERMISSIONS' + * | 'RACE_ALREADY_EXISTS' + * | 'INVALID_RACE_CONFIG'; + * + * export class CreateRaceUseCase implements UseCase, ViewModel, Presenter, 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 ViewModel - The view model type + * @template P - The presenter type, extending Presenter + */ export interface UseCase> { execute(input: Input, presenter: P): Promise | void; } \ No newline at end of file diff --git a/core/shared/errors/ApplicationError.ts b/core/shared/errors/ApplicationError.ts index c646b5f51..6a6cdcc54 100644 --- a/core/shared/errors/ApplicationError.ts +++ b/core/shared/errors/ApplicationError.ts @@ -1,3 +1,6 @@ +/** + * @deprecated Use error codes in Result instead of throwing ApplicationError. + */ export type CommonApplicationErrorKind = | 'not_found' | 'forbidden'