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