refactor
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
export interface AcceptSponsorshipRequestDTO {
|
||||||
|
requestId: string;
|
||||||
|
respondedBy: string; // driverId of the person accepting
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface AcceptSponsorshipRequestResultDTO {
|
||||||
|
requestId: string;
|
||||||
|
sponsorshipId: string;
|
||||||
|
status: 'accepted';
|
||||||
|
acceptedAt: Date;
|
||||||
|
platformFee: number;
|
||||||
|
netAmount: number;
|
||||||
|
}
|
||||||
13
core/racing/application/dto/ApplyForSponsorshipDTO.ts
Normal file
13
core/racing/application/dto/ApplyForSponsorshipDTO.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface ApplyForSponsorshipResultDTO {
|
||||||
|
requestId: string;
|
||||||
|
status: 'pending';
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ApproveLeagueJoinRequestResultDTO {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
3
core/racing/application/dto/CancelRaceCommandDTO.ts
Normal file
3
core/racing/application/dto/CancelRaceCommandDTO.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface CancelRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
3
core/racing/application/dto/CompleteRaceCommandDTO.ts
Normal file
3
core/racing/application/dto/CompleteRaceCommandDTO.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface CompleteRaceCommandDTO {
|
||||||
|
raceId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CreateLeagueWithSeasonAndScoringResultDTO {
|
||||||
|
leagueId: string;
|
||||||
|
seasonId: string;
|
||||||
|
scoringPresetId?: string;
|
||||||
|
scoringPresetName?: string;
|
||||||
|
}
|
||||||
10
core/racing/application/dto/CreateSponsorResultDTO.ts
Normal file
10
core/racing/application/dto/CreateSponsorResultDTO.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export interface CreateSponsorResultDTO {
|
||||||
|
sponsor: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
contactEmail: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
||||||
|
|
||||||
|
export interface GetEntitySponsorshipPricingDTO {
|
||||||
|
entityType: SponsorableEntityType;
|
||||||
|
entityId: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface GetLeagueAdminPermissionsResultDTO {
|
||||||
|
canRemoveMember: boolean;
|
||||||
|
canUpdateRoles: boolean;
|
||||||
|
}
|
||||||
7
core/racing/application/dto/GetLeagueAdminResultDTO.ts
Normal file
7
core/racing/application/dto/GetLeagueAdminResultDTO.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export interface GetLeagueAdminResultDTO {
|
||||||
|
league: {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
};
|
||||||
|
// Additional data would be populated by combining multiple use cases
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface GetLeagueJoinRequestsResultDTO {
|
||||||
|
joinRequests: Array<{
|
||||||
|
id: string;
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
requestedAt: Date;
|
||||||
|
message?: string;
|
||||||
|
driver: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface GetLeagueJoinRequestsUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { LeagueMembership } from '../../domain/entities/LeagueMembership';
|
||||||
|
|
||||||
|
export interface GetLeagueMembershipsResultDTO {
|
||||||
|
memberships: LeagueMembership[];
|
||||||
|
drivers: { id: string; name: string }[];
|
||||||
|
}
|
||||||
13
core/racing/application/dto/SponsorshipSlotDTO.ts
Normal file
13
core/racing/application/dto/SponsorshipSlotDTO.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
import type { Team } from '../../domain/entities/Team';
|
import type { Team } from '../../domain/entities/Team';
|
||||||
import type {
|
import type { TeamMembership } from '../../domain/types/TeamMembership';
|
||||||
TeamJoinRequest,
|
|
||||||
TeamMembership,
|
|
||||||
} from '../../domain/types/TeamMembership';
|
|
||||||
|
|
||||||
export interface JoinTeamCommandDTO {
|
export interface JoinTeamCommandDTO {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -15,6 +12,7 @@ export interface LeaveTeamCommandDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApproveTeamJoinRequestCommandDTO {
|
export interface ApproveTeamJoinRequestCommandDTO {
|
||||||
|
teamId: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Presenter } from '@core/shared/presentation';
|
import type { Presenter } from '@core/shared/presentation';
|
||||||
|
import type { FeedItemType } from '@core/social/domain/types/FeedItemType';
|
||||||
|
|
||||||
export interface DashboardDriverSummaryViewModel {
|
export interface DashboardDriverSummaryViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,7 +45,7 @@ export interface DashboardLeagueStandingSummaryViewModel {
|
|||||||
|
|
||||||
export interface DashboardFeedItemSummaryViewModel {
|
export interface DashboardFeedItemSummaryViewModel {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: FeedItemType;
|
||||||
headline: string;
|
headline: string;
|
||||||
body?: string;
|
body?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
|||||||
respondedBy: 'driver1',
|
respondedBy: 'driver1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result.isOk()).toBe(true);
|
||||||
|
const dto = result.unwrap();
|
||||||
|
expect(dto).toBeDefined();
|
||||||
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
|
||||||
recipientId: 'sponsor1',
|
recipientId: 'sponsor1',
|
||||||
type: 'sponsorship_request_accepted',
|
type: 'sponsorship_request_accepted',
|
||||||
@@ -149,7 +151,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
|
|||||||
urgency: 'toast',
|
urgency: 'toast',
|
||||||
data: {
|
data: {
|
||||||
requestId: 'req1',
|
requestId: 'req1',
|
||||||
sponsorshipId: expect.any(String),
|
sponsorshipId: dto.sponsorshipId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(
|
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Use Case: AcceptSponsorshipRequestUseCase
|
* Use Case: AcceptSponsorshipRequestUseCase
|
||||||
*
|
*
|
||||||
* Allows an entity owner to accept a sponsorship request.
|
* Allows an entity owner to accept a sponsorship request.
|
||||||
* This creates an active sponsorship and notifies the sponsor.
|
* 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 type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
|
||||||
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
|
||||||
import type { AsyncUseCase } from '@core/shared/application';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
|
import { Result } from '@core/shared/result/Result';
|
||||||
export interface AcceptSponsorshipRequestDTO {
|
import {
|
||||||
requestId: string;
|
RacingDomainValidationError,
|
||||||
respondedBy: string; // driverId of the person accepting
|
RacingDomainInvariantError,
|
||||||
}
|
} from '../../domain/errors/RacingDomainError';
|
||||||
|
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
|
||||||
export interface AcceptSponsorshipRequestResultDTO {
|
import type { AcceptSponsorshipRequestResultDTO } from '../dto/AcceptSponsorshipRequestResultDTO';
|
||||||
requestId: string;
|
|
||||||
sponsorshipId: string;
|
|
||||||
status: 'accepted';
|
|
||||||
acceptedAt: Date;
|
|
||||||
platformFee: number;
|
|
||||||
netAmount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AcceptSponsorshipRequestUseCase
|
export class AcceptSponsorshipRequestUseCase
|
||||||
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> {
|
implements AsyncUseCase<AcceptSponsorshipRequestDTO, Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||||
@@ -43,118 +36,113 @@ export class AcceptSponsorshipRequestUseCase
|
|||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> {
|
async execute(dto: AcceptSponsorshipRequestDTO): Promise<Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
|
||||||
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
|
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()) {
|
// Find the request
|
||||||
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status });
|
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
|
||||||
throw new Error(`Cannot accept a ${request.status} sponsorship request`);
|
if (!request) {
|
||||||
}
|
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
|
||||||
|
return Result.err(new RacingDomainValidationError('Sponsorship request not found'));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,41 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Use Case: ApplyForSponsorshipUseCase
|
* Use Case: ApplyForSponsorshipUseCase
|
||||||
*
|
*
|
||||||
* Allows a sponsor to apply for a sponsorship slot on any entity
|
* Allows a sponsor to apply for a sponsorship slot on any entity
|
||||||
* (driver, team, race, or season/league).
|
* (driver, team, race, or season/league).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
|
||||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
|
||||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||||
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
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 type { AsyncUseCase } from '@core/shared/application';
|
||||||
import {
|
import { Result } from '@core/shared/result/Result';
|
||||||
EntityNotFoundError,
|
|
||||||
BusinessRuleViolationError,
|
|
||||||
} from '../errors/RacingApplicationError';
|
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
export interface ApplyForSponsorshipDTO {
|
import type { ApplyForSponsorshipDTO } from '../dto/ApplyForSponsorshipDTO';
|
||||||
sponsorId: string;
|
import type { ApplyForSponsorshipResultDTO } from '../dto/ApplyForSponsorshipResultDTO';
|
||||||
entityType: SponsorableEntityType;
|
|
||||||
entityId: string;
|
|
||||||
tier: SponsorshipTier;
|
|
||||||
offeredAmount: number; // in cents
|
|
||||||
currency?: Currency;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApplyForSponsorshipResultDTO {
|
|
||||||
requestId: string;
|
|
||||||
status: 'pending';
|
|
||||||
createdAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApplyForSponsorshipUseCase
|
export class ApplyForSponsorshipUseCase
|
||||||
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO>
|
implements AsyncUseCase<ApplyForSponsorshipDTO, Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||||
@@ -44,37 +27,33 @@ export class ApplyForSponsorshipUseCase
|
|||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> {
|
async execute(dto: ApplyForSponsorshipDTO): Promise<Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>> {
|
||||||
this.logger.debug('Attempting to apply for sponsorship', { dto });
|
this.logger.debug('Attempting to apply for sponsorship', { dto });
|
||||||
|
|
||||||
// Validate sponsor exists
|
// Validate sponsor exists
|
||||||
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
|
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
|
||||||
if (!sponsor) {
|
if (!sponsor) {
|
||||||
this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId });
|
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
|
// Check if entity accepts sponsorship applications
|
||||||
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
||||||
if (!pricing) {
|
if (!pricing) {
|
||||||
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
|
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: 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) {
|
if (!pricing.acceptingApplications) {
|
||||||
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
|
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
|
||||||
throw new BusinessRuleViolationError(
|
return Result.err(new RacingDomainValidationError('This entity is not currently accepting sponsorship applications'));
|
||||||
'This entity is not currently accepting sponsorship applications',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the requested tier slot is available
|
// Check if the requested tier slot is available
|
||||||
const slotAvailable = pricing.isSlotAvailable(dto.tier);
|
const slotAvailable = pricing.isSlotAvailable(dto.tier);
|
||||||
if (!slotAvailable) {
|
if (!slotAvailable) {
|
||||||
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
|
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
|
||||||
throw new BusinessRuleViolationError(
|
return Result.err(new RacingDomainValidationError(`No ${dto.tier} sponsorship slots are available`));
|
||||||
`No ${dto.tier} sponsorship slots are available`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if sponsor already has a pending request for this entity
|
// Check if sponsor already has a pending request for this entity
|
||||||
@@ -85,18 +64,14 @@ export class ApplyForSponsorshipUseCase
|
|||||||
);
|
);
|
||||||
if (hasPending) {
|
if (hasPending) {
|
||||||
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
|
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
|
||||||
throw new BusinessRuleViolationError(
|
return Result.err(new RacingDomainValidationError('You already have a pending sponsorship request for this entity'));
|
||||||
'You already have a pending sponsorship request for this entity',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate offered amount meets minimum price
|
// Validate offered amount meets minimum price
|
||||||
const minPrice = pricing.getPrice(dto.tier);
|
const minPrice = pricing.getPrice(dto.tier);
|
||||||
if (minPrice && dto.offeredAmount < minPrice.amount) {
|
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}`);
|
this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`);
|
||||||
throw new BusinessRuleViolationError(
|
return Result.err(new RacingDomainValidationError(`Offered amount must be at least ${minPrice.format()}`));
|
||||||
`Offered amount must be at least ${minPrice.format()}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the sponsorship request
|
// Create the sponsorship request
|
||||||
@@ -115,10 +90,10 @@ export class ApplyForSponsorshipUseCase
|
|||||||
|
|
||||||
await this.sponsorshipRequestRepo.create(request);
|
await this.sponsorshipRequestRepo.create(request);
|
||||||
|
|
||||||
return {
|
return Result.ok({
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: request.createdAt,
|
createdAt: request.createdAt,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
core/racing/application/use-cases/ApplyPenaltyCommand.ts
Normal file
12
core/racing/application/use-cases/ApplyPenaltyCommand.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
231
core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts
Normal file
231
core/racing/application/use-cases/ApplyPenaltyUseCase.test.ts
Normal file
@@ -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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Application Use Case: ApplyPenaltyUseCase
|
* Application Use Case: ApplyPenaltyUseCase
|
||||||
*
|
*
|
||||||
* Allows a steward to apply a penalty to a driver for an incident during a race.
|
* 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.
|
* 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 { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||||
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { AsyncUseCase } from '@core/shared/application';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
|
import { Result } from '@core/shared/result/Result';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
export interface ApplyPenaltyCommand {
|
import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand';
|
||||||
raceId: string;
|
|
||||||
driverId: string;
|
|
||||||
stewardId: string;
|
|
||||||
type: PenaltyType;
|
|
||||||
value?: number;
|
|
||||||
reason: string;
|
|
||||||
protestId?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApplyPenaltyUseCase
|
export class ApplyPenaltyUseCase
|
||||||
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> {
|
implements AsyncUseCase<ApplyPenaltyCommand, Result<{ penaltyId: string }, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly penaltyRepository: IPenaltyRepository,
|
private readonly penaltyRepository: IPenaltyRepository,
|
||||||
private readonly protestRepository: IProtestRepository,
|
private readonly protestRepository: IProtestRepository,
|
||||||
@@ -35,70 +27,66 @@ export class ApplyPenaltyUseCase
|
|||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> {
|
async execute(command: ApplyPenaltyCommand): Promise<Result<{ penaltyId: string }, RacingDomainValidationError>> {
|
||||||
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
|
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)
|
// Validate race exists
|
||||||
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
|
const race = await this.raceRepository.findById(command.raceId);
|
||||||
const stewardMembership = memberships.find(
|
if (!race) {
|
||||||
m => m.driverId === command.stewardId && m.status === 'active'
|
this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
|
||||||
);
|
return Result.err(new RacingDomainValidationError('Race not found'));
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,23 @@
|
|||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import type { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '../presenters/IApproveLeagueJoinRequestPresenter';
|
import { Result } from '@core/shared/result/Result';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
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 {
|
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
|
||||||
leagueId: string;
|
|
||||||
requestId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApproveLeagueJoinRequestResultDTO {
|
|
||||||
success: boolean;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ApproveLeagueJoinRequestUseCase implements UseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel, IApproveLeagueJoinRequestPresenter> {
|
|
||||||
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
|
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
|
||||||
|
|
||||||
async execute(params: ApproveLeagueJoinRequestUseCaseParams, presenter: IApproveLeagueJoinRequestPresenter): Promise<void> {
|
async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise<Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
|
||||||
const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
|
const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
|
||||||
const request = requests.find(r => r.id === params.requestId);
|
const request = requests.find(r => r.id === params.requestId);
|
||||||
if (!request) {
|
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.removeJoinRequest(params.requestId);
|
||||||
await this.leagueMembershipRepository.saveMembership({
|
await this.leagueMembershipRepository.saveMembership({
|
||||||
|
id: randomUUID(),
|
||||||
leagueId: params.leagueId,
|
leagueId: params.leagueId,
|
||||||
driverId: request.driverId,
|
driverId: request.driverId,
|
||||||
role: 'member',
|
role: 'member',
|
||||||
@@ -30,7 +25,6 @@ export class ApproveLeagueJoinRequestUseCase implements UseCase<ApproveLeagueJoi
|
|||||||
joinedAt: new Date(),
|
joinedAt: new Date(),
|
||||||
});
|
});
|
||||||
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
|
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
|
||||||
presenter.reset();
|
return Result.ok(dto);
|
||||||
presenter.present(dto);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ApproveLeagueJoinRequestUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
requestId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||||
|
import { ApproveTeamJoinRequestUseCase } from './ApproveTeamJoinRequestUseCase';
|
||||||
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
|
|
||||||
|
describe('ApproveTeamJoinRequestUseCase', () => {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Logger } from '@core/shared/application';
|
|
||||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
import type {
|
import type {
|
||||||
TeamMembership,
|
TeamMembership,
|
||||||
@@ -8,36 +7,24 @@ import type {
|
|||||||
} from '../../domain/types/TeamMembership';
|
} from '../../domain/types/TeamMembership';
|
||||||
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
|
||||||
import type { AsyncUseCase } 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 ApproveTeamJoinRequestUseCase
|
export class ApproveTeamJoinRequestUseCase
|
||||||
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> {
|
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, Result<void, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly membershipRepository: ITeamMembershipRepository,
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> {
|
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<Result<void, RacingDomainValidationError>> {
|
||||||
const { requestId } = command;
|
const { teamId, requestId } = command;
|
||||||
this.logger.debug(
|
|
||||||
`Attempting to approve team join request with ID: ${requestId}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// There is no repository method to look up a single request by ID,
|
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId);
|
||||||
try {
|
const request = allRequests.find((r) => r.id === requestId);
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
this.logger.warn(`Team join request with ID ${requestId} not found`);
|
return Result.err(new RacingDomainValidationError('Join request not found'));
|
||||||
throw new Error('Join request not found');
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const membership: TeamMembership = {
|
const membership: TeamMembership = {
|
||||||
teamId: request.teamId,
|
teamId: request.teamId,
|
||||||
@@ -48,14 +35,7 @@ export class ApproveTeamJoinRequestUseCase
|
|||||||
};
|
};
|
||||||
|
|
||||||
await this.membershipRepository.saveMembership(membership);
|
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);
|
await this.membershipRepository.removeJoinRequest(requestId);
|
||||||
this.logger.info(`Team join request with ID ${requestId} removed`);
|
return Result.ok(undefined);
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to approve team join request ${requestId}`, error instanceof Error ? error : new Error(String(error)));
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
core/racing/application/use-cases/CancelRaceUseCase.test.ts
Normal file
113
core/racing/application/use-cases/CancelRaceUseCase.test.ts
Normal file
@@ -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`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,28 +1,27 @@
|
|||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type { AsyncUseCase } from '@core/shared/application';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
import { Result } from '@core/shared/result/Result';
|
||||||
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../../domain/errors/RacingDomainError';
|
||||||
|
import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case: CancelRaceUseCase
|
* Use Case: CancelRaceUseCase
|
||||||
*
|
*
|
||||||
* Encapsulates the workflow for cancelling a race:
|
* Encapsulates the workflow for cancelling a race:
|
||||||
* - loads the race by id
|
* - 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
|
* - delegates cancellation rules to the Race domain entity
|
||||||
* - persists the updated race via the repository.
|
* - persists the updated race via the repository.
|
||||||
*/
|
*/
|
||||||
export interface CancelRaceCommandDTO {
|
|
||||||
raceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CancelRaceUseCase
|
export class CancelRaceUseCase
|
||||||
implements AsyncUseCase<CancelRaceCommandDTO, void> {
|
implements AsyncUseCase<CancelRaceCommandDTO, Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CancelRaceCommandDTO): Promise<void> {
|
async execute(command: CancelRaceCommandDTO): Promise<Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
|
||||||
const { raceId } = command;
|
const { raceId } = command;
|
||||||
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
|
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
|
||||||
|
|
||||||
@@ -30,14 +29,19 @@ export class CancelRaceUseCase
|
|||||||
const race = await this.raceRepository.findById(raceId);
|
const race = await this.raceRepository.findById(raceId);
|
||||||
if (!race) {
|
if (!race) {
|
||||||
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
|
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();
|
const cancelledRace = race.cancel();
|
||||||
await this.raceRepository.update(cancelledRace);
|
await this.raceRepository.update(cancelledRace);
|
||||||
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
|
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
|
||||||
|
return Result.ok(undefined);
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Logger } from '@core/shared/application';
|
||||||
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
|
||||||
import type { IDomainEventPublisher } from '@core/shared/domain';
|
import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent';
|
||||||
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
|
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
|
* 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)
|
* This would typically be run by a scheduled job (e.g., every 5 minutes)
|
||||||
* to automatically close stewarding windows based on league configuration.
|
* to automatically close stewarding windows based on league configuration.
|
||||||
*/
|
*/
|
||||||
export interface CloseRaceEventStewardingCommand {
|
|
||||||
// No parameters needed - finds all expired events automatically
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CloseRaceEventStewardingUseCase
|
export class CloseRaceEventStewardingUseCase
|
||||||
implements UseCase<CloseRaceEventStewardingCommand, void, void, void>
|
implements AsyncUseCase<CloseRaceEventStewardingCommand, Result<void, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
@@ -27,26 +27,34 @@ export class CloseRaceEventStewardingUseCase
|
|||||||
private readonly domainEventPublisher: IDomainEventPublisher,
|
private readonly domainEventPublisher: IDomainEventPublisher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CloseRaceEventStewardingCommand): Promise<void> {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
// Find all race events awaiting stewarding that have expired windows
|
async execute(_command: CloseRaceEventStewardingCommand): Promise<Result<void, RacingDomainValidationError>> {
|
||||||
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
|
try {
|
||||||
|
// Find all race events awaiting stewarding that have expired windows
|
||||||
|
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
|
||||||
|
|
||||||
for (const raceEvent of expiredEvents) {
|
for (const raceEvent of expiredEvents) {
|
||||||
await this.closeStewardingForRaceEvent(raceEvent);
|
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<void> {
|
private async closeStewardingForRaceEvent(raceEvent: RaceEvent): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Close the stewarding window
|
// Close the stewarding window
|
||||||
const closedRaceEvent = raceEvent.closeStewarding();
|
const closedRaceEvent = raceEvent.closeStewarding();
|
||||||
await this.raceEventRepository.update(closedRaceEvent);
|
await this.raceEventRepository.update(closedRaceEvent);
|
||||||
|
|
||||||
// Get list of participating drivers (would need to be implemented)
|
// 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
|
// 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
|
// Publish domain event to trigger final results notifications
|
||||||
const event = new RaceEventStewardingClosedEvent({
|
const event = new RaceEventStewardingClosedEvent({
|
||||||
@@ -62,28 +70,19 @@ export class CloseRaceEventStewardingUseCase
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(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<string[]> {
|
private async getParticipatingDriverIds(): Promise<string[]> {
|
||||||
// In a real implementation, this would query race registrations
|
// TODO: Implement query for participating driver IDs from race event registrations
|
||||||
// For the prototype, we'll return a mock list
|
// This would typically involve querying race registrations for the event
|
||||||
// This would typically involve:
|
return [];
|
||||||
// 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 checkForAppliedPenalties(raceEvent: any): Promise<boolean> {
|
private async checkForAppliedPenalties(): Promise<boolean> {
|
||||||
// In a real implementation, this would check if any penalties were issued
|
// TODO: Implement check for applied penalties during stewarding window
|
||||||
// during the stewarding window for this race event
|
|
||||||
// This would query the penalty repository for penalties related to this race event
|
// This would query the penalty repository for penalties related to this race event
|
||||||
|
return false;
|
||||||
// Mock implementation for prototype - randomly simulate penalties
|
|
||||||
return Math.random() > 0.7; // 30% chance of penalties being applied
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface CompleteDriverOnboardingCommand {
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
displayName: string;
|
||||||
|
country: string;
|
||||||
|
timezone?: string;
|
||||||
|
bio?: string;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,60 +1,40 @@
|
|||||||
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
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';
|
import { Driver } from '../../domain/entities/Driver';
|
||||||
|
import { Result } from '@core/shared/result/Result';
|
||||||
export interface CompleteDriverOnboardingInput {
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
userId: string;
|
import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
displayName: string;
|
|
||||||
country: string;
|
|
||||||
timezone?: string;
|
|
||||||
bio?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for completing driver onboarding.
|
* Use Case for completing driver onboarding.
|
||||||
*/
|
*/
|
||||||
export class CompleteDriverOnboardingUseCase
|
export class CompleteDriverOnboardingUseCase
|
||||||
implements UseCase<CompleteDriverOnboardingInput, CompleteDriverOnboardingResultDTO, any, ICompleteDriverOnboardingPresenter>
|
implements AsyncUseCase<CompleteDriverOnboardingCommand, Result<{ driverId: string }, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(private readonly driverRepository: IDriverRepository) {}
|
constructor(private readonly driverRepository: IDriverRepository) {}
|
||||||
|
|
||||||
async execute(input: CompleteDriverOnboardingInput, presenter: ICompleteDriverOnboardingPresenter): Promise<void> {
|
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<{ driverId: string }, RacingDomainValidationError>> {
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if driver already exists
|
// Check if driver already exists
|
||||||
const existing = await this.driverRepository.findById(input.userId);
|
const existing = await this.driverRepository.findById(command.userId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
presenter.present({
|
return Result.err(new RacingDomainValidationError('Driver already exists'));
|
||||||
success: false,
|
|
||||||
errorMessage: 'Driver already exists',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new driver
|
// Create new driver
|
||||||
const driver = Driver.create({
|
const driver = Driver.create({
|
||||||
id: input.userId,
|
id: command.userId,
|
||||||
iracingId: input.userId, // Assuming userId is iracingId for now
|
iracingId: command.userId, // Assuming userId is iracingId for now
|
||||||
name: input.displayName,
|
name: command.displayName,
|
||||||
country: input.country,
|
country: command.country,
|
||||||
bio: input.bio,
|
...(command.bio !== undefined ? { bio: command.bio } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.driverRepository.save(driver);
|
await this.driverRepository.create(driver);
|
||||||
|
|
||||||
presenter.present({
|
return Result.ok({ driverId: driver.id });
|
||||||
success: true,
|
|
||||||
driverId: driver.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
presenter.present({
|
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
|
||||||
success: false,
|
|
||||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
147
core/racing/application/use-cases/CompleteRaceUseCase.test.ts
Normal file
147
core/racing/application/use-cases/CompleteRaceUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,79 +6,67 @@ import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
|
|||||||
import { Result } from '../../domain/entities/Result';
|
import { Result } from '../../domain/entities/Result';
|
||||||
import { Standing } from '../../domain/entities/Standing';
|
import { Standing } from '../../domain/entities/Standing';
|
||||||
import type { AsyncUseCase } from '@core/shared/application';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
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
|
* Use Case: CompleteRaceUseCase
|
||||||
*
|
*
|
||||||
* Encapsulates the workflow for completing a race:
|
* Encapsulates the workflow for completing a race:
|
||||||
* - loads the race by id
|
* - 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
|
* - delegates completion rules to the Race domain entity
|
||||||
* - automatically generates realistic results for registered drivers
|
* - automatically generates realistic results for registered drivers
|
||||||
* - updates league standings
|
* - updates league standings
|
||||||
* - persists all changes via repositories.
|
* - persists all changes via repositories.
|
||||||
*/
|
*/
|
||||||
export interface CompleteRaceCommandDTO {
|
|
||||||
raceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CompleteRaceUseCase
|
export class CompleteRaceUseCase
|
||||||
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<{}, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||||
private readonly resultRepository: IResultRepository,
|
private readonly resultRepository: IResultRepository,
|
||||||
private readonly standingRepository: IStandingRepository,
|
private readonly standingRepository: IStandingRepository,
|
||||||
private readonly driverRatingProvider: DriverRatingProvider,
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, RacingDomainValidationError>> {
|
||||||
this.logger.debug(`Executing CompleteRaceUseCase for raceId: ${command.raceId}`);
|
|
||||||
const { raceId } = command;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { raceId } = command;
|
||||||
|
|
||||||
const race = await this.raceRepository.findById(raceId);
|
const race = await this.raceRepository.findById(raceId);
|
||||||
if (!race) {
|
if (!race) {
|
||||||
this.logger.error(`Race with id ${raceId} not found.`);
|
return SharedResult.err(new RacingDomainValidationError('Race not found'));
|
||||||
throw new Error('Race not found');
|
|
||||||
}
|
}
|
||||||
this.logger.debug(`Race ${raceId} found. Status: ${race.status}`);
|
|
||||||
|
|
||||||
// Get registered drivers for this race
|
// Get registered drivers for this race
|
||||||
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||||
if (registeredDriverIds.length === 0) {
|
if (registeredDriverIds.length === 0) {
|
||||||
this.logger.warn(`No registered drivers found for race ${raceId}.`);
|
return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
|
||||||
throw new Error('Cannot complete race with no registered drivers');
|
|
||||||
}
|
}
|
||||||
this.logger.info(`${registeredDriverIds.length} drivers registered for race ${raceId}. Generating results.`);
|
|
||||||
|
|
||||||
// Get driver ratings
|
// Get driver ratings
|
||||||
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||||
this.logger.debug(`Driver ratings fetched for ${registeredDriverIds.length} drivers.`);
|
|
||||||
|
|
||||||
// Generate realistic race results
|
// Generate realistic race results
|
||||||
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||||
this.logger.debug(`Generated ${results.length} race results for race ${raceId}.`);
|
|
||||||
|
|
||||||
// Save results
|
// Save results
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
await this.resultRepository.create(result);
|
await this.resultRepository.create(result);
|
||||||
}
|
}
|
||||||
this.logger.info(`Persisted ${results.length} race results for race ${raceId}.`);
|
|
||||||
|
|
||||||
// Update standings
|
// Update standings
|
||||||
await this.updateStandings(race.leagueId, results);
|
await this.updateStandings(race.leagueId, results);
|
||||||
this.logger.info(`Standings updated for league ${race.leagueId}.`);
|
|
||||||
|
|
||||||
// Complete the race
|
// Complete the race
|
||||||
const completedRace = race.complete();
|
const completedRace = race.complete();
|
||||||
await this.raceRepository.update(completedRace);
|
await this.raceRepository.update(completedRace);
|
||||||
this.logger.info(`Race ${raceId} successfully completed and updated.`);
|
|
||||||
|
return SharedResult.ok({});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to complete race ${raceId}: ${error.message}`, error as Error);
|
return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +75,6 @@ export class CompleteRaceUseCase
|
|||||||
driverIds: string[],
|
driverIds: string[],
|
||||||
driverRatings: Map<string, number>
|
driverRatings: Map<string, number>
|
||||||
): Result[] {
|
): Result[] {
|
||||||
this.logger.debug(`Generating race results for race ${raceId} with ${driverIds.length} drivers.`);
|
|
||||||
// Create driver performance data
|
// Create driver performance data
|
||||||
const driverPerformances = driverIds.map(driverId => ({
|
const driverPerformances = driverIds.map(driverId => ({
|
||||||
driverId,
|
driverId,
|
||||||
@@ -101,7 +88,6 @@ export class CompleteRaceUseCase
|
|||||||
const perfB = b.rating + (b.randomFactor * 200);
|
const perfB = b.rating + (b.randomFactor * 200);
|
||||||
return perfB - perfA; // Higher performance first
|
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)
|
// Generate qualifying results for start positions (similar but different from race results)
|
||||||
const qualiPerformances = driverPerformances.map(p => ({
|
const qualiPerformances = driverPerformances.map(p => ({
|
||||||
@@ -113,12 +99,11 @@ export class CompleteRaceUseCase
|
|||||||
const perfB = b.rating + (b.randomFactor * 150);
|
const perfB = b.rating + (b.randomFactor * 150);
|
||||||
return perfB - perfA;
|
return perfB - perfA;
|
||||||
});
|
});
|
||||||
this.logger.debug(`Qualifying performances generated for race ${raceId}.`);
|
|
||||||
|
|
||||||
// Generate results
|
// Generate results
|
||||||
const results: Result[] = [];
|
const results: Result[] = [];
|
||||||
for (let i = 0; i < driverPerformances.length; i++) {
|
for (let i = 0; i < driverPerformances.length; i++) {
|
||||||
const { driverId } = driverPerformances[i];
|
const { driverId } = driverPerformances[i]!;
|
||||||
const position = i + 1;
|
const position = i + 1;
|
||||||
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 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;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
|
||||||
this.logger.debug(`Updating standings for league ${leagueId} with ${results.length} results.`);
|
|
||||||
// Group results by driver
|
// Group results by driver
|
||||||
const resultsByDriver = new Map<string, Result[]>();
|
const resultsByDriver = new Map<string, Result[]>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
@@ -157,7 +140,6 @@ export class CompleteRaceUseCase
|
|||||||
existing.push(result);
|
existing.push(result);
|
||||||
resultsByDriver.set(result.driverId, existing);
|
resultsByDriver.set(result.driverId, existing);
|
||||||
}
|
}
|
||||||
this.logger.debug(`Results grouped by driver for league ${leagueId}.`);
|
|
||||||
|
|
||||||
// Update or create standings for each driver
|
// Update or create standings for each driver
|
||||||
for (const [driverId, driverResults] of resultsByDriver) {
|
for (const [driverId, driverResults] of resultsByDriver) {
|
||||||
@@ -168,9 +150,6 @@ export class CompleteRaceUseCase
|
|||||||
leagueId,
|
leagueId,
|
||||||
driverId,
|
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)
|
// 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);
|
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}.`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,17 +8,15 @@ import { Standing } from '../../domain/entities/Standing';
|
|||||||
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
|
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
|
||||||
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
|
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
|
||||||
import type { AsyncUseCase } from '@core/shared/application';
|
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
|
* Enhanced CompleteRaceUseCase that includes rating updates
|
||||||
*/
|
*/
|
||||||
export interface CompleteRaceCommandDTO {
|
|
||||||
raceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CompleteRaceUseCaseWithRatings
|
export class CompleteRaceUseCaseWithRatings
|
||||||
implements AsyncUseCase<CompleteRaceCommandDTO, void> {
|
implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<void, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
|
||||||
@@ -26,64 +24,47 @@ export class CompleteRaceUseCaseWithRatings
|
|||||||
private readonly standingRepository: IStandingRepository,
|
private readonly standingRepository: IStandingRepository,
|
||||||
private readonly driverRatingProvider: DriverRatingProvider,
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
private readonly ratingUpdateService: RatingUpdateService,
|
private readonly ratingUpdateService: RatingUpdateService,
|
||||||
private readonly logger: Logger,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CompleteRaceCommandDTO): Promise<void> {
|
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<void, RacingDomainValidationError>> {
|
||||||
const { raceId } = command;
|
|
||||||
this.logger.debug(`Attempting to complete race with ID: ${raceId}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { raceId } = command;
|
||||||
|
|
||||||
const race = await this.raceRepository.findById(raceId);
|
const race = await this.raceRepository.findById(raceId);
|
||||||
if (!race) {
|
if (!race) {
|
||||||
this.logger.error(`Race not found for ID: ${raceId}`);
|
return SharedResult.err(new RacingDomainValidationError('Race not found'));
|
||||||
throw new Error('Race not found');
|
|
||||||
}
|
}
|
||||||
this.logger.debug(`Found race: ${race.id}`);
|
|
||||||
|
|
||||||
// Get registered drivers for this race
|
// Get registered drivers for this race
|
||||||
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
|
||||||
if (registeredDriverIds.length === 0) {
|
if (registeredDriverIds.length === 0) {
|
||||||
this.logger.warn(`No registered drivers for race ID: ${raceId}. Cannot complete race.`);
|
return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
|
||||||
throw new Error('Cannot complete race with no registered drivers');
|
|
||||||
}
|
}
|
||||||
this.logger.debug(`Found ${registeredDriverIds.length} registered drivers for race ID: ${raceId}`);
|
|
||||||
|
|
||||||
// Get driver ratings
|
// Get driver ratings
|
||||||
this.logger.debug('Fetching driver ratings...');
|
|
||||||
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
|
||||||
this.logger.debug('Driver ratings fetched.');
|
|
||||||
|
|
||||||
// Generate realistic race results
|
// Generate realistic race results
|
||||||
this.logger.debug('Generating race results...');
|
|
||||||
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
|
||||||
this.logger.info(`Generated ${results.length} race results for race ID: ${raceId}`);
|
|
||||||
|
|
||||||
// Save results
|
// Save results
|
||||||
this.logger.debug('Saving race results...');
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
await this.resultRepository.create(result);
|
await this.resultRepository.create(result);
|
||||||
}
|
}
|
||||||
this.logger.info('Race results saved successfully.');
|
|
||||||
|
|
||||||
// Update standings
|
// Update standings
|
||||||
this.logger.debug(`Updating standings for league ID: ${race.leagueId}`);
|
|
||||||
await this.updateStandings(race.leagueId, results);
|
await this.updateStandings(race.leagueId, results);
|
||||||
this.logger.info('Standings updated successfully.');
|
|
||||||
|
|
||||||
// Update driver ratings based on performance
|
// Update driver ratings based on performance
|
||||||
this.logger.debug('Updating driver ratings...');
|
|
||||||
await this.updateDriverRatings(results, registeredDriverIds.length);
|
await this.updateDriverRatings(results, registeredDriverIds.length);
|
||||||
this.logger.info('Driver ratings updated successfully.');
|
|
||||||
|
|
||||||
// Complete the race
|
// Complete the race
|
||||||
this.logger.debug(`Marking race ID: ${raceId} as complete...`);
|
|
||||||
const completedRace = race.complete();
|
const completedRace = race.complete();
|
||||||
await this.raceRepository.update(completedRace);
|
await this.raceRepository.update(completedRace);
|
||||||
this.logger.info(`Race ID: ${raceId} completed successfully.`);
|
|
||||||
|
return SharedResult.ok(undefined);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Error completing race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
|
return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<CreateLeagueWithSeasonAndScoringCommand> = {
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,43 +14,13 @@ import {
|
|||||||
LeagueVisibility,
|
LeagueVisibility,
|
||||||
MIN_RANKED_LEAGUE_DRIVERS,
|
MIN_RANKED_LEAGUE_DRIVERS,
|
||||||
} from '../../domain/value-objects/LeagueVisibility';
|
} from '../../domain/value-objects/LeagueVisibility';
|
||||||
|
import { Result } from '@core/shared/result/Result';
|
||||||
/**
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
* League visibility/ranking mode.
|
import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand';
|
||||||
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers.
|
import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO';
|
||||||
* - '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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateLeagueWithSeasonAndScoringUseCase
|
export class CreateLeagueWithSeasonAndScoringUseCase
|
||||||
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> {
|
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly seasonRepository: ISeasonRepository,
|
private readonly seasonRepository: ISeasonRepository,
|
||||||
@@ -61,11 +31,14 @@ export class CreateLeagueWithSeasonAndScoringUseCase
|
|||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
command: CreateLeagueWithSeasonAndScoringCommand,
|
command: CreateLeagueWithSeasonAndScoringCommand,
|
||||||
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> {
|
): Promise<Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
|
||||||
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
|
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 {
|
try {
|
||||||
this.validate(command);
|
|
||||||
this.logger.info('Command validated successfully.');
|
|
||||||
|
|
||||||
const leagueId = uuidv4();
|
const leagueId = uuidv4();
|
||||||
this.logger.debug(`Generated leagueId: ${leagueId}`);
|
this.logger.debug(`Generated leagueId: ${leagueId}`);
|
||||||
@@ -108,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
|
|||||||
|
|
||||||
if (!preset) {
|
if (!preset) {
|
||||||
this.logger.error(`Unknown scoring preset: ${presetId}`);
|
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.`);
|
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
|
||||||
|
|
||||||
@@ -119,45 +92,44 @@ export class CreateLeagueWithSeasonAndScoringUseCase
|
|||||||
await this.leagueScoringConfigRepository.save(finalConfig);
|
await this.leagueScoringConfigRepository.save(finalConfig);
|
||||||
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
|
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
|
||||||
|
|
||||||
const result = {
|
const result: CreateLeagueWithSeasonAndScoringResultDTO = {
|
||||||
leagueId: league.id,
|
leagueId: league.id,
|
||||||
seasonId,
|
seasonId,
|
||||||
scoringPresetId: preset.id,
|
scoringPresetId: preset.id,
|
||||||
scoringPresetName: preset.name,
|
scoringPresetName: preset.name,
|
||||||
};
|
};
|
||||||
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
|
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
|
||||||
return result;
|
return Result.ok(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', error, { command });
|
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void {
|
private validate(command: CreateLeagueWithSeasonAndScoringCommand): Result<void, RacingDomainValidationError> {
|
||||||
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
|
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
|
||||||
if (!command.name || command.name.trim().length === 0) {
|
if (!command.name || command.name.trim().length === 0) {
|
||||||
this.logger.warn('Validation failed: League name is required', { command });
|
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) {
|
if (!command.ownerId || command.ownerId.trim().length === 0) {
|
||||||
this.logger.warn('Validation failed: League ownerId is required', { command });
|
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) {
|
if (!command.gameId || command.gameId.trim().length === 0) {
|
||||||
this.logger.warn('Validation failed: gameId is required', { command });
|
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) {
|
if (!command.visibility) {
|
||||||
this.logger.warn('Validation failed: visibility is required', { command });
|
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) {
|
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
|
||||||
this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command });
|
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);
|
const visibility = LeagueVisibility.fromString(command.visibility);
|
||||||
|
|
||||||
if (visibility.isRanked()) {
|
if (visibility.isRanked()) {
|
||||||
const driverCount = command.maxDrivers ?? 0;
|
const driverCount = command.maxDrivers ?? 0;
|
||||||
if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) {
|
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}.`,
|
`Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`,
|
||||||
{ command }
|
{ command }
|
||||||
);
|
);
|
||||||
throw new Error(
|
return Result.err(new RacingDomainValidationError(
|
||||||
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
|
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
|
||||||
`Current setting: ${driverCount}. ` +
|
`Current setting: ${driverCount}. ` +
|
||||||
`For smaller groups, consider creating an Unranked (Friends) league instead.`
|
`For smaller groups, consider creating an Unranked (Friends) league instead.`
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.debug('Validation successful.');
|
this.logger.debug('Validation successful.');
|
||||||
|
return Result.ok(undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CreateSponsorCommand {
|
||||||
|
name: string;
|
||||||
|
contactEmail: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
}
|
||||||
136
core/racing/application/use-cases/CreateSponsorUseCase.test.ts
Normal file
136
core/racing/application/use-cases/CreateSponsorUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,59 +3,90 @@
|
|||||||
*
|
*
|
||||||
* Creates a new sponsor.
|
* 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 { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
|
||||||
import type {
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
ICreateSponsorPresenter,
|
import type { Logger } from '@core/shared/application';
|
||||||
CreateSponsorResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
CreateSponsorViewModel,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/ICreateSponsorPresenter';
|
import type { CreateSponsorCommand } from './CreateSponsorCommand';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import type { CreateSponsorResultDTO } from '../dto/CreateSponsorResultDTO';
|
||||||
|
|
||||||
export interface CreateSponsorInput {
|
|
||||||
name: string;
|
|
||||||
contactEmail: string;
|
|
||||||
websiteUrl?: string;
|
|
||||||
logoUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateSponsorUseCase
|
export class CreateSponsorUseCase
|
||||||
implements UseCase<CreateSponsorInput, CreateSponsorResultDTO, CreateSponsorViewModel, ICreateSponsorPresenter>
|
implements AsyncUseCase<CreateSponsorCommand, Result<CreateSponsorResultDTO, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sponsorRepository: ISponsorRepository,
|
private readonly sponsorRepository: ISponsorRepository,
|
||||||
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
input: CreateSponsorInput,
|
command: CreateSponsorCommand,
|
||||||
presenter: ICreateSponsorPresenter,
|
): Promise<Result<CreateSponsorResultDTO, RacingDomainValidationError>> {
|
||||||
): Promise<void> {
|
this.logger.debug('Executing CreateSponsorUseCase', { command });
|
||||||
presenter.reset();
|
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({
|
await this.sponsorRepository.create(sponsor);
|
||||||
id,
|
this.logger.info(`Sponsor ${sponsor.name} (${sponsor.id}) created successfully.`);
|
||||||
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);
|
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 = {
|
private validate(command: CreateSponsorCommand): Result<void, RacingDomainValidationError> {
|
||||||
sponsor: {
|
this.logger.debug('Validating CreateSponsorCommand', { command });
|
||||||
id: sponsor.id,
|
if (!command.name || command.name.trim().length === 0) {
|
||||||
name: sponsor.name,
|
this.logger.warn('Validation failed: Sponsor name is required', { command });
|
||||||
contactEmail: sponsor.contactEmail,
|
return Result.err(new RacingDomainValidationError('Sponsor name is required'));
|
||||||
websiteUrl: sponsor.websiteUrl,
|
}
|
||||||
logoUrl: sponsor.logoUrl,
|
if (!command.contactEmail || command.contactEmail.trim().length === 0) {
|
||||||
createdAt: sponsor.createdAt,
|
this.logger.warn('Validation failed: Sponsor contact email is required', { command });
|
||||||
},
|
return Result.err(new RacingDomainValidationError('Sponsor contact email is required'));
|
||||||
};
|
}
|
||||||
|
// Basic email validation
|
||||||
presenter.present(dto);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
123
core/racing/application/use-cases/CreateTeamUseCase.test.ts
Normal file
123
core/racing/application/use-cases/CreateTeamUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
import { Team } from '../../domain/entities/Team';
|
import { Team } from '../../domain/entities/Team';
|
||||||
@@ -10,44 +16,67 @@ import type {
|
|||||||
CreateTeamCommandDTO,
|
CreateTeamCommandDTO,
|
||||||
CreateTeamResultDTO,
|
CreateTeamResultDTO,
|
||||||
} from '../dto/CreateTeamCommandDTO';
|
} 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<CreateTeamCommandDTO, Result<CreateTeamResultDTO, RacingDomainValidationError>>
|
||||||
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly teamRepository: ITeamRepository,
|
private readonly teamRepository: ITeamRepository,
|
||||||
private readonly membershipRepository: ITeamMembershipRepository,
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> {
|
async execute(
|
||||||
|
command: CreateTeamCommandDTO,
|
||||||
|
): Promise<Result<CreateTeamResultDTO, RacingDomainValidationError>> {
|
||||||
|
this.logger.debug('Executing CreateTeamUseCase', { command });
|
||||||
const { name, tag, description, ownerId, leagues } = command;
|
const { name, tag, description, ownerId, leagues } = command;
|
||||||
|
|
||||||
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
|
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
|
||||||
ownerId,
|
ownerId,
|
||||||
);
|
);
|
||||||
if (existingMembership) {
|
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({
|
this.logger.info('Command validated successfully.');
|
||||||
id: `team-${Date.now()}`,
|
try {
|
||||||
name,
|
const teamId = uuidv4();
|
||||||
tag,
|
this.logger.debug(`Generated teamId: ${teamId}`);
|
||||||
description,
|
|
||||||
ownerId,
|
|
||||||
leagues,
|
|
||||||
});
|
|
||||||
|
|
||||||
const createdTeam = await this.teamRepository.create(team);
|
const team = Team.create({
|
||||||
|
id: teamId,
|
||||||
|
name,
|
||||||
|
tag,
|
||||||
|
description,
|
||||||
|
ownerId,
|
||||||
|
leagues,
|
||||||
|
});
|
||||||
|
|
||||||
const membership: TeamMembership = {
|
const createdTeam = await this.teamRepository.create(team);
|
||||||
teamId: createdTeam.id,
|
this.logger.info(`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`);
|
||||||
driverId: ownerId,
|
|
||||||
role: 'owner' as TeamRole,
|
|
||||||
status: 'active' as TeamMembershipStatus,
|
|
||||||
joinedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface DashboardOverviewParams {
|
||||||
|
driverId: string;
|
||||||
|
}
|
||||||
@@ -1,34 +1,14 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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 { Driver } from '@core/racing/domain/entities/Driver';
|
||||||
import { Race } from '@core/racing/domain/entities/Race';
|
import { Race } from '@core/racing/domain/entities/Race';
|
||||||
import { Result } from '@core/racing/domain/entities/Result';
|
import { Result } from '@core/racing/domain/entities/Result';
|
||||||
import { League } from '@core/racing/domain/entities/League';
|
import { League } from '@core/racing/domain/entities/League';
|
||||||
import { Standing } from '@core/racing/domain/entities/Standing';
|
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 { 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 {
|
interface TestImageService {
|
||||||
getDriverAvatar(driverId: string): string;
|
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 () => {
|
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
|
// Given a driver with memberships in two leagues and future races with mixed registration
|
||||||
const driverId = 'driver-1';
|
const driverId = 'driver-1';
|
||||||
@@ -189,10 +169,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||||
getJoinRequests: async (): Promise<any[]> => [],
|
getJoinRequests: async (): Promise<JoinRequest[]> => [],
|
||||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
|
||||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -234,9 +214,7 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const presenter = new FakeDashboardOverviewPresenter();
|
const useCase = new DashboardOverviewUseCase(
|
||||||
|
|
||||||
const useCase = new GetDashboardOverviewUseCase(
|
|
||||||
driverRepository,
|
driverRepository,
|
||||||
raceRepository,
|
raceRepository,
|
||||||
resultRepository,
|
resultRepository,
|
||||||
@@ -251,12 +229,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await useCase.execute({ driverId }, presenter);
|
const result = await useCase.execute({ driverId });
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
|
||||||
const viewModel = presenter.getViewModel();
|
const vm = result.unwrap();
|
||||||
expect(viewModel).not.toBeNull();
|
|
||||||
|
|
||||||
const vm = viewModel!;
|
|
||||||
|
|
||||||
// Then myUpcomingRaces only contains registered races from the driver's leagues
|
// Then myUpcomingRaces only contains registered races from the driver's leagues
|
||||||
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
|
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
|
||||||
@@ -422,10 +398,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||||
getJoinRequests: async (): Promise<any[]> => [],
|
getJoinRequests: async (): Promise<JoinRequest[]> => [],
|
||||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
|
||||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -464,9 +440,7 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const presenter = new FakeDashboardOverviewPresenter();
|
const useCase = new DashboardOverviewUseCase(
|
||||||
|
|
||||||
const useCase = new GetDashboardOverviewUseCase(
|
|
||||||
driverRepository,
|
driverRepository,
|
||||||
raceRepository,
|
raceRepository,
|
||||||
resultRepository,
|
resultRepository,
|
||||||
@@ -481,12 +455,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await useCase.execute({ driverId }, presenter);
|
const result = await useCase.execute({ driverId });
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
|
||||||
const viewModel = presenter.getViewModel();
|
const vm = result.unwrap();
|
||||||
expect(viewModel).not.toBeNull();
|
|
||||||
|
|
||||||
const vm = viewModel!;
|
|
||||||
|
|
||||||
// Then recentResults are sorted by finishedAt descending (newest first)
|
// Then recentResults are sorted by finishedAt descending (newest first)
|
||||||
expect(vm.recentResults.length).toBe(2);
|
expect(vm.recentResults.length).toBe(2);
|
||||||
@@ -584,10 +556,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
const leagueMembershipRepository = {
|
const leagueMembershipRepository = {
|
||||||
getMembership: async (): Promise<LeagueMembership | null> => null,
|
getMembership: async (): Promise<LeagueMembership | null> => null,
|
||||||
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
|
||||||
getJoinRequests: async (): Promise<any[]> => [],
|
getJoinRequests: async (): Promise<JoinRequest[]> => [],
|
||||||
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
|
||||||
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); },
|
saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
|
||||||
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -616,9 +588,7 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
|
|
||||||
const getDriverStats = () => null;
|
const getDriverStats = () => null;
|
||||||
|
|
||||||
const presenter = new FakeDashboardOverviewPresenter();
|
const useCase = new DashboardOverviewUseCase(
|
||||||
|
|
||||||
const useCase = new GetDashboardOverviewUseCase(
|
|
||||||
driverRepository,
|
driverRepository,
|
||||||
raceRepository,
|
raceRepository,
|
||||||
resultRepository,
|
resultRepository,
|
||||||
@@ -633,12 +603,10 @@ describe('GetDashboardOverviewUseCase', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await useCase.execute({ driverId }, presenter);
|
const result = await useCase.execute({ driverId });
|
||||||
|
expect(result.isOk()).toBe(true);
|
||||||
|
|
||||||
const viewModel = presenter.getViewModel();
|
const vm = result.unwrap();
|
||||||
expect(viewModel).not.toBeNull();
|
|
||||||
|
|
||||||
const vm = viewModel!;
|
|
||||||
|
|
||||||
// Then collections are empty and no errors are thrown
|
// Then collections are empty and no errors are thrown
|
||||||
expect(vm.myUpcomingRaces).toEqual([]);
|
expect(vm.myUpcomingRaces).toEqual([]);
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
|
|||||||
import type { IImageServicePort } from '../ports/IImageServicePort';
|
import type { IImageServicePort } from '../ports/IImageServicePort';
|
||||||
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
|
||||||
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
|
||||||
|
import { Result } from '@core/shared/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 {
|
import type {
|
||||||
IDashboardOverviewPresenter,
|
|
||||||
DashboardOverviewViewModel,
|
DashboardOverviewViewModel,
|
||||||
DashboardDriverSummaryViewModel,
|
DashboardDriverSummaryViewModel,
|
||||||
DashboardRaceSummaryViewModel,
|
DashboardRaceSummaryViewModel,
|
||||||
@@ -29,11 +37,7 @@ interface DashboardDriverStatsAdapter {
|
|||||||
consistency: number | null;
|
consistency: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetDashboardOverviewParams {
|
export class DashboardOverviewUseCase {
|
||||||
driverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetDashboardOverviewUseCase {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
@@ -48,7 +52,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
|
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise<void> {
|
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewViewModel, RacingDomainError>> {
|
||||||
const { driverId } = params;
|
const { driverId } = params;
|
||||||
|
|
||||||
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
|
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
|
||||||
@@ -134,12 +138,11 @@ export class GetDashboardOverviewUseCase {
|
|||||||
friends: friendsSummary,
|
friends: friendsSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.reset();
|
return Result.ok(viewModel);
|
||||||
presenter.present(viewModel);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getDriverLeagues(allLeagues: unknown[], driverId: string): Promise<any[]> {
|
private async getDriverLeagues(allLeagues: League[], driverId: string): Promise<League[]> {
|
||||||
const driverLeagues: unknown[] = [];
|
const driverLeagues: League[] = [];
|
||||||
|
|
||||||
for (const league of allLeagues) {
|
for (const league of allLeagues) {
|
||||||
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
|
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
|
||||||
@@ -152,7 +155,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async partitionUpcomingRacesByRegistration(
|
private async partitionUpcomingRacesByRegistration(
|
||||||
upcomingRaces: unknown[],
|
upcomingRaces: Race[],
|
||||||
driverId: string,
|
driverId: string,
|
||||||
leagueMap: Map<string, string>,
|
leagueMap: Map<string, string>,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -177,7 +180,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapRaceToSummary(
|
private mapRaceToSummary(
|
||||||
race: any,
|
race: Race,
|
||||||
leagueMap: Map<string, string>,
|
leagueMap: Map<string, string>,
|
||||||
isMyLeague: boolean,
|
isMyLeague: boolean,
|
||||||
): DashboardRaceSummaryViewModel {
|
): DashboardRaceSummaryViewModel {
|
||||||
@@ -194,9 +197,9 @@ export class GetDashboardOverviewUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildRecentResults(
|
private buildRecentResults(
|
||||||
allResults: unknown[],
|
allResults: RaceResult[],
|
||||||
allRaces: unknown[],
|
allRaces: Race[],
|
||||||
allLeagues: unknown[],
|
allLeagues: League[],
|
||||||
driverId: string,
|
driverId: string,
|
||||||
): DashboardRecentResultViewModel[] {
|
): DashboardRecentResultViewModel[] {
|
||||||
const raceById = new Map(allRaces.map(race => [race.id, race]));
|
const raceById = new Map(allRaces.map(race => [race.id, race]));
|
||||||
@@ -237,7 +240,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async buildLeagueStandingsSummaries(
|
private async buildLeagueStandingsSummaries(
|
||||||
driverLeagues: unknown[],
|
driverLeagues: League[],
|
||||||
driverId: string,
|
driverId: string,
|
||||||
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
|
): Promise<DashboardLeagueStandingSummaryViewModel[]> {
|
||||||
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
|
const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
|
||||||
@@ -245,7 +248,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
for (const league of driverLeagues.slice(0, 3)) {
|
for (const league of driverLeagues.slice(0, 3)) {
|
||||||
const standings = await this.standingRepository.findByLeagueId(league.id);
|
const standings = await this.standingRepository.findByLeagueId(league.id);
|
||||||
const driverStanding = standings.find(
|
const driverStanding = standings.find(
|
||||||
(standing: any) => standing.driverId === driverId,
|
(standing: Standing) => standing.driverId === driverId,
|
||||||
);
|
);
|
||||||
|
|
||||||
summaries.push({
|
summaries.push({
|
||||||
@@ -277,7 +280,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
return activeLeagueIds.size;
|
return activeLeagueIds.size;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFeedSummary(feedItems: unknown[]): DashboardFeedSummaryViewModel {
|
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryViewModel {
|
||||||
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
|
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
@@ -297,7 +300,7 @@ export class GetDashboardOverviewUseCase {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildFriendsSummary(friends: unknown[]): DashboardFriendSummaryViewModel[] {
|
private buildFriendsSummary(friends: Driver[]): DashboardFriendSummaryViewModel[] {
|
||||||
return friends.map(friend => ({
|
return friends.map(friend => ({
|
||||||
id: friend.id,
|
id: friend.id,
|
||||||
name: friend.name,
|
name: friend.name,
|
||||||
3
core/racing/application/use-cases/DriverRatingPort.ts
Normal file
3
core/racing/application/use-cases/DriverRatingPort.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export interface DriverRatingPort {
|
||||||
|
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
|
||||||
|
}
|
||||||
10
core/racing/application/use-cases/FileProtestCommand.ts
Normal file
10
core/racing/application/use-cases/FileProtestCommand.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
131
core/racing/application/use-cases/FileProtestUseCase.test.ts
Normal file
131
core/racing/application/use-cases/FileProtestUseCase.test.ts
Normal file
@@ -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',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Application Use Case: FileProtestUseCase
|
* Application Use Case: FileProtestUseCase
|
||||||
*
|
*
|
||||||
* Allows a driver to file a protest against another driver for an incident during a race.
|
* 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 { IProtestRepository } from '../../domain/repositories/IProtestRepository';
|
||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
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';
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
export interface FileProtestCommand {
|
|
||||||
raceId: string;
|
|
||||||
protestingDriverId: string;
|
|
||||||
accusedDriverId: string;
|
|
||||||
incident: ProtestIncident;
|
|
||||||
comment?: string;
|
|
||||||
proofVideoUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileProtestUseCase {
|
export class FileProtestUseCase {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly protestRepository: IProtestRepository,
|
private readonly protestRepository: IProtestRepository,
|
||||||
@@ -26,16 +20,16 @@ export class FileProtestUseCase {
|
|||||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: FileProtestCommand): Promise<{ protestId: string }> {
|
async execute(command: FileProtestCommand): Promise<Result<{ protestId: string }, RacingDomainValidationError>> {
|
||||||
// Validate race exists
|
// Validate race exists
|
||||||
const race = await this.raceRepository.findById(command.raceId);
|
const race = await this.raceRepository.findById(command.raceId);
|
||||||
if (!race) {
|
if (!race) {
|
||||||
throw new Error('Race not found');
|
return Result.err(new RacingDomainValidationError('Race not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate drivers are not the same
|
// Validate drivers are not the same
|
||||||
if (command.protestingDriverId === command.accusedDriverId) {
|
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
|
// Validate protesting driver is a member of the league
|
||||||
@@ -43,9 +37,9 @@ export class FileProtestUseCase {
|
|||||||
const protestingDriverMembership = memberships.find(
|
const protestingDriverMembership = memberships.find(
|
||||||
m => m.driverId === command.protestingDriverId && m.status === 'active'
|
m => m.driverId === command.protestingDriverId && m.status === 'active'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!protestingDriverMembership) {
|
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
|
// Create the protest
|
||||||
@@ -63,6 +57,6 @@ export class FileProtestUseCase {
|
|||||||
|
|
||||||
await this.protestRepository.create(protest);
|
await this.protestRepository.create(protest);
|
||||||
|
|
||||||
return { protestId: protest.id };
|
return Result.ok({ protestId: protest.id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,25 +4,17 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
|
|||||||
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
|
||||||
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
|
||||||
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
|
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
|
||||||
import type {
|
import type { LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
|
||||||
AllLeaguesWithCapacityAndScoringViewModel,
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
IAllLeaguesWithCapacityAndScoringPresenter,
|
import { Result } from '@core/shared/result/Result';
|
||||||
LeagueEnrichedData,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
|
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving all leagues with capacity and scoring information.
|
* Use Case for retrieving all leagues with capacity and scoring information.
|
||||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||||
*/
|
*/
|
||||||
export class GetAllLeaguesWithCapacityAndScoringUseCase
|
export class GetAllLeaguesWithCapacityAndScoringUseCase
|
||||||
implements
|
implements AsyncUseCase<void, Result<LeagueEnrichedData[], RacingDomainValidationError>>
|
||||||
UseCase<
|
|
||||||
void,
|
|
||||||
LeagueEnrichedData[],
|
|
||||||
AllLeaguesWithCapacityAndScoringViewModel,
|
|
||||||
IAllLeaguesWithCapacityAndScoringPresenter
|
|
||||||
>
|
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
@@ -33,12 +25,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
|
|||||||
private readonly presetProvider: LeagueScoringPresetProvider,
|
private readonly presetProvider: LeagueScoringPresetProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(): Promise<Result<LeagueEnrichedData[], RacingDomainValidationError>> {
|
||||||
_input: void,
|
|
||||||
presenter: IAllLeaguesWithCapacityAndScoringPresenter,
|
|
||||||
): Promise<void> {
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
const leagues = await this.leagueRepository.findAll();
|
const leagues = await this.leagueRepository.findAll();
|
||||||
|
|
||||||
const enrichedLeagues: LeagueEnrichedData[] = [];
|
const enrichedLeagues: LeagueEnrichedData[] = [];
|
||||||
@@ -88,7 +75,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
|
|||||||
...(preset ? { preset } : {}),
|
...(preset ? { preset } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return Result.ok(enrichedLeagues);
|
||||||
|
|
||||||
presenter.present(enrichedLeagues);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,23 @@
|
|||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import type {
|
import type { AllLeaguesWithCapacityResultDTO } from '../presenters/IAllLeaguesWithCapacityPresenter';
|
||||||
IAllLeaguesWithCapacityPresenter,
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
AllLeaguesWithCapacityResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
AllLeaguesWithCapacityViewModel,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/IAllLeaguesWithCapacityPresenter';
|
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving all leagues with capacity information.
|
* 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
|
export class GetAllLeaguesWithCapacityUseCase
|
||||||
implements UseCase<void, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel, IAllLeaguesWithCapacityPresenter>
|
implements AsyncUseCase<void, Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(): Promise<Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>> {
|
||||||
_input: void,
|
|
||||||
presenter: IAllLeaguesWithCapacityPresenter,
|
|
||||||
): Promise<void> {
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
const leagues = await this.leagueRepository.findAll();
|
const leagues = await this.leagueRepository.findAll();
|
||||||
|
|
||||||
const memberCounts = new Map<string, number>();
|
const memberCounts = new Map<string, number>();
|
||||||
@@ -49,6 +42,6 @@ export class GetAllLeaguesWithCapacityUseCase
|
|||||||
memberCounts,
|
memberCounts,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.present(dto);
|
return Result.ok(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,23 +2,24 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
|
|||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
import type {
|
import type {
|
||||||
IAllRacesPagePresenter,
|
|
||||||
AllRacesPageResultDTO,
|
AllRacesPageResultDTO,
|
||||||
AllRacesPageViewModel,
|
AllRacesPageViewModel,
|
||||||
AllRacesListItemViewModel,
|
AllRacesListItemViewModel,
|
||||||
AllRacesFilterOptionsViewModel,
|
AllRacesFilterOptionsViewModel,
|
||||||
} from '../presenters/IAllRacesPagePresenter';
|
} 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
|
export class GetAllRacesPageDataUseCase
|
||||||
implements UseCase<void, AllRacesPageResultDTO, AllRacesPageViewModel, IAllRacesPagePresenter> {
|
implements AsyncUseCase<void, Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(_input: void, presenter: IAllRacesPagePresenter): Promise<void> {
|
async execute(): Promise<Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
|
||||||
this.logger.debug('Executing GetAllRacesPageDataUseCase');
|
this.logger.debug('Executing GetAllRacesPageDataUseCase');
|
||||||
try {
|
try {
|
||||||
const [allRaces, allLeagues] = await Promise.all([
|
const [allRaces, allLeagues] = await Promise.all([
|
||||||
@@ -64,12 +65,11 @@ export class GetAllRacesPageDataUseCase
|
|||||||
filters,
|
filters,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.reset();
|
this.logger.debug('Successfully retrieved all races page data.');
|
||||||
presenter.present(viewModel);
|
return Result.ok(viewModel);
|
||||||
this.logger.debug('Successfully presented all races page data.');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error executing GetAllRacesPageDataUseCase', { error });
|
this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error)));
|
||||||
throw error;
|
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
106
core/racing/application/use-cases/GetAllRacesUseCase.test.ts
Normal file
106
core/racing/application/use-cases/GetAllRacesUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,41 @@
|
|||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import type { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '../presenters/IGetAllRacesPresenter';
|
import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
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 AsyncUseCase<void, Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
|
||||||
|
|
||||||
export class GetAllRacesUseCase implements UseCase<GetAllRacesUseCaseParams, GetAllRacesResultDTO, AllRacesPageViewModel, IGetAllRacesPresenter> {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly raceRepository: IRaceRepository,
|
private readonly raceRepository: IRaceRepository,
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetAllRacesUseCaseParams, presenter: IGetAllRacesPresenter): Promise<void> {
|
async execute(): Promise<Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
|
||||||
const races = await this.raceRepository.findAll();
|
this.logger.debug('Executing GetAllRacesUseCase');
|
||||||
const leagues = await this.leagueRepository.findAll();
|
try {
|
||||||
const leagueMap = new Map(leagues.map(league => [league.id, league.name]));
|
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 => ({
|
const raceViewModels = races.map(race => ({
|
||||||
id: race.id,
|
id: race.id,
|
||||||
name: `Race ${race.id}`, // Placeholder, adjust based on domain
|
name: `${race.track} - ${race.car}`,
|
||||||
date: race.scheduledAt.toISOString(),
|
date: race.scheduledAt.toISOString(),
|
||||||
leagueName: leagueMap.get(race.leagueId) || 'Unknown League',
|
leagueName: leagueMap.get(race.leagueId) || 'Unknown League',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dto: GetAllRacesResultDTO = {
|
const dto: GetAllRacesResultDTO = {
|
||||||
races: raceViewModels,
|
races: raceViewModels,
|
||||||
totalCount: races.length,
|
totalCount: races.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.reset();
|
this.logger.debug('Successfully retrieved all races.');
|
||||||
presenter.present(dto);
|
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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
113
core/racing/application/use-cases/GetAllTeamsUseCase.test.ts
Normal file
113
core/racing/application/use-cases/GetAllTeamsUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,25 @@
|
|||||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
import type {
|
import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter';
|
||||||
IAllTeamsPresenter,
|
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||||
AllTeamsResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
} from '../presenters/IAllTeamsPresenter';
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
import type { UseCase } from '@core/shared/application';
|
|
||||||
import { Logger } from "@core/shared/application";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving all teams.
|
* Use Case for retrieving all teams.
|
||||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
|
||||||
*/
|
*/
|
||||||
export class GetAllTeamsUseCase
|
export class GetAllTeamsUseCase implements AsyncUseCase<void, Result<AllTeamsResultDTO, RacingDomainValidationError>> {
|
||||||
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
|
|
||||||
{
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly teamRepository: ITeamRepository,
|
private readonly teamRepository: ITeamRepository,
|
||||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> {
|
async execute(): Promise<Result<AllTeamsResultDTO, RacingDomainValidationError>> {
|
||||||
this.logger.debug('Executing GetAllTeamsUseCase');
|
this.logger.debug('Executing GetAllTeamsUseCase');
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const teams = await this.teamRepository.findAll();
|
const teams = await this.teamRepository.findAll();
|
||||||
if (teams.length === 0) {
|
|
||||||
this.logger.warn('No teams found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
|
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
|
||||||
teams.map(async (team) => {
|
teams.map(async (team) => {
|
||||||
@@ -40,7 +31,7 @@ export class GetAllTeamsUseCase
|
|||||||
description: team.description,
|
description: team.description,
|
||||||
ownerId: team.ownerId,
|
ownerId: team.ownerId,
|
||||||
leagues: [...team.leagues],
|
leagues: [...team.leagues],
|
||||||
createdAt: team.createdAt,
|
createdAt: team.createdAt,
|
||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -50,11 +41,11 @@ export class GetAllTeamsUseCase
|
|||||||
teams: enrichedTeams,
|
teams: enrichedTeams,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.present(dto);
|
this.logger.debug('Successfully retrieved all teams.');
|
||||||
this.logger.info('Successfully retrieved all teams.');
|
return Result.ok(dto);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
100
core/racing/application/use-cases/GetDriverTeamUseCase.test.ts
Normal file
100
core/racing/application/use-cases/GetDriverTeamUseCase.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,54 +1,51 @@
|
|||||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||||
import type {
|
import type { DriverTeamResultDTO } from '../presenters/IDriverTeamPresenter';
|
||||||
IDriverTeamPresenter,
|
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||||
DriverTeamResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
DriverTeamViewModel,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/IDriverTeamPresenter';
|
|
||||||
import type { UseCase } from '@core/shared/application';
|
|
||||||
import type { Logger } from '@core/shared/application';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving a driver's team.
|
* 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
|
export class GetDriverTeamUseCase
|
||||||
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter>
|
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamResultDTO, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly teamRepository: ITeamRepository,
|
private readonly teamRepository: ITeamRepository,
|
||||||
private readonly membershipRepository: ITeamMembershipRepository,
|
private readonly membershipRepository: ITeamMembershipRepository,
|
||||||
private readonly logger: Logger,
|
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<void> {
|
async execute(input: { driverId: string }): Promise<Result<DriverTeamResultDTO, RacingDomainValidationError>> {
|
||||||
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
|
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);
|
const team = await this.teamRepository.findById(membership.teamId);
|
||||||
if (!membership) {
|
if (!team) {
|
||||||
this.logger.warn(`No active membership found for driverId: ${input.driverId}`);
|
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
|
||||||
return;
|
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}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,51 +2,55 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
|
|||||||
import type { IRankingService } from '../../domain/services/IRankingService';
|
import type { IRankingService } from '../../domain/services/IRankingService';
|
||||||
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
|
||||||
import type { IImageServicePort } from '../ports/IImageServicePort';
|
import type { IImageServicePort } from '../ports/IImageServicePort';
|
||||||
import type {
|
import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
|
||||||
IDriversLeaderboardPresenter,
|
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||||
DriversLeaderboardResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
DriversLeaderboardViewModel,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/IDriversLeaderboardPresenter';
|
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving driver leaderboard data.
|
* 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
|
export class GetDriversLeaderboardUseCase
|
||||||
implements UseCase<void, DriversLeaderboardResultDTO, DriversLeaderboardViewModel, IDriversLeaderboardPresenter>
|
implements AsyncUseCase<void, Result<DriversLeaderboardResultDTO, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
private readonly rankingService: IRankingService,
|
private readonly rankingService: IRankingService,
|
||||||
private readonly driverStatsService: IDriverStatsService,
|
private readonly driverStatsService: IDriverStatsService,
|
||||||
private readonly imageService: IImageServicePort,
|
private readonly imageService: IImageServicePort,
|
||||||
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise<void> {
|
async execute(): Promise<Result<DriversLeaderboardResultDTO, RacingDomainValidationError>> {
|
||||||
presenter.reset();
|
this.logger.debug('Executing GetDriversLeaderboardUseCase');
|
||||||
|
try {
|
||||||
|
const drivers = await this.driverRepository.findAll();
|
||||||
|
const rankings = this.rankingService.getAllDriverRankings();
|
||||||
|
|
||||||
const drivers = await this.driverRepository.findAll();
|
const stats: DriversLeaderboardResultDTO['stats'] = {};
|
||||||
const rankings = this.rankingService.getAllDriverRankings();
|
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
|
||||||
|
|
||||||
const stats: DriversLeaderboardResultDTO['stats'] = {};
|
for (const driver of drivers) {
|
||||||
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
|
const driverStats = this.driverStatsService.getDriverStats(driver.id);
|
||||||
|
if (driverStats) {
|
||||||
for (const driver of drivers) {
|
stats[driver.id] = driverStats;
|
||||||
const driverStats = this.driverStatsService.getDriverStats(driver.id);
|
}
|
||||||
if (driverStats) {
|
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
|
||||||
stats[driver.id] = driverStats;
|
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,58 +8,30 @@
|
|||||||
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
|
||||||
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
|
||||||
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
|
||||||
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
|
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||||
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
|
import { Result } from '@core/shared/result/Result';
|
||||||
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter';
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import type { GetEntitySponsorshipPricingDTO } from '../dto/GetEntitySponsorshipPricingDTO';
|
||||||
|
import type { GetEntitySponsorshipPricingResultDTO } from '../dto/GetEntitySponsorshipPricingResultDTO';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetEntitySponsorshipPricingUseCase
|
export class GetEntitySponsorshipPricingUseCase
|
||||||
implements UseCase<GetEntitySponsorshipPricingDTO, GetEntitySponsorshipPricingResultDTO | null, GetEntitySponsorshipPricingResultDTO | null, IEntitySponsorshipPricingPresenter>
|
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>>
|
||||||
{
|
{
|
||||||
constructor(
|
constructor(
|
||||||
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
|
||||||
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
|
||||||
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
|
||||||
|
private readonly logger: Logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>> {
|
||||||
dto: GetEntitySponsorshipPricingDTO,
|
this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
|
||||||
presenter: IEntitySponsorshipPricingPresenter,
|
|
||||||
): Promise<void> {
|
|
||||||
presenter.reset();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
|
||||||
|
|
||||||
if (!pricing) {
|
if (!pricing) {
|
||||||
presenter.present(null);
|
this.logger.info(`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
|
||||||
return;
|
return Result.ok(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count pending requests by tier
|
// Count pending requests by tier
|
||||||
@@ -121,9 +93,11 @@ export class GetEntitySponsorshipPricingUseCase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
presenter.present(result);
|
this.logger.info(`Successfully retrieved sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
|
||||||
} catch (error: unknown) {
|
return Result.ok(result);
|
||||||
throw error;
|
} 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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,42 +1,31 @@
|
|||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import { Result } from '@core/shared/result/Result';
|
||||||
|
import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams';
|
||||||
|
import type { GetLeagueAdminPermissionsResultDTO } from '../dto/GetLeagueAdminPermissionsResultDTO';
|
||||||
|
|
||||||
export interface GetLeagueAdminPermissionsUseCaseParams {
|
export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<GetLeagueAdminPermissionsUseCaseParams, Result<GetLeagueAdminPermissionsResultDTO, never>> {
|
||||||
leagueId: string;
|
|
||||||
performerDriverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetLeagueAdminPermissionsResultDTO {
|
|
||||||
canRemoveMember: boolean;
|
|
||||||
canUpdateRoles: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetLeagueAdminPermissionsUseCase implements UseCase<GetLeagueAdminPermissionsUseCaseParams, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel, IGetLeagueAdminPermissionsPresenter> {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetLeagueAdminPermissionsUseCaseParams, presenter: IGetLeagueAdminPermissionsPresenter): Promise<void> {
|
async execute(params: GetLeagueAdminPermissionsUseCaseParams): Promise<Result<GetLeagueAdminPermissionsResultDTO, never>> {
|
||||||
const league = await this.leagueRepository.findById(params.leagueId);
|
const league = await this.leagueRepository.findById(params.leagueId);
|
||||||
if (!league) {
|
if (!league) {
|
||||||
presenter.present({ canRemoveMember: false, canUpdateRoles: false });
|
return Result.ok({ canRemoveMember: false, canUpdateRoles: false });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId);
|
const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId);
|
||||||
if (!membership || membership.status !== 'active') {
|
if (!membership || membership.status !== 'active') {
|
||||||
presenter.present({ canRemoveMember: false, canUpdateRoles: false });
|
return Result.ok({ canRemoveMember: false, canUpdateRoles: false });
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business logic: owners and admins can remove members and update roles
|
// Business logic: owners and admins can remove members and update roles
|
||||||
const canRemoveMember = membership.role === 'owner' || membership.role === 'admin';
|
const canRemoveMember = membership.role === 'owner' || membership.role === 'admin';
|
||||||
const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin';
|
const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin';
|
||||||
|
|
||||||
presenter.reset();
|
return Result.ok({ canRemoveMember, canUpdateRoles });
|
||||||
presenter.present({ canRemoveMember, canUpdateRoles });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface GetLeagueAdminPermissionsUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
performerDriverId: string;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,29 +1,19 @@
|
|||||||
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
|
||||||
import { GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { IGetLeagueAdminPresenter } from '../presenters/IGetLeagueAdminPresenter';
|
import { Result } from '@core/shared/result/Result';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
|
import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams';
|
||||||
|
import type { GetLeagueAdminResultDTO } from '../dto/GetLeagueAdminResultDTO';
|
||||||
|
|
||||||
export interface GetLeagueAdminUseCaseParams {
|
export class GetLeagueAdminUseCase implements AsyncUseCase<GetLeagueAdminUseCaseParams, Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
|
||||||
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<GetLeagueAdminUseCaseParams, GetLeagueAdminResultDTO, GetLeagueAdminPermissionsViewModel, IGetLeagueAdminPresenter> {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetLeagueAdminUseCaseParams, presenter: IGetLeagueAdminPresenter): Promise<void> {
|
async execute(params: GetLeagueAdminUseCaseParams): Promise<Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
|
||||||
const league = await this.leagueRepository.findById(params.leagueId);
|
const league = await this.leagueRepository.findById(params.leagueId);
|
||||||
if (!league) {
|
if (!league) {
|
||||||
throw new Error('League not found');
|
return Result.err(new RacingDomainValidationError('League not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const dto: GetLeagueAdminResultDTO = {
|
const dto: GetLeagueAdminResultDTO = {
|
||||||
@@ -32,7 +22,6 @@ export class GetLeagueAdminUseCase implements UseCase<GetLeagueAdminUseCaseParam
|
|||||||
ownerId: league.ownerId,
|
ownerId: league.ownerId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
presenter.reset();
|
return Result.ok(dto);
|
||||||
presenter.present(dto);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface GetLeagueAdminUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { GetLeagueDriverSeasonStatsUseCase } from './GetLeagueDriverSeasonStatsUseCase';
|
||||||
|
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||||
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
|
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||||
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
|
import type { DriverRatingPort } from './DriverRatingPort';
|
||||||
|
import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams';
|
||||||
|
|
||||||
|
describe('GetLeagueDriverSeasonStatsUseCase', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,34 +2,18 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep
|
|||||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||||
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
|
||||||
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
|
||||||
import type {
|
import type { LeagueDriverSeasonStatsResultDTO } from '../presenters/ILeagueDriverSeasonStatsPresenter';
|
||||||
ILeagueDriverSeasonStatsPresenter,
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
LeagueDriverSeasonStatsResultDTO,
|
import { Result } from '@core/shared/result/Result';
|
||||||
LeagueDriverSeasonStatsViewModel,
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
} from '../presenters/ILeagueDriverSeasonStatsPresenter';
|
import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import type { DriverRatingPort } from './DriverRatingPort';
|
||||||
|
|
||||||
export interface DriverRatingPort {
|
|
||||||
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetLeagueDriverSeasonStatsUseCaseParams {
|
|
||||||
leagueId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving league driver season statistics.
|
* 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
|
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<GetLeagueDriverSeasonStatsUseCaseParams, Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
|
||||||
implements
|
|
||||||
UseCase<
|
|
||||||
GetLeagueDriverSeasonStatsUseCaseParams,
|
|
||||||
LeagueDriverSeasonStatsResultDTO,
|
|
||||||
LeagueDriverSeasonStatsViewModel,
|
|
||||||
ILeagueDriverSeasonStatsPresenter
|
|
||||||
>
|
|
||||||
{
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly standingRepository: IStandingRepository,
|
private readonly standingRepository: IStandingRepository,
|
||||||
private readonly resultRepository: IResultRepository,
|
private readonly resultRepository: IResultRepository,
|
||||||
@@ -38,11 +22,7 @@ export class GetLeagueDriverSeasonStatsUseCase
|
|||||||
private readonly driverRatingPort: DriverRatingPort,
|
private readonly driverRatingPort: DriverRatingPort,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(
|
async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise<Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
|
||||||
params: GetLeagueDriverSeasonStatsUseCaseParams,
|
|
||||||
presenter: ILeagueDriverSeasonStatsPresenter,
|
|
||||||
): Promise<void> {
|
|
||||||
presenter.reset();
|
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
// Get standings and races for the league
|
// Get standings and races for the league
|
||||||
@@ -62,15 +42,15 @@ export class GetLeagueDriverSeasonStatsUseCase
|
|||||||
for (const p of penaltiesForLeague) {
|
for (const p of penaltiesForLeague) {
|
||||||
// Only count applied penalties
|
// Only count applied penalties
|
||||||
if (p.status !== 'applied') continue;
|
if (p.status !== 'applied') continue;
|
||||||
|
|
||||||
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
|
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
|
||||||
|
|
||||||
// Convert penalty to points delta based on type
|
// Convert penalty to points delta based on type
|
||||||
if (p.type === 'points_deduction' && p.value) {
|
if (p.type === 'points_deduction' && p.value) {
|
||||||
// Points deductions are negative
|
// Points deductions are negative
|
||||||
current.baseDelta -= p.value;
|
current.baseDelta -= p.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
penaltiesByDriver.set(p.driverId, current);
|
penaltiesByDriver.set(p.driverId, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +84,6 @@ export class GetLeagueDriverSeasonStatsUseCase
|
|||||||
driverRatings,
|
driverRatings,
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.present(dto);
|
return Result.ok(dto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export interface GetLeagueDriverSeasonStatsUseCaseParams {
|
||||||
|
leagueId: string;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,29 +7,29 @@ import type {
|
|||||||
LeagueFullConfigData,
|
LeagueFullConfigData,
|
||||||
LeagueConfigFormViewModel,
|
LeagueConfigFormViewModel,
|
||||||
} from '../presenters/ILeagueFullConfigPresenter';
|
} from '../presenters/ILeagueFullConfigPresenter';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import { EntityNotFoundError } from '../errors/RacingApplicationError';
|
import { Result } from '@core/shared/result/Result';
|
||||||
|
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use Case for retrieving a league's full configuration.
|
* Use Case for retrieving a league's full configuration.
|
||||||
* Orchestrates domain logic and delegates presentation to the presenter.
|
* Orchestrates domain logic and delegates presentation to the presenter.
|
||||||
*/
|
*/
|
||||||
export class GetLeagueFullConfigUseCase
|
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
|
||||||
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
|
|
||||||
{
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueRepository: ILeagueRepository,
|
private readonly leagueRepository: ILeagueRepository,
|
||||||
private readonly seasonRepository: ISeasonRepository,
|
private readonly seasonRepository: ISeasonRepository,
|
||||||
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
|
||||||
private readonly gameRepository: IGameRepository,
|
private readonly gameRepository: IGameRepository,
|
||||||
|
private readonly presenter: ILeagueFullConfigPresenter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> {
|
async execute(params: { leagueId: string }): Promise<Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
|
||||||
const { leagueId } = params;
|
const { leagueId } = params;
|
||||||
|
|
||||||
const league = await this.leagueRepository.findById(leagueId);
|
const league = await this.leagueRepository.findById(leagueId);
|
||||||
if (!league) {
|
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);
|
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
|
||||||
@@ -54,7 +54,13 @@ export class GetLeagueFullConfigUseCase
|
|||||||
...(game ? { game } : {}),
|
...(game ? { game } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
presenter.reset();
|
this.presenter.reset();
|
||||||
presenter.present(data);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,29 @@
|
|||||||
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase';
|
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
|
||||||
import { LeagueJoinRequestsPresenter } from '@apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter';
|
import { GetLeagueJoinRequestsUseCase } from './GetLeagueJoinRequestsUseCase';
|
||||||
|
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
|
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||||
|
import { Driver } from '../../domain/entities/Driver';
|
||||||
|
|
||||||
describe('GetLeagueJoinRequestsUseCase', () => {
|
describe('GetLeagueJoinRequestsUseCase', () => {
|
||||||
let useCase: GetLeagueJoinRequestsUseCase;
|
let useCase: GetLeagueJoinRequestsUseCase;
|
||||||
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
|
let leagueMembershipRepository: {
|
||||||
let driverRepository: jest.Mocked<IDriverRepository>;
|
getJoinRequests: Mock;
|
||||||
let presenter: LeagueJoinRequestsPresenter;
|
};
|
||||||
|
let driverRepository: {
|
||||||
|
findById: Mock;
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
leagueMembershipRepository = {
|
leagueMembershipRepository = {
|
||||||
getJoinRequests: jest.fn(),
|
getJoinRequests: vi.fn(),
|
||||||
} as unknown;
|
};
|
||||||
driverRepository = {
|
driverRepository = {
|
||||||
findByIds: jest.fn(),
|
findById: vi.fn(),
|
||||||
} as unknown;
|
};
|
||||||
presenter = new LeagueJoinRequestsPresenter();
|
useCase = new GetLeagueJoinRequestsUseCase(
|
||||||
useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository);
|
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
|
||||||
|
driverRepository as unknown as IDriverRepository,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return join requests with drivers', async () => {
|
it('should return join requests with drivers', async () => {
|
||||||
@@ -25,22 +31,30 @@ describe('GetLeagueJoinRequestsUseCase', () => {
|
|||||||
const joinRequests = [
|
const joinRequests = [
|
||||||
{ id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' },
|
{ 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);
|
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([
|
expect(result.isOk()).toBe(true);
|
||||||
{
|
expect(result.unwrap()).toEqual({
|
||||||
id: 'req-1',
|
joinRequests: [
|
||||||
leagueId,
|
{
|
||||||
driverId: 'driver-1',
|
id: 'req-1',
|
||||||
requestedAt: expect.any(Date),
|
leagueId,
|
||||||
message: 'msg',
|
driverId: 'driver-1',
|
||||||
driver: { id: 'driver-1', name: 'Driver 1' },
|
requestedAt: expect.any(Date),
|
||||||
},
|
message: 'msg',
|
||||||
]);
|
driver: { id: 'driver-1', name: 'Driver 1' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,33 +1,27 @@
|
|||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||||
import type { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '../presenters/IGetLeagueJoinRequestsPresenter';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import { Result } from '@core/shared/result/Result';
|
||||||
|
import type { GetLeagueJoinRequestsUseCaseParams } from '../dto/GetLeagueJoinRequestsUseCaseParams';
|
||||||
|
import type { GetLeagueJoinRequestsResultDTO } from '../dto/GetLeagueJoinRequestsResultDTO';
|
||||||
|
|
||||||
export interface GetLeagueJoinRequestsUseCaseParams {
|
export class GetLeagueJoinRequestsUseCase implements AsyncUseCase<GetLeagueJoinRequestsUseCaseParams, Result<GetLeagueJoinRequestsResultDTO, never>> {
|
||||||
leagueId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetLeagueJoinRequestsResultDTO {
|
|
||||||
joinRequests: unknown[];
|
|
||||||
drivers: { id: string; name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetLeagueJoinRequestsUseCase implements UseCase<GetLeagueJoinRequestsUseCaseParams, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel, IGetLeagueJoinRequestsPresenter> {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetLeagueJoinRequestsUseCaseParams, presenter: IGetLeagueJoinRequestsPresenter): Promise<void> {
|
async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise<Result<GetLeagueJoinRequestsResultDTO, never>> {
|
||||||
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
|
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
|
||||||
const driverIds = joinRequests.map(r => r.driverId);
|
const driverIds = [...new Set(joinRequests.map(r => r.driverId))];
|
||||||
const drivers = await this.driverRepository.findByIds(driverIds);
|
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
|
||||||
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }]));
|
const driverMap = new Map(drivers.filter(d => d !== null).map(d => [d!.id, { id: d!.id, name: d!.name }]));
|
||||||
const dto: GetLeagueJoinRequestsResultDTO = {
|
const enrichedJoinRequests = joinRequests.map(request => ({
|
||||||
joinRequests,
|
...request,
|
||||||
drivers: Array.from(driverMap.values()),
|
driver: driverMap.get(request.driverId)!,
|
||||||
};
|
}));
|
||||||
presenter.reset();
|
return Result.ok({
|
||||||
presenter.present(dto);
|
joinRequests: enrichedJoinRequests,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
|
||||||
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
|
||||||
import type { LeagueMembership } from '../../domain/entities/LeagueMembership';
|
import type { AsyncUseCase } from '@core/shared/application';
|
||||||
import type { IGetLeagueMembershipsPresenter, GetLeagueMembershipsViewModel } from '../presenters/IGetLeagueMembershipsPresenter';
|
import { Result } from '@core/shared/result/Result';
|
||||||
import type { UseCase } from '@core/shared/application/UseCase';
|
import type { GetLeagueMembershipsResultDTO } from '../dto/GetLeagueMembershipsResultDTO';
|
||||||
|
|
||||||
export interface GetLeagueMembershipsUseCaseParams {
|
export interface GetLeagueMembershipsUseCaseParams {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetLeagueMembershipsResultDTO {
|
export class GetLeagueMembershipsUseCase implements AsyncUseCase<GetLeagueMembershipsUseCaseParams, Result<GetLeagueMembershipsResultDTO, never>> {
|
||||||
memberships: LeagueMembership[];
|
|
||||||
drivers: { id: string; name: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetLeagueMembershipsUseCase implements UseCase<GetLeagueMembershipsUseCaseParams, GetLeagueMembershipsResultDTO, GetLeagueMembershipsViewModel, IGetLeagueMembershipsPresenter> {
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
|
||||||
private readonly driverRepository: IDriverRepository,
|
private readonly driverRepository: IDriverRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(params: GetLeagueMembershipsUseCaseParams, presenter: IGetLeagueMembershipsPresenter): Promise<void> {
|
async execute(params: GetLeagueMembershipsUseCaseParams): Promise<Result<GetLeagueMembershipsResultDTO, never>> {
|
||||||
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
|
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
|
||||||
const drivers: { id: string; name: string }[] = [];
|
const drivers: { id: string; name: string }[] = [];
|
||||||
|
|
||||||
@@ -35,7 +30,6 @@ export class GetLeagueMembershipsUseCase implements UseCase<GetLeagueMemberships
|
|||||||
memberships,
|
memberships,
|
||||||
drivers,
|
drivers,
|
||||||
};
|
};
|
||||||
presenter.reset();
|
return Result.ok(dto);
|
||||||
presenter.present(dto);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 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';
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
import type { IEntity } from '@core/shared/domain';
|
||||||
import type { SessionType } from '../value-objects/SessionType';
|
import { SessionType } from '../value-objects/SessionType';
|
||||||
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
export class Race implements IEntity<string> {
|
export class Race implements IEntity<string> {
|
||||||
|
|||||||
@@ -110,6 +110,6 @@ export class Money implements IValueObject<MoneyProps> {
|
|||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
return formatter.format(this.amount);
|
return formatter.format(this.amount / 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<T, E> 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<Input, Result<SuccessDTO, YourUseCaseError>> {
|
||||||
|
* async execute(input: Input): Promise<Result<SuccessDTO, YourUseCaseError>> {
|
||||||
|
* // implementation
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @template Input - The input type for the use case
|
||||||
|
* @template Output - The output type returned by the use case, often Result<T, DomainErrorCode>
|
||||||
|
*/
|
||||||
export interface AsyncUseCase<Input, Output> {
|
export interface AsyncUseCase<Input, Output> {
|
||||||
execute(input: Input): Promise<Output>;
|
execute(input: Input): Promise<Output>;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,34 @@
|
|||||||
import type { Presenter } from '../presentation';
|
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<void, E> 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<CreateRaceInput, Result<void, CreateRaceError>, ViewModel, Presenter<Result<void, CreateRaceError>, ViewModel>> {
|
||||||
|
* execute(input: CreateRaceInput, presenter: Presenter<Result<void, CreateRaceError>, ViewModel>): Promise<void> {
|
||||||
|
* // implementation
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @template Input - The input type for the use case
|
||||||
|
* @template OutputDTO - The output DTO type, often Result<void, DomainErrorCode>
|
||||||
|
* @template ViewModel - The view model type
|
||||||
|
* @template P - The presenter type, extending Presenter<OutputDTO, ViewModel>
|
||||||
|
*/
|
||||||
export interface UseCase<Input, OutputDTO, ViewModel, P extends Presenter<OutputDTO, ViewModel>> {
|
export interface UseCase<Input, OutputDTO, ViewModel, P extends Presenter<OutputDTO, ViewModel>> {
|
||||||
execute(input: Input, presenter: P): Promise<void> | void;
|
execute(input: Input, presenter: P): Promise<void> | void;
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* @deprecated Use error codes in Result instead of throwing ApplicationError.
|
||||||
|
*/
|
||||||
export type CommonApplicationErrorKind =
|
export type CommonApplicationErrorKind =
|
||||||
| 'not_found'
|
| 'not_found'
|
||||||
| 'forbidden'
|
| 'forbidden'
|
||||||
|
|||||||
Reference in New Issue
Block a user