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 {
TeamJoinRequest,
TeamMembership,
} from '../../domain/types/TeamMembership';
import type { TeamMembership } from '../../domain/types/TeamMembership';
export interface JoinTeamCommandDTO {
teamId: string;
@@ -15,6 +12,7 @@ export interface LeaveTeamCommandDTO {
}
export interface ApproveTeamJoinRequestCommandDTO {
teamId: string;
requestId: string;
}

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

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

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