This commit is contained in:
2025-12-16 18:17:48 +01:00
parent 362894d1a5
commit ec7c0b8f2a
94 changed files with 4240 additions and 983 deletions

View File

@@ -0,0 +1,4 @@
export interface AcceptSponsorshipRequestDTO {
requestId: string;
respondedBy: string; // driverId of the person accepting
}

View File

@@ -0,0 +1,8 @@
export interface AcceptSponsorshipRequestResultDTO {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}

View 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;
}

View File

@@ -0,0 +1,5 @@
export interface ApplyForSponsorshipResultDTO {
requestId: string;
status: 'pending';
createdAt: Date;
}

View File

@@ -0,0 +1,4 @@
export interface ApproveLeagueJoinRequestResultDTO {
success: boolean;
message: string;
}

View File

@@ -0,0 +1,3 @@
export interface CancelRaceCommandDTO {
raceId: string;
}

View File

@@ -0,0 +1,3 @@
export interface CompleteRaceCommandDTO {
raceId: string;
}

View File

@@ -0,0 +1,6 @@
export interface CreateLeagueWithSeasonAndScoringResultDTO {
leagueId: string;
seasonId: string;
scoringPresetId?: string;
scoringPresetName?: string;
}

View File

@@ -0,0 +1,10 @@
export interface CreateSponsorResultDTO {
sponsor: {
id: string;
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
createdAt: Date;
};
}

View File

@@ -0,0 +1,6 @@
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
entityId: string;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
export interface GetLeagueAdminPermissionsResultDTO {
canRemoveMember: boolean;
canUpdateRoles: boolean;
}

View File

@@ -0,0 +1,7 @@
export interface GetLeagueAdminResultDTO {
league: {
id: string;
ownerId: string;
};
// Additional data would be populated by combining multiple use cases
}

View File

@@ -0,0 +1,13 @@
export interface GetLeagueJoinRequestsResultDTO {
joinRequests: Array<{
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
driver: {
id: string;
name: string;
};
}>;
}

View File

@@ -0,0 +1,3 @@
export interface GetLeagueJoinRequestsUseCaseParams {
leagueId: string;
}

View File

@@ -0,0 +1,6 @@
import type { LeagueMembership } from '../../domain/entities/LeagueMembership';
export interface GetLeagueMembershipsResultDTO {
memberships: LeagueMembership[];
drivers: { id: string; name: string }[];
}

View 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;
}

View File

@@ -1,8 +1,5 @@
import type { Team } from '../../domain/entities/Team'; import type { Team } from '../../domain/entities/Team';
import type { import type { TeamMembership } from '../../domain/types/TeamMembership';
TeamJoinRequest,
TeamMembership,
} from '../../domain/types/TeamMembership';
export interface JoinTeamCommandDTO { export interface JoinTeamCommandDTO {
teamId: string; teamId: string;
@@ -15,6 +12,7 @@ export interface LeaveTeamCommandDTO {
} }
export interface ApproveTeamJoinRequestCommandDTO { export interface ApproveTeamJoinRequestCommandDTO {
teamId: string;
requestId: string; requestId: string;
} }

View File

@@ -1,4 +1,5 @@
import type { Presenter } from '@core/shared/presentation'; import type { Presenter } from '@core/shared/presentation';
import type { FeedItemType } from '@core/social/domain/types/FeedItemType';
export interface DashboardDriverSummaryViewModel { export interface DashboardDriverSummaryViewModel {
id: string; id: string;
@@ -44,7 +45,7 @@ export interface DashboardLeagueStandingSummaryViewModel {
export interface DashboardFeedItemSummaryViewModel { export interface DashboardFeedItemSummaryViewModel {
id: string; id: string;
type: string; type: FeedItemType;
headline: string; headline: string;
body?: string; body?: string;
timestamp: string; timestamp: string;

View File

@@ -139,7 +139,9 @@ describe('AcceptSponsorshipRequestUseCase', () => {
respondedBy: 'driver1', respondedBy: 'driver1',
}); });
expect(result).toBeDefined(); expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto).toBeDefined();
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({ expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
recipientId: 'sponsor1', recipientId: 'sponsor1',
type: 'sponsorship_request_accepted', type: 'sponsorship_request_accepted',
@@ -149,7 +151,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
urgency: 'toast', urgency: 'toast',
data: { data: {
requestId: 'req1', requestId: 'req1',
sponsorshipId: expect.any(String), sponsorshipId: dto.sponsorshipId,
}, },
}); });
expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith( expect(mockPaymentGateway.processPayment).toHaveBeenCalledWith(

View File

@@ -1,6 +1,6 @@
/** /**
* Use Case: AcceptSponsorshipRequestUseCase * Use Case: AcceptSponsorshipRequestUseCase
* *
* Allows an entity owner to accept a sponsorship request. * Allows an entity owner to accept a sponsorship request.
* This creates an active sponsorship and notifies the sponsor. * This creates an active sponsorship and notifies the sponsor.
*/ */
@@ -15,23 +15,16 @@ import type { IWalletRepository } from '@core/payments/domain/repositories/IWall
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository'; import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship'; import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
export interface AcceptSponsorshipRequestDTO { import {
requestId: string; RacingDomainValidationError,
respondedBy: string; // driverId of the person accepting RacingDomainInvariantError,
} } from '../../domain/errors/RacingDomainError';
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
export interface AcceptSponsorshipRequestResultDTO { import type { AcceptSponsorshipRequestResultDTO } from '../dto/AcceptSponsorshipRequestResultDTO';
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export class AcceptSponsorshipRequestUseCase export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO> { implements AsyncUseCase<AcceptSponsorshipRequestDTO, Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
constructor( constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
@@ -43,118 +36,113 @@ export class AcceptSponsorshipRequestUseCase
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<AcceptSponsorshipRequestResultDTO> { async execute(dto: AcceptSponsorshipRequestDTO): Promise<Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy }); this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
try {
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
throw new Error('Sponsorship request not found');
}
if (!request.isPending()) { // Find the request
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status }); const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
throw new Error(`Cannot accept a ${request.status} sponsorship request`); if (!request) {
} this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
return Result.err(new RacingDomainValidationError('Sponsorship request not found'));
this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId });
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId });
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType });
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId });
throw new Error('Season not found for sponsorship request');
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
// Notify the sponsor
await this.notificationService.sendNotification({
recipientId: request.sponsorId,
type: 'sponsorship_request_accepted',
title: 'Sponsorship Accepted',
body: `Your sponsorship request for ${season.name} has been accepted.`,
channel: 'in_app',
urgency: 'toast',
data: {
requestId: request.id,
sponsorshipId,
},
});
// Process payment
const paymentResult = await this.paymentGateway.processPayment(
request.offeredAmount,
request.sponsorId,
`Sponsorship payment for ${request.entityType} ${request.entityId}`,
{ requestId: request.id }
);
if (!paymentResult.success) {
this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id });
throw new Error('Payment processing failed');
}
// Update wallets
const sponsorWallet = await this.walletRepository.findById(request.sponsorId);
if (!sponsorWallet) {
this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, { sponsorId: request.sponsorId });
throw new Error('Sponsor wallet not found');
}
const leagueWallet = await this.leagueWalletRepository.findById(season.leagueId);
if (!leagueWallet) {
this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, { leagueId: season.leagueId });
throw new Error('League wallet not found');
}
const netAmount = acceptedRequest.getNetAmount();
// Deduct from sponsor wallet
const updatedSponsorWallet = {
...sponsorWallet,
balance: sponsorWallet.balance - request.offeredAmount.amount,
};
await this.walletRepository.update(updatedSponsorWallet);
// Add to league wallet
const updatedLeagueWallet = leagueWallet.addFunds(netAmount, paymentResult.transactionId!);
await this.leagueWalletRepository.update(updatedLeagueWallet);
}
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
return {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
};
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.error(`Failed to accept sponsorship request ${dto.requestId}: ${err.message}`, err, { requestId: dto.requestId });
throw err;
} }
if (!request.isPending()) {
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, status: request.status });
return Result.err(new RacingDomainValidationError(`Cannot accept a ${request.status} sponsorship request`));
}
this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId });
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId });
// If this is a season sponsorship, create the SeasonSponsorship record
let sponsorshipId = `spons_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (request.entityType === 'season') {
this.logger.debug(`Sponsorship request ${dto.requestId} is for a season. Creating SeasonSponsorship record.`, { requestId: dto.requestId, entityType: request.entityType });
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId });
return Result.err(new RacingDomainValidationError('Season not found for sponsorship request'));
}
const sponsorship = SeasonSponsorship.create({
id: sponsorshipId,
seasonId: season.id,
leagueId: season.leagueId,
sponsorId: request.sponsorId,
tier: request.tier,
pricing: request.offeredAmount,
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
// Notify the sponsor
await this.notificationService.sendNotification({
recipientId: request.sponsorId,
type: 'sponsorship_request_accepted',
title: 'Sponsorship Accepted',
body: `Your sponsorship request for ${season.name} has been accepted.`,
channel: 'in_app',
urgency: 'toast',
data: {
requestId: request.id,
sponsorshipId,
},
});
// Process payment
const paymentResult = await this.paymentGateway.processPayment(
request.offeredAmount,
request.sponsorId,
`Sponsorship payment for ${request.entityType} ${request.entityId}`,
{ requestId: request.id }
);
if (!paymentResult.success) {
this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id });
return Result.err(new RacingDomainInvariantError('Payment processing failed'));
}
// Update wallets
const sponsorWallet = await this.walletRepository.findById(request.sponsorId);
if (!sponsorWallet) {
this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, { sponsorId: request.sponsorId });
return Result.err(new RacingDomainInvariantError('Sponsor wallet not found'));
}
const leagueWallet = await this.leagueWalletRepository.findById(season.leagueId);
if (!leagueWallet) {
this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, { leagueId: season.leagueId });
return Result.err(new RacingDomainInvariantError('League wallet not found'));
}
const netAmount = acceptedRequest.getNetAmount();
// Deduct from sponsor wallet
const updatedSponsorWallet = {
...sponsorWallet,
balance: sponsorWallet.balance - request.offeredAmount.amount,
};
await this.walletRepository.update(updatedSponsorWallet);
// Add to league wallet
const updatedLeagueWallet = leagueWallet.addFunds(netAmount, paymentResult.transactionId!);
await this.leagueWalletRepository.update(updatedLeagueWallet);
}
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
return Result.ok({
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
});
} }
} }

View File

@@ -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',
})
);
});
});

View File

@@ -1,41 +1,24 @@
/** /**
* Use Case: ApplyForSponsorshipUseCase * Use Case: ApplyForSponsorshipUseCase
* *
* Allows a sponsor to apply for a sponsorship slot on any entity * Allows a sponsor to apply for a sponsorship slot on any entity
* (driver, team, race, or season/league). * (driver, team, race, or season/league).
*/ */
import { SponsorshipRequest, type SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money, type Currency } from '../../domain/value-objects/Money'; import { Money } from '../../domain/value-objects/Money';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import { import { Result } from '@core/shared/result/Result';
EntityNotFoundError,
BusinessRuleViolationError,
} from '../errors/RacingApplicationError';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export interface ApplyForSponsorshipDTO { import type { ApplyForSponsorshipDTO } from '../dto/ApplyForSponsorshipDTO';
sponsorId: string; import type { ApplyForSponsorshipResultDTO } from '../dto/ApplyForSponsorshipResultDTO';
entityType: SponsorableEntityType;
entityId: string;
tier: SponsorshipTier;
offeredAmount: number; // in cents
currency?: Currency;
message?: string;
}
export interface ApplyForSponsorshipResultDTO {
requestId: string;
status: 'pending';
createdAt: Date;
}
export class ApplyForSponsorshipUseCase export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO> implements AsyncUseCase<ApplyForSponsorshipDTO, Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
@@ -44,37 +27,33 @@ export class ApplyForSponsorshipUseCase
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(dto: ApplyForSponsorshipDTO): Promise<ApplyForSponsorshipResultDTO> { async execute(dto: ApplyForSponsorshipDTO): Promise<Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>> {
this.logger.debug('Attempting to apply for sponsorship', { dto }); this.logger.debug('Attempting to apply for sponsorship', { dto });
// Validate sponsor exists // Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId); const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) { if (!sponsor) {
this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId }); this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId });
throw new EntityNotFoundError({ entity: 'sponsor', id: dto.sponsorId }); return Result.err(new RacingDomainValidationError('Sponsor not found'));
} }
// Check if entity accepts sponsorship applications // Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) { if (!pricing) {
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId }); this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError('This entity has not set up sponsorship pricing'); return Result.err(new RacingDomainValidationError('This entity has not set up sponsorship pricing'));
} }
if (!pricing.acceptingApplications) { if (!pricing.acceptingApplications) {
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId }); this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError( return Result.err(new RacingDomainValidationError('This entity is not currently accepting sponsorship applications'));
'This entity is not currently accepting sponsorship applications',
);
} }
// Check if the requested tier slot is available // Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier); const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) { if (!slotAvailable) {
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`); this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
throw new BusinessRuleViolationError( return Result.err(new RacingDomainValidationError(`No ${dto.tier} sponsorship slots are available`));
`No ${dto.tier} sponsorship slots are available`,
);
} }
// Check if sponsor already has a pending request for this entity // Check if sponsor already has a pending request for this entity
@@ -85,18 +64,14 @@ export class ApplyForSponsorshipUseCase
); );
if (hasPending) { if (hasPending) {
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId }); this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
throw new BusinessRuleViolationError( return Result.err(new RacingDomainValidationError('You already have a pending sponsorship request for this entity'));
'You already have a pending sponsorship request for this entity',
);
} }
// Validate offered amount meets minimum price // Validate offered amount meets minimum price
const minPrice = pricing.getPrice(dto.tier); const minPrice = pricing.getPrice(dto.tier);
if (minPrice && dto.offeredAmount < minPrice.amount) { if (minPrice && dto.offeredAmount < minPrice.amount) {
this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`); this.logger.warn(`Offered amount ${dto.offeredAmount} is less than minimum ${minPrice.amount} for entity ${dto.entityId}, tier ${dto.tier}`);
throw new BusinessRuleViolationError( return Result.err(new RacingDomainValidationError(`Offered amount must be at least ${minPrice.format()}`));
`Offered amount must be at least ${minPrice.format()}`,
);
} }
// Create the sponsorship request // Create the sponsorship request
@@ -115,10 +90,10 @@ export class ApplyForSponsorshipUseCase
await this.sponsorshipRequestRepo.create(request); await this.sponsorshipRequestRepo.create(request);
return { return Result.ok({
requestId: request.id, requestId: request.id,
status: 'pending', status: 'pending',
createdAt: request.createdAt, createdAt: request.createdAt,
}; });
} }
} }

View 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;
}

View 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',
})
);
});
});

View File

@@ -1,32 +1,24 @@
/** /**
* Application Use Case: ApplyPenaltyUseCase * Application Use Case: ApplyPenaltyUseCase
* *
* Allows a steward to apply a penalty to a driver for an incident during a race. * Allows a steward to apply a penalty to a driver for an incident during a race.
* The penalty can be standalone or linked to an upheld protest. * The penalty can be standalone or linked to an upheld protest.
*/ */
import { Penalty, type PenaltyType } from '../../domain/entities/Penalty'; import { Penalty } from '../../domain/entities/Penalty';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export interface ApplyPenaltyCommand { import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand';
raceId: string;
driverId: string;
stewardId: string;
type: PenaltyType;
value?: number;
reason: string;
protestId?: string;
notes?: string;
}
export class ApplyPenaltyUseCase export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }> { implements AsyncUseCase<ApplyPenaltyCommand, Result<{ penaltyId: string }, RacingDomainValidationError>> {
constructor( constructor(
private readonly penaltyRepository: IPenaltyRepository, private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository, private readonly protestRepository: IProtestRepository,
@@ -35,70 +27,66 @@ export class ApplyPenaltyUseCase
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: ApplyPenaltyCommand): Promise<{ penaltyId: string }> { async execute(command: ApplyPenaltyCommand): Promise<Result<{ penaltyId: string }, RacingDomainValidationError>> {
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command); this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
try {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
throw new Error('Race not found');
}
this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`);
// Validate steward has authority (owner or admin of the league) // Validate race exists
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId); const race = await this.raceRepository.findById(command.raceId);
const stewardMembership = memberships.find( if (!race) {
m => m.driverId === command.stewardId && m.status === 'active' this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
); return Result.err(new RacingDomainValidationError('Race not found'));
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
throw new Error('Only league owners and admins can apply penalties');
}
this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`);
// If linked to a protest, validate the protest exists and is upheld
if (command.protestId) {
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
throw new Error('Protest not found');
}
if (protest.status !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
throw new Error('Can only create penalties for upheld protests');
}
if (protest.raceId !== command.raceId) {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`);
throw new Error('Protest is not for this race');
}
this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`);
}
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`);
return { penaltyId: penalty.id };
} catch (error) {
this.logger.error('ApplyPenaltyUseCase: Failed to apply penalty', error, { command });
throw error;
} }
this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`);
// Validate steward has authority (owner or admin of the league)
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const stewardMembership = memberships.find(
m => m.driverId === command.stewardId && m.status === 'active'
);
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
return Result.err(new RacingDomainValidationError('Only league owners and admins can apply penalties'));
}
this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`);
// If linked to a protest, validate the protest exists and is upheld
if (command.protestId) {
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
return Result.err(new RacingDomainValidationError('Protest not found'));
}
if (protest.status !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
return Result.err(new RacingDomainValidationError('Can only create penalties for upheld protests'));
}
if (protest.raceId !== command.raceId) {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`);
return Result.err(new RacingDomainValidationError('Protest is not for this race'));
}
this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`);
}
// Create the penalty
const penalty = Penalty.create({
id: randomUUID(),
leagueId: race.leagueId,
raceId: command.raceId,
driverId: command.driverId,
type: command.type,
...(command.value !== undefined ? { value: command.value } : {}),
reason: command.reason,
...(command.protestId !== undefined ? { protestId: command.protestId } : {}),
issuedBy: command.stewardId,
status: 'pending',
issuedAt: new Date(),
...(command.notes !== undefined ? { notes: command.notes } : {}),
});
await this.penaltyRepository.create(penalty);
this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`);
return Result.ok({ penaltyId: penalty.id });
} }
} }

View File

@@ -1,28 +1,23 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IApproveLeagueJoinRequestPresenter, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel } from '../presenters/IApproveLeagueJoinRequestPresenter'; import { Result } from '@core/shared/result/Result';
import type { UseCase } from '@core/shared/application/UseCase'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { AsyncUseCase } from '@core/shared/application';
import { randomUUID } from 'crypto';
import type { ApproveLeagueJoinRequestUseCaseParams } from './ApproveLeagueJoinRequestUseCaseParams';
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
export interface ApproveLeagueJoinRequestUseCaseParams { export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
leagueId: string;
requestId: string;
}
export interface ApproveLeagueJoinRequestResultDTO {
success: boolean;
message: string;
}
export class ApproveLeagueJoinRequestUseCase implements UseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, ApproveLeagueJoinRequestViewModel, IApproveLeagueJoinRequestPresenter> {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {} constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
async execute(params: ApproveLeagueJoinRequestUseCaseParams, presenter: IApproveLeagueJoinRequestPresenter): Promise<void> { async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise<Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
const request = requests.find(r => r.id === params.requestId); const request = requests.find(r => r.id === params.requestId);
if (!request) { if (!request) {
throw new Error('Join request not found'); return Result.err(new RacingDomainValidationError('Join request not found'));
} }
await this.leagueMembershipRepository.removeJoinRequest(params.requestId); await this.leagueMembershipRepository.removeJoinRequest(params.requestId);
await this.leagueMembershipRepository.saveMembership({ await this.leagueMembershipRepository.saveMembership({
id: randomUUID(),
leagueId: params.leagueId, leagueId: params.leagueId,
driverId: request.driverId, driverId: request.driverId,
role: 'member', role: 'member',
@@ -30,7 +25,6 @@ export class ApproveLeagueJoinRequestUseCase implements UseCase<ApproveLeagueJoi
joinedAt: new Date(), joinedAt: new Date(),
}); });
const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' }; const dto: ApproveLeagueJoinRequestResultDTO = { success: true, message: 'Join request approved.' };
presenter.reset(); return Result.ok(dto);
presenter.present(dto);
} }
} }

View File

@@ -0,0 +1,4 @@
export interface ApproveLeagueJoinRequestUseCaseParams {
leagueId: string;
requestId: string;
}

View File

@@ -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');
});
});

View File

@@ -1,4 +1,3 @@
import type { Logger } from '@core/shared/application';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { import type {
TeamMembership, TeamMembership,
@@ -8,36 +7,24 @@ import type {
} from '../../domain/types/TeamMembership'; } from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO'; import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export class ApproveTeamJoinRequestUseCase export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void> { implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, Result<void, RacingDomainValidationError>> {
constructor( constructor(
private readonly membershipRepository: ITeamMembershipRepository, private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
) {} ) {}
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<void> { async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<Result<void, RacingDomainValidationError>> {
const { requestId } = command; const { teamId, requestId } = command;
this.logger.debug(
`Attempting to approve team join request with ID: ${requestId}`,
);
// There is no repository method to look up a single request by ID, const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId);
try { const request = allRequests.find((r) => r.id === requestId);
// There is no repository method to look up a single request by ID,
// so we rely on the repository implementation to surface all relevant
// requests via getJoinRequests and search by ID here.
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(
// For the in-memory fake used in tests, the teamId argument is ignored
// and all requests are returned.'
'' as string,
);
const request = allRequests.find((r) => r.id === requestId);
if (!request) { if (!request) {
this.logger.warn(`Team join request with ID ${requestId} not found`); return Result.err(new RacingDomainValidationError('Join request not found'));
throw new Error('Join request not found'); }
}
const membership: TeamMembership = { const membership: TeamMembership = {
teamId: request.teamId, teamId: request.teamId,
@@ -48,14 +35,7 @@ export class ApproveTeamJoinRequestUseCase
}; };
await this.membershipRepository.saveMembership(membership); await this.membershipRepository.saveMembership(membership);
this.logger.info(
`Team membership created for driver ${request.driverId} in team ${request.teamId} from request ${requestId}`,
);
await this.membershipRepository.removeJoinRequest(requestId); await this.membershipRepository.removeJoinRequest(requestId);
this.logger.info(`Team join request with ID ${requestId} removed`); return Result.ok(undefined);
} catch (error) {
this.logger.error(`Failed to approve team join request ${requestId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
}
} }
} }

View 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`);
});
});

View File

@@ -1,28 +1,27 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError, RacingDomainInvariantError } from '../../domain/errors/RacingDomainError';
import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
/** /**
* Use Case: CancelRaceUseCase * Use Case: CancelRaceUseCase
* *
* Encapsulates the workflow for cancelling a race: * Encapsulates the workflow for cancelling a race:
* - loads the race by id * - loads the race by id
* - throws if the race does not exist * - returns error if the race does not exist
* - delegates cancellation rules to the Race domain entity * - delegates cancellation rules to the Race domain entity
* - persists the updated race via the repository. * - persists the updated race via the repository.
*/ */
export interface CancelRaceCommandDTO {
raceId: string;
}
export class CancelRaceUseCase export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, void> { implements AsyncUseCase<CancelRaceCommandDTO, Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
constructor( constructor(
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(command: CancelRaceCommandDTO): Promise<void> { async execute(command: CancelRaceCommandDTO): Promise<Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
const { raceId } = command; const { raceId } = command;
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`); this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
@@ -30,14 +29,19 @@ export class CancelRaceUseCase
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`); this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
throw new Error('Race not found'); return Result.err(new RacingDomainValidationError('Race not found'));
} }
const cancelledRace = race.cancel(); const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace); await this.raceRepository.update(cancelledRace);
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`); this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
return Result.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`[CancelRaceUseCase] Error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error))); if (error instanceof RacingDomainInvariantError || error instanceof RacingDomainValidationError) {
this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`);
return Result.err(error);
}
this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
throw error; throw error;
} }
} }

View File

@@ -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
}

View File

@@ -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);
});
});

View File

@@ -1,8 +1,12 @@
import type { UseCase } from '@core/shared/application/UseCase'; import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository'; import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@core/shared/domain'; import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent';
import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed'; import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CloseRaceEventStewardingCommand } from './CloseRaceEventStewardingCommand';
import type { RaceEvent } from '../../domain/entities/RaceEvent';
/** /**
* Use Case: CloseRaceEventStewardingUseCase * Use Case: CloseRaceEventStewardingUseCase
@@ -13,12 +17,8 @@ import type { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEve
* This would typically be run by a scheduled job (e.g., every 5 minutes) * This would typically be run by a scheduled job (e.g., every 5 minutes)
* to automatically close stewarding windows based on league configuration. * to automatically close stewarding windows based on league configuration.
*/ */
export interface CloseRaceEventStewardingCommand {
// No parameters needed - finds all expired events automatically
}
export class CloseRaceEventStewardingUseCase export class CloseRaceEventStewardingUseCase
implements UseCase<CloseRaceEventStewardingCommand, void, void, void> implements AsyncUseCase<CloseRaceEventStewardingCommand, Result<void, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
@@ -27,26 +27,34 @@ export class CloseRaceEventStewardingUseCase
private readonly domainEventPublisher: IDomainEventPublisher, private readonly domainEventPublisher: IDomainEventPublisher,
) {} ) {}
async execute(command: CloseRaceEventStewardingCommand): Promise<void> { // eslint-disable-next-line @typescript-eslint/no-unused-vars
// Find all race events awaiting stewarding that have expired windows async execute(_command: CloseRaceEventStewardingCommand): Promise<Result<void, RacingDomainValidationError>> {
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose(); try {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
for (const raceEvent of expiredEvents) { for (const raceEvent of expiredEvents) {
await this.closeStewardingForRaceEvent(raceEvent); await this.closeStewardingForRaceEvent(raceEvent);
}
return Result.ok(undefined);
} catch (error) {
this.logger.error('Failed to close race event stewarding', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError('Failed to close stewarding for race events'));
} }
} }
private async closeStewardingForRaceEvent(raceEvent: any): Promise<void> { private async closeStewardingForRaceEvent(raceEvent: RaceEvent): Promise<void> {
try { try {
// Close the stewarding window // Close the stewarding window
const closedRaceEvent = raceEvent.closeStewarding(); const closedRaceEvent = raceEvent.closeStewarding();
await this.raceEventRepository.update(closedRaceEvent); await this.raceEventRepository.update(closedRaceEvent);
// Get list of participating drivers (would need to be implemented) // Get list of participating drivers (would need to be implemented)
const driverIds = await this.getParticipatingDriverIds(raceEvent); const driverIds = await this.getParticipatingDriverIds();
// Check if any penalties were applied during stewarding // Check if any penalties were applied during stewarding
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent); const hadPenaltiesApplied = await this.checkForAppliedPenalties();
// Publish domain event to trigger final results notifications // Publish domain event to trigger final results notifications
const event = new RaceEventStewardingClosedEvent({ const event = new RaceEventStewardingClosedEvent({
@@ -62,28 +70,19 @@ export class CloseRaceEventStewardingUseCase
} catch (error) { } catch (error) {
this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(error))); this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(error)));
// In production, this would trigger alerts/monitoring // TODO: In production, this would trigger alerts/monitoring
} }
} }
private async getParticipatingDriverIds(raceEvent: any): Promise<string[]> { private async getParticipatingDriverIds(): Promise<string[]> {
// In a real implementation, this would query race registrations // TODO: Implement query for participating driver IDs from race event registrations
// For the prototype, we'll return a mock list // This would typically involve querying race registrations for the event
// This would typically involve: return [];
// 1. Get all sessions in the race event
// 2. For each session, get registered drivers
// 3. Return unique driver IDs across all sessions
// Mock implementation for prototype
return ['driver-1', 'driver-2', 'driver-3']; // Would be dynamic in real implementation
} }
private async checkForAppliedPenalties(raceEvent: any): Promise<boolean> { private async checkForAppliedPenalties(): Promise<boolean> {
// In a real implementation, this would check if any penalties were issued // TODO: Implement check for applied penalties during stewarding window
// during the stewarding window for this race event
// This would query the penalty repository for penalties related to this race event // This would query the penalty repository for penalties related to this race event
return false;
// Mock implementation for prototype - randomly simulate penalties
return Math.random() > 0.7; // 30% chance of penalties being applied
} }
} }

View File

@@ -0,0 +1,9 @@
export interface CompleteDriverOnboardingCommand {
userId: string;
firstName: string;
lastName: string;
displayName: string;
country: string;
timezone?: string;
bio?: string;
}

View File

@@ -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,
})
);
});
});

View File

@@ -1,60 +1,40 @@
import type { AsyncUseCase } from '@core/shared/application';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../presenters/ICompleteDriverOnboardingPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import { Driver } from '../../domain/entities/Driver'; import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/result/Result';
export interface CompleteDriverOnboardingInput { import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
userId: string; import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
firstName: string;
lastName: string;
displayName: string;
country: string;
timezone?: string;
bio?: string;
}
/** /**
* Use Case for completing driver onboarding. * Use Case for completing driver onboarding.
*/ */
export class CompleteDriverOnboardingUseCase export class CompleteDriverOnboardingUseCase
implements UseCase<CompleteDriverOnboardingInput, CompleteDriverOnboardingResultDTO, any, ICompleteDriverOnboardingPresenter> implements AsyncUseCase<CompleteDriverOnboardingCommand, Result<{ driverId: string }, RacingDomainValidationError>>
{ {
constructor(private readonly driverRepository: IDriverRepository) {} constructor(private readonly driverRepository: IDriverRepository) {}
async execute(input: CompleteDriverOnboardingInput, presenter: ICompleteDriverOnboardingPresenter): Promise<void> { async execute(command: CompleteDriverOnboardingCommand): Promise<Result<{ driverId: string }, RacingDomainValidationError>> {
presenter.reset();
try { try {
// Check if driver already exists // Check if driver already exists
const existing = await this.driverRepository.findById(input.userId); const existing = await this.driverRepository.findById(command.userId);
if (existing) { if (existing) {
presenter.present({ return Result.err(new RacingDomainValidationError('Driver already exists'));
success: false,
errorMessage: 'Driver already exists',
});
return;
} }
// Create new driver // Create new driver
const driver = Driver.create({ const driver = Driver.create({
id: input.userId, id: command.userId,
iracingId: input.userId, // Assuming userId is iracingId for now iracingId: command.userId, // Assuming userId is iracingId for now
name: input.displayName, name: command.displayName,
country: input.country, country: command.country,
bio: input.bio, ...(command.bio !== undefined ? { bio: command.bio } : {}),
}); });
await this.driverRepository.save(driver); await this.driverRepository.create(driver);
presenter.present({ return Result.ok({ driverId: driver.id });
success: true,
driverId: driver.id,
});
} catch (error) { } catch (error) {
presenter.present({ return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown error',
});
} }
} }
} }

View 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');
});
});

View File

@@ -6,79 +6,67 @@ import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result'; import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing'; import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result as SharedResult } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
/** /**
* Use Case: CompleteRaceUseCase * Use Case: CompleteRaceUseCase
* *
* Encapsulates the workflow for completing a race: * Encapsulates the workflow for completing a race:
* - loads the race by id * - loads the race by id
* - throws if the race does not exist * - returns error if the race does not exist
* - delegates completion rules to the Race domain entity * - delegates completion rules to the Race domain entity
* - automatically generates realistic results for registered drivers * - automatically generates realistic results for registered drivers
* - updates league standings * - updates league standings
* - persists all changes via repositories. * - persists all changes via repositories.
*/ */
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCase export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, void> { implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<{}, RacingDomainValidationError>> {
constructor( constructor(
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository, private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider, private readonly driverRatingProvider: DriverRatingProvider,
private readonly logger: Logger,
) {} ) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> { async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, RacingDomainValidationError>> {
this.logger.debug(`Executing CompleteRaceUseCase for raceId: ${command.raceId}`);
const { raceId } = command;
try { try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
this.logger.error(`Race with id ${raceId} not found.`); return SharedResult.err(new RacingDomainValidationError('Race not found'));
throw new Error('Race not found');
} }
this.logger.debug(`Race ${raceId} found. Status: ${race.status}`);
// Get registered drivers for this race // Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) { if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers found for race ${raceId}.`); return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
throw new Error('Cannot complete race with no registered drivers');
} }
this.logger.info(`${registeredDriverIds.length} drivers registered for race ${raceId}. Generating results.`);
// Get driver ratings // Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug(`Driver ratings fetched for ${registeredDriverIds.length} drivers.`);
// Generate realistic race results // Generate realistic race results
const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings); const results = this.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.debug(`Generated ${results.length} race results for race ${raceId}.`);
// Save results // Save results
for (const result of results) { for (const result of results) {
await this.resultRepository.create(result); await this.resultRepository.create(result);
} }
this.logger.info(`Persisted ${results.length} race results for race ${raceId}.`);
// Update standings // Update standings
await this.updateStandings(race.leagueId, results); await this.updateStandings(race.leagueId, results);
this.logger.info(`Standings updated for league ${race.leagueId}.`);
// Complete the race // Complete the race
const completedRace = race.complete(); const completedRace = race.complete();
await this.raceRepository.update(completedRace); await this.raceRepository.update(completedRace);
this.logger.info(`Race ${raceId} successfully completed and updated.`);
return SharedResult.ok({});
} catch (error) { } catch (error) {
this.logger.error(`Failed to complete race ${raceId}: ${error.message}`, error as Error); return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
throw error;
} }
} }
@@ -87,7 +75,6 @@ export class CompleteRaceUseCase
driverIds: string[], driverIds: string[],
driverRatings: Map<string, number> driverRatings: Map<string, number>
): Result[] { ): Result[] {
this.logger.debug(`Generating race results for race ${raceId} with ${driverIds.length} drivers.`);
// Create driver performance data // Create driver performance data
const driverPerformances = driverIds.map(driverId => ({ const driverPerformances = driverIds.map(driverId => ({
driverId, driverId,
@@ -101,7 +88,6 @@ export class CompleteRaceUseCase
const perfB = b.rating + (b.randomFactor * 200); const perfB = b.rating + (b.randomFactor * 200);
return perfB - perfA; // Higher performance first return perfB - perfA; // Higher performance first
}); });
this.logger.debug(`Driver performances sorted for race ${raceId}.`);
// Generate qualifying results for start positions (similar but different from race results) // Generate qualifying results for start positions (similar but different from race results)
const qualiPerformances = driverPerformances.map(p => ({ const qualiPerformances = driverPerformances.map(p => ({
@@ -113,12 +99,11 @@ export class CompleteRaceUseCase
const perfB = b.rating + (b.randomFactor * 150); const perfB = b.rating + (b.randomFactor * 150);
return perfB - perfA; return perfB - perfA;
}); });
this.logger.debug(`Qualifying performances generated for race ${raceId}.`);
// Generate results // Generate results
const results: Result[] = []; const results: Result[] = [];
for (let i = 0; i < driverPerformances.length; i++) { for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i]; const { driverId } = driverPerformances[i]!;
const position = i + 1; const position = i + 1;
const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1; const startPosition = qualiPerformances.findIndex(p => p.driverId === driverId) + 1;
@@ -143,13 +128,11 @@ export class CompleteRaceUseCase
}) })
); );
} }
this.logger.debug(`Individual results created for race ${raceId}.`);
return results; return results;
} }
private async updateStandings(leagueId: string, results: Result[]): Promise<void> { private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
this.logger.debug(`Updating standings for league ${leagueId} with ${results.length} results.`);
// Group results by driver // Group results by driver
const resultsByDriver = new Map<string, Result[]>(); const resultsByDriver = new Map<string, Result[]>();
for (const result of results) { for (const result of results) {
@@ -157,7 +140,6 @@ export class CompleteRaceUseCase
existing.push(result); existing.push(result);
resultsByDriver.set(result.driverId, existing); resultsByDriver.set(result.driverId, existing);
} }
this.logger.debug(`Results grouped by driver for league ${leagueId}.`);
// Update or create standings for each driver // Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) { for (const [driverId, driverResults] of resultsByDriver) {
@@ -168,9 +150,6 @@ export class CompleteRaceUseCase
leagueId, leagueId,
driverId, driverId,
}); });
this.logger.debug(`Created new standing for driver ${driverId} in league ${leagueId}.`);
} else {
this.logger.debug(`Found existing standing for driver ${driverId} in league ${leagueId}.`);
} }
// Add all results for this driver (should be just one for this race) // Add all results for this driver (should be just one for this race)
@@ -181,8 +160,6 @@ export class CompleteRaceUseCase
} }
await this.standingRepository.save(standing); await this.standingRepository.save(standing);
this.logger.debug(`Standing saved for driver ${driverId} in league ${leagueId}.`);
} }
this.logger.info(`Standings update complete for league ${leagueId}.`);
} }
} }

View File

@@ -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');
});
});

View File

@@ -8,17 +8,15 @@ import { Standing } from '../../domain/entities/Standing';
import { RaceResultGenerator } from '../utils/RaceResultGenerator'; import { RaceResultGenerator } from '../utils/RaceResultGenerator';
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
import type { AsyncUseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application'; import { Result as SharedResult } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
/** /**
* Enhanced CompleteRaceUseCase that includes rating updates * Enhanced CompleteRaceUseCase that includes rating updates
*/ */
export interface CompleteRaceCommandDTO {
raceId: string;
}
export class CompleteRaceUseCaseWithRatings export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, void> { implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<void, RacingDomainValidationError>> {
constructor( constructor(
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository, private readonly raceRegistrationRepository: IRaceRegistrationRepository,
@@ -26,64 +24,47 @@ export class CompleteRaceUseCaseWithRatings
private readonly standingRepository: IStandingRepository, private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider, private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService, private readonly ratingUpdateService: RatingUpdateService,
private readonly logger: Logger,
) {} ) {}
async execute(command: CompleteRaceCommandDTO): Promise<void> { async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<void, RacingDomainValidationError>> {
const { raceId } = command;
this.logger.debug(`Attempting to complete race with ID: ${raceId}`);
try { try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId); const race = await this.raceRepository.findById(raceId);
if (!race) { if (!race) {
this.logger.error(`Race not found for ID: ${raceId}`); return SharedResult.err(new RacingDomainValidationError('Race not found'));
throw new Error('Race not found');
} }
this.logger.debug(`Found race: ${race.id}`);
// Get registered drivers for this race // Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId); const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) { if (registeredDriverIds.length === 0) {
this.logger.warn(`No registered drivers for race ID: ${raceId}. Cannot complete race.`); return SharedResult.err(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
throw new Error('Cannot complete race with no registered drivers');
} }
this.logger.debug(`Found ${registeredDriverIds.length} registered drivers for race ID: ${raceId}`);
// Get driver ratings // Get driver ratings
this.logger.debug('Fetching driver ratings...');
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds); const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
this.logger.debug('Driver ratings fetched.');
// Generate realistic race results // Generate realistic race results
this.logger.debug('Generating race results...');
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings); const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
this.logger.info(`Generated ${results.length} race results for race ID: ${raceId}`);
// Save results // Save results
this.logger.debug('Saving race results...');
for (const result of results) { for (const result of results) {
await this.resultRepository.create(result); await this.resultRepository.create(result);
} }
this.logger.info('Race results saved successfully.');
// Update standings // Update standings
this.logger.debug(`Updating standings for league ID: ${race.leagueId}`);
await this.updateStandings(race.leagueId, results); await this.updateStandings(race.leagueId, results);
this.logger.info('Standings updated successfully.');
// Update driver ratings based on performance // Update driver ratings based on performance
this.logger.debug('Updating driver ratings...');
await this.updateDriverRatings(results, registeredDriverIds.length); await this.updateDriverRatings(results, registeredDriverIds.length);
this.logger.info('Driver ratings updated successfully.');
// Complete the race // Complete the race
this.logger.debug(`Marking race ID: ${raceId} as complete...`);
const completedRace = race.complete(); const completedRace = race.complete();
await this.raceRepository.update(completedRace); await this.raceRepository.update(completedRace);
this.logger.info(`Race ID: ${raceId} completed successfully.`);
return SharedResult.ok(undefined);
} catch (error) { } catch (error) {
this.logger.error(`Error completing race ${raceId}`, error instanceof Error ? error : new Error(String(error))); return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
throw error;
} }
} }

View File

@@ -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;
}

View File

@@ -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');
});
});

View File

@@ -14,43 +14,13 @@ import {
LeagueVisibility, LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS, MIN_RANKED_LEAGUE_DRIVERS,
} from '../../domain/value-objects/LeagueVisibility'; } from '../../domain/value-objects/LeagueVisibility';
import { Result } from '@core/shared/result/Result';
/** import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
* League visibility/ranking mode. import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand';
* - 'ranked' (or legacy 'public'): Competitive, public, affects driver ratings. Min 10 drivers. import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO';
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
export type LeagueVisibilityInput = 'ranked' | 'unranked' | 'public' | 'private';
export interface CreateLeagueWithSeasonAndScoringCommand {
name: string;
description?: string;
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityInput;
ownerId: string;
gameId: string;
maxDrivers?: number;
maxTeams?: number;
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
export interface CreateLeagueWithSeasonAndScoringResultDTO {
leagueId: string;
seasonId: string;
scoringPresetId?: string;
scoringPresetName?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO> { implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository, private readonly seasonRepository: ISeasonRepository,
@@ -61,11 +31,14 @@ export class CreateLeagueWithSeasonAndScoringUseCase
async execute( async execute(
command: CreateLeagueWithSeasonAndScoringCommand, command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<CreateLeagueWithSeasonAndScoringResultDTO> { ): Promise<Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command }); this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
const validation = this.validate(command);
if (validation.isErr()) {
return Result.err(validation.unwrapErr());
}
this.logger.info('Command validated successfully.');
try { try {
this.validate(command);
this.logger.info('Command validated successfully.');
const leagueId = uuidv4(); const leagueId = uuidv4();
this.logger.debug(`Generated leagueId: ${leagueId}`); this.logger.debug(`Generated leagueId: ${leagueId}`);
@@ -108,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
if (!preset) { if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`); this.logger.error(`Unknown scoring preset: ${presetId}`);
throw new Error(`Unknown scoring preset: ${presetId}`); return Result.err(new RacingDomainValidationError(`Unknown scoring preset: ${presetId}`));
} }
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`); this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
@@ -119,45 +92,44 @@ export class CreateLeagueWithSeasonAndScoringUseCase
await this.leagueScoringConfigRepository.save(finalConfig); await this.leagueScoringConfigRepository.save(finalConfig);
this.logger.info(`Scoring configuration saved for season ${seasonId}.`); this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
const result = { const result: CreateLeagueWithSeasonAndScoringResultDTO = {
leagueId: league.id, leagueId: league.id,
seasonId, seasonId,
scoringPresetId: preset.id, scoringPresetId: preset.id,
scoringPresetName: preset.name, scoringPresetName: preset.name,
}; };
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result }); this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return result; return Result.ok(result);
} catch (error) { } catch (error) {
this.logger.error('Error during CreateLeagueWithSeasonAndScoringUseCase execution.', error, { command }); return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
throw error;
} }
} }
private validate(command: CreateLeagueWithSeasonAndScoringCommand): void { private validate(command: CreateLeagueWithSeasonAndScoringCommand): Result<void, RacingDomainValidationError> {
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command }); this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
if (!command.name || command.name.trim().length === 0) { if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: League name is required', { command }); this.logger.warn('Validation failed: League name is required', { command });
throw new Error('League name is required'); return Result.err(new RacingDomainValidationError('League name is required'));
} }
if (!command.ownerId || command.ownerId.trim().length === 0) { if (!command.ownerId || command.ownerId.trim().length === 0) {
this.logger.warn('Validation failed: League ownerId is required', { command }); this.logger.warn('Validation failed: League ownerId is required', { command });
throw new Error('League ownerId is required'); return Result.err(new RacingDomainValidationError('League ownerId is required'));
} }
if (!command.gameId || command.gameId.trim().length === 0) { if (!command.gameId || command.gameId.trim().length === 0) {
this.logger.warn('Validation failed: gameId is required', { command }); this.logger.warn('Validation failed: gameId is required', { command });
throw new Error('gameId is required'); return Result.err(new RacingDomainValidationError('gameId is required'));
} }
if (!command.visibility) { if (!command.visibility) {
this.logger.warn('Validation failed: visibility is required', { command }); this.logger.warn('Validation failed: visibility is required', { command });
throw new Error('visibility is required'); return Result.err(new RacingDomainValidationError('visibility is required'));
} }
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) { if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command }); this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command });
throw new Error('maxDrivers must be greater than 0 when provided'); return Result.err(new RacingDomainValidationError('maxDrivers must be greater than 0 when provided'));
} }
const visibility = LeagueVisibility.fromString(command.visibility); const visibility = LeagueVisibility.fromString(command.visibility);
if (visibility.isRanked()) { if (visibility.isRanked()) {
const driverCount = command.maxDrivers ?? 0; const driverCount = command.maxDrivers ?? 0;
if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) { if (driverCount < MIN_RANKED_LEAGUE_DRIVERS) {
@@ -165,13 +137,14 @@ export class CreateLeagueWithSeasonAndScoringUseCase
`Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`, `Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`,
{ command } { command }
); );
throw new Error( return Result.err(new RacingDomainValidationError(
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` + `Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
`Current setting: ${driverCount}. ` + `Current setting: ${driverCount}. ` +
`For smaller groups, consider creating an Unranked (Friends) league instead.` `For smaller groups, consider creating an Unranked (Friends) league instead.`
); ));
} }
} }
this.logger.debug('Validation successful.'); this.logger.debug('Validation successful.');
return Result.ok(undefined);
} }
} }

View File

@@ -0,0 +1,6 @@
export interface CreateSponsorCommand {
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
}

View 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');
});
});

View File

@@ -3,59 +3,90 @@
* *
* Creates a new sponsor. * Creates a new sponsor.
*/ */
import { v4 as uuidv4 } from 'uuid';
import { Sponsor } from '../../domain/entities/Sponsor';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository'; import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { import type { AsyncUseCase } from '@core/shared/application';
ICreateSponsorPresenter, import type { Logger } from '@core/shared/application';
CreateSponsorResultDTO, import { Result } from '@core/shared/result/Result';
CreateSponsorViewModel, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/ICreateSponsorPresenter'; import type { CreateSponsorCommand } from './CreateSponsorCommand';
import type { UseCase } from '@core/shared/application/UseCase'; import type { CreateSponsorResultDTO } from '../dto/CreateSponsorResultDTO';
export interface CreateSponsorInput {
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
}
export class CreateSponsorUseCase export class CreateSponsorUseCase
implements UseCase<CreateSponsorInput, CreateSponsorResultDTO, CreateSponsorViewModel, ICreateSponsorPresenter> implements AsyncUseCase<CreateSponsorCommand, Result<CreateSponsorResultDTO, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly sponsorRepository: ISponsorRepository, private readonly sponsorRepository: ISponsorRepository,
private readonly logger: Logger,
) {} ) {}
async execute( async execute(
input: CreateSponsorInput, command: CreateSponsorCommand,
presenter: ICreateSponsorPresenter, ): Promise<Result<CreateSponsorResultDTO, RacingDomainValidationError>> {
): Promise<void> { this.logger.debug('Executing CreateSponsorUseCase', { command });
presenter.reset(); const validation = this.validate(command);
if (validation.isErr()) {
return Result.err(validation.unwrapErr());
}
this.logger.info('Command validated successfully.');
try {
const sponsorId = uuidv4();
this.logger.debug(`Generated sponsorId: ${sponsorId}`);
const id = `sponsor-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const sponsor = Sponsor.create({
id: sponsorId,
name: command.name,
contactEmail: command.contactEmail,
...(command.websiteUrl !== undefined ? { websiteUrl: command.websiteUrl } : {}),
...(command.logoUrl !== undefined ? { logoUrl: command.logoUrl } : {}),
});
const sponsor = Sponsor.create({ await this.sponsorRepository.create(sponsor);
id, this.logger.info(`Sponsor ${sponsor.name} (${sponsor.id}) created successfully.`);
name: input.name,
contactEmail: input.contactEmail,
...(input.websiteUrl !== undefined ? { websiteUrl: input.websiteUrl } : {}),
...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl } : {}),
} as unknown);
await this.sponsorRepository.create(sponsor); const result: CreateSponsorResultDTO = {
sponsor: {
id: sponsor.id,
name: sponsor.name,
contactEmail: sponsor.contactEmail,
websiteUrl: sponsor.websiteUrl,
logoUrl: sponsor.logoUrl,
createdAt: sponsor.createdAt,
},
};
this.logger.debug('CreateSponsorUseCase completed successfully.', { result });
return Result.ok(result);
} catch (error) {
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
}
}
const dto: CreateSponsorResultDTO = { private validate(command: CreateSponsorCommand): Result<void, RacingDomainValidationError> {
sponsor: { this.logger.debug('Validating CreateSponsorCommand', { command });
id: sponsor.id, if (!command.name || command.name.trim().length === 0) {
name: sponsor.name, this.logger.warn('Validation failed: Sponsor name is required', { command });
contactEmail: sponsor.contactEmail, return Result.err(new RacingDomainValidationError('Sponsor name is required'));
websiteUrl: sponsor.websiteUrl, }
logoUrl: sponsor.logoUrl, if (!command.contactEmail || command.contactEmail.trim().length === 0) {
createdAt: sponsor.createdAt, this.logger.warn('Validation failed: Sponsor contact email is required', { command });
}, return Result.err(new RacingDomainValidationError('Sponsor contact email is required'));
}; }
// Basic email validation
presenter.present(dto); const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.contactEmail)) {
this.logger.warn('Validation failed: Invalid sponsor contact email format', { command });
return Result.err(new RacingDomainValidationError('Invalid sponsor contact email format'));
}
if (command.websiteUrl && command.websiteUrl.trim().length > 0) {
try {
new URL(command.websiteUrl);
} catch {
this.logger.warn('Validation failed: Invalid sponsor website URL', { command });
return Result.err(new RacingDomainValidationError('Invalid sponsor website URL'));
}
}
this.logger.debug('Validation successful.');
return Result.ok(undefined);
} }
} }

View 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');
});
});

View File

@@ -1,3 +1,9 @@
/**
* Application Use Case: CreateTeamUseCase
*
* Creates a new team.
*/
import { v4 as uuidv4 } from 'uuid';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { Team } from '../../domain/entities/Team'; import { Team } from '../../domain/entities/Team';
@@ -10,44 +16,67 @@ import type {
CreateTeamCommandDTO, CreateTeamCommandDTO,
CreateTeamResultDTO, CreateTeamResultDTO,
} from '../dto/CreateTeamCommandDTO'; } from '../dto/CreateTeamCommandDTO';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export class CreateTeamUseCase { export class CreateTeamUseCase
implements AsyncUseCase<CreateTeamCommandDTO, Result<CreateTeamResultDTO, RacingDomainValidationError>>
{
constructor( constructor(
private readonly teamRepository: ITeamRepository, private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository, private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
) {} ) {}
async execute(command: CreateTeamCommandDTO): Promise<CreateTeamResultDTO> { async execute(
command: CreateTeamCommandDTO,
): Promise<Result<CreateTeamResultDTO, RacingDomainValidationError>> {
this.logger.debug('Executing CreateTeamUseCase', { command });
const { name, tag, description, ownerId, leagues } = command; const { name, tag, description, ownerId, leagues } = command;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver( const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId, ownerId,
); );
if (existingMembership) { if (existingMembership) {
throw new Error('Driver already belongs to a team'); this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId });
return Result.err(new RacingDomainValidationError('Driver already belongs to a team'));
} }
const team = Team.create({ this.logger.info('Command validated successfully.');
id: `team-${Date.now()}`, try {
name, const teamId = uuidv4();
tag, this.logger.debug(`Generated teamId: ${teamId}`);
description,
ownerId,
leagues,
});
const createdTeam = await this.teamRepository.create(team); const team = Team.create({
id: teamId,
name,
tag,
description,
ownerId,
leagues,
});
const membership: TeamMembership = { const createdTeam = await this.teamRepository.create(team);
teamId: createdTeam.id, this.logger.info(`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`);
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership); const membership: TeamMembership = {
teamId: createdTeam.id,
driverId: ownerId,
role: 'owner' as TeamRole,
status: 'active' as TeamMembershipStatus,
joinedAt: new Date(),
};
return { team: createdTeam }; await this.membershipRepository.saveMembership(membership);
this.logger.debug('Team membership created successfully.');
const result: CreateTeamResultDTO = { team: createdTeam };
this.logger.debug('CreateTeamUseCase completed successfully.', { result });
return Result.ok(result);
} catch (error) {
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
}
} }
} }

View File

@@ -0,0 +1,3 @@
export interface DashboardOverviewParams {
driverId: string;
}

View File

@@ -1,34 +1,14 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { GetDashboardOverviewUseCase } from '@core/racing/application/use-cases/GetDashboardOverviewUseCase'; import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import { Driver } from '@core/racing/domain/entities/Driver'; import { Driver } from '@core/racing/domain/entities/Driver';
import { Race } from '@core/racing/domain/entities/Race'; import { Race } from '@core/racing/domain/entities/Race';
import { Result } from '@core/racing/domain/entities/Result'; import { Result } from '@core/racing/domain/entities/Result';
import { League } from '@core/racing/domain/entities/League'; import { League } from '@core/racing/domain/entities/League';
import { Standing } from '@core/racing/domain/entities/Standing'; import { Standing } from '@core/racing/domain/entities/Standing';
import { LeagueMembership, JoinRequest } from '@core/racing/domain/entities/LeagueMembership';
import type { FeedItem } from '@core/social/domain/types/FeedItem'; import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel,
DashboardFeedItemSummaryViewModel,
} from '@core/racing/application/presenters/IDashboardOverviewPresenter';
class FakeDashboardOverviewPresenter implements IDashboardOverviewPresenter {
viewModel: DashboardOverviewViewModel | null = null;
reset(): void {
this.viewModel = null;
}
present(viewModel: DashboardOverviewViewModel): void {
this.viewModel = viewModel;
}
getViewModel(): DashboardOverviewViewModel | null {
return this.viewModel;
}
}
interface TestImageService { interface TestImageService {
getDriverAvatar(driverId: string): string; getDriverAvatar(driverId: string): string;
@@ -46,7 +26,7 @@ function createTestImageService(): TestImageService {
}; };
} }
describe('GetDashboardOverviewUseCase', () => { describe('DashboardOverviewUseCase', () => {
it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => { it('partitions upcoming races into myUpcomingRaces and otherUpcomingRaces and selects nextRace from myUpcomingRaces', async () => {
// Given a driver with memberships in two leagues and future races with mixed registration // Given a driver with memberships in two leagues and future races with mixed registration
const driverId = 'driver-1'; const driverId = 'driver-1';
@@ -189,10 +169,10 @@ describe('GetDashboardOverviewUseCase', () => {
); );
}, },
getLeagueMembers: async (): Promise<LeagueMembership[]> => [], getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [], getJoinRequests: async (): Promise<JoinRequest[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); }, saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); }, removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); }, saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
}; };
@@ -234,9 +214,7 @@ describe('GetDashboardOverviewUseCase', () => {
} }
: null; : null;
const presenter = new FakeDashboardOverviewPresenter(); const useCase = new DashboardOverviewUseCase(
const useCase = new GetDashboardOverviewUseCase(
driverRepository, driverRepository,
raceRepository, raceRepository,
resultRepository, resultRepository,
@@ -251,12 +229,10 @@ describe('GetDashboardOverviewUseCase', () => {
); );
// When // When
await useCase.execute({ driverId }, presenter); const result = await useCase.execute({ driverId });
expect(result.isOk()).toBe(true);
const viewModel = presenter.getViewModel(); const vm = result.unwrap();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then myUpcomingRaces only contains registered races from the driver's leagues // Then myUpcomingRaces only contains registered races from the driver's leagues
expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']); expect(vm.myUpcomingRaces.map((r) => r.id)).toEqual(['race-1', 'race-3']);
@@ -422,10 +398,10 @@ describe('GetDashboardOverviewUseCase', () => {
); );
}, },
getLeagueMembers: async (): Promise<LeagueMembership[]> => [], getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [], getJoinRequests: async (): Promise<JoinRequest[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); }, saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); }, removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); }, saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
}; };
@@ -464,9 +440,7 @@ describe('GetDashboardOverviewUseCase', () => {
} }
: null; : null;
const presenter = new FakeDashboardOverviewPresenter(); const useCase = new DashboardOverviewUseCase(
const useCase = new GetDashboardOverviewUseCase(
driverRepository, driverRepository,
raceRepository, raceRepository,
resultRepository, resultRepository,
@@ -481,12 +455,10 @@ describe('GetDashboardOverviewUseCase', () => {
); );
// When // When
await useCase.execute({ driverId }, presenter); const result = await useCase.execute({ driverId });
expect(result.isOk()).toBe(true);
const viewModel = presenter.getViewModel(); const vm = result.unwrap();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then recentResults are sorted by finishedAt descending (newest first) // Then recentResults are sorted by finishedAt descending (newest first)
expect(vm.recentResults.length).toBe(2); expect(vm.recentResults.length).toBe(2);
@@ -584,10 +556,10 @@ describe('GetDashboardOverviewUseCase', () => {
const leagueMembershipRepository = { const leagueMembershipRepository = {
getMembership: async (): Promise<LeagueMembership | null> => null, getMembership: async (): Promise<LeagueMembership | null> => null,
getLeagueMembers: async (): Promise<LeagueMembership[]> => [], getLeagueMembers: async (): Promise<LeagueMembership[]> => [],
getJoinRequests: async (): Promise<any[]> => [], getJoinRequests: async (): Promise<JoinRequest[]> => [],
saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); }, saveMembership: async (): Promise<LeagueMembership> => { throw new Error('Not implemented'); },
removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); }, removeMembership: async (): Promise<void> => { throw new Error('Not implemented'); },
saveJoinRequest: async (): Promise<any> => { throw new Error('Not implemented'); }, saveJoinRequest: async (): Promise<JoinRequest> => { throw new Error('Not implemented'); },
removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); }, removeJoinRequest: async (): Promise<void> => { throw new Error('Not implemented'); },
}; };
@@ -616,9 +588,7 @@ describe('GetDashboardOverviewUseCase', () => {
const getDriverStats = () => null; const getDriverStats = () => null;
const presenter = new FakeDashboardOverviewPresenter(); const useCase = new DashboardOverviewUseCase(
const useCase = new GetDashboardOverviewUseCase(
driverRepository, driverRepository,
raceRepository, raceRepository,
resultRepository, resultRepository,
@@ -633,12 +603,10 @@ describe('GetDashboardOverviewUseCase', () => {
); );
// When // When
await useCase.execute({ driverId }, presenter); const result = await useCase.execute({ driverId });
expect(result.isOk()).toBe(true);
const viewModel = presenter.getViewModel(); const vm = result.unwrap();
expect(viewModel).not.toBeNull();
const vm = viewModel!;
// Then collections are empty and no errors are thrown // Then collections are empty and no errors are thrown
expect(vm.myUpcomingRaces).toEqual([]); expect(vm.myUpcomingRaces).toEqual([]);

View File

@@ -8,8 +8,16 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IImageServicePort } from '../ports/IImageServicePort'; import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository'; import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository'; import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/result/Result';
import { RacingDomainError } from '../../domain/errors/RacingDomainError';
import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import { Result as RaceResult } from '../../domain/entities/Result';
import { Driver } from '../../domain/entities/Driver';
import { Standing } from '../../domain/entities/Standing';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type { DashboardOverviewParams } from './DashboardOverviewParams';
import type { import type {
IDashboardOverviewPresenter,
DashboardOverviewViewModel, DashboardOverviewViewModel,
DashboardDriverSummaryViewModel, DashboardDriverSummaryViewModel,
DashboardRaceSummaryViewModel, DashboardRaceSummaryViewModel,
@@ -29,11 +37,7 @@ interface DashboardDriverStatsAdapter {
consistency: number | null; consistency: number | null;
} }
export interface GetDashboardOverviewParams { export class DashboardOverviewUseCase {
driverId: string;
}
export class GetDashboardOverviewUseCase {
constructor( constructor(
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
@@ -48,7 +52,7 @@ export class GetDashboardOverviewUseCase {
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null, private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {} ) {}
async execute(params: GetDashboardOverviewParams, presenter: IDashboardOverviewPresenter): Promise<void> { async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewViewModel, RacingDomainError>> {
const { driverId } = params; const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([ const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
@@ -134,12 +138,11 @@ export class GetDashboardOverviewUseCase {
friends: friendsSummary, friends: friendsSummary,
}; };
presenter.reset(); return Result.ok(viewModel);
presenter.present(viewModel);
} }
private async getDriverLeagues(allLeagues: unknown[], driverId: string): Promise<any[]> { private async getDriverLeagues(allLeagues: League[], driverId: string): Promise<League[]> {
const driverLeagues: unknown[] = []; const driverLeagues: League[] = [];
for (const league of allLeagues) { for (const league of allLeagues) {
const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId); const membership = await this.leagueMembershipRepository.getMembership(league.id, driverId);
@@ -152,7 +155,7 @@ export class GetDashboardOverviewUseCase {
} }
private async partitionUpcomingRacesByRegistration( private async partitionUpcomingRacesByRegistration(
upcomingRaces: unknown[], upcomingRaces: Race[],
driverId: string, driverId: string,
leagueMap: Map<string, string>, leagueMap: Map<string, string>,
): Promise<{ ): Promise<{
@@ -177,7 +180,7 @@ export class GetDashboardOverviewUseCase {
} }
private mapRaceToSummary( private mapRaceToSummary(
race: any, race: Race,
leagueMap: Map<string, string>, leagueMap: Map<string, string>,
isMyLeague: boolean, isMyLeague: boolean,
): DashboardRaceSummaryViewModel { ): DashboardRaceSummaryViewModel {
@@ -194,9 +197,9 @@ export class GetDashboardOverviewUseCase {
} }
private buildRecentResults( private buildRecentResults(
allResults: unknown[], allResults: RaceResult[],
allRaces: unknown[], allRaces: Race[],
allLeagues: unknown[], allLeagues: League[],
driverId: string, driverId: string,
): DashboardRecentResultViewModel[] { ): DashboardRecentResultViewModel[] {
const raceById = new Map(allRaces.map(race => [race.id, race])); const raceById = new Map(allRaces.map(race => [race.id, race]));
@@ -237,7 +240,7 @@ export class GetDashboardOverviewUseCase {
} }
private async buildLeagueStandingsSummaries( private async buildLeagueStandingsSummaries(
driverLeagues: unknown[], driverLeagues: League[],
driverId: string, driverId: string,
): Promise<DashboardLeagueStandingSummaryViewModel[]> { ): Promise<DashboardLeagueStandingSummaryViewModel[]> {
const summaries: DashboardLeagueStandingSummaryViewModel[] = []; const summaries: DashboardLeagueStandingSummaryViewModel[] = [];
@@ -245,7 +248,7 @@ export class GetDashboardOverviewUseCase {
for (const league of driverLeagues.slice(0, 3)) { for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id); const standings = await this.standingRepository.findByLeagueId(league.id);
const driverStanding = standings.find( const driverStanding = standings.find(
(standing: any) => standing.driverId === driverId, (standing: Standing) => standing.driverId === driverId,
); );
summaries.push({ summaries.push({
@@ -277,7 +280,7 @@ export class GetDashboardOverviewUseCase {
return activeLeagueIds.size; return activeLeagueIds.size;
} }
private buildFeedSummary(feedItems: unknown[]): DashboardFeedSummaryViewModel { private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryViewModel {
const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({ const items: DashboardFeedItemSummaryViewModel[] = feedItems.map(item => ({
id: item.id, id: item.id,
type: item.type, type: item.type,
@@ -297,7 +300,7 @@ export class GetDashboardOverviewUseCase {
}; };
} }
private buildFriendsSummary(friends: unknown[]): DashboardFriendSummaryViewModel[] { private buildFriendsSummary(friends: Driver[]): DashboardFriendSummaryViewModel[] {
return friends.map(friend => ({ return friends.map(friend => ({
id: friend.id, id: friend.id,
name: friend.name, name: friend.name,

View File

@@ -0,0 +1,3 @@
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
}

View 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;
}

View 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',
})
);
});
});

View File

@@ -1,24 +1,18 @@
/** /**
* Application Use Case: FileProtestUseCase * Application Use Case: FileProtestUseCase
* *
* Allows a driver to file a protest against another driver for an incident during a race. * Allows a driver to file a protest against another driver for an incident during a race.
*/ */
import { Protest, type ProtestIncident } from '../../domain/entities/Protest'; import { Protest } from '../../domain/entities/Protest';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository'; import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { FileProtestCommand } from './FileProtestCommand';
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto';
export interface FileProtestCommand {
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
}
export class FileProtestUseCase { export class FileProtestUseCase {
constructor( constructor(
private readonly protestRepository: IProtestRepository, private readonly protestRepository: IProtestRepository,
@@ -26,16 +20,16 @@ export class FileProtestUseCase {
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {} ) {}
async execute(command: FileProtestCommand): Promise<{ protestId: string }> { async execute(command: FileProtestCommand): Promise<Result<{ protestId: string }, RacingDomainValidationError>> {
// Validate race exists // Validate race exists
const race = await this.raceRepository.findById(command.raceId); const race = await this.raceRepository.findById(command.raceId);
if (!race) { if (!race) {
throw new Error('Race not found'); return Result.err(new RacingDomainValidationError('Race not found'));
} }
// Validate drivers are not the same // Validate drivers are not the same
if (command.protestingDriverId === command.accusedDriverId) { if (command.protestingDriverId === command.accusedDriverId) {
throw new Error('Cannot file a protest against yourself'); return Result.err(new RacingDomainValidationError('Cannot file a protest against yourself'));
} }
// Validate protesting driver is a member of the league // Validate protesting driver is a member of the league
@@ -43,9 +37,9 @@ export class FileProtestUseCase {
const protestingDriverMembership = memberships.find( const protestingDriverMembership = memberships.find(
m => m.driverId === command.protestingDriverId && m.status === 'active' m => m.driverId === command.protestingDriverId && m.status === 'active'
); );
if (!protestingDriverMembership) { if (!protestingDriverMembership) {
throw new Error('Protesting driver is not an active member of this league'); return Result.err(new RacingDomainValidationError('Protesting driver is not an active member of this league'));
} }
// Create the protest // Create the protest
@@ -63,6 +57,6 @@ export class FileProtestUseCase {
await this.protestRepository.create(protest); await this.protestRepository.create(protest);
return { protestId: protest.id }; return Result.ok({ protestId: protest.id });
} }
} }

View File

@@ -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,
},
]);
});
});

View File

@@ -4,25 +4,17 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository'; import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository'; import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { import type { LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
AllLeaguesWithCapacityAndScoringViewModel, import type { AsyncUseCase } from '@core/shared/application';
IAllLeaguesWithCapacityAndScoringPresenter, import { Result } from '@core/shared/result/Result';
LeagueEnrichedData, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
/** /**
* Use Case for retrieving all leagues with capacity and scoring information. * Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and delegates presentation to the presenter.
*/ */
export class GetAllLeaguesWithCapacityAndScoringUseCase export class GetAllLeaguesWithCapacityAndScoringUseCase
implements implements AsyncUseCase<void, Result<LeagueEnrichedData[], RacingDomainValidationError>>
UseCase<
void,
LeagueEnrichedData[],
AllLeaguesWithCapacityAndScoringViewModel,
IAllLeaguesWithCapacityAndScoringPresenter
>
{ {
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
@@ -33,12 +25,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
private readonly presetProvider: LeagueScoringPresetProvider, private readonly presetProvider: LeagueScoringPresetProvider,
) {} ) {}
async execute( async execute(): Promise<Result<LeagueEnrichedData[], RacingDomainValidationError>> {
_input: void,
presenter: IAllLeaguesWithCapacityAndScoringPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll(); const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = []; const enrichedLeagues: LeagueEnrichedData[] = [];
@@ -88,7 +75,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
...(preset ? { preset } : {}), ...(preset ? { preset } : {}),
}); });
} }
return Result.ok(enrichedLeagues);
presenter.present(enrichedLeagues);
} }
} }

View File

@@ -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(),
});
});
});

View File

@@ -1,30 +1,23 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { import type { AllLeaguesWithCapacityResultDTO } from '../presenters/IAllLeaguesWithCapacityPresenter';
IAllLeaguesWithCapacityPresenter, import type { AsyncUseCase } from '@core/shared/application';
AllLeaguesWithCapacityResultDTO, import { Result } from '@core/shared/result/Result';
AllLeaguesWithCapacityViewModel, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
/** /**
* Use Case for retrieving all leagues with capacity information. * Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and returns result.
*/ */
export class GetAllLeaguesWithCapacityUseCase export class GetAllLeaguesWithCapacityUseCase
implements UseCase<void, AllLeaguesWithCapacityResultDTO, AllLeaguesWithCapacityViewModel, IAllLeaguesWithCapacityPresenter> implements AsyncUseCase<void, Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {} ) {}
async execute( async execute(): Promise<Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>> {
_input: void,
presenter: IAllLeaguesWithCapacityPresenter,
): Promise<void> {
presenter.reset();
const leagues = await this.leagueRepository.findAll(); const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>(); const memberCounts = new Map<string, number>();
@@ -49,6 +42,6 @@ export class GetAllLeaguesWithCapacityUseCase
memberCounts, memberCounts,
}; };
presenter.present(dto); return Result.ok(dto);
} }
} }

View File

@@ -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');
});
});

View File

@@ -2,23 +2,24 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application'; import type { Logger } from '@core/shared/application';
import type { import type {
IAllRacesPagePresenter,
AllRacesPageResultDTO, AllRacesPageResultDTO,
AllRacesPageViewModel, AllRacesPageViewModel,
AllRacesListItemViewModel, AllRacesListItemViewModel,
AllRacesFilterOptionsViewModel, AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter'; } from '../presenters/IAllRacesPagePresenter';
import type { UseCase } from '@core/shared/application'; import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export class GetAllRacesPageDataUseCase export class GetAllRacesPageDataUseCase
implements UseCase<void, AllRacesPageResultDTO, AllRacesPageViewModel, IAllRacesPagePresenter> { implements AsyncUseCase<void, Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
constructor( constructor(
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(_input: void, presenter: IAllRacesPagePresenter): Promise<void> { async execute(): Promise<Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
this.logger.debug('Executing GetAllRacesPageDataUseCase'); this.logger.debug('Executing GetAllRacesPageDataUseCase');
try { try {
const [allRaces, allLeagues] = await Promise.all([ const [allRaces, allLeagues] = await Promise.all([
@@ -64,12 +65,11 @@ export class GetAllRacesPageDataUseCase
filters, filters,
}; };
presenter.reset(); this.logger.debug('Successfully retrieved all races page data.');
presenter.present(viewModel); return Result.ok(viewModel);
this.logger.debug('Successfully presented all races page data.');
} catch (error) { } catch (error) {
this.logger.error('Error executing GetAllRacesPageDataUseCase', { error }); this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error)));
throw error; return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
} }
} }
} }

View 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');
});
});

View File

@@ -1,34 +1,41 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IGetAllRacesPresenter, GetAllRacesResultDTO, AllRacesPageViewModel } from '../presenters/IGetAllRacesPresenter'; import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter';
import type { UseCase } from '@core/shared/application/UseCase'; import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export interface GetAllRacesUseCaseParams {} export class GetAllRacesUseCase implements AsyncUseCase<void, Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
export class GetAllRacesUseCase implements UseCase<GetAllRacesUseCaseParams, GetAllRacesResultDTO, AllRacesPageViewModel, IGetAllRacesPresenter> {
constructor( constructor(
private readonly raceRepository: IRaceRepository, private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {} ) {}
async execute(params: GetAllRacesUseCaseParams, presenter: IGetAllRacesPresenter): Promise<void> { async execute(): Promise<Result<GetAllRacesResultDTO, RacingDomainValidationError>> {
const races = await this.raceRepository.findAll(); this.logger.debug('Executing GetAllRacesUseCase');
const leagues = await this.leagueRepository.findAll(); try {
const leagueMap = new Map(leagues.map(league => [league.id, league.name])); const races = await this.raceRepository.findAll();
const leagues = await this.leagueRepository.findAll();
const leagueMap = new Map(leagues.map(league => [league.id, league.name]));
const raceViewModels = races.map(race => ({ const raceViewModels = races.map(race => ({
id: race.id, id: race.id,
name: `Race ${race.id}`, // Placeholder, adjust based on domain name: `${race.track} - ${race.car}`,
date: race.scheduledAt.toISOString(), date: race.scheduledAt.toISOString(),
leagueName: leagueMap.get(race.leagueId) || 'Unknown League', leagueName: leagueMap.get(race.leagueId) || 'Unknown League',
})); }));
const dto: GetAllRacesResultDTO = { const dto: GetAllRacesResultDTO = {
races: raceViewModels, races: raceViewModels,
totalCount: races.length, totalCount: races.length,
}; };
presenter.reset(); this.logger.debug('Successfully retrieved all races.');
presenter.present(dto); return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
}
} }
} }

View 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');
});
});

View File

@@ -1,34 +1,25 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter';
IAllTeamsPresenter, import type { AsyncUseCase, Logger } from '@core/shared/application';
AllTeamsResultDTO, import { Result } from '@core/shared/result/Result';
} from '../presenters/IAllTeamsPresenter'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { UseCase } from '@core/shared/application';
import { Logger } from "@core/shared/application";
/** /**
* Use Case for retrieving all teams. * Use Case for retrieving all teams.
* Orchestrates domain logic and delegates presentation to the presenter.
*/ */
export class GetAllTeamsUseCase export class GetAllTeamsUseCase implements AsyncUseCase<void, Result<AllTeamsResultDTO, RacingDomainValidationError>> {
implements UseCase<void, AllTeamsResultDTO, import('../presenters/IAllTeamsPresenter').AllTeamsViewModel, IAllTeamsPresenter>
{
constructor( constructor(
private readonly teamRepository: ITeamRepository, private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository, private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly logger: Logger, private readonly logger: Logger,
) {} ) {}
async execute(_input: void, presenter: IAllTeamsPresenter): Promise<void> { async execute(): Promise<Result<AllTeamsResultDTO, RacingDomainValidationError>> {
this.logger.debug('Executing GetAllTeamsUseCase'); this.logger.debug('Executing GetAllTeamsUseCase');
presenter.reset();
try { try {
const teams = await this.teamRepository.findAll(); const teams = await this.teamRepository.findAll();
if (teams.length === 0) {
this.logger.warn('No teams found.');
}
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all( const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
teams.map(async (team) => { teams.map(async (team) => {
@@ -40,7 +31,7 @@ export class GetAllTeamsUseCase
description: team.description, description: team.description,
ownerId: team.ownerId, ownerId: team.ownerId,
leagues: [...team.leagues], leagues: [...team.leagues],
createdAt: team.createdAt, createdAt: team.createdAt,
memberCount, memberCount,
}; };
}), }),
@@ -50,11 +41,11 @@ export class GetAllTeamsUseCase
teams: enrichedTeams, teams: enrichedTeams,
}; };
presenter.present(dto); this.logger.debug('Successfully retrieved all teams.');
this.logger.info('Successfully retrieved all teams.'); return Result.ok(dto);
} catch (error) { } catch (error) {
this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error))); this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error)));
throw error; // Re-throw the error after logging return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
} }
} }
} }

View 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');
});
});

View File

@@ -1,54 +1,51 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'; import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository'; import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { import type { DriverTeamResultDTO } from '../presenters/IDriverTeamPresenter';
IDriverTeamPresenter, import type { AsyncUseCase, Logger } from '@core/shared/application';
DriverTeamResultDTO, import { Result } from '@core/shared/result/Result';
DriverTeamViewModel, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/IDriverTeamPresenter';
import type { UseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
/** /**
* Use Case for retrieving a driver's team. * Use Case for retrieving a driver's team.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and returns result.
*/ */
export class GetDriverTeamUseCase export class GetDriverTeamUseCase
implements UseCase<{ driverId: string }, DriverTeamResultDTO, DriverTeamViewModel, IDriverTeamPresenter> implements AsyncUseCase<{ driverId: string }, Result<DriverTeamResultDTO, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly teamRepository: ITeamRepository, private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository, private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: Logger, private readonly logger: Logger,
// Kept for backward compatibility; callers must pass their own presenter.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public readonly presenter: IDriverTeamPresenter,
) {} ) {}
async execute(input: { driverId: string }, presenter: IDriverTeamPresenter): Promise<void> { async execute(input: { driverId: string }): Promise<Result<DriverTeamResultDTO, RacingDomainValidationError>> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`); this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
presenter.reset(); try {
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
this.logger.warn(`No active membership found for driverId: ${input.driverId}`);
return Result.err(new RacingDomainValidationError(`No active membership found for driver ${input.driverId}`));
}
this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`);
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId); const team = await this.teamRepository.findById(membership.teamId);
if (!membership) { if (!team) {
this.logger.warn(`No active membership found for driverId: ${input.driverId}`); this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return; return Result.err(new RacingDomainValidationError(`Team not found for teamId ${membership.teamId}`));
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
const dto: DriverTeamResultDTO = {
team,
membership,
driverId: input.driverId,
};
this.logger.info(`Successfully retrieved driver team for driverId: ${input.driverId}`);
return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
} }
this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`);
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return;
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
const dto: DriverTeamResultDTO = {
team,
membership,
driverId: input.driverId,
};
presenter.present(dto);
this.logger.info(`Successfully presented driver team for driverId: ${input.driverId}`);
} }
} }

View File

@@ -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);
});
});

View File

@@ -2,51 +2,55 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import type { IRankingService } from '../../domain/services/IRankingService'; import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService'; import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { IImageServicePort } from '../ports/IImageServicePort'; import type { IImageServicePort } from '../ports/IImageServicePort';
import type { import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
IDriversLeaderboardPresenter, import type { AsyncUseCase, Logger } from '@core/shared/application';
DriversLeaderboardResultDTO, import { Result } from '@core/shared/result/Result';
DriversLeaderboardViewModel, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/IDriversLeaderboardPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
/** /**
* Use Case for retrieving driver leaderboard data. * Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and returns result.
*/ */
export class GetDriversLeaderboardUseCase export class GetDriversLeaderboardUseCase
implements UseCase<void, DriversLeaderboardResultDTO, DriversLeaderboardViewModel, IDriversLeaderboardPresenter> implements AsyncUseCase<void, Result<DriversLeaderboardResultDTO, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService, private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService, private readonly driverStatsService: IDriverStatsService,
private readonly imageService: IImageServicePort, private readonly imageService: IImageServicePort,
private readonly logger: Logger,
) {} ) {}
async execute(_input: void, presenter: IDriversLeaderboardPresenter): Promise<void> { async execute(): Promise<Result<DriversLeaderboardResultDTO, RacingDomainValidationError>> {
presenter.reset(); this.logger.debug('Executing GetDriversLeaderboardUseCase');
try {
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const drivers = await this.driverRepository.findAll(); const stats: DriversLeaderboardResultDTO['stats'] = {};
const rankings = this.rankingService.getAllDriverRankings(); const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {};
const stats: DriversLeaderboardResultDTO['stats'] = {}; for (const driver of drivers) {
const avatarUrls: DriversLeaderboardResultDTO['avatarUrls'] = {}; const driverStats = this.driverStatsService.getDriverStats(driver.id);
if (driverStats) {
for (const driver of drivers) { stats[driver.id] = driverStats;
const driverStats = this.driverStatsService.getDriverStats(driver.id); }
if (driverStats) { avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
stats[driver.id] = driverStats;
} }
avatarUrls[driver.id] = this.imageService.getDriverAvatar(driver.id);
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
};
this.logger.debug('Successfully retrieved drivers leaderboard.');
return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
} }
const dto: DriversLeaderboardResultDTO = {
drivers,
rankings,
stats,
avatarUrls,
};
presenter.present(dto);
} }
} }

View File

@@ -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');
});
});

View File

@@ -8,58 +8,30 @@
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository'; import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository'; import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository'; import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest'; import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship'; import { Result } from '@core/shared/result/Result';
import type { IEntitySponsorshipPricingPresenter } from '../presenters/IEntitySponsorshipPricingPresenter'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { UseCase } from '@core/shared/application/UseCase'; import type { GetEntitySponsorshipPricingDTO } from '../dto/GetEntitySponsorshipPricingDTO';
import type { GetEntitySponsorshipPricingResultDTO } from '../dto/GetEntitySponsorshipPricingResultDTO';
export interface GetEntitySponsorshipPricingDTO {
entityType: SponsorableEntityType;
entityId: string;
}
export interface SponsorshipSlotDTO {
tier: SponsorshipTier;
price: number;
currency: string;
formattedPrice: string;
benefits: string[];
available: boolean;
maxSlots: number;
filledSlots: number;
pendingRequests: number;
}
export interface GetEntitySponsorshipPricingResultDTO {
entityType: SponsorableEntityType;
entityId: string;
acceptingApplications: boolean;
customRequirements?: string;
mainSlot?: SponsorshipSlotDTO;
secondarySlot?: SponsorshipSlotDTO;
}
export class GetEntitySponsorshipPricingUseCase export class GetEntitySponsorshipPricingUseCase
implements UseCase<GetEntitySponsorshipPricingDTO, GetEntitySponsorshipPricingResultDTO | null, GetEntitySponsorshipPricingResultDTO | null, IEntitySponsorshipPricingPresenter> implements AsyncUseCase<GetEntitySponsorshipPricingDTO, Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>>
{ {
constructor( constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository, private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository, private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository, private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly logger: Logger,
) {} ) {}
async execute( async execute(dto: GetEntitySponsorshipPricingDTO): Promise<Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>> {
dto: GetEntitySponsorshipPricingDTO, this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
presenter: IEntitySponsorshipPricingPresenter,
): Promise<void> {
presenter.reset();
try { try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId); const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) { if (!pricing) {
presenter.present(null); this.logger.info(`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
return; return Result.ok(null);
} }
// Count pending requests by tier // Count pending requests by tier
@@ -121,9 +93,11 @@ export class GetEntitySponsorshipPricingUseCase
}; };
} }
presenter.present(result); this.logger.info(`Successfully retrieved sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
} catch (error: unknown) { return Result.ok(result);
throw error; } catch (error) {
this.logger.error('Error executing GetEntitySponsorshipPricingUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
} }
} }
} }

View File

@@ -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 });
});
});

View File

@@ -1,42 +1,31 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IGetLeagueAdminPermissionsPresenter, GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter'; import type { AsyncUseCase } from '@core/shared/application';
import type { UseCase } from '@core/shared/application/UseCase'; import { Result } from '@core/shared/result/Result';
import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams';
import type { GetLeagueAdminPermissionsResultDTO } from '../dto/GetLeagueAdminPermissionsResultDTO';
export interface GetLeagueAdminPermissionsUseCaseParams { export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<GetLeagueAdminPermissionsUseCaseParams, Result<GetLeagueAdminPermissionsResultDTO, never>> {
leagueId: string;
performerDriverId: string;
}
export interface GetLeagueAdminPermissionsResultDTO {
canRemoveMember: boolean;
canUpdateRoles: boolean;
}
export class GetLeagueAdminPermissionsUseCase implements UseCase<GetLeagueAdminPermissionsUseCaseParams, GetLeagueAdminPermissionsResultDTO, GetLeagueAdminPermissionsViewModel, IGetLeagueAdminPermissionsPresenter> {
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {} ) {}
async execute(params: GetLeagueAdminPermissionsUseCaseParams, presenter: IGetLeagueAdminPermissionsPresenter): Promise<void> { async execute(params: GetLeagueAdminPermissionsUseCaseParams): Promise<Result<GetLeagueAdminPermissionsResultDTO, never>> {
const league = await this.leagueRepository.findById(params.leagueId); const league = await this.leagueRepository.findById(params.leagueId);
if (!league) { if (!league) {
presenter.present({ canRemoveMember: false, canUpdateRoles: false }); return Result.ok({ canRemoveMember: false, canUpdateRoles: false });
return;
} }
const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId); const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId);
if (!membership || membership.status !== 'active') { if (!membership || membership.status !== 'active') {
presenter.present({ canRemoveMember: false, canUpdateRoles: false }); return Result.ok({ canRemoveMember: false, canUpdateRoles: false });
return;
} }
// Business logic: owners and admins can remove members and update roles // Business logic: owners and admins can remove members and update roles
const canRemoveMember = membership.role === 'owner' || membership.role === 'admin'; const canRemoveMember = membership.role === 'owner' || membership.role === 'admin';
const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin'; const canUpdateRoles = membership.role === 'owner' || membership.role === 'admin';
presenter.reset(); return Result.ok({ canRemoveMember, canUpdateRoles });
presenter.present({ canRemoveMember, canUpdateRoles });
} }
} }

View File

@@ -0,0 +1,4 @@
export interface GetLeagueAdminPermissionsUseCaseParams {
leagueId: string;
performerDriverId: string;
}

View File

@@ -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',
},
});
});
});

View File

@@ -1,29 +1,19 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository'; import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { GetLeagueAdminPermissionsViewModel } from '../presenters/IGetLeagueAdminPermissionsPresenter'; import type { AsyncUseCase } from '@core/shared/application';
import type { IGetLeagueAdminPresenter } from '../presenters/IGetLeagueAdminPresenter'; import { Result } from '@core/shared/result/Result';
import type { UseCase } from '@core/shared/application/UseCase'; import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams';
import type { GetLeagueAdminResultDTO } from '../dto/GetLeagueAdminResultDTO';
export interface GetLeagueAdminUseCaseParams { export class GetLeagueAdminUseCase implements AsyncUseCase<GetLeagueAdminUseCaseParams, Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
leagueId: string;
}
export interface GetLeagueAdminResultDTO {
league: {
id: string;
ownerId: string;
};
// Additional data would be populated by combining multiple use cases
}
export class GetLeagueAdminUseCase implements UseCase<GetLeagueAdminUseCaseParams, GetLeagueAdminResultDTO, GetLeagueAdminPermissionsViewModel, IGetLeagueAdminPresenter> {
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
) {} ) {}
async execute(params: GetLeagueAdminUseCaseParams, presenter: IGetLeagueAdminPresenter): Promise<void> { async execute(params: GetLeagueAdminUseCaseParams): Promise<Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
const league = await this.leagueRepository.findById(params.leagueId); const league = await this.leagueRepository.findById(params.leagueId);
if (!league) { if (!league) {
throw new Error('League not found'); return Result.err(new RacingDomainValidationError('League not found'));
} }
const dto: GetLeagueAdminResultDTO = { const dto: GetLeagueAdminResultDTO = {
@@ -32,7 +22,6 @@ export class GetLeagueAdminUseCase implements UseCase<GetLeagueAdminUseCaseParam
ownerId: league.ownerId, ownerId: league.ownerId,
}, },
}; };
presenter.reset(); return Result.ok(dto);
presenter.present(dto);
} }
} }

View File

@@ -0,0 +1,3 @@
export interface GetLeagueAdminUseCaseParams {
leagueId: string;
}

View File

@@ -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);
});
});

View File

@@ -2,34 +2,18 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep
import type { IResultRepository } from '../../domain/repositories/IResultRepository'; import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository'; import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'; import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { import type { LeagueDriverSeasonStatsResultDTO } from '../presenters/ILeagueDriverSeasonStatsPresenter';
ILeagueDriverSeasonStatsPresenter, import type { AsyncUseCase } from '@core/shared/application';
LeagueDriverSeasonStatsResultDTO, import { Result } from '@core/shared/result/Result';
LeagueDriverSeasonStatsViewModel, import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
} from '../presenters/ILeagueDriverSeasonStatsPresenter'; import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams';
import type { UseCase } from '@core/shared/application/UseCase'; import type { DriverRatingPort } from './DriverRatingPort';
export interface DriverRatingPort {
getRating(driverId: string): { rating: number | null; ratingChange: number | null };
}
export interface GetLeagueDriverSeasonStatsUseCaseParams {
leagueId: string;
}
/** /**
* Use Case for retrieving league driver season statistics. * Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and returns the result.
*/ */
export class GetLeagueDriverSeasonStatsUseCase export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<GetLeagueDriverSeasonStatsUseCaseParams, Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
implements
UseCase<
GetLeagueDriverSeasonStatsUseCaseParams,
LeagueDriverSeasonStatsResultDTO,
LeagueDriverSeasonStatsViewModel,
ILeagueDriverSeasonStatsPresenter
>
{
constructor( constructor(
private readonly standingRepository: IStandingRepository, private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository, private readonly resultRepository: IResultRepository,
@@ -38,11 +22,7 @@ export class GetLeagueDriverSeasonStatsUseCase
private readonly driverRatingPort: DriverRatingPort, private readonly driverRatingPort: DriverRatingPort,
) {} ) {}
async execute( async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise<Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
params: GetLeagueDriverSeasonStatsUseCaseParams,
presenter: ILeagueDriverSeasonStatsPresenter,
): Promise<void> {
presenter.reset();
const { leagueId } = params; const { leagueId } = params;
// Get standings and races for the league // Get standings and races for the league
@@ -62,15 +42,15 @@ export class GetLeagueDriverSeasonStatsUseCase
for (const p of penaltiesForLeague) { for (const p of penaltiesForLeague) {
// Only count applied penalties // Only count applied penalties
if (p.status !== 'applied') continue; if (p.status !== 'applied') continue;
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 }; const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
// Convert penalty to points delta based on type // Convert penalty to points delta based on type
if (p.type === 'points_deduction' && p.value) { if (p.type === 'points_deduction' && p.value) {
// Points deductions are negative // Points deductions are negative
current.baseDelta -= p.value; current.baseDelta -= p.value;
} }
penaltiesByDriver.set(p.driverId, current); penaltiesByDriver.set(p.driverId, current);
} }
@@ -104,6 +84,6 @@ export class GetLeagueDriverSeasonStatsUseCase
driverRatings, driverRatings,
}; };
presenter.present(dto); return Result.ok(dto);
} }
} }

View File

@@ -0,0 +1,3 @@
export interface GetLeagueDriverSeasonStatsUseCaseParams {
leagueId: string;
}

View File

@@ -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,
});
});
});

View File

@@ -7,29 +7,29 @@ import type {
LeagueFullConfigData, LeagueFullConfigData,
LeagueConfigFormViewModel, LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter'; } from '../presenters/ILeagueFullConfigPresenter';
import type { UseCase } from '@core/shared/application/UseCase'; import type { AsyncUseCase } from '@core/shared/application';
import { EntityNotFoundError } from '../errors/RacingApplicationError'; import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
/** /**
* Use Case for retrieving a league's full configuration. * Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter. * Orchestrates domain logic and delegates presentation to the presenter.
*/ */
export class GetLeagueFullConfigUseCase export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
implements UseCase<{ leagueId: string }, LeagueFullConfigData, LeagueConfigFormViewModel, ILeagueFullConfigPresenter>
{
constructor( constructor(
private readonly leagueRepository: ILeagueRepository, private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository, private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository, private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository, private readonly gameRepository: IGameRepository,
private readonly presenter: ILeagueFullConfigPresenter,
) {} ) {}
async execute(params: { leagueId: string }, presenter: ILeagueFullConfigPresenter): Promise<void> { async execute(params: { leagueId: string }): Promise<Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
const { leagueId } = params; const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId); const league = await this.leagueRepository.findById(leagueId);
if (!league) { if (!league) {
throw new EntityNotFoundError({ entity: 'league', id: leagueId }); return Result.err(new RacingDomainValidationError(`League with id ${leagueId} not found`));
} }
const seasons = await this.seasonRepository.findByLeagueId(leagueId); const seasons = await this.seasonRepository.findByLeagueId(leagueId);
@@ -54,7 +54,13 @@ export class GetLeagueFullConfigUseCase
...(game ? { game } : {}), ...(game ? { game } : {}),
}; };
presenter.reset(); this.presenter.reset();
presenter.present(data); this.presenter.present(data);
const viewModel = this.presenter.getViewModel();
if (!viewModel) {
return Result.err(new RacingDomainValidationError('Failed to present league config'));
}
return Result.ok(viewModel);
} }
} }

View File

@@ -1,23 +1,29 @@
import { GetLeagueJoinRequestsUseCase } from '@core/racing/application/use-cases/GetLeagueJoinRequestsUseCase'; import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { LeagueJoinRequestsPresenter } from '@apps/api/src/modules/league/presenters/LeagueJoinRequestsPresenter'; import { GetLeagueJoinRequestsUseCase } from './GetLeagueJoinRequestsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository'; import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
describe('GetLeagueJoinRequestsUseCase', () => { describe('GetLeagueJoinRequestsUseCase', () => {
let useCase: GetLeagueJoinRequestsUseCase; let useCase: GetLeagueJoinRequestsUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>; let leagueMembershipRepository: {
let driverRepository: jest.Mocked<IDriverRepository>; getJoinRequests: Mock;
let presenter: LeagueJoinRequestsPresenter; };
let driverRepository: {
findById: Mock;
};
beforeEach(() => { beforeEach(() => {
leagueMembershipRepository = { leagueMembershipRepository = {
getJoinRequests: jest.fn(), getJoinRequests: vi.fn(),
} as unknown; };
driverRepository = { driverRepository = {
findByIds: jest.fn(), findById: vi.fn(),
} as unknown; };
presenter = new LeagueJoinRequestsPresenter(); useCase = new GetLeagueJoinRequestsUseCase(
useCase = new GetLeagueJoinRequestsUseCase(leagueMembershipRepository, driverRepository); leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
);
}); });
it('should return join requests with drivers', async () => { it('should return join requests with drivers', async () => {
@@ -25,22 +31,30 @@ describe('GetLeagueJoinRequestsUseCase', () => {
const joinRequests = [ const joinRequests = [
{ id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }, { id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' },
]; ];
const drivers = [{ id: 'driver-1', name: 'Driver 1' }]; const driver = Driver.create({
id: 'driver-1',
iracingId: '123',
name: 'Driver 1',
country: 'US',
});
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests); leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
driverRepository.findByIds.mockResolvedValue(drivers); driverRepository.findById.mockResolvedValue(driver);
await useCase.execute({ leagueId }, presenter); const result = await useCase.execute({ leagueId });
expect(presenter.viewModel.joinRequests).toEqual([ expect(result.isOk()).toBe(true);
{ expect(result.unwrap()).toEqual({
id: 'req-1', joinRequests: [
leagueId, {
driverId: 'driver-1', id: 'req-1',
requestedAt: expect.any(Date), leagueId,
message: 'msg', driverId: 'driver-1',
driver: { id: 'driver-1', name: 'Driver 1' }, requestedAt: expect.any(Date),
}, message: 'msg',
]); driver: { id: 'driver-1', name: 'Driver 1' },
},
],
});
}); });
}); });

View File

@@ -1,33 +1,27 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IGetLeagueJoinRequestsPresenter, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel } from '../presenters/IGetLeagueJoinRequestsPresenter'; import type { AsyncUseCase } from '@core/shared/application';
import type { UseCase } from '@core/shared/application/UseCase'; import { Result } from '@core/shared/result/Result';
import type { GetLeagueJoinRequestsUseCaseParams } from '../dto/GetLeagueJoinRequestsUseCaseParams';
import type { GetLeagueJoinRequestsResultDTO } from '../dto/GetLeagueJoinRequestsResultDTO';
export interface GetLeagueJoinRequestsUseCaseParams { export class GetLeagueJoinRequestsUseCase implements AsyncUseCase<GetLeagueJoinRequestsUseCaseParams, Result<GetLeagueJoinRequestsResultDTO, never>> {
leagueId: string;
}
export interface GetLeagueJoinRequestsResultDTO {
joinRequests: unknown[];
drivers: { id: string; name: string }[];
}
export class GetLeagueJoinRequestsUseCase implements UseCase<GetLeagueJoinRequestsUseCaseParams, GetLeagueJoinRequestsResultDTO, GetLeagueJoinRequestsViewModel, IGetLeagueJoinRequestsPresenter> {
constructor( constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
) {} ) {}
async execute(params: GetLeagueJoinRequestsUseCaseParams, presenter: IGetLeagueJoinRequestsPresenter): Promise<void> { async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise<Result<GetLeagueJoinRequestsResultDTO, never>> {
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId); const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
const driverIds = joinRequests.map(r => r.driverId); const driverIds = [...new Set(joinRequests.map(r => r.driverId))];
const drivers = await this.driverRepository.findByIds(driverIds); const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }])); const driverMap = new Map(drivers.filter(d => d !== null).map(d => [d!.id, { id: d!.id, name: d!.name }]));
const dto: GetLeagueJoinRequestsResultDTO = { const enrichedJoinRequests = joinRequests.map(request => ({
joinRequests, ...request,
drivers: Array.from(driverMap.values()), driver: driverMap.get(request.driverId)!,
}; }));
presenter.reset(); return Result.ok({
presenter.present(dto); joinRequests: enrichedJoinRequests,
});
} }
} }

View File

@@ -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: [],
});
});
});

View File

@@ -1,25 +1,20 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository'; import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository'; import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { LeagueMembership } from '../../domain/entities/LeagueMembership'; import type { AsyncUseCase } from '@core/shared/application';
import type { IGetLeagueMembershipsPresenter, GetLeagueMembershipsViewModel } from '../presenters/IGetLeagueMembershipsPresenter'; import { Result } from '@core/shared/result/Result';
import type { UseCase } from '@core/shared/application/UseCase'; import type { GetLeagueMembershipsResultDTO } from '../dto/GetLeagueMembershipsResultDTO';
export interface GetLeagueMembershipsUseCaseParams { export interface GetLeagueMembershipsUseCaseParams {
leagueId: string; leagueId: string;
} }
export interface GetLeagueMembershipsResultDTO { export class GetLeagueMembershipsUseCase implements AsyncUseCase<GetLeagueMembershipsUseCaseParams, Result<GetLeagueMembershipsResultDTO, never>> {
memberships: LeagueMembership[];
drivers: { id: string; name: string }[];
}
export class GetLeagueMembershipsUseCase implements UseCase<GetLeagueMembershipsUseCaseParams, GetLeagueMembershipsResultDTO, GetLeagueMembershipsViewModel, IGetLeagueMembershipsPresenter> {
constructor( constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository, private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository, private readonly driverRepository: IDriverRepository,
) {} ) {}
async execute(params: GetLeagueMembershipsUseCaseParams, presenter: IGetLeagueMembershipsPresenter): Promise<void> { async execute(params: GetLeagueMembershipsUseCaseParams): Promise<Result<GetLeagueMembershipsResultDTO, never>> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId); const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const drivers: { id: string; name: string }[] = []; const drivers: { id: string; name: string }[] = [];
@@ -35,7 +30,6 @@ export class GetLeagueMembershipsUseCase implements UseCase<GetLeagueMemberships
memberships, memberships,
drivers, drivers,
}; };
presenter.reset(); return Result.ok(dto);
presenter.present(dto);
} }
} }

View File

@@ -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';

View File

@@ -7,7 +7,7 @@
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain'; import type { IEntity } from '@core/shared/domain';
import type { SessionType } from '../value-objects/SessionType'; import { SessionType } from '../value-objects/SessionType';
export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled'; export type RaceStatus = 'scheduled' | 'running' | 'completed' | 'cancelled';
export class Race implements IEntity<string> { export class Race implements IEntity<string> {

View File

@@ -110,6 +110,6 @@ export class Money implements IValueObject<MoneyProps> {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
return formatter.format(this.amount); return formatter.format(this.amount / 100);
} }
} }

View File

@@ -1,3 +1,30 @@
/**
* Async Use Case interface for queries.
*
* Queries do not change system state and return data directly.
* The output is most often a Result<T, E> where T is the data and E is a domain error code,
* to handle business rejections explicitly. Use cases do not throw errors; they use error codes in Result.
*
* Example:
* ```typescript
* export type YourUseCaseError =
* | 'SPONSOR_NOT_FOUND'
* | 'PRICING_NOT_CONFIGURED'
* | 'APPLICATIONS_CLOSED'
* | 'NO_SLOTS_AVAILABLE'
* | 'DUPLICATE_PENDING_REQUEST'
* | 'OFFER_BELOW_MINIMUM';
*
* export class ApplyForSponsorshipUseCase implements AsyncUseCase<Input, Result<SuccessDTO, YourUseCaseError>> {
* async execute(input: Input): Promise<Result<SuccessDTO, YourUseCaseError>> {
* // implementation
* }
* }
* ```
*
* @template Input - The input type for the use case
* @template Output - The output type returned by the use case, often Result<T, DomainErrorCode>
*/
export interface AsyncUseCase<Input, Output> { export interface AsyncUseCase<Input, Output> {
execute(input: Input): Promise<Output>; execute(input: Input): Promise<Output>;
} }

View File

@@ -1,5 +1,34 @@
import type { Presenter } from '../presentation'; import type { Presenter } from '../presentation';
/**
* Use Case interface for commands.
*
* Use cases represent application-level business logic. They coordinate domain objects and repositories,
* but contain no infrastructure or framework concerns.
*
* Commands change system state and return nothing on success. They use a presenter to handle the output.
* If a business rejection is possible, the output may be a Result<void, E> handled by the presenter.
* Use cases do not throw errors; they use error codes in Result.
*
* Example:
* ```typescript
* export type CreateRaceError =
* | 'INSUFFICIENT_PERMISSIONS'
* | 'RACE_ALREADY_EXISTS'
* | 'INVALID_RACE_CONFIG';
*
* export class CreateRaceUseCase implements UseCase<CreateRaceInput, Result<void, CreateRaceError>, ViewModel, Presenter<Result<void, CreateRaceError>, ViewModel>> {
* execute(input: CreateRaceInput, presenter: Presenter<Result<void, CreateRaceError>, ViewModel>): Promise<void> {
* // implementation
* }
* }
* ```
*
* @template Input - The input type for the use case
* @template OutputDTO - The output DTO type, often Result<void, DomainErrorCode>
* @template ViewModel - The view model type
* @template P - The presenter type, extending Presenter<OutputDTO, ViewModel>
*/
export interface UseCase<Input, OutputDTO, ViewModel, P extends Presenter<OutputDTO, ViewModel>> { export interface UseCase<Input, OutputDTO, ViewModel, P extends Presenter<OutputDTO, ViewModel>> {
execute(input: Input, presenter: P): Promise<void> | void; execute(input: Input, presenter: P): Promise<void> | void;
} }

View File

@@ -1,3 +1,6 @@
/**
* @deprecated Use error codes in Result instead of throwing ApplicationError.
*/
export type CommonApplicationErrorKind = export type CommonApplicationErrorKind =
| 'not_found' | 'not_found'
| 'forbidden' | 'forbidden'