refactor racing use cases

This commit is contained in:
2025-12-21 00:43:42 +01:00
parent e9d6f90bb2
commit c12656d671
308 changed files with 14401 additions and 7419 deletions

View File

@@ -2,8 +2,8 @@ import type { NotificationService } from '@/notifications/application/ports/Noti
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { Logger } from '@core/shared/application';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import { LeagueWallet } from '../../domain/entities/LeagueWallet';
import { Season } from '../../domain/entities/Season';
import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet';
import { Season } from '../../domain/entities/season/Season';
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
@@ -73,7 +73,11 @@ describe('AcceptSponsorshipRequestUseCase', () => {
};
});
it('should send notification to sponsor, process payment, and update wallets when accepting season sponsorship', async () => {
it('should send notification to sponsor, process payment, update wallets, and present result when accepting season sponsorship', async () => {
const output = {
present: vi.fn(),
};
const useCase = new AcceptSponsorshipRequestUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
@@ -83,6 +87,7 @@ describe('AcceptSponsorshipRequestUseCase', () => {
mockWalletRepo as unknown as IWalletRepository,
mockLeagueWalletRepo as unknown as ILeagueWalletRepository,
mockLogger as unknown as Logger,
output,
);
const request = SponsorshipRequest.create({
@@ -135,8 +140,8 @@ describe('AcceptSponsorshipRequestUseCase', () => {
});
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto).toBeDefined();
expect(result.unwrap()).toBeUndefined();
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith({
recipientId: 'sponsor1',
type: 'sponsorship_request_accepted',
@@ -146,28 +151,35 @@ describe('AcceptSponsorshipRequestUseCase', () => {
urgency: 'toast',
data: {
requestId: 'req1',
sponsorshipId: dto.sponsorshipId,
sponsorshipId: expect.any(String),
},
});
expect(processPayment).toHaveBeenCalledWith(
{
amount: Money.create(1000),
payerId: 'sponsor1',
description: 'Sponsorship payment for season season1',
metadata: { requestId: 'req1' }
}
);
expect(processPayment).toHaveBeenCalledWith({
amount: 1000,
payerId: 'sponsor1',
description: 'Sponsorship payment for season season1',
metadata: { requestId: 'req1' },
});
expect(mockWalletRepo.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'sponsor1',
balance: 1000,
})
}),
);
expect(mockLeagueWalletRepo.update).toHaveBeenCalledWith(
expect.objectContaining({
id: 'league1',
balance: expect.objectContaining({ amount: 1400 }),
})
}),
);
expect(output.present).toHaveBeenCalledWith({
requestId: 'req1',
sponsorshipId: expect.any(String),
status: 'accepted',
acceptedAt: expect.any(Date),
platformFee: expect.any(Number),
netAmount: expect.any(Number),
});
});
});

View File

@@ -7,62 +7,116 @@
import type { NotificationService } from '@/notifications/application/ports/NotificationService';
import type { IWalletRepository } from '@core/payments/domain/repositories/IWalletRepository';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
import type { ProcessPaymentInputPort } from '../ports/input/ProcessPaymentInputPort';
import type { AcceptSponsorshipOutputPort } from '../ports/output/AcceptSponsorshipOutputPort';
import type { ProcessPaymentOutputPort } from '../ports/output/ProcessPaymentOutputPort';
export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipOutputPort, string> {
export interface AcceptSponsorshipRequestInput {
requestId: string;
respondedBy: string;
}
export interface AcceptSponsorshipResult {
requestId: string;
sponsorshipId: string;
status: 'accepted';
acceptedAt: Date;
platformFee: number;
netAmount: number;
}
export interface ProcessPaymentInput {
amount: number;
payerId: string;
description: string;
metadata?: Record<string, unknown>;
}
export interface ProcessPaymentResult {
success: boolean;
transactionId?: string;
error?: string;
}
export class AcceptSponsorshipRequestUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly notificationService: NotificationService,
private readonly paymentProcessor: (input: ProcessPaymentInputPort) => Promise<ProcessPaymentOutputPort>,
private readonly paymentProcessor: (input: ProcessPaymentInput) => Promise<ProcessPaymentResult>,
private readonly walletRepository: IWalletRepository,
private readonly leagueWalletRepository: ILeagueWalletRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<AcceptSponsorshipResult>,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<Result<AcceptSponsorshipOutputPort, ApplicationErrorCode<string>>> {
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
async execute(
input: AcceptSponsorshipRequestInput,
): Promise<
Result<
void,
ApplicationErrorCode<
| 'SPONSORSHIP_REQUEST_NOT_FOUND'
| 'SPONSORSHIP_REQUEST_NOT_PENDING'
| 'SEASON_NOT_FOUND'
| 'PAYMENT_PROCESSING_FAILED'
| 'SPONSOR_WALLET_NOT_FOUND'
| 'LEAGUE_WALLET_NOT_FOUND'
>
>
> {
this.logger.debug(`Attempting to accept sponsorship request: ${input.requestId}`, {
requestId: input.requestId,
respondedBy: input.respondedBy,
});
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
const request = await this.sponsorshipRequestRepo.findById(input.requestId);
if (!request) {
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
this.logger.warn(`Sponsorship request not found: ${input.requestId}`, { requestId: input.requestId });
return Result.err({ code: '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 });
this.logger.warn(`Cannot accept a ${request.status} sponsorship request: ${input.requestId}`, {
requestId: input.requestId,
status: request.status,
});
return Result.err({ code: 'SPONSORSHIP_REQUEST_NOT_PENDING' });
}
this.logger.info(`Sponsorship request ${dto.requestId} found and is pending. Proceeding with acceptance.`, { requestId: dto.requestId });
this.logger.info(`Sponsorship request ${input.requestId} found and is pending. Proceeding with acceptance.`, {
requestId: input.requestId,
});
// Accept the request
const acceptedRequest = request.accept(dto.respondedBy);
const acceptedRequest = request.accept(input.respondedBy);
await this.sponsorshipRequestRepo.update(acceptedRequest);
this.logger.debug(`Sponsorship request ${dto.requestId} accepted and updated in repository.`, { requestId: dto.requestId });
this.logger.debug(`Sponsorship request ${input.requestId} accepted and updated in repository.`, {
requestId: input.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 });
this.logger.debug(`Sponsorship request ${input.requestId} is for a season. Creating SeasonSponsorship record.`, {
requestId: input.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 });
this.logger.warn(
`Season not found for sponsorship request ${input.requestId} and entityId ${request.entityId}`,
{ requestId: input.requestId, entityId: request.entityId },
);
return Result.err({ code: 'SEASON_NOT_FOUND' });
}
@@ -76,7 +130,10 @@ export class AcceptSponsorshipRequestUseCase
status: 'active',
});
await this.seasonSponsorshipRepo.create(sponsorship);
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${dto.requestId}.`, { sponsorshipId, requestId: dto.requestId });
this.logger.info(`Season sponsorship ${sponsorshipId} created for request ${input.requestId}.`, {
sponsorshipId,
requestId: input.requestId,
});
// Notify the sponsor
await this.notificationService.sendNotification({
@@ -93,29 +150,37 @@ export class AcceptSponsorshipRequestUseCase
});
// Process payment using clean input/output ports with primitive types
const paymentInput: ProcessPaymentInputPort = {
amount: request.offeredAmount.amount, // Extract primitive number from value object
const paymentInput: ProcessPaymentInput = {
amount: request.offeredAmount.amount,
payerId: request.sponsorId,
description: `Sponsorship payment for ${request.entityType} ${request.entityId}`,
metadata: { requestId: request.id }
metadata: { requestId: request.id },
};
const paymentResult = await this.paymentProcessor(paymentInput);
if (!paymentResult.success) {
this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id });
this.logger.error(
`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`,
undefined,
{ requestId: request.id },
);
return Result.err({ code: '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 });
this.logger.error(`Sponsor wallet not found for ${request.sponsorId}`, undefined, {
sponsorId: request.sponsorId,
});
return Result.err({ code: '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 });
this.logger.error(`League wallet not found for ${season.leagueId}`, undefined, {
leagueId: season.leagueId,
});
return Result.err({ code: 'LEAGUE_WALLET_NOT_FOUND' });
}
@@ -133,15 +198,22 @@ export class AcceptSponsorshipRequestUseCase
await this.leagueWalletRepository.update(updatedLeagueWallet);
}
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, { requestId: acceptedRequest.id, sponsorshipId });
this.logger.info(`Sponsorship request ${acceptedRequest.id} successfully accepted.`, {
requestId: acceptedRequest.id,
sponsorshipId,
});
return Result.ok({
const result: AcceptSponsorshipResult = {
requestId: acceptedRequest.id,
sponsorshipId,
status: 'accepted',
acceptedAt: acceptedRequest.respondedAt!,
platformFee: acceptedRequest.getPlatformFee().amount,
netAmount: acceptedRequest.getNetAmount().amount,
});
};
this.output.present(result);
return Result.ok(undefined);
}
}

View File

@@ -4,6 +4,7 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Money } from '../../domain/value-objects/Money';
describe('ApplyForSponsorshipUseCase', () => {
@@ -42,11 +43,16 @@ describe('ApplyForSponsorshipUseCase', () => {
});
it('should return error when sponsor does not exist', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue(null);
@@ -61,14 +67,20 @@ describe('ApplyForSponsorshipUseCase', () => {
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('SPONSOR_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when sponsorship pricing is not set up', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -84,14 +96,20 @@ describe('ApplyForSponsorshipUseCase', () => {
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('SPONSORSHIP_PRICING_NOT_SETUP');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when entity is not accepting applications', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -111,14 +129,20 @@ describe('ApplyForSponsorshipUseCase', () => {
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('ENTITY_NOT_ACCEPTING_APPLICATIONS');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when no slots are available', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -138,14 +162,20 @@ describe('ApplyForSponsorshipUseCase', () => {
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('NO_SLOTS_AVAILABLE');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when sponsor has pending request', async () => {
const output = {
present: vi.fn(),
};
const useCase = new ApplyForSponsorshipUseCase(
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSponsorshipPricingRepo as unknown as ISponsorshipPricingRepository,
mockSponsorRepo as unknown as ISponsorRepository,
mockLogger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
mockSponsorRepo.findById.mockResolvedValue({ id: 'sponsor1' });
@@ -166,6 +196,7 @@ describe('ApplyForSponsorshipUseCase', () => {
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('PENDING_REQUEST_EXISTS');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when offered amount is less than minimum', async () => {

View File

@@ -10,89 +10,133 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money } from '../../domain/value-objects/Money';
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ApplyForSponsorshipPort } from '../ports/input/ApplyForSponsorshipPort';
import type { ApplyForSponsorshipResultPort } from '../ports/output/ApplyForSponsorshipResultPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipPort, ApplyForSponsorshipResultPort, string>
{
export interface ApplyForSponsorshipInput {
sponsorId: string;
entityType: SponsorshipRequest['entityType'];
entityId: string;
tier: string;
offeredAmount: number;
currency?: string;
message?: string;
}
export interface ApplyForSponsorshipResult {
requestId: string;
status: SponsorshipRequest['status'];
createdAt: Date;
}
export class ApplyForSponsorshipUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ApplyForSponsorshipResult>,
) {}
async execute(dto: ApplyForSponsorshipPort): Promise<Result<ApplyForSponsorshipResultPort, ApplicationErrorCode<string>>> {
this.logger.debug('Attempting to apply for sponsorship', { dto });
async execute(
input: ApplyForSponsorshipInput,
): Promise<
Result<
void,
ApplicationErrorCode<
| 'SPONSOR_NOT_FOUND'
| 'SPONSORSHIP_PRICING_NOT_SETUP'
| 'ENTITY_NOT_ACCEPTING_APPLICATIONS'
| 'NO_SLOTS_AVAILABLE'
| 'PENDING_REQUEST_EXISTS'
| 'OFFERED_AMOUNT_TOO_LOW'
>
>
> {
this.logger.debug('Attempting to apply for sponsorship', { input });
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
const sponsor = await this.sponsorRepo.findById(input.sponsorId);
if (!sponsor) {
this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId });
this.logger.error('Sponsor not found', undefined, { sponsorId: input.sponsorId });
return Result.err({ code: 'SPONSOR_NOT_FOUND' });
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
const pricing = await this.sponsorshipPricingRepo.findByEntity(input.entityType, input.entityId);
if (!pricing) {
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
this.logger.warn('Sponsorship pricing not set up for this entity', {
entityType: input.entityType,
entityId: input.entityId,
});
return Result.err({ code: 'SPONSORSHIP_PRICING_NOT_SETUP' });
}
if (!pricing.acceptingApplications) {
this.logger.warn('Entity not accepting sponsorship applications', { entityType: dto.entityType, entityId: dto.entityId });
this.logger.warn('Entity not accepting sponsorship applications', {
entityType: input.entityType,
entityId: input.entityId,
});
return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' });
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
const slotAvailable = pricing.isSlotAvailable(input.tier);
if (!slotAvailable) {
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
this.logger.warn(`No ${input.tier} sponsorship slots are available for entity ${input.entityId}`);
return Result.err({ code: 'NO_SLOTS_AVAILABLE' });
}
// Check if sponsor already has a pending request for this entity
const hasPending = await this.sponsorshipRequestRepo.hasPendingRequest(
dto.sponsorId,
dto.entityType,
dto.entityId,
input.sponsorId,
input.entityType,
input.entityId,
);
if (hasPending) {
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
this.logger.warn('Sponsor already has a pending request for this entity', {
sponsorId: input.sponsorId,
entityType: input.entityType,
entityId: input.entityId,
});
return Result.err({ code: 'PENDING_REQUEST_EXISTS' });
}
// 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}`);
const minPrice = pricing.getPrice(input.tier);
if (minPrice && input.offeredAmount < minPrice.amount) {
this.logger.warn(
`Offered amount ${input.offeredAmount} is less than minimum ${minPrice.amount} for entity ${input.entityId}, tier ${input.tier}`,
);
return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' });
}
// Create the sponsorship request
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const offeredAmount = Money.create(dto.offeredAmount, dto.currency ?? 'USD');
const offeredAmount = Money.create(input.offeredAmount, input.currency ?? 'USD');
const request = SponsorshipRequest.create({
id: requestId,
sponsorId: dto.sponsorId,
entityType: dto.entityType,
entityId: dto.entityId,
tier: dto.tier,
sponsorId: input.sponsorId,
entityType: input.entityType,
entityId: input.entityId,
tier: input.tier,
offeredAmount,
...(dto.message !== undefined ? { message: dto.message } : {}),
...(input.message !== undefined ? { message: input.message } : {}),
});
await this.sponsorshipRequestRepo.create(request);
return Result.ok({
const result: ApplyForSponsorshipResult = {
requestId: request.id,
status: 'pending',
status: request.status,
createdAt: request.createdAt,
});
};
this.output.present(result);
return Result.ok(undefined);
}
}

View File

@@ -5,6 +5,7 @@ import type { IProtestRepository } from '../../domain/repositories/IProtestRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApplyPenaltyUseCase', () => {
let mockPenaltyRepo: {

View File

@@ -5,28 +5,56 @@
* The penalty can be standalone or linked to an upheld protest.
*/
import { Penalty } from '../../domain/entities/Penalty';
import { Penalty } from '../../domain/entities/penalty/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 , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ApplyPenaltyCommandPort } from '../ports/input/ApplyPenaltyCommandPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommandPort, { penaltyId: string }, string> {
export interface ApplyPenaltyInput {
raceId: string;
driverId: string;
stewardId: string;
type: Penalty['type'];
value?: Penalty['value'];
reason: string;
protestId?: string;
notes?: string;
}
export interface ApplyPenaltyResult {
penaltyId: string;
}
export class ApplyPenaltyUseCase {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<ApplyPenaltyResult>,
) {}
async execute(command: ApplyPenaltyCommandPort): Promise<Result<{ penaltyId: string }, ApplicationErrorCode<string>>> {
async execute(
command: ApplyPenaltyInput,
): Promise<
Result<
void,
ApplicationErrorCode<
| 'RACE_NOT_FOUND'
| 'INSUFFICIENT_AUTHORITY'
| 'PROTEST_NOT_FOUND'
| 'PROTEST_NOT_UPHELD'
| 'PROTEST_NOT_FOR_RACE'
>
>
> {
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
// Validate race exists
@@ -84,8 +112,13 @@ export class ApplyPenaltyUseCase
});
await this.penaltyRepository.create(penalty);
this.logger.info(`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`);
this.logger.info(
`ApplyPenaltyUseCase: Successfully applied penalty ${penalty.id} for driver ${command.driverId} in race ${command.raceId}.`,
);
return Result.ok({ penaltyId: penalty.id });
const result: ApplyPenaltyResult = { penaltyId: penalty.id };
this.output.present(result);
return Result.ok(undefined);
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ApproveLeagueJoinRequestUseCase } from './ApproveLeagueJoinRequestUseCase';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApproveLeagueJoinRequestUseCase', () => {
let mockLeagueMembershipRepo: {
@@ -18,7 +19,14 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
});
it('should approve join request and save membership', async () => {
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository);
const output = {
present: vi.fn(),
};
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
);
const leagueId = 'league-1';
const requestId = 'req-1';
@@ -29,7 +37,7 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
const result = await useCase.execute({ leagueId, requestId });
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ success: true, message: 'Join request approved.' });
expect(result.unwrap()).toBeUndefined();
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({
id: expect.any(String),
@@ -39,10 +47,18 @@ describe('ApproveLeagueJoinRequestUseCase', () => {
status: 'active',
joinedAt: expect.any(Date),
});
expect(output.present).toHaveBeenCalledWith({ success: true, message: 'Join request approved.' });
});
it('should return error if request not found', async () => {
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository);
const output = {
present: vi.fn(),
};
const useCase = new ApproveLeagueJoinRequestUseCase(
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output as unknown as UseCaseOutputPort<any>,
);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]);

View File

@@ -1,31 +1,48 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application';
import { randomUUID } from 'crypto';
import type { ApproveLeagueJoinRequestUseCaseParams } from '../dto/ApproveLeagueJoinRequestUseCaseParams';
import type { ApproveLeagueJoinRequestResultPort } from '../ports/output/ApproveLeagueJoinRequestResultPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { JoinedAt } from '../../domain/value-objects/JoinedAt';
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultPort, string> {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
export interface ApproveLeagueJoinRequestInput {
leagueId: string;
requestId: string;
}
async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise<Result<ApproveLeagueJoinRequestResultPort, ApplicationErrorCode<string>>> {
const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
const request = requests.find(r => r.id === params.requestId);
export interface ApproveLeagueJoinRequestResult {
success: boolean;
message: string;
}
export class ApproveLeagueJoinRequestUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly output: UseCaseOutputPort<ApproveLeagueJoinRequestResult>,
) {}
async execute(
input: ApproveLeagueJoinRequestInput,
): Promise<Result<void, ApplicationErrorCode<'JOIN_REQUEST_NOT_FOUND'>>> {
const requests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId);
const request = requests.find(r => r.id === input.requestId);
if (!request) {
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
}
await this.leagueMembershipRepository.removeJoinRequest(params.requestId);
await this.leagueMembershipRepository.removeJoinRequest(input.requestId);
await this.leagueMembershipRepository.saveMembership({
id: randomUUID(),
leagueId: params.leagueId,
leagueId: input.leagueId,
driverId: request.driverId,
role: 'member',
status: 'active',
joinedAt: JoinedAt.create(new Date()),
});
const dto: ApproveLeagueJoinRequestResultPort = { success: true, message: 'Join request approved.' };
return Result.ok(dto);
const result: ApproveLeagueJoinRequestResult = { success: true, message: 'Join request approved.' };
this.output.present(result);
return Result.ok(undefined);
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ApproveTeamJoinRequestUseCase } from './ApproveTeamJoinRequestUseCase';
import { ApproveTeamJoinRequestUseCase, type ApproveTeamJoinRequestResult } from './ApproveTeamJoinRequestUseCase';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('ApproveTeamJoinRequestUseCase', () => {
let useCase: ApproveTeamJoinRequestUseCase;
@@ -9,6 +10,7 @@ describe('ApproveTeamJoinRequestUseCase', () => {
removeJoinRequest: Mock;
saveMembership: Mock;
};
let output: UseCaseOutputPort<ApproveTeamJoinRequestResult> & { present: Mock };
beforeEach(() => {
membershipRepository = {
@@ -16,7 +18,13 @@ describe('ApproveTeamJoinRequestUseCase', () => {
removeJoinRequest: vi.fn(),
saveMembership: vi.fn(),
};
useCase = new ApproveTeamJoinRequestUseCase(membershipRepository as unknown as ITeamMembershipRepository);
output = { present: vi.fn() } as unknown as UseCaseOutputPort<ApproveTeamJoinRequestResult> & {
present: Mock;
};
useCase = new ApproveTeamJoinRequestUseCase(
membershipRepository as unknown as ITeamMembershipRepository,
output,
);
});
it('should approve join request and save membership', async () => {
@@ -37,6 +45,16 @@ describe('ApproveTeamJoinRequestUseCase', () => {
status: 'active',
joinedAt: expect.any(Date),
});
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
membership: {
teamId,
driverId: 'driver-1',
role: 'driver',
status: 'active',
joinedAt: expect.any(Date),
},
});
});
it('should return error if request not found', async () => {
@@ -45,6 +63,7 @@ describe('ApproveTeamJoinRequestUseCase', () => {
const result = await useCase.execute({ teamId: 'team-1', requestId: 'req-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('JOIN_REQUEST_NOT_FOUND');
expect(result.unwrapErr().code).toBe('REQUEST_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,3 @@
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
@@ -6,34 +5,67 @@ import type {
TeamJoinRequest,
TeamMembership,
} from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestInputPort } from '../ports/input/ApproveTeamJoinRequestInputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestInputPort, void, string> {
export type ApproveTeamJoinRequestInput = {
teamId: string;
requestId: string;
};
export type ApproveTeamJoinRequestResult = {
membership: TeamMembership;
};
export type ApproveTeamJoinRequestErrorCode =
| 'TEAM_NOT_FOUND'
| 'REQUEST_NOT_FOUND'
| 'NOT_AUTHORIZED'
| 'REPOSITORY_ERROR';
export class ApproveTeamJoinRequestUseCase {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
private readonly output: UseCaseOutputPort<ApproveTeamJoinRequestResult>,
) {}
async execute(command: ApproveTeamJoinRequestInputPort): Promise<Result<void, ApplicationErrorCode<string>>> {
async execute(command: ApproveTeamJoinRequestInput): Promise<
Result<void, ApplicationErrorCode<ApproveTeamJoinRequestErrorCode>>
> {
const { teamId, requestId } = command;
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId);
const request = allRequests.find((r) => r.id === requestId);
try {
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId);
const request = allRequests.find((r) => r.id === requestId);
if (!request) {
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
if (!request) {
return Result.err({ code: 'REQUEST_NOT_FOUND' });
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver',
status: 'active',
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
await this.membershipRepository.removeJoinRequest(requestId);
const result: ApproveTeamJoinRequestResult = {
membership,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : String(error),
},
});
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver',
status: 'active',
joinedAt: new Date(),
};
await this.membershipRepository.saveMembership(membership);
await this.membershipRepository.removeJoinRequest(requestId);
return Result.ok(undefined);
}
}

View File

@@ -1,9 +1,10 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CancelRaceUseCase } from './CancelRaceUseCase';
import { CancelRaceUseCase, type CancelRaceResult } 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 type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CancelRaceUseCase', () => {
let useCase: CancelRaceUseCase;
@@ -17,6 +18,7 @@ describe('CancelRaceUseCase', () => {
info: Mock;
error: Mock;
};
let output: UseCaseOutputPort<CancelRaceResult> & { present: Mock };
beforeEach(() => {
raceRepository = {
@@ -29,7 +31,12 @@ describe('CancelRaceUseCase', () => {
info: vi.fn(),
error: vi.fn(),
};
useCase = new CancelRaceUseCase(raceRepository as unknown as IRaceRepository, logger as unknown as Logger);
output = { present: vi.fn() } as unknown as UseCaseOutputPort<CancelRaceResult> & { present: Mock };
useCase = new CancelRaceUseCase(
raceRepository as unknown as IRaceRepository,
logger as unknown as Logger,
output,
);
});
it('should cancel race successfully', async () => {
@@ -46,21 +53,25 @@ describe('CancelRaceUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
const result = await useCase.execute({ raceId });
const result = await useCase.execute({ raceId, cancelledById: 'admin-1' });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(raceRepository.findById).toHaveBeenCalledWith(raceId);
expect(raceRepository.update).toHaveBeenCalledWith(expect.objectContaining({ id: raceId, status: 'cancelled' }));
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ race: expect.objectContaining({ id: raceId, status: 'cancelled' }) });
});
it('should return error if race not found', async () => {
const raceId = 'race-1';
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId });
const result = await useCase.execute({ raceId, cancelledById: 'admin-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('should return domain error if race is already cancelled', async () => {
@@ -77,10 +88,12 @@ describe('CancelRaceUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
const result = await useCase.execute({ raceId });
const result = await useCase.execute({ raceId, cancelledById: 'admin-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('RACE_ALREADY_CANCELLED');
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('already cancelled');
expect(output.present).not.toHaveBeenCalled();
});
it('should return domain error if race is completed', async () => {
@@ -97,9 +110,11 @@ describe('CancelRaceUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
const result = await useCase.execute({ raceId });
const result = await useCase.execute({ raceId, cancelledById: 'admin-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('CANNOT_CANCEL_COMPLETED_RACE');
expect(result.unwrapErr().code).toBe('NOT_AUTHORIZED');
expect(result.unwrapErr().details?.message).toContain('completed race');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,20 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Race } from '../../domain/entities/Race';
export type CancelRaceInput = {
raceId: string;
cancelledById: string;
};
export type CancelRaceErrorCode = 'RACE_NOT_FOUND' | 'NOT_AUTHORIZED' | 'REPOSITORY_ERROR';
export type CancelRaceResult = {
race: Race;
};
/**
* Use Case: CancelRaceUseCase
@@ -13,14 +25,16 @@ import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
* - delegates cancellation rules to the Race domain entity
* - persists the updated race via the repository.
*/
export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, void, string> {
export class CancelRaceUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CancelRaceResult>,
) {}
async execute(command: CancelRaceCommandDTO): Promise<Result<void, ApplicationErrorCode<string>>> {
async execute(command: CancelRaceInput): Promise<
Result<void, ApplicationErrorCode<CancelRaceErrorCode>>
> {
const { raceId } = command;
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
@@ -34,18 +48,39 @@ export class CancelRaceUseCase
const cancelledRace = race.cancel();
await this.raceRepository.update(cancelledRace);
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
const result: CancelRaceResult = {
race: cancelledRace,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
if (error instanceof Error && error.message.includes('already cancelled')) {
this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`);
return Result.err({ code: 'RACE_ALREADY_CANCELLED' });
return Result.err({
code: 'NOT_AUTHORIZED',
details: { message: error.message },
});
}
if (error instanceof Error && error.message.includes('completed race')) {
this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`);
return Result.err({ code: 'CANNOT_CANCEL_COMPLETED_RACE' });
return Result.err({
code: 'NOT_AUTHORIZED',
details: { message: error.message },
});
}
this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
return Result.err({ code: 'UNEXPECTED_ERROR' });
this.logger.error(
`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`,
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}
}

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CloseRaceEventStewardingUseCase } from './CloseRaceEventStewardingUseCase';
import { CloseRaceEventStewardingUseCase, type CloseRaceEventStewardingResult } from './CloseRaceEventStewardingUseCase';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
@@ -8,6 +8,7 @@ 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 type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CloseRaceEventStewardingUseCase', () => {
let useCase: CloseRaceEventStewardingUseCase;
@@ -27,6 +28,7 @@ describe('CloseRaceEventStewardingUseCase', () => {
let logger: {
error: Mock;
};
let output: UseCaseOutputPort<CloseRaceEventStewardingResult> & { present: Mock };
beforeEach(() => {
raceEventRepository = {
@@ -45,12 +47,14 @@ describe('CloseRaceEventStewardingUseCase', () => {
logger = {
error: vi.fn(),
};
output = { present: vi.fn() } as unknown as UseCaseOutputPort<CloseRaceEventStewardingResult> & { present: Mock };
useCase = new CloseRaceEventStewardingUseCase(
logger as unknown as Logger,
raceEventRepository as unknown as IRaceEventRepository,
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
penaltyRepository as unknown as IPenaltyRepository,
domainEventPublisher as unknown as DomainEventPublisher,
output,
);
});
@@ -80,32 +84,41 @@ describe('CloseRaceEventStewardingUseCase', () => {
penaltyRepository.findByRaceId.mockResolvedValue([]);
domainEventPublisher.publish.mockResolvedValue(undefined);
const result = await useCase.execute({});
const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(raceEventRepository.findAwaitingStewardingClose).toHaveBeenCalled();
expect(raceEventRepository.update).toHaveBeenCalledWith(
expect.objectContaining({ id: 'event-1', status: 'closed' })
);
expect(domainEventPublisher.publish).toHaveBeenCalled();
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
race: expect.objectContaining({ id: 'event-1', status: 'closed' })
});
});
it('should handle no expired events', async () => {
raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([]);
const result = await useCase.execute({});
const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(raceEventRepository.update).not.toHaveBeenCalled();
expect(domainEventPublisher.publish).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
raceEventRepository.findAwaitingStewardingClose.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({});
const result = await useCase.execute({ raceId: 'event-1', closedById: 'admin-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('FAILED_TO_CLOSE_STEWARDING');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toContain('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,4 @@
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
@@ -6,8 +6,17 @@ import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CloseRaceEventStewardingCommand } from '../dto/CloseRaceEventStewardingCommand';
import type { RaceEvent } from '../../domain/entities/RaceEvent';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export type CloseRaceEventStewardingInput = {
raceId: string;
closedById: string;
};
export type CloseRaceEventStewardingResult = {
race: RaceEvent;
};
/**
* Use Case: CloseRaceEventStewardingUseCase
@@ -18,9 +27,7 @@ import type { RaceEvent } from '../../domain/entities/RaceEvent';
* This would typically be run by a scheduled job (e.g., every 5 minutes)
* to automatically close stewarding windows based on league configuration.
*/
export class CloseRaceEventStewardingUseCase
implements AsyncUseCase<CloseRaceEventStewardingCommand, void, string>
{
export class CloseRaceEventStewardingUseCase {
constructor(
private readonly logger: Logger,
@@ -28,22 +35,41 @@ export class CloseRaceEventStewardingUseCase
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly domainEventPublisher: DomainEventPublisher,
private readonly output: UseCaseOutputPort<CloseRaceEventStewardingResult>,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async execute(_command: CloseRaceEventStewardingCommand): Promise<Result<void, ApplicationErrorCode<string>>> {
async execute(_: CloseRaceEventStewardingInput): Promise<Result<void, ApplicationErrorCode<'RACE_NOT_FOUND' | 'STEWARDING_ALREADY_CLOSED' | 'REPOSITORY_ERROR'>>> {
try {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
const closedRaceEventIds: string[] = [];
for (const raceEvent of expiredEvents) {
await this.closeStewardingForRaceEvent(raceEvent);
closedRaceEventIds.push(raceEvent.id);
}
// When multiple race events are processed, we present the last closed event for simplicity
const lastClosedEventId = closedRaceEventIds[closedRaceEventIds.length - 1];
if (lastClosedEventId) {
const lastClosedEvent = await this.raceEventRepository.findById(lastClosedEventId);
if (lastClosedEvent) {
this.output.present({
race: lastClosedEvent,
});
}
}
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({ code: 'FAILED_TO_CLOSE_STEWARDING' });
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : String(error),
},
});
}
}

View File

@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteDriverOnboardingUseCase } from './CompleteDriverOnboardingUseCase';
import { CompleteDriverOnboardingUseCase, type CompleteDriverOnboardingResult } from './CompleteDriverOnboardingUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand';
import type { CompleteDriverOnboardingInput } from './CompleteDriverOnboardingUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CompleteDriverOnboardingUseCase', () => {
let useCase: CompleteDriverOnboardingUseCase;
@@ -10,19 +11,22 @@ describe('CompleteDriverOnboardingUseCase', () => {
findById: Mock;
create: Mock;
};
let output: { present: Mock };
beforeEach(() => {
driverRepository = {
findById: vi.fn(),
create: vi.fn(),
};
output = { present: vi.fn() };
useCase = new CompleteDriverOnboardingUseCase(
driverRepository as unknown as IDriverRepository,
output as unknown as UseCaseOutputPort<CompleteDriverOnboardingResult>,
);
});
it('should create driver successfully when driver does not exist', async () => {
const command: CompleteDriverOnboardingCommand = {
const command: CompleteDriverOnboardingInput = {
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
@@ -44,7 +48,9 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({ driverId: 'user-1' });
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
expect(driverRepository.findById).toHaveBeenCalledWith('user-1');
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
@@ -58,7 +64,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
});
it('should return error when driver already exists', async () => {
const command: CompleteDriverOnboardingCommand = {
const command: CompleteDriverOnboardingInput = {
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
@@ -79,10 +85,11 @@ describe('CompleteDriverOnboardingUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS');
expect(driverRepository.create).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository create throws', async () => {
const command: CompleteDriverOnboardingCommand = {
const command: CompleteDriverOnboardingInput = {
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
@@ -96,11 +103,14 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
const error = result.unwrapErr() as { code: 'REPOSITORY_ERROR'; details?: { message: string } };
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
it('should handle bio being undefined', async () => {
const command: CompleteDriverOnboardingCommand = {
const command: CompleteDriverOnboardingInput = {
userId: 'user-1',
firstName: 'John',
lastName: 'Doe',
@@ -120,6 +130,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(driverRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
id: 'user-1',
@@ -129,5 +140,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
bio: undefined,
})
);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ driver: createdDriver });
});
});

View File

@@ -1,31 +1,41 @@
import type { AsyncUseCase } from '@core/shared/application';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteDriverOnboardingCommand } from '../dto/CompleteDriverOnboardingCommand';
import type { CompleteDriverOnboardingOutputPort } from '../ports/output/CompleteDriverOnboardingOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface CompleteDriverOnboardingInput {
userId: string;
firstName: string;
lastName: string;
displayName: string;
country: string;
bio?: string;
}
export type CompleteDriverOnboardingResult = {
driver: Driver;
};
/**
* Use Case for completing driver onboarding.
*/
export class CompleteDriverOnboardingUseCase
implements AsyncUseCase<CompleteDriverOnboardingCommand, CompleteDriverOnboardingOutputPort, string>
{
constructor(private readonly driverRepository: IDriverRepository) {}
export class CompleteDriverOnboardingUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly output: UseCaseOutputPort<CompleteDriverOnboardingResult>,
) {}
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<CompleteDriverOnboardingOutputPort, ApplicationErrorCode<string>>> {
async execute(command: CompleteDriverOnboardingInput): Promise<Result<void, ApplicationErrorCode<'DRIVER_ALREADY_EXISTS' | 'REPOSITORY_ERROR'>>> {
try {
// Check if driver already exists
const existing = await this.driverRepository.findById(command.userId);
if (existing) {
return Result.err({ code: 'DRIVER_ALREADY_EXISTS' });
}
// Create new driver
const driver = Driver.create({
id: command.userId,
iracingId: command.userId, // Assuming userId is iracingId for now
iracingId: command.userId,
name: command.displayName,
country: command.country,
...(command.bio !== undefined ? { bio: command.bio } : {}),
@@ -33,9 +43,16 @@ export class CompleteDriverOnboardingUseCase
await this.driverRepository.create(driver);
return Result.ok({ driverId: driver.id });
} catch {
return Result.err({ code: 'UNKNOWN_ERROR' });
this.output.present({ driver });
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
}

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteRaceUseCase } from './CompleteRaceUseCase';
import { CompleteRaceUseCase, type CompleteRaceInput, type CompleteRaceResult } 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 { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CompleteRaceUseCase', () => {
let useCase: CompleteRaceUseCase;
@@ -23,6 +23,7 @@ describe('CompleteRaceUseCase', () => {
save: Mock;
};
let getDriverRating: Mock;
let output: { present: Mock };
beforeEach(() => {
raceRepository = {
@@ -40,17 +41,19 @@ describe('CompleteRaceUseCase', () => {
save: vi.fn(),
};
getDriverRating = vi.fn();
output = { present: 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,
getDriverRating,
output as unknown as UseCaseOutputPort<CompleteRaceResult>,
);
});
it('should complete race successfully when race exists and has registered drivers', async () => {
const command: CompleteRaceCommandDTO = {
const command: CompleteRaceInput = {
raceId: 'race-1',
};
@@ -75,7 +78,7 @@ describe('CompleteRaceUseCase', () => {
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({});
expect(result.unwrap()).toBeUndefined();
expect(raceRepository.findById).toHaveBeenCalledWith('race-1');
expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1');
expect(getDriverRating).toHaveBeenCalledTimes(2);
@@ -83,10 +86,12 @@ describe('CompleteRaceUseCase', () => {
expect(standingRepository.save).toHaveBeenCalledTimes(2);
expect(mockRace.complete).toHaveBeenCalled();
expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' });
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ raceId: 'race-1', registeredDriverIds: ['driver-1', 'driver-2'] });
});
it('should return error when race does not exist', async () => {
const command: CompleteRaceCommandDTO = {
const command: CompleteRaceInput = {
raceId: 'race-1',
};
@@ -96,10 +101,11 @@ describe('CompleteRaceUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when no registered drivers', async () => {
const command: CompleteRaceCommandDTO = {
const command: CompleteRaceInput = {
raceId: 'race-1',
};
@@ -116,10 +122,11 @@ describe('CompleteRaceUseCase', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
const command: CompleteRaceCommandDTO = {
const command: CompleteRaceInput = {
raceId: 'race-1',
};
@@ -137,6 +144,9 @@ describe('CompleteRaceUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -2,14 +2,22 @@ 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 { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { Result } from '../../domain/entities/Result';
import { Result as RaceResult } from '../../domain/entities/result/Result';
import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/application/Result';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface CompleteRaceInput {
raceId: string;
}
export type CompleteRaceResult = {
raceId: string;
registeredDriverIds: string[];
};
export type CompleteRaceErrorCode = 'RACE_NOT_FOUND' | 'NO_REGISTERED_DRIVERS' | 'REPOSITORY_ERROR';
/**
* Use Case: CompleteRaceUseCase
@@ -22,41 +30,52 @@ import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
* - updates league standings
* - persists all changes via repositories.
*/
export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, {}, string> {
interface DriverRatingInput {
driverId: string;
}
interface DriverRatingOutput {
rating: number | null;
ratingChange: number | null;
}
export class CompleteRaceUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly standingRepository: IStandingRepository,
private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
private readonly getDriverRating: (input: DriverRatingInput) => Promise<DriverRatingOutput>,
private readonly output: UseCaseOutputPort<CompleteRaceResult>,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, ApplicationErrorCode<string>>> {
async execute(command: CompleteRaceInput): Promise<
Result<void, ApplicationErrorCode<CompleteRaceErrorCode | 'REPOSITORY_ERROR', { message: string }>>
> {
try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return SharedResult.err({ code: 'RACE_NOT_FOUND' });
return Result.err({ code: 'RACE_NOT_FOUND' });
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' });
return Result.err({ code: 'NO_REGISTERED_DRIVERS' });
}
// Get driver ratings using clean ports
const ratingPromises = registeredDriverIds.map(driverId =>
this.getDriverRating({ driverId })
// Get driver ratings using injected provider
const ratingPromises = registeredDriverIds.map((driverId) =>
this.getDriverRating({ driverId }),
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = new Map<string, number>();
registeredDriverIds.forEach((driverId, index) => {
const rating = ratingResults[index].rating;
const rating = ratingResults[index]?.rating ?? null;
if (rating !== null) {
driverRatings.set(driverId, rating);
}
@@ -77,17 +96,24 @@ export class CompleteRaceUseCase
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
return SharedResult.ok({});
} catch {
return SharedResult.err({ code: 'UNKNOWN_ERROR' });
this.output.present({ raceId, registeredDriverIds });
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private generateRaceResults(
raceId: string,
driverIds: string[],
driverRatings: Map<string, number>
): Result[] {
driverRatings: Map<string, number>,
): RaceResult[] {
// Create driver performance data
const driverPerformances = driverIds.map(driverId => ({
driverId,
@@ -114,7 +140,7 @@ export class CompleteRaceUseCase
});
// Generate results
const results: Result[] = [];
const results: RaceResult[] = [];
for (let i = 0; i < driverPerformances.length; i++) {
const { driverId } = driverPerformances[i]!;
const position = i + 1;
@@ -130,7 +156,7 @@ export class CompleteRaceUseCase
const incidents = Math.random() < incidentProbability ? Math.floor(Math.random() * 3) + 1 : 0;
results.push(
Result.create({
RaceResult.create({
id: `${raceId}-${driverId}`,
raceId,
driverId,
@@ -138,16 +164,16 @@ export class CompleteRaceUseCase
startPosition,
fastestLap,
incidents,
})
}),
);
}
return results;
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
private async updateStandings(leagueId: string, results: RaceResult[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);

View File

@@ -1,12 +1,15 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteRaceUseCaseWithRatings } from './CompleteRaceUseCaseWithRatings';
import {
CompleteRaceUseCaseWithRatings,
type CompleteRaceWithRatingsInput,
type CompleteRaceWithRatingsResult,
} 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 type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CompleteRaceUseCaseWithRatings', () => {
let useCase: CompleteRaceUseCaseWithRatings;
@@ -30,6 +33,7 @@ describe('CompleteRaceUseCaseWithRatings', () => {
let ratingUpdateService: {
updateDriverRatingsAfterRace: Mock;
};
let output: { present: Mock };
beforeEach(() => {
raceRepository = {
@@ -52,18 +56,21 @@ describe('CompleteRaceUseCaseWithRatings', () => {
ratingUpdateService = {
updateDriverRatingsAfterRace: vi.fn(),
};
output = { present: 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,
driverRatingProvider,
ratingUpdateService as unknown as RatingUpdateService,
output as unknown as UseCaseOutputPort<CompleteRaceWithRatingsResult>,
);
});
it('should complete race successfully when race exists and has registered drivers', async () => {
const command: CompleteRaceCommandDTO = {
it('completes race with ratings when race exists and has registered drivers', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
@@ -75,7 +82,12 @@ describe('CompleteRaceUseCaseWithRatings', () => {
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600], ['driver-2', 1500]]));
driverRatingProvider.getRatings.mockReturnValue(
new Map([
['driver-1', 1600],
['driver-2', 1500],
]),
);
resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
@@ -94,10 +106,15 @@ describe('CompleteRaceUseCaseWithRatings', () => {
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
expect(mockRace.complete).toHaveBeenCalled();
expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' });
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
raceId: 'race-1',
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
});
});
it('should return error when race does not exist', async () => {
const command: CompleteRaceCommandDTO = {
it('returns error when race does not exist', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
@@ -107,10 +124,32 @@ describe('CompleteRaceUseCaseWithRatings', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when no registered drivers', async () => {
const command: CompleteRaceCommandDTO = {
it('returns error when race is already completed', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'completed',
complete: vi.fn(),
};
raceRepository.findById.mockResolvedValue(mockRace);
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('ALREADY_COMPLETED');
expect(output.present).not.toHaveBeenCalled();
expect(raceRegistrationRepository.getRegisteredDrivers).not.toHaveBeenCalled();
});
it('returns error when no registered drivers', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
@@ -127,10 +166,39 @@ describe('CompleteRaceUseCaseWithRatings', () => {
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
const command: CompleteRaceCommandDTO = {
it('returns rating update error when rating service throws', async () => {
const command: CompleteRaceWithRatingsInput = {
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.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
ratingUpdateService.updateDriverRatingsAfterRace.mockRejectedValue(new Error('Rating error'));
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('RATING_UPDATE_FAILED');
expect(error.details?.message).toBe('Rating error');
expect(output.present).not.toHaveBeenCalled();
});
it('returns repository error when persistence fails', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
@@ -148,6 +216,9 @@ describe('CompleteRaceUseCaseWithRatings', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -2,21 +2,38 @@ 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 { Result } from '../../domain/entities/Result';
import { Result as RaceResult } from '../../domain/entities/result/Result';
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 { Result as SharedResult } from '@core/shared/application/Result';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface CompleteRaceWithRatingsInput {
raceId: string;
}
export type CompleteRaceWithRatingsResult = {
raceId: string;
ratingsUpdatedForDriverIds: string[];
};
export type CompleteRaceWithRatingsErrorCode =
| 'RACE_NOT_FOUND'
| 'NO_REGISTERED_DRIVERS'
| 'ALREADY_COMPLETED'
| 'RATING_UPDATE_FAILED'
| 'REPOSITORY_ERROR';
interface DriverRatingProvider {
getRatings(driverIds: string[]): Map<string, number>;
}
/**
* Enhanced CompleteRaceUseCase that includes rating updates
* Enhanced CompleteRaceUseCase that includes rating updates.
*/
export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, void, string> {
export class CompleteRaceUseCaseWithRatings {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
@@ -24,60 +41,77 @@ export class CompleteRaceUseCaseWithRatings
private readonly standingRepository: IStandingRepository,
private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService,
private readonly output: UseCaseOutputPort<CompleteRaceWithRatingsResult>,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<void, ApplicationErrorCode<string>>> {
async execute(command: CompleteRaceWithRatingsInput): Promise<
Result<void, ApplicationErrorCode<CompleteRaceWithRatingsErrorCode, { message: string }>>
> {
try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return SharedResult.err({ code: 'RACE_NOT_FOUND' });
return Result.err({ code: 'RACE_NOT_FOUND' });
}
if (race.status === 'completed') {
return Result.err({ code: 'ALREADY_COMPLETED' });
}
// Get registered drivers for this race
const registeredDriverIds = await this.raceRegistrationRepository.getRegisteredDrivers(raceId);
if (registeredDriverIds.length === 0) {
return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' });
return Result.err({ code: 'NO_REGISTERED_DRIVERS' });
}
// Get driver ratings
const driverRatings = this.driverRatingProvider.getRatings(registeredDriverIds);
// Generate realistic race results
const results = RaceResultGenerator.generateRaceResults(raceId, registeredDriverIds, driverRatings);
// Save results
for (const result of results) {
await this.resultRepository.create(result);
}
// Update standings
await this.updateStandings(race.leagueId, results);
// Update driver ratings based on performance
await this.updateDriverRatings(results, registeredDriverIds.length);
try {
await this.updateDriverRatings(results, registeredDriverIds.length);
} catch (error) {
return Result.err({
code: 'RATING_UPDATE_FAILED',
details: {
message: error instanceof Error ? error.message : 'Failed to update driver ratings',
},
});
}
// Complete the race
const completedRace = race.complete();
await this.raceRepository.update(completedRace);
return SharedResult.ok(undefined);
} catch {
return SharedResult.err({ code: 'UNKNOWN_ERROR' });
this.output.present({
raceId,
ratingsUpdatedForDriverIds: registeredDriverIds,
});
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private async updateStandings(leagueId: string, results: Result[]): Promise<void> {
// Group results by driver
const resultsByDriver = new Map<string, Result[]>();
private async updateStandings(leagueId: string, results: RaceResult[]): Promise<void> {
const resultsByDriver = new Map<string, RaceResult[]>();
for (const result of results) {
const existing = resultsByDriver.get(result.driverId) || [];
existing.push(result);
resultsByDriver.set(result.driverId, existing);
}
// Update or create standings for each driver
for (const [driverId, driverResults] of resultsByDriver) {
let standing = await this.standingRepository.findByDriverIdAndLeagueId(driverId, leagueId);
@@ -88,10 +122,18 @@ export class CompleteRaceUseCaseWithRatings
});
}
// Add all results for this driver (should be just one for this race)
for (const result of driverResults) {
standing = standing.addRaceResult(result.position, {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1
1: 25,
2: 18,
3: 15,
4: 12,
5: 10,
6: 8,
7: 6,
8: 4,
9: 2,
10: 1,
});
}
@@ -99,8 +141,8 @@ export class CompleteRaceUseCaseWithRatings
}
}
private async updateDriverRatings(results: Result[], totalDrivers: number): Promise<void> {
const driverResults = results.map(result => ({
private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> {
const driverResults = results.map((result) => ({
driverId: result.driverId,
position: result.position,
totalDrivers,

View File

@@ -1,10 +1,13 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase';
import type { CreateLeagueWithSeasonAndScoringCommand } from '../dto/CreateLeagueWithSeasonAndScoringCommand';
import {
CreateLeagueWithSeasonAndScoringUseCase,
type CreateLeagueWithSeasonAndScoringCommand,
type CreateLeagueWithSeasonAndScoringResult,
} from './CreateLeagueWithSeasonAndScoringUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { Logger } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
let useCase: CreateLeagueWithSeasonAndScoringUseCase;
@@ -24,6 +27,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
warn: Mock;
error: Mock;
};
let output: { present: Mock } & UseCaseOutputPort<CreateLeagueWithSeasonAndScoringResult>;
beforeEach(() => {
leagueRepository = {
@@ -42,12 +46,14 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
};
output = { present: vi.fn() } as unknown as typeof output;
useCase = new CreateLeagueWithSeasonAndScoringUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository,
getLeagueScoringPresetById,
logger as unknown as Logger,
output,
);
});
@@ -80,11 +86,13 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
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(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateLeagueWithSeasonAndScoringResult;
expect(presented.league.id.toString()).toBeDefined();
expect(presented.season.id).toBeDefined();
expect(presented.scoringConfig.seasonId.toString()).toBe(presented.season.id);
expect(leagueRepository.create).toHaveBeenCalledTimes(1);
expect(seasonRepository.create).toHaveBeenCalledTimes(1);
expect(leagueScoringConfigRepository.save).toHaveBeenCalledTimes(1);
@@ -106,7 +114,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('League name is required');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('League name is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when ownerId is empty', async () => {
@@ -125,7 +135,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('League ownerId is required');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('League ownerId is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when gameId is empty', async () => {
@@ -144,7 +156,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('gameId is required');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('gameId is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when visibility is missing', async () => {
@@ -161,7 +175,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('visibility is required');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('visibility is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when maxDrivers is invalid', async () => {
@@ -181,7 +197,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('maxDrivers must be greater than 0 when provided');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe('maxDrivers must be greater than 0 when provided');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when ranked league has insufficient drivers', async () => {
@@ -201,7 +219,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toContain('Ranked leagues require at least 10 drivers');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toContain('Ranked leagues require at least 10 drivers');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when scoring preset is unknown', async () => {
@@ -223,7 +243,9 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Unknown scoring preset: unknown-preset');
expect(result.unwrapErr().code).toBe('UNKNOWN_PRESET');
expect(result.unwrapErr().details?.message).toBe('Unknown scoring preset: unknown-preset');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
@@ -250,6 +272,8 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('DB error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,22 +1,19 @@
import { v4 as uuidv4 } from 'uuid';
import { League } from '../../domain/entities/League';
import { Season } from '../../domain/entities/Season';
import { Season } from '../../domain/entities/season/Season';
import { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort';
import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import {
LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS,
} from '../../domain/value-objects/LeagueVisibility';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { LeagueVisibilityInput } from '../dto/LeagueVisibilityInput';
import type { CreateLeagueWithSeasonAndScoringOutputPort } from '../ports/output/CreateLeagueWithSeasonAndScoringOutputPort';
export interface CreateLeagueWithSeasonAndScoringCommand {
export type CreateLeagueWithSeasonAndScoringCommand = {
name: string;
description?: string;
/**
@@ -24,7 +21,7 @@ export interface CreateLeagueWithSeasonAndScoringCommand {
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityInput;
visibility: string;
ownerId: string;
gameId: string;
maxDrivers?: number;
@@ -34,21 +31,37 @@ export interface CreateLeagueWithSeasonAndScoringCommand {
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
};
export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringOutputPort, 'VALIDATION_ERROR' | 'UNKNOWN_PRESET' | 'REPOSITORY_ERROR'> {
export type CreateLeagueWithSeasonAndScoringResult = {
league: League;
season: Season;
scoringConfig: LeagueScoringConfig;
};
type CreateLeagueWithSeasonAndScoringErrorCode =
| 'VALIDATION_ERROR'
| 'UNKNOWN_PRESET'
| 'REPOSITORY_ERROR';
type ScoringPreset = {
id: string;
name: string;
};
export class CreateLeagueWithSeasonAndScoringUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise<LeagueScoringPresetOutputPort | undefined>,
private readonly getLeagueScoringPresetById: (input: { presetId: string }) => Promise<ScoringPreset | undefined>,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CreateLeagueWithSeasonAndScoringResult>,
) {}
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<Result<CreateLeagueWithSeasonAndScoringOutputPort, ApplicationErrorCode<'VALIDATION_ERROR' | 'UNKNOWN_PRESET' | 'REPOSITORY_ERROR', { message: string }>>> {
): Promise<Result<void, ApplicationErrorCode<CreateLeagueWithSeasonAndScoringErrorCode>>> {
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
const validation = this.validate(command);
if (validation.isErr()) {
@@ -93,8 +106,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
const presetId = command.scoringPresetId ?? 'club-default';
this.logger.debug(`Attempting to retrieve scoring preset: ${presetId}`);
const preset: LeagueScoringPresetOutputPort | undefined =
await this.getLeagueScoringPresetById({ presetId });
const preset = await this.getLeagueScoringPresetById({ presetId });
if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`);
@@ -102,10 +114,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
}
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
// Note: createScoringConfigFromPreset business logic should be moved to domain layer
// For now, we'll create a basic config structure
const finalConfig = {
id: uuidv4(),
const scoringConfig = LeagueScoringConfig.create({
seasonId,
scoringPresetId: preset.id,
championships: {
@@ -114,26 +123,33 @@ export class CreateLeagueWithSeasonAndScoringUseCase
nations: command.enableNationsChampionship,
trophy: command.enableTrophyChampionship,
},
};
});
this.logger.debug(`Scoring configuration created from preset ${preset.id}.`);
await this.leagueScoringConfigRepository.save(finalConfig);
await this.leagueScoringConfigRepository.save(scoringConfig);
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
const result: CreateLeagueWithSeasonAndScoringOutputPort = {
leagueId: league.id.toString(),
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,
const result: CreateLeagueWithSeasonAndScoringResult = {
league,
season,
scoringConfig,
};
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return Result.ok(result);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private validate(command: CreateLeagueWithSeasonAndScoringCommand): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
private validate(
command: CreateLeagueWithSeasonAndScoringCommand,
): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
this.logger.debug('Validating CreateLeagueWithSeasonAndScoringCommand', { command });
if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: League name is required', { command });

View File

@@ -1,13 +1,17 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, Mock } from 'vitest';
import { Season } from '@core/racing/domain/entities/season/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
CreateSeasonForLeagueUseCase,
type CreateSeasonForLeagueCommand,
type CreateSeasonForLeagueInput,
type CreateSeasonForLeagueResult,
} from '@core/racing/application/use-cases/CreateSeasonForLeagueUseCase';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
return {
@@ -66,6 +70,8 @@ function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>)
};
}
type CreateSeasonErrorCode = ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>;
describe('CreateSeasonForLeagueUseCase', () => {
const mockLeagueFindById = vi.fn();
const mockLeagueRepo: ILeagueRepository = {
@@ -91,15 +97,18 @@ describe('CreateSeasonForLeagueUseCase', () => {
listActiveByLeague: vi.fn(),
};
let output: { present: Mock } & UseCaseOutputPort<CreateSeasonForLeagueResult>;
beforeEach(() => {
vi.clearAllMocks();
output = { present: vi.fn() } as unknown as typeof output;
});
it('creates a planned Season for an existing league with config-derived props', async () => {
mockLeagueFindById.mockResolvedValue({ id: 'league-1' });
mockSeasonAdd.mockResolvedValue(undefined);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output);
const config = createLeagueConfigFormModel({
basics: {
@@ -124,17 +133,22 @@ describe('CreateSeasonForLeagueUseCase', () => {
},
});
const command: CreateSeasonForLeagueCommand = {
const command: CreateSeasonForLeagueInput = {
leagueId: 'league-1',
name: 'Season from Config',
gameId: 'iracing',
config,
};
const result = await useCase.execute(command);
const result: Result<void, CreateSeasonErrorCode> = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.value!.seasonId).toBeDefined();
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult;
expect(presented.season).toBeInstanceOf(Season);
expect(presented.league.id).toBe('league-1');
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
@@ -150,18 +164,64 @@ describe('CreateSeasonForLeagueUseCase', () => {
mockSeasonFindById.mockResolvedValue(sourceSeason);
mockSeasonAdd.mockResolvedValue(undefined);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output);
const command: CreateSeasonForLeagueCommand = {
const command: CreateSeasonForLeagueInput = {
leagueId: 'league-1',
name: 'Cloned Season',
gameId: 'iracing',
sourceSeasonId: 'source-season',
};
const result = await useCase.execute(command);
const result: Result<void, CreateSeasonErrorCode> = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.value!.seasonId).toBeDefined();
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as CreateSeasonForLeagueResult;
expect(presented.season.maxDrivers).toBe(40);
});
});
it('returns error when league not found and does not call output', async () => {
mockLeagueFindById.mockResolvedValue(null);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output);
const command: CreateSeasonForLeagueInput = {
leagueId: 'missing-league',
name: 'Any',
gameId: 'iracing',
};
const result: Result<void, CreateSeasonErrorCode> = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details?.message).toBe('League not found: missing-league');
expect(output.present).not.toHaveBeenCalled();
});
it('returns validation error when source season is missing and does not call output', async () => {
mockLeagueFindById.mockResolvedValue({ id: 'league-1' });
mockSeasonFindById.mockResolvedValue(undefined);
const useCase = new CreateSeasonForLeagueUseCase(mockLeagueRepo, mockSeasonRepo, output);
const command: CreateSeasonForLeagueInput = {
leagueId: 'league-1',
name: 'Cloned Season',
gameId: 'iracing',
sourceSeasonId: 'missing-source',
};
const result: Result<void, CreateSeasonErrorCode> = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.details?.message).toBe('Source Season not found: missing-source');
expect(output.present).not.toHaveBeenCalled();
});
})

View File

@@ -1,4 +1,5 @@
import { Season } from '../../domain/entities/Season';
import { League } from '../../domain/entities/League';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
@@ -13,10 +14,11 @@ import { WeekdaySet } from '../../domain/value-objects/WeekdaySet';
import { MonthlyRecurrencePattern } from '../../domain/value-objects/MonthlyRecurrencePattern';
import type { Weekday } from '../../domain/types/Weekday';
import { v4 as uuidv4 } from 'uuid';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CreateSeasonForLeagueCommand {
export type CreateSeasonForLeagueInput = {
leagueId: string;
name: string;
gameId: string;
@@ -26,13 +28,14 @@ export interface CreateSeasonForLeagueCommand {
* When omitted, the Season will be created with minimal metadata only.
*/
config?: LeagueConfigFormModel;
}
};
export interface CreateSeasonForLeagueResultDTO {
seasonId: string;
}
export type CreateSeasonForLeagueResult = {
league: League;
season: Season;
};
type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'SOURCE_SEASON_NOT_FOUND';
type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'VALIDATION_ERROR' | 'REPOSITORY_ERROR';
/**
* CreateSeasonForLeagueUseCase
@@ -44,79 +47,77 @@ export class CreateSeasonForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly output: UseCaseOutputPort<CreateSeasonForLeagueResult>,
) {}
async execute(
command: CreateSeasonForLeagueCommand,
): Promise<Result<CreateSeasonForLeagueResultDTO, ApplicationErrorCode<CreateSeasonForLeagueErrorCode>>> {
const league = await this.leagueRepository.findById(command.leagueId);
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: `League not found: ${command.leagueId}` },
});
}
let baseSeasonProps: {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} = {};
if (command.sourceSeasonId) {
const source = await this.seasonRepository.findById(command.sourceSeasonId);
if (!source) {
input: CreateSeasonForLeagueInput,
): Promise<Result<void, ApplicationErrorCode<CreateSeasonForLeagueErrorCode>>> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
if (!league) {
return Result.err({
code: 'SOURCE_SEASON_NOT_FOUND',
details: { message: `Source Season not found: ${command.sourceSeasonId}` },
code: 'LEAGUE_NOT_FOUND',
details: { message: `League not found: ${input.leagueId}` },
});
}
baseSeasonProps = {
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
...(source.scoringConfig !== undefined
? { scoringConfig: source.scoringConfig }
: {}),
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
...(source.stewardingConfig !== undefined
? { stewardingConfig: source.stewardingConfig }
: {}),
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
};
} else if (command.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
let baseSeasonProps: {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} = {};
if (input.sourceSeasonId) {
const source = await this.seasonRepository.findById(input.sourceSeasonId);
if (!source) {
return Result.err({
code: 'VALIDATION_ERROR',
details: { message: `Source Season not found: ${input.sourceSeasonId}` },
});
}
baseSeasonProps = {
...(source.schedule !== undefined ? { schedule: source.schedule } : {}),
...(source.scoringConfig !== undefined ? { scoringConfig: source.scoringConfig } : {}),
...(source.dropPolicy !== undefined ? { dropPolicy: source.dropPolicy } : {}),
...(source.stewardingConfig !== undefined ? { stewardingConfig: source.stewardingConfig } : {}),
...(source.maxDrivers !== undefined ? { maxDrivers: source.maxDrivers } : {}),
};
} else if (input.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(input.config);
}
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: input.gameId,
name: input.name,
year: new Date().getFullYear(),
status: 'planned',
...(baseSeasonProps?.schedule ? { schedule: baseSeasonProps.schedule } : {}),
...(baseSeasonProps?.scoringConfig ? { scoringConfig: baseSeasonProps.scoringConfig } : {}),
...(baseSeasonProps?.dropPolicy ? { dropPolicy: baseSeasonProps.dropPolicy } : {}),
...(baseSeasonProps?.stewardingConfig ? { stewardingConfig: baseSeasonProps.stewardingConfig } : {}),
...(baseSeasonProps?.maxDrivers !== undefined ? { maxDrivers: baseSeasonProps.maxDrivers } : {}),
});
await this.seasonRepository.add(season);
this.output.present({ league, season });
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
const seasonId = uuidv4();
const season = Season.create({
id: seasonId,
leagueId: league.id,
gameId: command.gameId,
name: command.name,
year: new Date().getFullYear(),
status: 'planned',
...(baseSeasonProps?.schedule
? { schedule: baseSeasonProps.schedule }
: {}),
...(baseSeasonProps?.scoringConfig
? { scoringConfig: baseSeasonProps.scoringConfig }
: {}),
...(baseSeasonProps?.dropPolicy
? { dropPolicy: baseSeasonProps.dropPolicy }
: {}),
...(baseSeasonProps?.stewardingConfig
? { stewardingConfig: baseSeasonProps.stewardingConfig }
: {}),
...(baseSeasonProps?.maxDrivers !== undefined
? { maxDrivers: baseSeasonProps.maxDrivers }
: {}),
});
await this.seasonRepository.add(season);
return Result.ok({ seasonId });
}
private deriveSeasonPropsFromConfig(config: LeagueConfigFormModel): {
@@ -216,4 +217,4 @@ export class CreateSeasonForLeagueUseCase {
plannedRounds,
});
}
}
}

View File

@@ -1,8 +1,8 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateSponsorUseCase } from './CreateSponsorUseCase';
import type { CreateSponsorCommand } from '../dto/CreateSponsorCommand';
import { CreateSponsorUseCase, type CreateSponsorInput } from './CreateSponsorUseCase';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CreateSponsorUseCase', () => {
let useCase: CreateSponsorUseCase;
@@ -15,6 +15,9 @@ describe('CreateSponsorUseCase', () => {
warn: Mock;
error: Mock;
};
let output: {
present: Mock;
};
beforeEach(() => {
sponsorRepository = {
@@ -26,14 +29,18 @@ describe('CreateSponsorUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new CreateSponsorUseCase(
sponsorRepository as unknown as ISponsorRepository,
logger as unknown as Logger,
output as unknown as UseCaseOutputPort<any>,
);
});
it('should create sponsor successfully', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: 'test@example.com',
websiteUrl: 'https://example.com',
@@ -42,95 +49,104 @@ describe('CreateSponsorUseCase', () => {
sponsorRepository.create.mockResolvedValue(undefined);
const result = await useCase.execute(command);
const result = await useCase.execute(input);
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(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0];
expect(presented.sponsor.id).toBeDefined();
expect(presented.sponsor.name).toBe('Test Sponsor');
expect(presented.sponsor.contactEmail).toBe('test@example.com');
expect(presented.sponsor.websiteUrl).toBe('https://example.com');
expect(presented.sponsor.logoUrl).toBe('https://example.com/logo.png');
expect(presented.sponsor.createdAt).toBeInstanceOf(Date);
expect(sponsorRepository.create).toHaveBeenCalledTimes(1);
});
it('should create sponsor without optional fields', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: 'test@example.com',
};
sponsorRepository.create.mockResolvedValue(undefined);
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const data = result.unwrap();
expect(data.sponsor.websiteUrl).toBeUndefined();
expect(data.sponsor.logoUrl).toBeUndefined();
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0];
expect(presented.sponsor.websiteUrl).toBeUndefined();
expect(presented.sponsor.logoUrl).toBeUndefined();
});
it('should return error when name is empty', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: '',
contactEmail: 'test@example.com',
};
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Sponsor name is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when contactEmail is empty', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: '',
};
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Sponsor contact email is required');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when contactEmail is invalid', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: 'invalid-email',
};
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Invalid sponsor contact email format');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when websiteUrl is invalid', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: 'test@example.com',
websiteUrl: 'invalid-url',
};
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Invalid sponsor website URL');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
const command: CreateSponsorCommand = {
const input: CreateSponsorInput = {
name: 'Test Sponsor',
contactEmail: 'test@example.com',
};
sponsorRepository.create.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute(command);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -4,64 +4,58 @@
* Creates a new sponsor.
*/
import { v4 as uuidv4 } from 'uuid';
import { Sponsor } from '../../domain/entities/Sponsor';
import { Sponsor } from '../../domain/entities/sponsor/Sponsor';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CreateSponsorOutputPort } from '../ports/output/CreateSponsorOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface CreateSponsorCommand {
export interface CreateSponsorInput {
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
}
export class CreateSponsorUseCase
implements AsyncUseCase<CreateSponsorCommand, CreateSponsorOutputPort, 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>
{
type CreateSponsorResult = {
sponsor: Sponsor;
};
export class CreateSponsorUseCase {
constructor(
private readonly sponsorRepository: ISponsorRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CreateSponsorResult>,
) {}
async execute(
command: CreateSponsorCommand,
): Promise<Result<CreateSponsorOutputPort, ApplicationErrorCode<'VALIDATION_ERROR' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateSponsorUseCase', { command });
const validation = this.validate(command);
input: CreateSponsorInput,
): Promise<Result<void, ApplicationErrorCode<'VALIDATION_ERROR' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateSponsorUseCase', { input });
const validation = this.validate(input);
if (validation.isErr()) {
return Result.err(validation.unwrapErr());
}
this.logger.info('Command validated successfully.');
this.logger.info('Input validated successfully.');
try {
const sponsorId = uuidv4();
this.logger.debug(`Generated sponsorId: ${sponsorId}`);
const sponsor = Sponsor.create({
id: sponsorId,
name: command.name,
contactEmail: command.contactEmail,
...(command.websiteUrl !== undefined ? { websiteUrl: command.websiteUrl } : {}),
...(command.logoUrl !== undefined ? { logoUrl: command.logoUrl } : {}),
name: input.name,
contactEmail: input.contactEmail,
...(input.websiteUrl !== undefined ? { websiteUrl: input.websiteUrl } : {}),
...(input.logoUrl !== undefined ? { logoUrl: input.logoUrl } : {}),
});
await this.sponsorRepository.create(sponsor);
this.logger.info(`Sponsor ${sponsor.name} (${sponsor.id}) created successfully.`);
const result: CreateSponsorOutputPort = {
sponsor: {
id: sponsor.id,
name: sponsor.name,
contactEmail: sponsor.contactEmail,
createdAt: sponsor.createdAt,
...(sponsor.websiteUrl !== undefined ? { websiteUrl: sponsor.websiteUrl } : {}),
...(sponsor.logoUrl !== undefined ? { logoUrl: sponsor.logoUrl } : {}),
},
};
this.logger.debug('CreateSponsorUseCase completed successfully.', { result });
return Result.ok(result);
this.output.present({ sponsor });
this.logger.debug('CreateSponsorUseCase completed successfully.');
return Result.ok(undefined);
} catch (error) {
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });
}

View File

@@ -1,8 +1,13 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateTeamUseCase, type CreateTeamCommandDTO } from './CreateTeamUseCase';
import {
CreateTeamUseCase,
type CreateTeamInput,
type CreateTeamResult,
} from './CreateTeamUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('CreateTeamUseCase', () => {
let useCase: CreateTeamUseCase;
@@ -19,6 +24,7 @@ describe('CreateTeamUseCase', () => {
warn: Mock;
error: Mock;
};
let output: { present: Mock };
beforeEach(() => {
teamRepository = {
@@ -34,15 +40,17 @@ describe('CreateTeamUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
};
output = { present: vi.fn() };
useCase = new CreateTeamUseCase(
teamRepository as unknown as ITeamRepository,
membershipRepository as unknown as ITeamMembershipRepository,
logger as unknown as Logger,
output as unknown as UseCaseOutputPort<CreateTeamResult>,
);
});
it('should create team successfully', async () => {
const command: CreateTeamCommandDTO = {
const command: CreateTeamInput = {
name: 'Test Team',
tag: 'TT',
description: 'A test team',
@@ -66,19 +74,15 @@ describe('CreateTeamUseCase', () => {
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(result.unwrap()).toBeUndefined();
expect(teamRepository.create).toHaveBeenCalledTimes(1);
expect(membershipRepository.saveMembership).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({ team: mockTeam });
});
it('should return error when driver already belongs to a team', async () => {
const command: CreateTeamCommandDTO = {
const command: CreateTeamInput = {
name: 'Test Team',
tag: 'TT',
description: 'A test team',
@@ -97,13 +101,17 @@ describe('CreateTeamUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('Driver already belongs to a team');
expect(result.unwrapErr().code).toBe('VALIDATION_ERROR');
expect(result.unwrapErr().details?.message).toBe(
'Driver already belongs to a team',
);
expect(teamRepository.create).not.toHaveBeenCalled();
expect(membershipRepository.saveMembership).not.toHaveBeenCalled();
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
const command: CreateTeamCommandDTO = {
const command: CreateTeamInput = {
name: 'Test Team',
tag: 'TT',
description: 'A test team',
@@ -117,6 +125,8 @@ describe('CreateTeamUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().details.message).toBe('DB error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -12,12 +12,12 @@ import type {
TeamMembershipStatus,
TeamRole,
} from '../../domain/types/TeamMembership';
import type { AsyncUseCase , Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CreateTeamOutputPort } from '../ports/output/CreateTeamOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface CreateTeamCommandDTO {
export interface CreateTeamInput {
name: string;
tag: string;
description: string;
@@ -25,27 +25,42 @@ export interface CreateTeamCommandDTO {
leagues: string[];
}
export class CreateTeamUseCase
implements AsyncUseCase<CreateTeamCommandDTO, CreateTeamOutputPort, 'ALREADY_IN_TEAM' | 'REPOSITORY_ERROR'>
{
export interface CreateTeamResult {
team: Team;
}
export type CreateTeamErrorCode =
| 'VALIDATION_ERROR'
| 'LEAGUE_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class CreateTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<CreateTeamResult>,
) {}
async execute(
command: CreateTeamCommandDTO,
): Promise<Result<CreateTeamOutputPort, ApplicationErrorCode<'ALREADY_IN_TEAM' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateTeamUseCase', { command });
const { name, tag, description, ownerId, leagues } = command;
input: CreateTeamInput,
): Promise<
Result<void, ApplicationErrorCode<CreateTeamErrorCode, { message: string }>>
> {
this.logger.debug('Executing CreateTeamUseCase', { input });
const { name, tag, description, ownerId, leagues } = input;
const existingMembership = await this.membershipRepository.getActiveMembershipForDriver(
ownerId,
);
const existingMembership =
await this.membershipRepository.getActiveMembershipForDriver(ownerId);
if (existingMembership) {
this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId });
return Result.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } });
this.logger.warn(
'Validation failed: Driver already belongs to a team',
{ ownerId },
);
return Result.err({
code: 'VALIDATION_ERROR',
details: { message: 'Driver already belongs to a team' },
});
}
this.logger.info('Command validated successfully.');
@@ -63,7 +78,9 @@ export class CreateTeamUseCase
});
const createdTeam = await this.teamRepository.create(team);
this.logger.info(`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`);
this.logger.info(
`Team ${createdTeam.name} (${createdTeam.id}) created successfully.`,
);
const membership: TeamMembership = {
teamId: createdTeam.id,
@@ -76,11 +93,17 @@ export class CreateTeamUseCase
await this.membershipRepository.saveMembership(membership);
this.logger.debug('Team membership created successfully.');
const result: CreateTeamOutputPort = { team: createdTeam };
const result: CreateTeamResult = { team: createdTeam };
this.logger.debug('CreateTeamUseCase completed successfully.', { result });
return Result.ok(result);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
}

View File

@@ -5,29 +5,19 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
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 {
DashboardOverviewOutputPort,
DashboardDriverSummaryOutputPort,
DashboardRaceSummaryOutputPort,
DashboardRecentResultOutputPort,
DashboardLeagueStandingSummaryOutputPort,
DashboardFeedItemSummaryOutputPort,
DashboardFeedSummaryOutputPort,
DashboardFriendSummaryOutputPort,
} from '../ports/output/DashboardOverviewOutputPort';
interface DashboardOverviewParams {
export interface DashboardOverviewInput {
driverId: string;
}
@@ -40,6 +30,58 @@ interface DashboardDriverStatsAdapter {
consistency: number | null;
}
export interface DashboardDriverSummary {
driver: Driver;
avatarUrl: string | null;
rating: number | null;
globalRank: number | null;
totalRaces: number;
wins: number;
podiums: number;
consistency: number | null;
}
export interface DashboardRaceSummary {
race: Race;
league: League | null;
isMyLeague: boolean;
}
export interface DashboardRecentRaceResultSummary {
race: Race;
league: League | null;
result: RaceResult;
}
export interface DashboardLeagueStandingSummary {
league: League;
standing: Standing | null;
totalDrivers: number;
}
export interface DashboardFeedSummary {
notificationCount: number;
items: FeedItem[];
}
export interface DashboardFriendSummary {
driver: Driver;
avatarUrl: string | null;
}
export interface DashboardOverviewResult {
currentDriver: DashboardDriverSummary | null;
myUpcomingRaces: DashboardRaceSummary[];
otherUpcomingRaces: DashboardRaceSummary[];
upcomingRaces: DashboardRaceSummary[];
activeLeaguesCount: number;
nextRace: DashboardRaceSummary | null;
recentResults: DashboardRecentRaceResultSummary[];
leagueStandingsSummaries: DashboardLeagueStandingSummary[];
feedSummary: DashboardFeedSummary;
friends: DashboardFriendSummary[];
}
export class DashboardOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
@@ -51,104 +93,146 @@ export class DashboardOverviewUseCase {
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly feedRepository: IFeedRepository,
private readonly socialRepository: ISocialGraphRepository,
private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
private readonly getDriverAvatar: (driverId: string) => Promise<string>,
private readonly getDriverStats: (
driverId: string,
) => DashboardDriverStatsAdapter | null,
private readonly output: UseCaseOutputPort<DashboardOverviewResult>,
) {}
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewOutputPort>> {
const { driverId } = params;
async execute(
input: DashboardOverviewInput,
): Promise<
Result<
void,
ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>
>
> {
const { driverId } = input;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
this.driverRepository.findById(driverId),
this.leagueRepository.findAll(),
this.raceRepository.findAll(),
this.resultRepository.findAll(),
this.feedRepository.getFeedForDriver(driverId),
this.socialRepository.getFriends(driverId),
]);
try {
const [driver, allLeagues, allRaces, allResults, feedItems, friends] =
await Promise.all([
this.driverRepository.findById(driverId),
this.leagueRepository.findAll(),
this.raceRepository.findAll(),
this.resultRepository.findAll(),
this.feedRepository.getFeedForDriver(driverId),
this.socialRepository.getFriends(driverId),
]);
const leagueMap = new Map(allLeagues.map(league => [league.id, league.name]));
if (!driver) {
return Result.err({
code: 'DRIVER_NOT_FOUND',
details: { message: 'Driver not found' },
});
}
const driverStats = this.getDriverStats(driverId);
const leagueMap = new Map(allLeagues.map(league => [league.id, league]));
const currentDriver: DashboardDriverSummaryOutputPort | null = driver
? {
id: driver.id,
name: driver.name,
country: driver.country,
avatarUrl: (await this.getDriverAvatar({ driverId: driver.id })).avatarUrl,
rating: driverStats?.rating ?? null,
globalRank: driverStats?.overallRank ?? null,
totalRaces: driverStats?.totalRaces ?? 0,
wins: driverStats?.wins ?? 0,
podiums: driverStats?.podiums ?? 0,
consistency: driverStats?.consistency ?? null,
}
: null;
const driverStats = this.getDriverStats(driverId);
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
const driverLeagueIds = new Set(driverLeagues.map(league => league.id));
const currentDriver: DashboardDriverSummary = {
driver,
avatarUrl: await this.getDriverAvatar(driver.id),
rating: driverStats?.rating ?? null,
globalRank: driverStats?.overallRank ?? null,
totalRaces: driverStats?.totalRaces ?? 0,
wins: driverStats?.wins ?? 0,
podiums: driverStats?.podiums ?? 0,
consistency: driverStats?.consistency ?? null,
};
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const driverLeagues = await this.getDriverLeagues(allLeagues, driverId);
const driverLeagueIds = new Set(driverLeagues.map(league => league.id));
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
driverLeagueIds.has(race.leagueId),
);
const now = new Date();
const upcomingRaces = allRaces
.filter(race => race.status === 'scheduled' && race.scheduledAt > now)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(upcomingRacesInDriverLeagues, driverId, leagueMap);
const upcomingRacesInDriverLeagues = upcomingRaces.filter(race =>
driverLeagueIds.has(race.leagueId),
);
const nextRace: DashboardRaceSummaryOutputPort | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const { myUpcomingRaces, otherUpcomingRaces } =
await this.partitionUpcomingRacesByRegistration(
upcomingRacesInDriverLeagues,
driverId,
leagueMap,
);
const upcomingRacesSummaries: DashboardRaceSummaryOutputPort[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
].slice().sort(
(a, b) =>
new Date(a.scheduledAt).getTime() - new Date(b.scheduledAt).getTime(),
);
const nextRace: DashboardRaceSummary | null =
myUpcomingRaces.length > 0 ? myUpcomingRaces[0]! : null;
const recentResults = this.buildRecentResults(allResults, allRaces, allLeagues, driverId);
const upcomingRacesSummaries: DashboardRaceSummary[] = [
...myUpcomingRaces,
...otherUpcomingRaces,
]
.slice()
.sort(
(a, b) =>
a.race.scheduledAt.getTime() - b.race.scheduledAt.getTime(),
);
const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries(
driverLeagues,
driverId,
);
const recentResults = this.buildRecentResults(
allResults,
allRaces,
allLeagues,
driverId,
);
const activeLeaguesCount = this.computeActiveLeaguesCount(
upcomingRacesSummaries,
leagueStandingsSummaries,
);
const leagueStandingsSummaries = await this.buildLeagueStandingsSummaries(
driverLeagues,
driverId,
);
const feedSummary = this.buildFeedSummary(feedItems);
const activeLeaguesCount = this.computeActiveLeaguesCount(
upcomingRacesSummaries,
leagueStandingsSummaries,
);
const friendsSummary = await this.buildFriendsSummary(friends);
const feedSummary = this.buildFeedSummary(feedItems);
const viewModel: DashboardOverviewOutputPort = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces: upcomingRacesSummaries,
activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends: friendsSummary,
};
const friendsSummary = await this.buildFriendsSummary(friends);
return Result.ok(viewModel);
const result: DashboardOverviewResult = {
currentDriver,
myUpcomingRaces,
otherUpcomingRaces,
upcomingRaces: upcomingRacesSummaries,
activeLeaguesCount,
nextRace,
recentResults,
leagueStandingsSummaries,
feedSummary,
friends: friendsSummary,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error instanceof Error ? error.message : 'Unknown error',
},
});
}
}
private async getDriverLeagues(allLeagues: League[], driverId: string): Promise<League[]> {
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);
const membership = await this.leagueMembershipRepository.getMembership(
league.id,
driverId,
);
if (membership && membership.status === 'active') {
driverLeagues.push(league);
}
@@ -160,16 +244,19 @@ export class DashboardOverviewUseCase {
private async partitionUpcomingRacesByRegistration(
upcomingRaces: Race[],
driverId: string,
leagueMap: Map<string, string>,
leagueMap: Map<string, League>,
): Promise<{
myUpcomingRaces: DashboardRaceSummaryOutputPort[];
otherUpcomingRaces: DashboardRaceSummaryOutputPort[];
myUpcomingRaces: DashboardRaceSummary[];
otherUpcomingRaces: DashboardRaceSummary[];
}> {
const myUpcomingRaces: DashboardRaceSummaryOutputPort[] = [];
const otherUpcomingRaces: DashboardRaceSummaryOutputPort[] = [];
const myUpcomingRaces: DashboardRaceSummary[] = [];
const otherUpcomingRaces: DashboardRaceSummary[] = [];
for (const race of upcomingRaces) {
const isRegistered = await this.raceRegistrationRepository.isRegistered(race.id, driverId);
const isRegistered = await this.raceRegistrationRepository.isRegistered(
race.id,
driverId,
);
const summary = this.mapRaceToSummary(race, leagueMap, true);
if (isRegistered) {
@@ -184,17 +271,12 @@ export class DashboardOverviewUseCase {
private mapRaceToSummary(
race: Race,
leagueMap: Map<string, string>,
leagueMap: Map<string, League>,
isMyLeague: boolean,
): DashboardRaceSummaryOutputPort {
): DashboardRaceSummary {
return {
id: race.id,
leagueId: race.leagueId,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt.toISOString(),
status: race.status,
race,
league: leagueMap.get(race.leagueId) ?? null,
isMyLeague,
};
}
@@ -204,7 +286,7 @@ export class DashboardOverviewUseCase {
allRaces: Race[],
allLeagues: League[],
driverId: string,
): DashboardRecentResultOutputPort[] {
): DashboardRecentRaceResultSummary[] {
const raceById = new Map(allRaces.map(race => [race.id, race]));
const leagueById = new Map(allLeagues.map(league => [league.id, league]));
@@ -215,26 +297,20 @@ export class DashboardOverviewUseCase {
const race = raceById.get(result.raceId);
if (!race) return null;
const league = leagueById.get(race.leagueId);
const league = leagueById.get(race.leagueId) ?? null;
const finishedAt = race.scheduledAt.toISOString();
const item: DashboardRecentResultOutputPort = {
raceId: race.id,
raceName: race.track,
leagueId: race.leagueId,
leagueName: league?.name ?? 'Unknown League',
finishedAt,
position: result.position,
incidents: result.incidents,
const item: DashboardRecentRaceResultSummary = {
race,
league,
result,
};
return item;
})
.filter((item): item is DashboardRecentResultOutputPort => !!item)
.filter((item): item is DashboardRecentRaceResultSummary => !!item)
.sort(
(a, b) =>
new Date(b.finishedAt).getTime() - new Date(a.finishedAt).getTime(),
b.race.scheduledAt.getTime() - a.race.scheduledAt.getTime(),
);
const RECENT_RESULTS_LIMIT = 5;
@@ -245,8 +321,8 @@ export class DashboardOverviewUseCase {
private async buildLeagueStandingsSummaries(
driverLeagues: League[],
driverId: string,
): Promise<DashboardLeagueStandingSummaryOutputPort[]> {
const summaries: DashboardLeagueStandingSummaryOutputPort[] = [];
): Promise<DashboardLeagueStandingSummary[]> {
const summaries: DashboardLeagueStandingSummary[] = [];
for (const league of driverLeagues.slice(0, 3)) {
const standings = await this.standingRepository.findByLeagueId(league.id);
@@ -255,10 +331,8 @@ export class DashboardOverviewUseCase {
);
summaries.push({
leagueId: league.id,
leagueName: league.name,
position: driverStanding?.position ?? 0,
points: driverStanding?.points ?? 0,
league,
standing: driverStanding ?? null,
totalDrivers: standings.length,
});
}
@@ -267,55 +341,42 @@ export class DashboardOverviewUseCase {
}
private computeActiveLeaguesCount(
upcomingRaces: DashboardRaceSummaryOutputPort[],
leagueStandingsSummaries: DashboardLeagueStandingSummaryOutputPort[],
upcomingRaces: DashboardRaceSummary[],
leagueStandingsSummaries: DashboardLeagueStandingSummary[],
): number {
const activeLeagueIds = new Set<string>();
for (const race of upcomingRaces) {
activeLeagueIds.add(race.leagueId);
activeLeagueIds.add(race.race.leagueId);
}
for (const standing of leagueStandingsSummaries) {
activeLeagueIds.add(standing.leagueId);
activeLeagueIds.add(standing.league.id);
}
return activeLeagueIds.size;
}
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummaryOutputPort {
const items: DashboardFeedItemSummaryOutputPort[] = feedItems.map(item => ({
id: item.id,
type: item.type,
headline: item.headline,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
...(item.body !== undefined ? { body: item.body } : {}),
...(item.ctaLabel !== undefined ? { ctaLabel: item.ctaLabel } : {}),
...(item.ctaHref !== undefined ? { ctaHref: item.ctaHref } : {}),
}));
private buildFeedSummary(feedItems: FeedItem[]): DashboardFeedSummary {
return {
notificationCount: items.length,
items,
notificationCount: feedItems.length,
items: feedItems,
};
}
private async buildFriendsSummary(friends: Driver[]): Promise<DashboardFriendSummaryOutputPort[]> {
const friendSummaries: DashboardFriendSummaryOutputPort[] = [];
private async buildFriendsSummary(
friends: Driver[],
): Promise<DashboardFriendSummary[]> {
const friendSummaries: DashboardFriendSummary[] = [];
for (const friend of friends) {
const avatarResult = await this.getDriverAvatar({ driverId: friend.id });
const avatarUrl = await this.getDriverAvatar(friend.id);
friendSummaries.push({
id: friend.id,
name: friend.name,
country: friend.country,
avatarUrl: avatarResult.avatarUrl,
driver: friend,
avatarUrl,
});
}
return friendSummaries;
}
}
}

View File

@@ -1,8 +1,11 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { FileProtestUseCase } from './FileProtestUseCase';
import { FileProtestUseCase, type FileProtestInput, type FileProtestResult, type FileProtestErrorCode } from './FileProtestUseCase';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Result } from '@core/shared/application/Result';
describe('FileProtestUseCase', () => {
let mockProtestRepo: {
@@ -14,6 +17,7 @@ describe('FileProtestUseCase', () => {
let mockLeagueMembershipRepo: {
getLeagueMembers: Mock;
};
let output: UseCaseOutputPort<FileProtestResult> & { present: Mock };
beforeEach(() => {
mockProtestRepo = {
@@ -25,6 +29,9 @@ describe('FileProtestUseCase', () => {
mockLeagueMembershipRepo = {
getLeagueMembers: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<FileProtestResult> & { present: Mock };
});
it('should return error when race does not exist', async () => {
@@ -32,6 +39,7 @@ describe('FileProtestUseCase', () => {
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
mockRaceRepo.findById.mockResolvedValue(null);
@@ -41,10 +49,13 @@ describe('FileProtestUseCase', () => {
protestingDriverId: 'driver1',
accusedDriverId: 'driver2',
incident: { lap: 5, description: 'Collision' },
});
} as FileProtestInput);
expect(result.isOk()).toBe(false);
expect(result.error!.details.message).toBe('Race not found');
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<FileProtestErrorCode, { message: string }>;
expect(err.code).toBe('RACE_NOT_FOUND');
expect(err.details?.message).toBe('Race not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when protesting against self', async () => {
@@ -52,6 +63,7 @@ describe('FileProtestUseCase', () => {
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -61,10 +73,13 @@ describe('FileProtestUseCase', () => {
protestingDriverId: 'driver1',
accusedDriverId: 'driver1',
incident: { lap: 5, description: 'Collision' },
});
} as FileProtestInput);
expect(result.isOk()).toBe(false);
expect(result.error!.details.message).toBe('Cannot file a protest against yourself');
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<FileProtestErrorCode, { message: string }>;
expect(err.code).toBe('SELF_PROTEST');
expect(err.details?.message).toBe('Cannot file a protest against yourself');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when protesting driver is not an active member', async () => {
@@ -72,6 +87,7 @@ describe('FileProtestUseCase', () => {
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -84,10 +100,13 @@ describe('FileProtestUseCase', () => {
protestingDriverId: 'driver1',
accusedDriverId: 'driver2',
incident: { lap: 5, description: 'Collision' },
});
} as FileProtestInput);
expect(result.isOk()).toBe(false);
expect(result.error!.details.message).toBe('Protesting driver is not an active member of this league');
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<FileProtestErrorCode, { message: string }>;
expect(err.code).toBe('NOT_MEMBER');
expect(err.details?.message).toBe('Protesting driver is not an active member of this league');
expect(output.present).not.toHaveBeenCalled();
});
it('should create protest and return protestId on success', async () => {
@@ -95,6 +114,7 @@ describe('FileProtestUseCase', () => {
mockProtestRepo as unknown as IProtestRepository,
mockRaceRepo as unknown as IRaceRepository,
mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
mockRaceRepo.findById.mockResolvedValue({ id: 'race1', leagueId: 'league1' });
@@ -110,12 +130,10 @@ describe('FileProtestUseCase', () => {
incident: { lap: 5, description: 'Collision' },
comment: 'Test comment',
proofVideoUrl: 'http://example.com/video',
});
} as FileProtestInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
protestId: expect.any(String),
});
expect(result.unwrap()).toBeUndefined();
expect(mockProtestRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
raceId: 'race1',
@@ -127,5 +145,14 @@ describe('FileProtestUseCase', () => {
status: 'pending',
})
);
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as unknown as Mock).mock.calls[0][0] as FileProtestResult;
expect(presented.protest.raceId).toBe('race1');
expect(presented.protest.protestingDriverId).toBe('driver1');
expect(presented.protest.accusedDriverId).toBe('driver2');
expect(presented.protest.incident).toEqual({ lap: 5, description: 'Collision', timeInRace: undefined });
expect(presented.protest.comment).toBe('Test comment');
expect(presented.protest.proofVideoUrl).toBe('http://example.com/video');
});
});

View File

@@ -10,62 +10,82 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ProtestIncident } from '../../domain/entities/Protest';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { randomUUID } from 'crypto';
export interface FileProtestCommand {
export type FileProtestErrorCode = 'RACE_NOT_FOUND' | 'SELF_PROTEST' | 'NOT_MEMBER' | 'REPOSITORY_ERROR';
export interface FileProtestInput {
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
incident: {
lap: number;
description: string;
timeInRace?: number;
};
comment?: string;
proofVideoUrl?: string;
}
export interface FileProtestResult {
protest: Protest;
}
export class FileProtestUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly output: UseCaseOutputPort<FileProtestResult>,
) {}
async execute(command: FileProtestCommand): Promise<Result<{ protestId: string }, ApplicationErrorCode<'RACE_NOT_FOUND' | 'SELF_PROTEST' | 'NOT_MEMBER', { message: string }>>> {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } });
async execute(command: FileProtestInput): Promise<Result<void, ApplicationErrorCode<FileProtestErrorCode, { message: string }>>> {
try {
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND', details: { message: 'Race not found' } });
}
// Validate drivers are not the same
if (command.protestingDriverId === command.accusedDriverId) {
return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } });
}
// Validate protesting driver is a member of the league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const protestingDriverMembership = memberships.find(m => {
const driverId = (m as any).driverId;
const status = (m as any).status;
return driverId === command.protestingDriverId && status === 'active';
});
if (!protestingDriverMembership) {
return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } });
}
// Create the protest
const protest = Protest.create({
id: randomUUID(),
raceId: command.raceId,
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});
await this.protestRepository.create(protest);
this.output.present({ protest });
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to file protest';
return Result.err({ code: 'REPOSITORY_ERROR', details: { message } });
}
// Validate drivers are not the same
if (command.protestingDriverId === command.accusedDriverId) {
return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } });
}
// Validate protesting driver is a member of the league
const memberships = await this.leagueMembershipRepository.getLeagueMembers(race.leagueId);
const protestingDriverMembership = memberships.find(
m => m.driverId === command.protestingDriverId && m.status === 'active'
);
if (!protestingDriverMembership) {
return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } });
}
// Create the protest
const protest = Protest.create({
id: randomUUID(),
raceId: command.raceId,
protestingDriverId: command.protestingDriverId,
accusedDriverId: command.accusedDriverId,
incident: command.incident,
...(command.comment !== undefined ? { comment: command.comment } : {}),
...(command.proofVideoUrl !== undefined ? { proofVideoUrl: command.proofVideoUrl } : {}),
status: 'pending',
filedAt: new Date(),
});
await this.protestRepository.create(protest);
return Result.ok({ protestId: protest.id });
}
}

View File

@@ -1,11 +1,16 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetAllLeaguesWithCapacityAndScoringUseCase } from './GetAllLeaguesWithCapacityAndScoringUseCase';
import {
GetAllLeaguesWithCapacityAndScoringUseCase,
type GetAllLeaguesWithCapacityAndScoringInput,
type GetAllLeaguesWithCapacityAndScoringResult,
type LeagueCapacityAndScoringSummary,
} 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';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
let mockLeagueRepo: { findAll: Mock };
@@ -13,7 +18,7 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
let mockSeasonRepo: { findByLeagueId: Mock };
let mockScoringConfigRepo: { findBySeasonId: Mock };
let mockGameRepo: { findById: Mock };
let mockPresetProvider: { getPresetById: Mock };
let output: UseCaseOutputPort<GetAllLeaguesWithCapacityAndScoringResult> & { present: Mock };
beforeEach(() => {
mockLeagueRepo = { findAll: vi.fn() };
@@ -21,7 +26,7 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
mockSeasonRepo = { findByLeagueId: vi.fn() };
mockScoringConfigRepo = { findBySeasonId: vi.fn() };
mockGameRepo = { findById: vi.fn() };
mockPresetProvider = { getPresetById: vi.fn() };
output = { present: vi.fn() } as unknown as typeof output;
});
it('should return enriched leagues with capacity and scoring', async () => {
@@ -31,10 +36,11 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
mockSeasonRepo as unknown as ISeasonRepository,
mockScoringConfigRepo as unknown as ILeagueScoringConfigRepository,
mockGameRepo as unknown as IGameRepository,
mockPresetProvider as unknown as LeagueScoringPresetProvider,
{ getPresetById: vi.fn().mockReturnValue({ id: 'preset1', name: 'Default' }) },
output,
);
const league = { id: 'league1', name: 'Test League' };
const league = { id: 'league1', name: 'Test League', settings: { maxDrivers: 30 } };
const members = [
{ status: 'active', role: 'member' },
{ status: 'active', role: 'owner' },
@@ -42,29 +48,33 @@ describe('GetAllLeaguesWithCapacityAndScoringUseCase', () => {
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();
const result = await useCase.execute({} as GetAllLeaguesWithCapacityAndScoringInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
leagues: [
{
league,
usedDriverSlots: 2,
season,
scoringConfig,
game,
preset,
},
],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityAndScoringResult;
expect(presented.leagues).toHaveLength(1);
const [summary] = presented.leagues as LeagueCapacityAndScoringSummary[];
expect(summary.league).toEqual(league);
expect(summary.currentDrivers).toBe(2);
expect(summary.maxDrivers).toBe(30);
expect(summary.season).toEqual(season);
expect(summary.scoringConfig).toEqual(scoringConfig);
expect(summary.game).toEqual(game);
expect(summary.preset).toEqual({ id: 'preset1', name: 'Default' });
});
});

View File

@@ -3,76 +3,126 @@ import type { ILeagueMembershipRepository } from '../../domain/repositories/ILea
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';
import type { LeagueEnrichedData, AllLeaguesWithCapacityAndScoringOutputPort } from '../ports/output/AllLeaguesWithCapacityAndScoringOutputPort';
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/season/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export type GetAllLeaguesWithCapacityAndScoringInput = {};
export type LeagueCapacityAndScoringSummary = {
league: League;
currentDrivers: number;
maxDrivers: number;
season?: Season;
scoringConfig?: LeagueScoringConfig;
game?: Game;
preset?: LeagueScoringPreset;
};
export type GetAllLeaguesWithCapacityAndScoringResult = {
leagues: LeagueCapacityAndScoringSummary[];
};
export type GetAllLeaguesWithCapacityAndScoringErrorCode = 'REPOSITORY_ERROR';
/**
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
* Orchestrates domain logic and delegates presentation to an output port.
*/
export class GetAllLeaguesWithCapacityAndScoringUseCase
{
export class GetAllLeaguesWithCapacityAndScoringUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly presetProvider: LeagueScoringPresetProvider,
private readonly presetProvider: { getPresetById(presetId: string): LeagueScoringPreset | undefined },
private readonly output: UseCaseOutputPort<GetAllLeaguesWithCapacityAndScoringResult>,
) {}
async execute(): Promise<Result<AllLeaguesWithCapacityAndScoringOutputPort>> {
const leagues = await this.leagueRepository.findAll();
async execute(
_input: GetAllLeaguesWithCapacityAndScoringInput = {},
): Promise<
Result<
void,
ApplicationErrorCode<
GetAllLeaguesWithCapacityAndScoringErrorCode,
{ message: string }
>
>
> {
try {
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];
const enrichedLeagues: LeagueCapacityAndScoringSummary[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedDriverSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const currentDrivers = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const seasons = await this.seasonRepository.findByLeagueId(league.id);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
const seasons = await this.seasonRepository.findByLeagueId(league.id);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig: LeagueEnrichedData['scoringConfig'];
let game: LeagueEnrichedData['game'];
let preset: LeagueEnrichedData['preset'];
let scoringConfig: LeagueScoringConfig | undefined;
let game: Game | undefined;
let preset: LeagueScoringPreset | undefined;
if (activeSeason) {
const scoringConfigResult =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
scoringConfig = scoringConfigResult ?? undefined;
if (scoringConfig) {
const gameResult = await this.gameRepository.findById(activeSeason.gameId);
game = gameResult ?? undefined;
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
if (activeSeason) {
const scoringConfigResult =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
scoringConfig = scoringConfigResult ?? undefined;
if (scoringConfig) {
const gameResult = await this.gameRepository.findById(activeSeason.gameId);
game = gameResult ?? undefined;
const presetId = scoringConfig.scoringPresetId;
if (presetId) {
preset = this.presetProvider.getPresetById(presetId);
}
}
}
const maxDrivers = league.settings.maxDrivers ?? 0;
enrichedLeagues.push({
league,
currentDrivers,
maxDrivers,
...(activeSeason ? { season: activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
...(preset ? { preset } : {}),
});
}
enrichedLeagues.push({
league,
usedDriverSlots,
...(activeSeason ? { season: activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
...(preset ? { preset } : {}),
this.output.present({ leagues: enrichedLeagues });
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load leagues with capacity and scoring';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
return Result.ok({ leagues: enrichedLeagues });
}
}

View File

@@ -1,25 +1,34 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetAllLeaguesWithCapacityUseCase } from './GetAllLeaguesWithCapacityUseCase';
import {
GetAllLeaguesWithCapacityUseCase,
type GetAllLeaguesWithCapacityInput,
type GetAllLeaguesWithCapacityResult,
type LeagueCapacitySummary,
} from './GetAllLeaguesWithCapacityUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetAllLeaguesWithCapacityUseCase', () => {
let mockLeagueRepo: { findAll: Mock };
let mockMembershipRepo: { getLeagueMembers: Mock };
let output: UseCaseOutputPort<GetAllLeaguesWithCapacityResult> & { present: Mock };
beforeEach(() => {
mockLeagueRepo = { findAll: vi.fn() };
mockMembershipRepo = { getLeagueMembers: vi.fn() };
output = { present: vi.fn() } as unknown as typeof output;
});
it('should return leagues with capacity information', async () => {
const useCase = new GetAllLeaguesWithCapacityUseCase(
mockLeagueRepo as unknown as ILeagueRepository,
mockMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
const league1 = { id: 'league1', name: 'Test League 1' };
const league2 = { id: 'league2', name: 'Test League 2' };
const league1 = { id: 'league1', name: 'Test League 1', settings: { maxDrivers: 10 } };
const league2 = { id: 'league2', name: 'Test League 2', settings: { maxDrivers: 20 } };
const members1 = [
{ status: 'active', role: 'member' },
{ status: 'active', role: 'owner' },
@@ -34,33 +43,43 @@ describe('GetAllLeaguesWithCapacityUseCase', () => {
.mockResolvedValueOnce(members1)
.mockResolvedValueOnce(members2);
const result = await useCase.execute();
const result = await useCase.execute({} as GetAllLeaguesWithCapacityInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
leagues: [league1, league2],
memberCounts: new Map([
['league1', 2],
['league2', 1],
]),
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityResult;
expect(presented.leagues).toHaveLength(2);
const [first, second] = presented.leagues as LeagueCapacitySummary[];
expect(first.league).toEqual(league1);
expect(first.currentDrivers).toBe(2);
expect(first.maxDrivers).toBe(10);
expect(second.league).toEqual(league2);
expect(second.currentDrivers).toBe(1);
expect(second.maxDrivers).toBe(20);
});
it('should return empty result when no leagues', async () => {
const useCase = new GetAllLeaguesWithCapacityUseCase(
mockLeagueRepo as unknown as ILeagueRepository,
mockMembershipRepo as unknown as ILeagueMembershipRepository,
output,
);
mockLeagueRepo.findAll.mockResolvedValue([]);
mockMembershipRepo.getLeagueMembers.mockResolvedValue([]);
const result = await useCase.execute();
const result = await useCase.execute({} as GetAllLeaguesWithCapacityInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
leagues: [],
memberCounts: new Map(),
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllLeaguesWithCapacityResult;
expect(presented.leagues).toEqual([]);
});
});

View File

@@ -1,47 +1,78 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { AllLeaguesWithCapacityOutputPort } from '../ports/output/AllLeaguesWithCapacityOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@/shared/application/Result';
import type { League } from '../../domain/entities/League';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export type GetAllLeaguesWithCapacityInput = {};
export type LeagueCapacitySummary = {
league: League;
currentDrivers: number;
maxDrivers: number;
};
export type GetAllLeaguesWithCapacityResult = {
leagues: LeagueCapacitySummary[];
};
export type GetAllLeaguesWithCapacityErrorCode = 'REPOSITORY_ERROR';
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and returns result.
* Orchestrates domain logic and delegates presentation to an output port.
*/
export class GetAllLeaguesWithCapacityUseCase
implements AsyncUseCase<void, AllLeaguesWithCapacityOutputPort, string>
{
export class GetAllLeaguesWithCapacityUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly output: UseCaseOutputPort<GetAllLeaguesWithCapacityResult>,
) {}
async execute(): Promise<Result<AllLeaguesWithCapacityOutputPort, ApplicationErrorCode<string>>> {
const leagues = await this.leagueRepository.findAll();
async execute(
_input: GetAllLeaguesWithCapacityInput = {},
): Promise<
Result<
void,
ApplicationErrorCode<GetAllLeaguesWithCapacityErrorCode, { message: string }>
>
> {
try {
const leagues = await this.leagueRepository.findAll();
const memberCounts: Record<string, number> = {};
const summaries: LeagueCapacitySummary[] = [];
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
for (const league of leagues) {
const members = await this.leagueMembershipRepository.getLeagueMembers(league.id);
const usedSlots = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
const currentDrivers = members.filter(
(m) =>
m.status === 'active' &&
(m.role === 'owner' ||
m.role === 'admin' ||
m.role === 'steward' ||
m.role === 'member'),
).length;
memberCounts[league.id] = usedSlots;
const maxDrivers = league.settings.maxDrivers ?? 0;
summaries.push({ league, currentDrivers, maxDrivers });
}
this.output.present({ leagues: summaries });
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load leagues with capacity';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
const output: AllLeaguesWithCapacityOutputPort = {
leagues,
memberCounts,
};
return Result.ok(output);
}
}

View File

@@ -1,8 +1,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllRacesPageDataUseCase } from './GetAllRacesPageDataUseCase';
import {
GetAllRacesPageDataUseCase,
type GetAllRacesPageDataResult,
type GetAllRacesPageDataInput,
} from './GetAllRacesPageDataUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetAllRacesPageDataUseCase', () => {
const mockRaceFindAll = vi.fn();
@@ -39,15 +44,21 @@ describe('GetAllRacesPageDataUseCase', () => {
error: vi.fn(),
};
let output: UseCaseOutputPort<GetAllRacesPageDataResult> & { present: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetAllRacesPageDataResult> & { present: ReturnType<typeof vi.fn> };
});
it('should return races and filters data', async () => {
it('should present races and filters data', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
const race1 = {
@@ -58,7 +69,7 @@ describe('GetAllRacesPageDataUseCase', () => {
status: 'scheduled' as const,
leagueId: 'league1',
strengthOfField: 5,
};
} as any;
const race2 = {
id: 'race2',
track: 'Track B',
@@ -67,97 +78,110 @@ describe('GetAllRacesPageDataUseCase', () => {
status: 'completed' as const,
leagueId: 'league2',
strengthOfField: null,
};
const league1 = { id: 'league1', name: 'League One' };
const league2 = { id: 'league2', name: 'League Two' };
} as any;
const league1 = { id: 'league1', name: 'League One' } as any;
const league2 = { id: 'league2', name: 'League Two' } as any;
mockRaceFindAll.mockResolvedValue([race1, race2]);
mockLeagueFindAll.mockResolvedValue([league1, league2]);
const result = await useCase.execute();
const input: GetAllRacesPageDataInput = {};
const result = await useCase.execute(input);
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' },
],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllRacesPageDataResult;
expect(presented.races).toEqual([
{
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,
},
]);
expect(presented.filters).toEqual({
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 () => {
it('should present empty result when no races or leagues', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
mockRaceFindAll.mockResolvedValue([]);
mockLeagueFindAll.mockResolvedValue([]);
const result = await useCase.execute();
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: [],
},
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllRacesPageDataResult;
expect(presented.races).toEqual([]);
expect(presented.filters).toEqual({
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 () => {
it('should return error when repository throws and not present data', async () => {
const useCase = new GetAllRacesPageDataUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
const error = new Error('Repository error');
mockRaceFindAll.mockRejectedValue(error);
const result = await useCase.execute();
const result = await useCase.execute({});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,19 +1,47 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger , AsyncUseCase } from '@core/shared/application';
import type { AllRacesPageOutputPort, AllRacesListItem, AllRacesFilterOptions } from '../ports/output/AllRacesPageOutputPort';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { RaceStatus } from '../../domain/entities/Race';
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, AllRacesPageOutputPort, 'REPOSITORY_ERROR'> {
export type GetAllRacesPageDataInput = {};
export interface GetAllRacesPageRaceItem {
id: string;
track: string;
car: string;
scheduledAt: string;
status: RaceStatus;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
}
export interface GetAllRacesPageDataFilters {
statuses: { value: 'all' | RaceStatus; label: string }[];
leagues: { id: string; name: string }[];
}
export interface GetAllRacesPageDataResult {
races: GetAllRacesPageRaceItem[];
filters: GetAllRacesPageDataFilters;
}
export type GetAllRacesPageDataErrorCode = 'REPOSITORY_ERROR';
export class GetAllRacesPageDataUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAllRacesPageDataResult>,
) {}
async execute(): Promise<Result<AllRacesPageResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(
_input: GetAllRacesPageDataInput,
): Promise<Result<void, ApplicationErrorCode<GetAllRacesPageDataErrorCode, { message: string }>>> {
this.logger.debug('Executing GetAllRacesPageDataUseCase');
try {
const [allRaces, allLeagues] = await Promise.all([
@@ -22,12 +50,12 @@ export class GetAllRacesPageDataUseCase
]);
this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`);
const leagueMap = new Map(allLeagues.map((league) => [league.id.toString(), league.name.toString()]));
const leagueMap = new Map(allLeagues.map(league => [league.id.toString(), league.name.toString()]));
const races: AllRacesListItem[] = allRaces
const races: GetAllRacesPageRaceItem[] = allRaces
.slice()
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
.map((race) => ({
.map(race => ({
id: race.id,
track: race.track,
car: race.car,
@@ -43,7 +71,7 @@ export class GetAllRacesPageDataUseCase
uniqueLeagues.set(league.id.toString(), { id: league.id.toString(), name: league.name.toString() });
}
const filters: AllRacesFilterOptions = {
const filters: GetAllRacesPageDataFilters = {
statuses: [
{ value: 'all', label: 'All Statuses' },
{ value: 'scheduled', label: 'Scheduled' },
@@ -54,19 +82,24 @@ export class GetAllRacesPageDataUseCase
leagues: Array.from(uniqueLeagues.values()),
};
const viewModel: AllRacesPageOutputPort = {
const result: GetAllRacesPageDataResult = {
races,
filters,
};
this.logger.debug('Successfully retrieved all races page data.');
return Result.ok(viewModel);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error executing GetAllRacesPageDataUseCase', error instanceof Error ? error : new Error(String(error)));
this.logger.error(
'Error executing GetAllRacesPageDataUseCase',
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}
}

View File

@@ -1,8 +1,13 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllRacesUseCase } from './GetAllRacesUseCase';
import {
GetAllRacesUseCase,
type GetAllRacesResult,
type GetAllRacesInput,
} from './GetAllRacesUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetAllRacesUseCase', () => {
const mockRaceFindAll = vi.fn();
@@ -39,15 +44,21 @@ describe('GetAllRacesUseCase', () => {
error: vi.fn(),
};
let output: UseCaseOutputPort<GetAllRacesResult> & { present: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetAllRacesResult> & { present: ReturnType<typeof vi.fn> };
});
it('should return races data', async () => {
it('should present domain races and leagues data', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
const race1 = {
@@ -56,75 +67,74 @@ describe('GetAllRacesUseCase', () => {
car: 'Car A',
scheduledAt: new Date('2023-01-01T10:00:00Z'),
leagueId: 'league1',
};
} as any;
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' };
} as any;
const league1 = { id: 'league1' } as any;
const league2 = { id: 'league2' } as any;
mockRaceFindAll.mockResolvedValue([race1, race2]);
mockLeagueFindAll.mockResolvedValue([league1, league2]);
const result = await useCase.execute();
const input: GetAllRacesInput = {};
const result = await useCase.execute(input);
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,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllRacesResult;
expect(presented.totalCount).toBe(2);
expect(presented.races).toEqual([race1, race2]);
expect(presented.leagues).toEqual([league1, league2]);
});
it('should return empty result when no races or leagues', async () => {
it('should present empty result when no races or leagues', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
mockRaceFindAll.mockResolvedValue([]);
mockLeagueFindAll.mockResolvedValue([]);
const result = await useCase.execute();
const result = await useCase.execute({});
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
races: [],
totalCount: 0,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllRacesResult;
expect(presented.totalCount).toBe(0);
expect(presented.races).toEqual([]);
expect(presented.leagues).toEqual([]);
});
it('should return error when repository throws', async () => {
it('should return error when repository throws and not present data', async () => {
const useCase = new GetAllRacesUseCase(
mockRaceRepo,
mockLeagueRepo,
mockLogger,
output,
);
const error = new Error('Repository error');
mockRaceFindAll.mockRejectedValue(error);
const result = await useCase.execute();
const result = await useCase.execute({});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,46 +1,56 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetAllRacesOutputPort } from '../ports/output/GetAllRacesOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Race } from '../../domain/entities/Race';
import type { League } from '../../domain/entities/League';
export class GetAllRacesUseCase implements AsyncUseCase<void, GetAllRacesOutputPort, 'REPOSITORY_ERROR'> {
export type GetAllRacesInput = {};
export interface GetAllRacesResult {
races: Race[];
leagues: League[];
totalCount: number;
}
export type GetAllRacesErrorCode = 'REPOSITORY_ERROR';
export class GetAllRacesUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAllRacesResult>,
) {}
async execute(): Promise<Result<GetAllRacesOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(
_input: GetAllRacesInput,
): Promise<Result<void, ApplicationErrorCode<GetAllRacesErrorCode, { message: string }>>> {
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 output: GetAllRacesOutputPort = {
races: races.map(race => ({
id: race.id,
leagueId: race.leagueId,
track: race.track,
car: race.car,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
scheduledAt: race.scheduledAt.toISOString(),
strengthOfField: race.strengthOfField || null,
leagueName: (leagueMap.get(race.leagueId) || 'Unknown League').toString(),
})),
const result: GetAllRacesResult = {
races,
leagues,
totalCount: races.length,
};
this.logger.debug('Successfully retrieved all races.');
return Result.ok(output);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error executing GetAllRacesUseCase', error instanceof Error ? error : new Error(String(error)));
this.logger.error(
'Error executing GetAllRacesUseCase',
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}
}

View File

@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetAllTeamsUseCase } from './GetAllTeamsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { GetAllTeamsUseCase, type GetAllTeamsInput, type GetAllTeamsResult } from './GetAllTeamsUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetAllTeamsUseCase', () => {
const mockTeamFindAll = vi.fn();
@@ -36,8 +37,13 @@ describe('GetAllTeamsUseCase', () => {
error: vi.fn(),
};
let output: UseCaseOutputPort<GetAllTeamsResult> & { present: Mock };
beforeEach(() => {
vi.clearAllMocks();
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetAllTeamsResult> & { present: Mock };
});
it('should return teams data', async () => {
@@ -45,6 +51,7 @@ describe('GetAllTeamsUseCase', () => {
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
output,
);
const team1 = {
@@ -69,10 +76,15 @@ describe('GetAllTeamsUseCase', () => {
mockTeamFindAll.mockResolvedValue([team1, team2]);
mockTeamMembershipCountByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3));
const result = await useCase.execute();
const result = await useCase.execute({} as GetAllTeamsInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllTeamsResult;
expect(presented).toEqual({
teams: [
{
id: 'team1',
@@ -95,6 +107,7 @@ describe('GetAllTeamsUseCase', () => {
memberCount: 3,
},
],
totalCount: 2,
});
});
@@ -103,15 +116,22 @@ describe('GetAllTeamsUseCase', () => {
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
output,
);
mockTeamFindAll.mockResolvedValue([]);
const result = await useCase.execute();
const result = await useCase.execute({} as GetAllTeamsInput);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetAllTeamsResult;
expect(presented).toEqual({
teams: [],
totalCount: 0,
});
});
@@ -120,15 +140,20 @@ describe('GetAllTeamsUseCase', () => {
mockTeamRepo,
mockTeamMembershipRepo,
mockLogger,
output,
);
const error = new Error('Repository error');
mockTeamFindAll.mockRejectedValue(error);
const result = await useCase.execute();
const result = await useCase.execute({} as GetAllTeamsInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,27 +1,50 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { GetAllTeamsOutputPort } from '../ports/output/GetAllTeamsOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export type GetAllTeamsInput = {};
export type GetAllTeamsErrorCode = 'REPOSITORY_ERROR';
export interface TeamSummary {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
memberCount: number;
}
export interface GetAllTeamsResult {
teams: TeamSummary[];
totalCount: number;
}
/**
* Use Case for retrieving all teams.
*/
export class GetAllTeamsUseCase implements AsyncUseCase<void, GetAllTeamsOutputPort, 'REPOSITORY_ERROR'> {
export class GetAllTeamsUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly teamMembershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetAllTeamsResult>,
) {}
async execute(): Promise<Result<AllTeamsResultDTO, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(
_input: GetAllTeamsInput = {},
): Promise<Result<void, ApplicationErrorCode<GetAllTeamsErrorCode, { message: string }>>> {
this.logger.debug('Executing GetAllTeamsUseCase');
try {
const teams = await this.teamRepository.findAll();
const enrichedTeams: AllTeamsResultDTO['teams'] = await Promise.all(
const enrichedTeams: TeamSummary[] = await Promise.all(
teams.map(async (team) => {
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
return {
@@ -37,19 +60,21 @@ export class GetAllTeamsUseCase implements AsyncUseCase<void, GetAllTeamsOutputP
}),
);
const dto: GetAllTeamsOutputPort = {
const result: GetAllTeamsResult = {
teams: enrichedTeams,
totalCount: enrichedTeams.length,
};
this.logger.debug('Successfully retrieved all teams.');
return Result.ok(dto);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error retrieving all teams', error instanceof Error ? error : new Error(String(error)));
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
details: { message: error instanceof Error ? error.message : 'Failed to load teams' },
});
}
}
}
}

View File

@@ -1,8 +1,9 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetDriverTeamUseCase } from './GetDriverTeamUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { GetDriverTeamUseCase, type GetDriverTeamInput, type GetDriverTeamResult } from './GetDriverTeamUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
describe('GetDriverTeamUseCase', () => {
const mockFindById = vi.fn();
@@ -36,8 +37,11 @@ describe('GetDriverTeamUseCase', () => {
error: vi.fn(),
};
let output: UseCaseOutputPort<GetDriverTeamResult> & { present: Mock };
beforeEach(() => {
vi.clearAllMocks();
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetDriverTeamResult> & { present: Mock };
});
it('should return driver team data when membership and team exist', async () => {
@@ -45,6 +49,7 @@ describe('GetDriverTeamUseCase', () => {
mockTeamRepo,
mockMembershipRepo,
mockLogger,
output as unknown as UseCaseOutputPort<GetDriverTeamResult>,
);
const driverId = 'driver1';
@@ -54,14 +59,16 @@ describe('GetDriverTeamUseCase', () => {
mockGetActiveMembershipForDriver.mockResolvedValue(membership);
mockFindById.mockResolvedValue(team);
const result = await useCase.execute({ driverId });
const input: GetDriverTeamInput = { driverId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
team,
membership,
driverId,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = (output.present as Mock).mock.calls as [[GetDriverTeamResult]];
expect(presented.driverId).toBe(driverId);
expect(presented.team).toBe(team);
expect(presented.membership).toBe(membership);
});
it('should return error when no active membership found', async () => {
@@ -69,17 +76,20 @@ describe('GetDriverTeamUseCase', () => {
mockTeamRepo,
mockMembershipRepo,
mockLogger,
output as unknown as UseCaseOutputPort<GetDriverTeamResult>,
);
const driverId = 'driver1';
mockGetActiveMembershipForDriver.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
const input: GetDriverTeamInput = { driverId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('MEMBERSHIP_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('No active membership found for driver driver1');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when team not found', async () => {
@@ -87,6 +97,7 @@ describe('GetDriverTeamUseCase', () => {
mockTeamRepo,
mockMembershipRepo,
mockLogger,
output as unknown as UseCaseOutputPort<GetDriverTeamResult>,
);
const driverId = 'driver1';
@@ -95,11 +106,13 @@ describe('GetDriverTeamUseCase', () => {
mockGetActiveMembershipForDriver.mockResolvedValue(membership);
mockFindById.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
const input: GetDriverTeamInput = { driverId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('TEAM_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('Team not found for teamId team1');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository throws', async () => {
@@ -107,6 +120,7 @@ describe('GetDriverTeamUseCase', () => {
mockTeamRepo,
mockMembershipRepo,
mockLogger,
output as unknown as UseCaseOutputPort<GetDriverTeamResult>,
);
const driverId = 'driver1';
@@ -114,10 +128,12 @@ describe('GetDriverTeamUseCase', () => {
mockGetActiveMembershipForDriver.mockRejectedValue(error);
const result = await useCase.execute({ driverId });
const input: GetDriverTeamInput = { driverId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,24 +1,37 @@
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { DriverTeamOutputPort } from '../ports/output/DriverTeamOutputPort';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Team } from '../../domain/entities/Team';
import type { TeamMembership } from '../../domain/types/TeamMembership';
export type GetDriverTeamInput = {
driverId: string;
};
export type GetDriverTeamResult = {
driverId: string;
team: Team;
membership: TeamMembership;
};
export type GetDriverTeamErrorCode = 'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and returns result.
*/
export class GetDriverTeamUseCase
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>>
{
export class GetDriverTeamUseCase {
constructor(
private readonly teamRepository: ITeamRepository,
private readonly membershipRepository: ITeamMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetDriverTeamResult>,
) {}
async execute(input: { driverId: string }): Promise<Result<DriverTeamOutputPort, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>> {
async execute(input: GetDriverTeamInput): Promise<Result<void, ApplicationErrorCode<GetDriverTeamErrorCode, { message: string }>>> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
try {
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
@@ -33,24 +46,18 @@ export class GetDriverTeamUseCase
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return Result.err({ code: 'TEAM_NOT_FOUND', details: { message: `Team not found for teamId ${membership.teamId}` } });
}
this.logger.debug(`Found team for teamId: ${team.id}, name: ${team.name}`);
this.logger.debug(`Found team for teamId: ${team.id}`);
const output: DriverTeamOutputPort = {
const result: GetDriverTeamResult = {
driverId: input.driverId,
team: {
id: team.id,
name: team.name.value,
tag: team.tag.value,
description: team.description.value,
ownerId: team.ownerId.value,
leagues: team.leagues.map(l => l.value),
createdAt: team.createdAt.value,
},
team,
membership,
};
this.logger.info(`Successfully retrieved driver team for driverId: ${input.driverId}`);
return Result.ok(output);
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } });

View File

@@ -1,9 +1,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { GetDriversLeaderboardUseCase } from './GetDriversLeaderboardUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetDriversLeaderboardUseCase,
type GetDriversLeaderboardResult,
type GetDriversLeaderboardInput,
type GetDriversLeaderboardErrorCode,
} 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 { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetDriversLeaderboardUseCase', () => {
const mockDriverFindAll = vi.fn();
@@ -34,8 +41,13 @@ describe('GetDriversLeaderboardUseCase', () => {
error: vi.fn(),
};
let output: UseCaseOutputPort<GetDriversLeaderboardResult> & { present: Mock };
beforeEach(() => {
vi.clearAllMocks();
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetDriversLeaderboardResult> & { present: Mock };
});
it('should return drivers leaderboard data', async () => {
@@ -45,6 +57,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverStatsService,
mockGetDriverAvatar,
mockLogger,
output,
);
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
@@ -63,49 +76,52 @@ describe('GetDriversLeaderboardUseCase', () => {
if (id === 'driver2') return stats2;
return null;
});
mockGetDriverAvatar.mockImplementation((input) => {
if (input.driverId === 'driver1') return Promise.resolve({ avatarUrl: 'avatar-driver1' });
if (input.driverId === 'driver2') return Promise.resolve({ avatarUrl: 'avatar-driver2' });
return Promise.resolve({ avatarUrl: 'avatar-default' });
mockGetDriverAvatar.mockImplementation((driverId: string) => {
if (driverId === 'driver1') return Promise.resolve('avatar-driver1');
if (driverId === 'driver2') return Promise.resolve('avatar-driver2');
return Promise.resolve('avatar-default');
});
const result = await useCase.execute();
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'advanced',
nationality: 'US',
racesCompleted: 10,
wins: 5,
podiums: 7,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
},
{
id: 'driver2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'intermediate',
nationality: 'US',
racesCompleted: 8,
wins: 3,
podiums: 4,
isActive: true,
rank: 2,
avatarUrl: 'avatar-driver2',
},
],
totalRaces: 18,
totalWins: 8,
activeCount: 2,
});
});
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult;
expect(presented).toEqual({
items: [
expect.objectContaining({
driver: driver1,
rating: 2500,
skillLevel: 'advanced',
racesCompleted: 10,
wins: 5,
podiums: 7,
isActive: true,
rank: 1,
avatarUrl: 'avatar-driver1',
}),
expect.objectContaining({
driver: driver2,
rating: 2400,
skillLevel: 'intermediate',
racesCompleted: 8,
wins: 3,
podiums: 4,
isActive: true,
rank: 2,
avatarUrl: 'avatar-driver2',
}),
],
totalRaces: 18,
totalWins: 8,
activeCount: 2,
});
});
it('should return empty result when no drivers', async () => {
const useCase = new GetDriversLeaderboardUseCase(
@@ -114,16 +130,24 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverStatsService,
mockGetDriverAvatar,
mockLogger,
output,
);
mockDriverFindAll.mockResolvedValue([]);
mockRankingGetAllDriverRankings.mockReturnValue([]);
const result = await useCase.execute();
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult;
expect(presented).toEqual({
items: [],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
@@ -137,6 +161,7 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverStatsService,
mockGetDriverAvatar,
mockLogger,
output,
);
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
@@ -145,32 +170,37 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverFindAll.mockResolvedValue([driver1]);
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
mockDriverStatsGetDriverStats.mockReturnValue(null);
mockGetDriverAvatar.mockResolvedValue({ avatarUrl: 'avatar-driver1' });
mockGetDriverAvatar.mockResolvedValue('avatar-driver1');
const result = await useCase.execute();
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
drivers: [
{
id: 'driver1',
name: 'Driver One',
rating: 2500,
skillLevel: 'advanced',
nationality: 'US',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: false,
rank: 1,
avatarUrl: 'avatar-driver1',
},
],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
});
});
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as any).mock.calls[0][0] as GetDriversLeaderboardResult;
expect(presented).toEqual({
items: [
expect.objectContaining({
driver: driver1,
rating: 2500,
skillLevel: 'advanced',
racesCompleted: 0,
wins: 0,
podiums: 0,
isActive: false,
rank: 1,
avatarUrl: 'avatar-driver1',
}),
],
totalRaces: 0,
totalWins: 0,
activeCount: 0,
});
});
it('should return error when repository throws', async () => {
const useCase = new GetDriversLeaderboardUseCase(
@@ -179,15 +209,20 @@ describe('GetDriversLeaderboardUseCase', () => {
mockDriverStatsService,
mockGetDriverAvatar,
mockLogger,
output,
);
const error = new Error('Repository error');
mockDriverFindAll.mockRejectedValue(error);
const result = await useCase.execute();
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
const err = result.unwrapErr();
expect(err.code).toBe('REPOSITORY_ERROR');
expect((err as any).details?.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,55 +1,80 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRankingService } from '../../domain/services/IRankingService';
import type { IDriverStatsService } from '../../domain/services/IDriverStatsService';
import type { GetDriverAvatarInputPort } from '../ports/input/GetDriverAvatarInputPort';
import type { GetDriverAvatarOutputPort } from '../ports/output/GetDriverAvatarOutputPort';
import type { DriversLeaderboardOutputPort, DriverLeaderboardItemOutputPort } from '../ports/output/DriversLeaderboardOutputPort';
import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Driver } from '../../domain/entities/Driver';
import type { Team } from '../../domain/entities/Team';
export type GetDriversLeaderboardInput = {
leagueId: string;
seasonId?: string;
};
export interface DriverLeaderboardItem {
driver: Driver;
team?: Team;
rating: number;
skillLevel: SkillLevel;
racesCompleted: number;
wins: number;
podiums: number;
isActive: boolean;
rank: number;
avatarUrl?: string;
}
export interface GetDriversLeaderboardResult {
items: DriverLeaderboardItem[];
totalRaces: number;
totalWins: number;
activeCount: number;
}
export type GetDriversLeaderboardErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND' | 'REPOSITORY_ERROR';
/**
* Use Case for retrieving driver leaderboard data.
* Orchestrates domain logic and returns result.
*/
export class GetDriversLeaderboardUseCase
implements AsyncUseCase<void, DriversLeaderboardOutputPort, 'REPOSITORY_ERROR'>
{
export class GetDriversLeaderboardUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
private readonly rankingService: IRankingService,
private readonly driverStatsService: IDriverStatsService,
private readonly getDriverAvatar: (input: GetDriverAvatarInputPort) => Promise<GetDriverAvatarOutputPort>,
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
) {}
async execute(): Promise<Result<DriversLeaderboardOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
async execute(
_input: GetDriversLeaderboardInput,
): Promise<Result<void, ApplicationErrorCode<GetDriversLeaderboardErrorCode, { message: string }>>> {
this.logger.debug('Executing GetDriversLeaderboardUseCase');
try {
const drivers = await this.driverRepository.findAll();
const rankings = this.rankingService.getAllDriverRankings();
const avatarUrls: Record<string, string> = {};
const avatarUrls: Record<string, string | undefined> = {};
for (const driver of drivers) {
const avatarResult = await this.getDriverAvatar({ driverId: driver.id });
avatarUrls[driver.id] = avatarResult.avatarUrl;
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
}
const driverItems: DriverLeaderboardItemOutputPort[] = drivers.map(driver => {
const ranking = rankings.find(r => r.driverId === driver.id);
const items: DriverLeaderboardItem[] = drivers.map((driver) => {
const ranking = rankings.find((r) => r.driverId === driver.id);
const stats = this.driverStatsService.getDriverStats(driver.id);
const rating = ranking?.rating ?? 0;
const racesCompleted = stats?.totalRaces ?? 0;
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
return {
id: driver.id,
name: driver.name.value,
driver,
rating,
skillLevel,
nationality: driver.country.value,
racesCompleted,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
@@ -59,26 +84,29 @@ export class GetDriversLeaderboardUseCase
};
});
// Calculate totals
const totalRaces = driverItems.reduce((sum, d) => sum + d.racesCompleted, 0);
const totalWins = driverItems.reduce((sum, d) => sum + d.wins, 0);
const activeCount = driverItems.filter(d => d.isActive).length;
const totalRaces = items.reduce((sum, d) => sum + d.racesCompleted, 0);
const totalWins = items.reduce((sum, d) => sum + d.wins, 0);
const activeCount = items.filter((d) => d.isActive).length;
const result: DriversLeaderboardOutputPort = {
drivers: driverItems.sort((a, b) => b.rating - a.rating),
this.logger.debug('Successfully retrieved drivers leaderboard.');
this.output.present({
items: items.sort((a, b) => b.rating - a.rating),
totalRaces,
totalWins,
activeCount,
};
});
this.logger.debug('Successfully retrieved drivers leaderboard.');
return Result.ok(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error executing GetDriversLeaderboardUseCase', error instanceof Error ? error : new Error(String(error)));
this.logger.error(
'Error executing GetDriversLeaderboardUseCase',
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: error instanceof Error ? error.message : 'Unknown error occurred' },
});
}
}
}
}

View File

@@ -1,9 +1,15 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetEntitySponsorshipPricingUseCase } from './GetEntitySponsorshipPricingUseCase';
import {
GetEntitySponsorshipPricingUseCase,
type GetEntitySponsorshipPricingInput,
type GetEntitySponsorshipPricingResult,
} 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';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetEntitySponsorshipPricingUseCase', () => {
let mockSponsorshipPricingRepo: ISponsorshipPricingRepository;
@@ -13,6 +19,9 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
let mockFindByEntity: Mock;
let mockFindPendingByEntity: Mock;
let mockFindBySeasonId: Mock;
let output: UseCaseOutputPort<GetEntitySponsorshipPricingResult> & {
present: Mock;
};
beforeEach(() => {
mockFindByEntity = vi.fn();
@@ -65,24 +74,35 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
warn: vi.fn(),
error: vi.fn(),
};
output = { present: vi.fn() } as unknown as typeof output;
});
it('should return null when no pricing found', async () => {
it('should return PRICING_NOT_CONFIGURED 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,
output,
);
const dto = { entityType: 'season' as const, entityId: 'season1' };
const dto: GetEntitySponsorshipPricingInput = {
entityType: 'season',
entityId: 'season1',
};
mockFindByEntity.mockResolvedValue(null);
const result = await useCase.execute(dto);
expect(result.isOk()).toBe(true);
expect(result.value).toBe(null);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
'PRICING_NOT_CONFIGURED',
{ message: string }
>;
expect(err.code).toBe('PRICING_NOT_CONFIGURED');
expect(err.details.message).toContain('No sponsorship pricing configured');
expect(output.present).not.toHaveBeenCalled();
});
it('should return pricing data when found', async () => {
@@ -91,9 +111,13 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
mockLogger,
output,
);
const dto = { entityType: 'season' as const, entityId: 'season1' };
const dto: GetEntitySponsorshipPricingInput = {
entityType: 'season',
entityId: 'season1',
};
const pricing = {
acceptingApplications: true,
customRequirements: 'Some requirements',
@@ -118,34 +142,29 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
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,
},
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0][0] as GetEntitySponsorshipPricingResult;
expect(presented.entityType).toBe('season');
expect(presented.entityId).toBe('season1');
expect(presented.acceptingApplications).toBe(true);
expect(presented.customRequirements).toBe('Some requirements');
expect(presented.tiers).toHaveLength(2);
const mainTier = presented.tiers.find(tier => tier.name === 'main');
const secondaryTier = presented.tiers.find(tier => tier.name === 'secondary');
expect(mainTier).toBeDefined();
expect(mainTier?.price.amount).toBe(100);
expect(mainTier?.price.currency).toBe('USD');
expect(mainTier?.benefits).toEqual(['Benefit 1']);
expect(secondaryTier).toBeDefined();
expect(secondaryTier?.price.amount).toBe(50);
expect(secondaryTier?.price.currency).toBe('USD');
expect(secondaryTier?.benefits).toEqual(['Benefit 2']);
});
it('should return error when repository throws', async () => {
@@ -154,9 +173,13 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
mockSponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
mockSeasonSponsorshipRepo as unknown as ISeasonSponsorshipRepository,
mockLogger,
output,
);
const dto = { entityType: 'season' as const, entityId: 'season1' };
const dto: GetEntitySponsorshipPricingInput = {
entityType: 'season',
entityId: 'season1',
};
const error = new Error('Repository error');
mockFindByEntity.mockRejectedValue(error);
@@ -164,7 +187,12 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
const result = await useCase.execute(dto);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
const err = result.unwrapErr() as ApplicationErrorCode<
'REPOSITORY_ERROR',
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -8,96 +8,128 @@
import type { ISponsorshipPricingRepository } from '../../domain/repositories/ISponsorshipPricingRepository';
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import type { SponsorshipPricing, SponsorshipSlotConfig } from '../../domain/value-objects/SponsorshipPricing';
import type { Logger } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetEntitySponsorshipPricingInputPort } from '../ports/input/GetEntitySponsorshipPricingInputPort';
import type { GetEntitySponsorshipPricingOutputPort } from '../ports/output/GetEntitySponsorshipPricingOutputPort';
export class GetEntitySponsorshipPricingUseCase
implements AsyncUseCase<GetEntitySponsorshipPricingInputPort, GetEntitySponsorshipPricingOutputPort | null, 'REPOSITORY_ERROR'>
{
export type SponsorshipEntityType = 'season' | 'league' | 'team';
export type GetEntitySponsorshipPricingInput = {
entityType: SponsorshipEntityType;
entityId: string;
};
export type SponsorshipPricingTier = {
name: string;
price: SponsorshipPricing['mainSlot'] extends SponsorshipSlotConfig
? SponsorshipSlotConfig['price']
: SponsorshipPricing['secondarySlots'] extends SponsorshipSlotConfig
? SponsorshipSlotConfig['price']
: never;
benefits: string[];
};
export type GetEntitySponsorshipPricingResult = {
entityType: SponsorshipEntityType;
entityId: string;
acceptingApplications: boolean;
customRequirements?: string;
tiers: SponsorshipPricingTier[];
};
export type GetEntitySponsorshipPricingErrorCode =
| 'ENTITY_NOT_FOUND'
| 'PRICING_NOT_CONFIGURED'
| 'REPOSITORY_ERROR';
export class GetEntitySponsorshipPricingUseCase {
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetEntitySponsorshipPricingResult>,
) {}
async execute(dto: GetEntitySponsorshipPricingInputPort): Promise<Result<GetEntitySponsorshipPricingOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
async execute(
input: GetEntitySponsorshipPricingInput,
): Promise<
Result<void, ApplicationErrorCode<GetEntitySponsorshipPricingErrorCode, { message: string }>>
> {
this.logger.debug(
`Executing GetEntitySponsorshipPricingUseCase for entityType: ${input.entityType}, entityId: ${input.entityId}`,
);
try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
const pricing = await this.sponsorshipPricingRepo.findByEntity(
input.entityType,
input.entityId,
);
if (!pricing) {
this.logger.info(`No pricing found for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
return Result.ok(null);
this.logger.info(
`No pricing configured for entityType: ${input.entityType}, entityId: ${input.entityId}`,
);
return Result.err({
code: 'PRICING_NOT_CONFIGURED',
details: {
message: `No sponsorship pricing configured for entityType: ${input.entityType}, entityId: ${input.entityId}`,
},
});
}
// Count pending requests by tier
const pendingRequests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId,
);
const pendingMainCount = pendingRequests.filter(r => r.tier === 'main').length;
const pendingSecondaryCount = pendingRequests.filter(r => r.tier === 'secondary').length;
const tiers: SponsorshipPricingTier[] = [];
// Count filled slots (for seasons, check SeasonSponsorship table)
let filledMainSlots = 0;
let filledSecondarySlots = 0;
if (dto.entityType === 'season') {
const sponsorships = await this.seasonSponsorshipRepo.findBySeasonId(dto.entityId);
const activeSponsorships = sponsorships.filter(s => s.isActive());
filledMainSlots = activeSponsorships.filter(s => s.tier === 'main').length;
filledSecondarySlots = activeSponsorships.filter(s => s.tier === 'secondary').length;
if (pricing.mainSlot) {
tiers.push({
name: 'main',
price: pricing.mainSlot.price,
benefits: pricing.mainSlot.benefits,
});
}
const result: GetEntitySponsorshipPricingOutputPort = {
entityType: dto.entityType,
entityId: dto.entityId,
if (pricing.secondarySlots) {
tiers.push({
name: 'secondary',
price: pricing.secondarySlots.price,
benefits: pricing.secondarySlots.benefits,
});
}
const result: GetEntitySponsorshipPricingResult = {
entityType: input.entityType,
entityId: input.entityId,
acceptingApplications: pricing.acceptingApplications,
...(pricing.customRequirements !== undefined
? { customRequirements: pricing.customRequirements }
: {}),
tiers,
};
if (pricing.mainSlot) {
const mainMaxSlots = pricing.mainSlot.maxSlots;
result.mainSlot = {
tier: 'main',
price: pricing.mainSlot.price.amount,
currency: pricing.mainSlot.price.currency,
formattedPrice: pricing.mainSlot.price.format(),
benefits: pricing.mainSlot.benefits,
available: pricing.mainSlot.available && filledMainSlots < mainMaxSlots,
maxSlots: mainMaxSlots,
filledSlots: filledMainSlots,
pendingRequests: pendingMainCount,
};
}
this.logger.info(
`Successfully retrieved sponsorship pricing for entityType: ${input.entityType}, entityId: ${input.entityId}`,
);
this.output.present(result);
if (pricing.secondarySlots) {
const secondaryMaxSlots = pricing.secondarySlots.maxSlots;
result.secondarySlot = {
tier: 'secondary',
price: pricing.secondarySlots.price.amount,
currency: pricing.secondarySlots.price.currency,
formattedPrice: pricing.secondarySlots.price.format(),
benefits: pricing.secondarySlots.benefits,
available:
pricing.secondarySlots.available && filledSecondarySlots < secondaryMaxSlots,
maxSlots: secondaryMaxSlots,
filledSlots: filledSecondarySlots,
pendingRequests: pendingSecondaryCount,
};
}
this.logger.info(`Successfully retrieved sponsorship pricing for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
return Result.ok(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error('Error executing GetEntitySponsorshipPricingUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } });
this.logger.error(
'Error executing GetEntitySponsorshipPricingUseCase',
error instanceof Error ? error : new Error(String(error)),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to load sponsorship pricing',
},
});
}
}
}

View File

@@ -1,13 +1,27 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueAdminPermissionsUseCase } from './GetLeagueAdminPermissionsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueAdminPermissionsUseCase,
type GetLeagueAdminPermissionsInput,
type GetLeagueAdminPermissionsResult,
type GetLeagueAdminPermissionsErrorCode,
} from './GetLeagueAdminPermissionsUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueAdminPermissionsUseCase', () => {
let mockLeagueRepo: ILeagueRepository;
let mockMembershipRepo: ILeagueMembershipRepository;
let mockFindById: Mock;
let mockGetMembership: Mock;
let output: UseCaseOutputPort<GetLeagueAdminPermissionsResult> & { present: Mock };
const logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;
beforeEach(() => {
mockFindById = vi.fn();
@@ -22,6 +36,7 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
findByOwnerId: vi.fn(),
searchByName: vi.fn(),
} as ILeagueRepository;
mockMembershipRepo = {
getMembership: mockGetMembership,
getMembershipsForDriver: vi.fn(),
@@ -33,80 +48,134 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
countByLeagueId: vi.fn(),
getLeagueMembers: vi.fn(),
} as ILeagueMembershipRepository;
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueAdminPermissionsResult> & { present: Mock };
});
const createUseCase = () => new GetLeagueAdminPermissionsUseCase(
mockLeagueRepo,
mockMembershipRepo,
logger,
output,
);
const params = {
const input: GetLeagueAdminPermissionsInput = {
leagueId: 'league1',
performerDriverId: 'driver1',
};
it('should return no permissions when league not found', async () => {
it('returns LEAGUE_NOT_FOUND when league does not exist and does not call output', async () => {
mockFindById.mockResolvedValue(null);
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false });
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return no permissions when membership not found', async () => {
it('returns USER_NOT_MEMBER when membership is missing and does not call output', async () => {
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue(null);
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false });
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>;
expect(err.code).toBe('USER_NOT_MEMBER');
expect(err.details.message).toBe('User is not a member of this league');
expect(output.present).not.toHaveBeenCalled();
});
it('should return no permissions when membership not active', async () => {
it('returns USER_NOT_MEMBER when membership is not active and does not call output', async () => {
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'inactive', role: 'admin' });
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false });
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>;
expect(err.code).toBe('USER_NOT_MEMBER');
expect(err.details.message).toBe('User is not a member of this league');
expect(output.present).not.toHaveBeenCalled();
});
it('should return no permissions when role is member', async () => {
it('returns USER_NOT_MEMBER when role is member and does not call output', async () => {
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'active', role: 'member' });
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: false, canUpdateRoles: false });
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>;
expect(err.code).toBe('USER_NOT_MEMBER');
expect(err.details.message).toBe('User is not a member of this league');
expect(output.present).not.toHaveBeenCalled();
});
it('should return permissions when role is admin', async () => {
mockFindById.mockResolvedValue({ id: 'league1' });
it('returns admin permissions for admin role and calls output once', async () => {
const league = { id: 'league1' } as any;
mockFindById.mockResolvedValue(league);
mockGetMembership.mockResolvedValue({ status: 'active', role: 'admin' });
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true });
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueAdminPermissionsResult;
expect(presented.league).toBe(league);
expect(presented.permissions).toEqual({
canManageSchedule: true,
canManageMembers: true,
canManageSponsorships: true,
canManageScoring: true,
});
});
it('should return permissions when role is owner', async () => {
mockFindById.mockResolvedValue({ id: 'league1' });
it('returns admin permissions for owner role and calls output once', async () => {
const league = { id: 'league1' } as any;
mockFindById.mockResolvedValue(league);
mockGetMembership.mockResolvedValue({ status: 'active', role: 'owner' });
const useCase = createUseCase();
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ canRemoveMember: true, canUpdateRoles: true });
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueAdminPermissionsResult;
expect(presented.league).toBe(league);
expect(presented.permissions).toEqual({
canManageSchedule: true,
canManageMembers: true,
canManageSponsorships: true,
canManageScoring: true,
});
});
});
it('wraps repository errors in REPOSITORY_ERROR and does not call output', async () => {
const error = new Error('repo failed');
mockFindById.mockRejectedValue(error);
const useCase = createUseCase();
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('repo failed');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,30 +1,90 @@
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { GetLeagueAdminPermissionsOutputPort } from '../ports/output/GetLeagueAdminPermissionsOutputPort';
export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<{ leagueId: string; performerDriverId: string }, GetLeagueAdminPermissionsOutputPort, 'NO_ERROR'> {
export type GetLeagueAdminPermissionsInput = {
leagueId: string;
performerDriverId: string;
};
export type LeagueAdminPermissions = {
canManageSchedule: boolean;
canManageMembers: boolean;
canManageSponsorships: boolean;
canManageScoring: boolean;
};
export type GetLeagueAdminPermissionsResult = {
league: League;
permissions: LeagueAdminPermissions;
};
export type GetLeagueAdminPermissionsErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'USER_NOT_MEMBER'
| 'REPOSITORY_ERROR';
export class GetLeagueAdminPermissionsUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetLeagueAdminPermissionsResult>,
) {}
async execute(params: { leagueId: string; performerDriverId: string }): Promise<Result<GetLeagueAdminPermissionsOutputPort, never>> {
const league = await this.leagueRepository.findById(params.leagueId);
if (!league) {
return Result.ok({ canRemoveMember: false, canUpdateRoles: false });
async execute(
input: GetLeagueAdminPermissionsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueAdminPermissionsErrorCode, { message: string }>>> {
const { leagueId, performerDriverId } = input;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.warn('League not found when checking admin permissions', { leagueId, performerDriverId });
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const membership = await this.leagueMembershipRepository.getMembership(leagueId, performerDriverId);
if (!membership || membership.status !== 'active' || (membership.role !== 'owner' && membership.role !== 'admin')) {
this.logger.warn('User is not a member or not authorized for league admin permissions', {
leagueId,
performerDriverId,
});
return Result.err({
code: 'USER_NOT_MEMBER',
details: { message: 'User is not a member of this league' },
});
}
const permissions: LeagueAdminPermissions = {
canManageSchedule: true,
canManageMembers: true,
canManageSponsorships: true,
canManageScoring: true,
};
const result: GetLeagueAdminPermissionsResult = {
league,
permissions,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
this.logger.error('Failed to load league admin permissions', err);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to load league admin permissions' },
});
}
const membership = await this.leagueMembershipRepository.getMembership(params.leagueId, params.performerDriverId);
if (!membership || membership.status !== 'active') {
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';
return Result.ok({ canRemoveMember, canUpdateRoles });
}
}
}

View File

@@ -1,10 +1,18 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueAdminUseCase } from './GetLeagueAdminUseCase';
import {
GetLeagueAdminUseCase,
type GetLeagueAdminInput,
type GetLeagueAdminResult,
type GetLeagueAdminErrorCode,
} from './GetLeagueAdminUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueAdminUseCase', () => {
let mockLeagueRepo: ILeagueRepository;
let mockFindById: Mock;
let output: UseCaseOutputPort<GetLeagueAdminResult> & { present: Mock };
beforeEach(() => {
mockFindById = vi.fn();
@@ -18,13 +26,18 @@ describe('GetLeagueAdminUseCase', () => {
findByOwnerId: vi.fn(),
searchByName: vi.fn(),
} as ILeagueRepository;
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueAdminResult> & { present: Mock };
});
const createUseCase = () => new GetLeagueAdminUseCase(
mockLeagueRepo,
output,
);
const params = {
const params: GetLeagueAdminInput = {
leagueId: 'league1',
};
@@ -35,8 +48,10 @@ describe('GetLeagueAdminUseCase', () => {
const result = await useCase.execute(params);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('League not found');
const error = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminErrorCode, { message: string }>;
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return league data when league found', async () => {
@@ -47,11 +62,24 @@ describe('GetLeagueAdminUseCase', () => {
const result = await useCase.execute(params);
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({
league: {
id: 'league1',
ownerId: 'owner1',
},
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueAdminResult;
expect(presented.league.id).toBe('league1');
expect(presented.league.ownerId).toBe('owner1');
});
it('should return repository error when repository throws', async () => {
const repoError = new Error('Repository failure');
mockFindById.mockRejectedValue(repoError);
const useCase = createUseCase();
const result = await useCase.execute(params);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<GetLeagueAdminErrorCode, { message: string }>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,26 +1,43 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueAdminOutputPort } from '../ports/output/GetLeagueAdminOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { League } from '../../domain/entities/League';
export class GetLeagueAdminUseCase implements AsyncUseCase<{ leagueId: string }, GetLeagueAdminOutputPort, 'LEAGUE_NOT_FOUND'> {
export type GetLeagueAdminInput = {
leagueId: string;
};
export type GetLeagueAdminResult = {
league: League;
};
export type GetLeagueAdminErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetLeagueAdminUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueAdminResult>,
) {}
async execute(params: { leagueId: string }): Promise<Result<GetLeagueAdminOutputPort, ApplicationErrorCode<'LEAGUE_NOT_FOUND', { message: string }>>> {
const league = await this.leagueRepository.findById(params.leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } });
}
async execute(
input: GetLeagueAdminInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueAdminErrorCode, { message: string }>>> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } });
}
const dto: GetLeagueAdminOutputPort = {
league: {
id: league.id,
ownerId: league.ownerId,
},
};
return Result.ok(dto);
this.output.present({ league });
return Result.ok(undefined);
} catch (error) {
const err = error instanceof Error ? error : new Error('Unknown error');
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message: err.message ?? 'Failed to load league admin data' },
});
}
}
}

View File

@@ -1,5 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetLeagueDriverSeasonStatsUseCase } from './GetLeagueDriverSeasonStatsUseCase';
import {
GetLeagueDriverSeasonStatsUseCase,
type GetLeagueDriverSeasonStatsResult,
type GetLeagueDriverSeasonStatsInput,
type GetLeagueDriverSeasonStatsErrorCode,
} from './GetLeagueDriverSeasonStatsUseCase';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
@@ -7,6 +12,8 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueDriverSeasonStatsUseCase', () => {
const mockStandingFindByLeagueId = vi.fn();
@@ -25,8 +32,17 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
let driverRepository: IDriverRepository;
let teamRepository: ITeamRepository;
let driverRatingPort: DriverRatingPort;
let output: UseCaseOutputPort<GetLeagueDriverSeasonStatsResult> & { present: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockStandingFindByLeagueId.mockReset();
mockResultFindByDriverIdAndLeagueId.mockReset();
mockPenaltyFindByRaceId.mockReset();
mockRaceFindByLeagueId.mockReset();
mockDriverRatingGetRating.mockReset();
mockDriverFindById.mockReset();
mockTeamFindById.mockReset();
standingRepository = {
findByLeagueId: mockStandingFindByLeagueId,
findByDriverIdAndLeagueId: vi.fn(),
@@ -67,6 +83,12 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
getRating: mockDriverRatingGetRating,
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueDriverSeasonStatsResult> & {
present: ReturnType<typeof vi.fn>;
};
useCase = new GetLeagueDriverSeasonStatsUseCase(
standingRepository,
resultRepository,
@@ -75,20 +97,18 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
driverRepository,
teamRepository,
driverRatingPort,
output,
);
});
it('should return league driver season stats for given league id', async () => {
const params = { leagueId: 'league-1' };
const input: GetLeagueDriverSeasonStatsInput = { 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 mockRaces = [{ id: 'race-1' }, { id: 'race-2' }];
const mockPenalties = [
{ driverId: 'driver-1', status: 'applied', type: 'points_deduction', value: 10 },
];
@@ -97,28 +117,31 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
const mockDriver = { id: 'driver-1', name: 'Driver One', teamId: 'team-1' };
const mockTeam = { id: 'team-1', name: 'Team One' };
standingRepository.findByLeagueId.mockResolvedValue(mockStandings);
raceRepository.findByLeagueId.mockResolvedValue(mockRaces);
penaltyRepository.findByRaceId.mockImplementation((raceId) => {
mockStandingFindByLeagueId.mockResolvedValue(mockStandings);
mockRaceFindByLeagueId.mockResolvedValue(mockRaces);
mockPenaltyFindByRaceId.mockImplementation((raceId: string) => {
if (raceId === 'race-1') return Promise.resolve(mockPenalties);
return Promise.resolve([]);
});
driverRatingPort.getRating.mockReturnValue(mockRating);
resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults);
driverRepository.findById.mockImplementation((id) => {
mockDriverRatingGetRating.mockReturnValue(mockRating);
mockResultFindByDriverIdAndLeagueId.mockResolvedValue(mockResults);
mockDriverFindById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(mockDriver);
if (id === 'driver-2') return Promise.resolve({ id: 'driver-2', name: 'Driver Two' });
return Promise.resolve(null);
});
teamRepository.findById.mockResolvedValue(mockTeam);
mockTeamFindById.mockResolvedValue(mockTeam);
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const output = result.value!;
expect(output.leagueId).toBe('league-1');
expect(output.stats).toHaveLength(2);
expect(output.stats[0]).toEqual({
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueDriverSeasonStatsResult;
expect(presented.leagueId).toBe('league-1');
expect(presented.stats).toHaveLength(2);
expect(presented.stats[0]).toEqual({
leagueId: 'league-1',
driverId: 'driver-1',
position: 1,
@@ -141,26 +164,68 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
});
it('should handle no penalties', async () => {
const params = { leagueId: 'league-1' };
const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'league-1' };
const mockStandings = [{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }];
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 };
const mockDriver = { id: 'driver-1', name: 'Driver One' };
standingRepository.findByLeagueId.mockResolvedValue(mockStandings);
raceRepository.findByLeagueId.mockResolvedValue(mockRaces);
penaltyRepository.findByRaceId.mockResolvedValue([]);
driverRatingPort.getRating.mockReturnValue(mockRating);
resultRepository.findByDriverIdAndLeagueId.mockResolvedValue(mockResults);
driverRepository.findById.mockResolvedValue(mockDriver);
teamRepository.findById.mockResolvedValue(null);
mockStandingFindByLeagueId.mockResolvedValue(mockStandings);
mockRaceFindByLeagueId.mockResolvedValue(mockRaces);
mockPenaltyFindByRaceId.mockResolvedValue([]);
mockDriverRatingGetRating.mockReturnValue(mockRating);
mockResultFindByDriverIdAndLeagueId.mockResolvedValue(mockResults);
mockDriverFindById.mockResolvedValue(mockDriver);
mockTeamFindById.mockResolvedValue(null);
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const output = result.value!;
expect(output.stats[0].penaltyPoints).toBe(0);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueDriverSeasonStatsResult;
expect(presented.stats[0].penaltyPoints).toBe(0);
});
});
it('should return LEAGUE_NOT_FOUND when no standings are found', async () => {
const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'missing-league' };
mockStandingFindByLeagueId.mockResolvedValue([]);
mockRaceFindByLeagueId.mockResolvedValue([]);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueDriverSeasonStatsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when an unexpected error occurs', async () => {
const input: GetLeagueDriverSeasonStatsInput = { leagueId: 'league-1' };
const thrown = new Error('repository failure');
mockStandingFindByLeagueId.mockRejectedValue(thrown);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueDriverSeasonStatsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -4,16 +4,52 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { LeagueDriverSeasonStatsOutputPort } from '../ports/output/LeagueDriverSeasonStatsOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
export type DriverSeasonStats = {
leagueId: string;
driverId: string;
position: number;
driverName: string;
teamId?: string;
teamName?: string;
totalPoints: number;
basePoints: number;
penaltyPoints: number;
bonusPoints: number;
pointsPerRace: number;
racesStarted: number;
racesFinished: number;
dnfs: number;
noShows: number;
avgFinish: number | null;
rating: number | null;
ratingChange: number | null;
};
export type GetLeagueDriverSeasonStatsInput = {
leagueId: string;
};
export type GetLeagueDriverSeasonStatsResult = {
leagueId: string;
stats: DriverSeasonStats[];
};
export type GetLeagueDriverSeasonStatsErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'SEASON_NOT_FOUND'
| 'DRIVER_NOT_FOUND'
| 'REPOSITORY_ERROR';
/**
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and returns the result.
*/
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsOutputPort, 'NO_ERROR'> {
export class GetLeagueDriverSeasonStatsUseCase {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
@@ -22,105 +58,137 @@ export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueI
private readonly driverRepository: IDriverRepository,
private readonly teamRepository: ITeamRepository,
private readonly driverRatingPort: DriverRatingPort,
private readonly output: UseCaseOutputPort<GetLeagueDriverSeasonStatsResult>,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueDriverSeasonStatsOutputPort, never>> {
const { leagueId } = params;
async execute(
input: GetLeagueDriverSeasonStatsInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueDriverSeasonStatsErrorCode, { message: string }>>
> {
try {
const { leagueId } = input;
// Get standings and races for the league
const [standings, races] = await Promise.all([
this.standingRepository.findByLeagueId(leagueId),
this.raceRepository.findByLeagueId(leagueId),
]);
const [standings, races] = await Promise.all([
this.standingRepository.findByLeagueId(leagueId),
this.raceRepository.findByLeagueId(leagueId),
]);
// Fetch all penalties for all races in the league
const penaltiesArrays = await Promise.all(
races.map(race => this.penaltyRepository.findByRaceId(race.id))
);
const penaltiesForLeague = penaltiesArrays.flat();
// Group penalties by driver for quick lookup
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
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;
if (!standings || standings.length === 0) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
penaltiesByDriver.set(p.driverId, current);
}
// Collect driver ratings
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const ratingInfo = this.driverRatingPort.getRating(standing.driverId);
driverRatings.set(standing.driverId, ratingInfo);
}
// Collect driver results
const driverResults = new Map<string, Array<{ position: number }>>();
for (const standing of standings) {
const results = await this.resultRepository.findByDriverIdAndLeagueId(
standing.driverId,
leagueId,
const penaltiesArrays = await Promise.all(
races.map(race => this.penaltyRepository.findByRaceId(race.id)),
);
driverResults.set(standing.driverId, results);
}
const penaltiesForLeague = penaltiesArrays.flat();
// Fetch drivers and teams
const driverIds = standings.map(s => s.driverId);
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driversMap = new Map(drivers.filter(d => d).map(d => [d!.id, d!]));
const teamIds = Array.from(new Set(drivers.filter(d => d?.teamId).map(d => d!.teamId!)));
const teams = await Promise.all(teamIds.map(id => this.teamRepository.findById(id)));
const teamsMap = new Map(teams.filter(t => t).map(t => [t!.id, t!]));
const penaltiesByDriver = new Map<string, { baseDelta: number; bonusDelta: number }>();
for (const p of penaltiesForLeague) {
if (p.status !== 'applied') continue;
// Compute stats
const stats = standings.map(standing => {
const driver = driversMap.get(standing.driverId);
const team = driver?.teamId ? teamsMap.get(driver.teamId) : undefined;
const penalties = penaltiesByDriver.get(standing.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const results = driverResults.get(standing.driverId) ?? [];
const rating = driverRatings.get(standing.driverId);
const current = penaltiesByDriver.get(p.driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const racesStarted = results.length;
const racesFinished = results.filter(r => r.position > 0).length;
const dnfs = results.filter(r => r.position === 0).length;
const noShows = races.length - racesStarted;
const avgFinish = results.length > 0 ? results.reduce((sum, r) => sum + r.position, 0) / results.length : null;
const pointsPerRace = racesStarted > 0 ? standing.points / racesStarted : 0;
if (p.type === 'points_deduction' && p.value) {
current.baseDelta -= p.value;
}
return {
penaltiesByDriver.set(p.driverId, current);
}
const driverRatings = new Map<string, { rating: number | null; ratingChange: number | null }>();
for (const standing of standings) {
const driverId = String(standing.driverId);
const ratingInfo = this.driverRatingPort.getRating(driverId);
driverRatings.set(driverId, ratingInfo);
}
const driverResults = new Map<string, Array<{ position: number }>>();
for (const standing of standings) {
const driverId = String(standing.driverId);
const results = await this.resultRepository.findByDriverIdAndLeagueId(driverId, leagueId);
driverResults.set(
driverId,
results.map(result => ({ position: Number((result as any).position) })),
);
}
const driverIds = standings.map(s => String(s.driverId));
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driversMap = new Map(drivers.filter(d => d).map(d => [String(d!.id), d!]));
const teamIds = Array.from(
new Set(
drivers
.filter(d => (d as any)?.teamId)
.map(d => (d as any).teamId as string),
),
);
const teams = await Promise.all(teamIds.map(id => this.teamRepository.findById(id)));
const teamsMap = new Map(teams.filter(t => t).map(t => [String(t!.id), t!]));
const stats: DriverSeasonStats[] = standings.map(standing => {
const driverId = String(standing.driverId);
const driver = driversMap.get(driverId) as any;
const teamId = driver?.teamId as string | undefined;
const team = teamId ? teamsMap.get(String(teamId)) : undefined;
const penalties = penaltiesByDriver.get(driverId) ?? { baseDelta: 0, bonusDelta: 0 };
const results = driverResults.get(driverId) ?? [];
const rating = driverRatings.get(driverId);
const racesStarted = results.length;
const racesFinished = results.filter(r => r.position > 0).length;
const dnfs = results.filter(r => r.position === 0).length;
const noShows = races.length - racesStarted;
const avgFinish =
results.length > 0
? results.reduce((sum, r) => sum + r.position, 0) / results.length
: null;
const totalPoints = Number(standing.points);
const pointsPerRace = racesStarted > 0 ? totalPoints / racesStarted : 0;
return {
leagueId,
driverId,
position: Number(standing.position),
driverName: String(driver?.name ?? ''),
teamId,
teamName: (team as any)?.name as string | undefined,
totalPoints,
basePoints: totalPoints - penalties.baseDelta,
penaltyPoints: penalties.baseDelta,
bonusPoints: penalties.bonusDelta,
pointsPerRace,
racesStarted,
racesFinished,
dnfs,
noShows,
avgFinish,
rating: rating?.rating ?? null,
ratingChange: rating?.ratingChange ?? null,
};
});
const result: GetLeagueDriverSeasonStatsResult = {
leagueId,
driverId: standing.driverId,
position: standing.position,
driverName: driver?.name ?? '',
teamId: driver?.teamId ?? undefined,
teamName: team?.name ?? undefined,
totalPoints: standing.points,
basePoints: standing.points - penalties.baseDelta,
penaltyPoints: penalties.baseDelta,
bonusPoints: penalties.bonusDelta,
pointsPerRace,
racesStarted,
racesFinished,
dnfs,
noShows,
avgFinish,
rating: rating?.rating ?? null,
ratingChange: rating?.ratingChange ?? null,
stats,
};
});
return Result.ok({
leagueId,
stats,
});
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to fetch league driver season stats';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}
}

View File

@@ -1,17 +1,26 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { GetLeagueFullConfigUseCase } from './GetLeagueFullConfigUseCase';
import {
GetLeagueFullConfigUseCase,
type GetLeagueFullConfigInput,
type GetLeagueFullConfigResult,
type GetLeagueFullConfigErrorCode,
} 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 { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueFullConfigUseCase', () => {
let useCase: GetLeagueFullConfigUseCase;
let leagueRepository: ILeagueRepository;
let seasonRepository: ISeasonRepository;
let leagueScoringConfigRepository: ILeagueScoringConfigRepository;
let gameRepository: IGameRepository;
let leagueRepository: ILeagueRepository & { findById: ReturnType<typeof vi.fn> };
let seasonRepository: ISeasonRepository & { findByLeagueId: ReturnType<typeof vi.fn> };
let leagueScoringConfigRepository: ILeagueScoringConfigRepository & {
findBySeasonId: ReturnType<typeof vi.fn>;
};
let gameRepository: IGameRepository & { findById: ReturnType<typeof vi.fn> };
let output: UseCaseOutputPort<GetLeagueFullConfigResult> & { present: ReturnType<typeof vi.fn> };
beforeEach(() => {
leagueRepository = {
@@ -31,16 +40,21 @@ describe('GetLeagueFullConfigUseCase', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetLeagueFullConfigResult> & {
present: ReturnType<typeof vi.fn>;
};
useCase = new GetLeagueFullConfigUseCase(
leagueRepository,
seasonRepository,
leagueScoringConfigRepository,
gameRepository,
output,
);
});
it('should return league config when league exists', async () => {
const params = { leagueId: 'league-1' };
const input: GetLeagueFullConfigInput = { leagueId: 'league-1' };
const mockLeague = {
id: 'league-1',
@@ -69,33 +83,41 @@ describe('GetLeagueFullConfigUseCase', () => {
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(mockScoringConfig);
gameRepository.findById.mockResolvedValue(mockGame);
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const output = result.value!;
expect(output).toEqual({
league: mockLeague,
activeSeason: mockSeasons[0],
scoringConfig: mockScoringConfig,
game: mockGame,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const firstCall = output.present.mock.calls[0]!;
const presented = firstCall[0] as GetLeagueFullConfigResult;
expect(presented.config.league).toEqual(mockLeague);
expect(presented.config.activeSeason).toEqual(mockSeasons[0]);
expect(presented.config.scoringConfig).toEqual(mockScoringConfig);
expect(presented.config.game).toEqual(mockGame);
});
it('should return error when league not found', async () => {
const params = { leagueId: 'league-1' };
it('should return error when league not found and not call presenter', async () => {
const input: GetLeagueFullConfigInput = { leagueId: 'league-1' };
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
const error = result.unwrapErr() as ApplicationErrorCode<
GetLeagueFullConfigErrorCode,
{ message: string }
>;
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details!.message).toBe('League with id league-1 not found');
expect(error.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should handle no active season', async () => {
const params = { leagueId: 'league-1' };
const input: GetLeagueFullConfigInput = { leagueId: 'league-1' };
const mockLeague = {
id: 'league-1',
@@ -107,12 +129,37 @@ describe('GetLeagueFullConfigUseCase', () => {
leagueRepository.findById.mockResolvedValue(mockLeague);
seasonRepository.findByLeagueId.mockResolvedValue([]);
const result = await useCase.execute(params);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const output = result.value!;
expect(output).toEqual({
league: mockLeague,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const firstCall = output.present.mock.calls[0]!;
const presented = firstCall[0] as GetLeagueFullConfigResult;
expect(presented.config.league).toEqual(mockLeague);
expect(presented.config.activeSeason).toBeUndefined();
expect(presented.config.scoringConfig).toBeUndefined();
expect(presented.config.game).toBeUndefined();
});
});
it('should return repository error when repository throws and not call presenter', async () => {
const input: GetLeagueFullConfigInput = { leagueId: 'league-1' };
const thrownError = new Error('Repository failure');
leagueRepository.findById.mockRejectedValue(thrownError);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetLeagueFullConfigErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -2,53 +2,93 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueFullConfigOutputPort } from '../ports/output/LeagueFullConfigOutputPort';
import type { AsyncUseCase } from '@core/shared/application';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export type GetLeagueFullConfigInput = {
leagueId: string;
};
export type LeagueFullConfig = {
league: unknown;
activeSeason?: unknown;
scoringConfig?: unknown | null;
game?: unknown | null;
};
export type GetLeagueFullConfigResult = {
config: LeagueFullConfig;
};
export type GetLeagueFullConfigErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and returns the configuration data.
*/
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueFullConfigOutputPort, 'LEAGUE_NOT_FOUND'> {
export class GetLeagueFullConfigUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly output: UseCaseOutputPort<GetLeagueFullConfigResult>,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueFullConfigOutputPort, ApplicationErrorCode<'LEAGUE_NOT_FOUND', { message: string }>>> {
const { leagueId } = params;
async execute(
input: GetLeagueFullConfigInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueFullConfigErrorCode, { message: string }>>> {
const { leagueId } = input;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League with id ${leagueId} not found` } });
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
const scoringConfig = await (async () => {
if (!activeSeason) return null;
return (await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id)) ?? null;
})();
const game = await (async () => {
if (!activeSeason || !activeSeason.gameId) return null;
return (await this.gameRepository.findById(activeSeason.gameId)) ?? null;
})();
const config: LeagueFullConfig = {
league,
...(activeSeason ? { activeSeason } : {}),
...(scoringConfig !== null ? { scoringConfig } : {}),
...(game !== null ? { game } : {}),
};
const result: GetLeagueFullConfigResult = {
config,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load league full configuration';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeSeason =
seasons && seasons.length > 0
? seasons.find((s) => s.status === 'active') ?? seasons[0]
: undefined;
let scoringConfig = await (async () => {
if (!activeSeason) return undefined;
return this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
})();
let game = await (async () => {
if (!activeSeason || !activeSeason.gameId) return undefined;
return this.gameRepository.findById(activeSeason.gameId);
})();
const output: LeagueFullConfigOutputPort = {
league,
...(activeSeason ? { activeSeason } : {}),
...(scoringConfig ? { scoringConfig } : {}),
...(game ? { game } : {}),
};
return Result.ok(output);
}
}
}

View File

@@ -1,8 +1,16 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueJoinRequestsUseCase } from './GetLeagueJoinRequestsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueJoinRequestsUseCase,
type GetLeagueJoinRequestsInput,
type GetLeagueJoinRequestsResult,
type GetLeagueJoinRequestsErrorCode,
} from './GetLeagueJoinRequestsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Driver } from '../../domain/entities/Driver';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueJoinRequestsUseCase', () => {
let useCase: GetLeagueJoinRequestsUseCase;
@@ -12,6 +20,10 @@ describe('GetLeagueJoinRequestsUseCase', () => {
let driverRepository: {
findById: Mock;
};
let leagueRepository: {
exists: Mock;
};
let output: UseCaseOutputPort<GetLeagueJoinRequestsResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
@@ -20,17 +32,29 @@ describe('GetLeagueJoinRequestsUseCase', () => {
driverRepository = {
findById: vi.fn(),
};
leagueRepository = {
exists: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetLeagueJoinRequestsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('should return join requests with drivers', async () => {
it('should return join requests with drivers when league exists', async () => {
const leagueId = 'league-1';
const input: GetLeagueJoinRequestsInput = { leagueId };
const joinRequests = [
{ id: 'req-1', leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' },
];
const driver = Driver.create({
id: 'driver-1',
iracingId: '123',
@@ -38,23 +62,66 @@ describe('GetLeagueJoinRequestsUseCase', () => {
country: 'US',
});
leagueRepository.exists.mockResolvedValue(true);
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
driverRepository.findById.mockResolvedValue(driver);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
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' },
},
],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueJoinRequestsResult;
expect(presented.joinRequests).toHaveLength(1);
expect(presented.joinRequests[0]).toMatchObject({
id: 'req-1',
leagueId,
driverId: 'driver-1',
message: 'msg',
});
expect(presented.joinRequests[0].driver).toBe(driver);
});
});
it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => {
const leagueId = 'missing-league';
const input: GetLeagueJoinRequestsInput = { leagueId };
leagueRepository.exists.mockResolvedValue(false);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueJoinRequestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
const leagueId = 'league-1';
const input: GetLeagueJoinRequestsInput = { leagueId };
const error = new Error('Repository failure');
leagueRepository.exists.mockRejectedValue(error);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueJoinRequestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,33 +1,83 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Driver } from '../../domain/entities/Driver';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueJoinRequestsOutputPort } from '../ports/output/GetLeagueJoinRequestsOutputPort';
export interface GetLeagueJoinRequestsUseCaseParams {
export interface GetLeagueJoinRequestsInput {
leagueId: string;
}
export class GetLeagueJoinRequestsUseCase implements AsyncUseCase<GetLeagueJoinRequestsUseCaseParams, GetLeagueJoinRequestsOutputPort, 'NO_ERROR'> {
export type GetLeagueJoinRequestsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface LeagueJoinRequestWithDriver {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
driver: Driver;
}
export interface GetLeagueJoinRequestsResult {
joinRequests: LeagueJoinRequestWithDriver[];
}
export class GetLeagueJoinRequestsUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueJoinRequestsResult>,
) {}
async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise<Result<GetLeagueJoinRequestsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
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
.filter(request => driverMap.has(request.driverId))
.map(request => ({
...request,
driver: driverMap.get(request.driverId)!,
}));
return Result.ok({
joinRequests: enrichedJoinRequests,
});
async execute(
input: GetLeagueJoinRequestsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueJoinRequestsErrorCode, { message: string }>>> {
try {
const leagueExists = await this.leagueRepository.exists(input.leagueId);
if (!leagueExists) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const joinRequests = await this.leagueMembershipRepository.getJoinRequests(input.leagueId);
const driverIds = [...new Set(joinRequests.map(request => request.driverId))];
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driverMap = new Map(
drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]),
);
const enrichedJoinRequests: LeagueJoinRequestWithDriver[] = joinRequests
.filter(request => driverMap.has(request.driverId))
.map(request => ({
...request,
driver: driverMap.get(request.driverId)!,
}));
const result: GetLeagueJoinRequestsResult = {
joinRequests: enrichedJoinRequests,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string'
? (error as any).message
: 'Failed to load league join requests';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}
}

View File

@@ -1,9 +1,18 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueMembershipsUseCase } from './GetLeagueMembershipsUseCase';
import {
GetLeagueMembershipsUseCase,
type GetLeagueMembershipsInput,
type GetLeagueMembershipsResult,
type GetLeagueMembershipsErrorCode,
} from './GetLeagueMembershipsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { LeagueMembership } from '../../domain/entities/LeagueMembership';
import { Driver } from '../../domain/entities/Driver';
import { League } from '../../domain/entities/League';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueMembershipsUseCase', () => {
let useCase: GetLeagueMembershipsUseCase;
@@ -13,6 +22,10 @@ describe('GetLeagueMembershipsUseCase', () => {
let driverRepository: {
findById: Mock;
};
let leagueRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetLeagueMembershipsResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
@@ -21,14 +34,28 @@ describe('GetLeagueMembershipsUseCase', () => {
driverRepository = {
findById: vi.fn(),
};
leagueRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetLeagueMembershipsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('should return league memberships with drivers', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'Test Description',
ownerId: 'owner-1',
});
const memberships = [
LeagueMembership.create({
id: 'membership-1',
@@ -58,6 +85,7 @@ describe('GetLeagueMembershipsUseCase', () => {
country: 'UK',
});
leagueRepository.findById.mockResolvedValue(league);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
driverRepository.findById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(driver1);
@@ -65,20 +93,30 @@ describe('GetLeagueMembershipsUseCase', () => {
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
memberships,
drivers: [
{ id: 'driver-1', name: 'Driver 1' },
{ id: 'driver-2', name: 'Driver 2' },
],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueMembershipsResult;
expect(presented.league).toEqual(league);
expect(presented.memberships).toHaveLength(2);
expect(presented.memberships[0].membership).toEqual(memberships[0]);
expect(presented.memberships[0].driver).toEqual(driver1);
expect(presented.memberships[1].membership).toEqual(memberships[1]);
expect(presented.memberships[1].driver).toEqual(driver2);
});
it('should handle drivers not found', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'Test Description',
ownerId: 'owner-1',
});
const memberships = [
LeagueMembership.create({
id: 'membership-1',
@@ -89,15 +127,61 @@ describe('GetLeagueMembershipsUseCase', () => {
}),
];
leagueRepository.findById.mockResolvedValue(league);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
memberships,
drivers: [],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueMembershipsResult;
expect(presented.league).toEqual(league);
expect(presented.memberships).toHaveLength(1);
expect(presented.memberships[0].membership).toEqual(memberships[0]);
expect(presented.memberships[0].driver).toBeNull();
});
it('should return error when league not found', async () => {
const leagueId = 'non-existent-league';
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueMembershipsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details?.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return repository error on unexpected failure', async () => {
const leagueId = 'league-1';
leagueRepository.findById.mockImplementation(() => {
throw new Error('Database connection failed');
});
const result = await useCase.execute({ leagueId } as GetLeagueMembershipsInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueMembershipsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('Database connection failed');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,36 +1,81 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueMembership } from '../../domain/entities/LeagueMembership';
import type { Driver } from '../../domain/entities/Driver';
import type { League } from '../../domain/entities/League';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueMembershipsOutputPort } from '../ports/output/GetLeagueMembershipsOutputPort';
export interface GetLeagueMembershipsUseCaseParams {
export interface GetLeagueMembershipsInput {
leagueId: string;
}
export class GetLeagueMembershipsUseCase implements AsyncUseCase<GetLeagueMembershipsUseCaseParams, GetLeagueMembershipsOutputPort, 'NO_ERROR'> {
export type GetLeagueMembershipsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface LeagueMembershipWithDriver {
membership: LeagueMembership;
driver: Driver | null;
}
export interface GetLeagueMembershipsResult {
league: League;
memberships: LeagueMembershipWithDriver[];
}
export class GetLeagueMembershipsUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueMembershipsResult>,
) {}
async execute(params: GetLeagueMembershipsUseCaseParams): Promise<Result<GetLeagueMembershipsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const drivers: { id: string; name: string }[] = [];
async execute(
input: GetLeagueMembershipsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueMembershipsErrorCode, { message: string }>>> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
// Get driver details for each membership
for (const membership of memberships) {
const driver = await this.driverRepository.findById(membership.driverId);
if (driver) {
drivers.push({ id: driver.id, name: driver.name });
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
}
const dto: GetLeagueMembershipsOutputPort = {
memberships,
drivers,
};
return Result.ok(dto);
const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId);
const driverIds = [...new Set(memberships.map(membership => membership.driverId.toString()))];
const drivers = await Promise.all(driverIds.map(id => this.driverRepository.findById(id)));
const driverMap = new Map<string, Driver>(
drivers.filter((driver): driver is Driver => driver !== null).map(driver => [driver.id, driver]),
);
const membershipsWithDrivers: LeagueMembershipWithDriver[] = memberships.map(membership => ({
membership,
driver: driverMap.get(membership.driverId.toString()) ?? null,
}));
const result: GetLeagueMembershipsResult = {
league,
memberships: membershipsWithDrivers,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string'
? (error as any).message
: 'Failed to load league memberships';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -1,25 +1,55 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueOwnerSummaryUseCase } from './GetLeagueOwnerSummaryUseCase';
import {
GetLeagueOwnerSummaryUseCase,
type GetLeagueOwnerSummaryInput,
type GetLeagueOwnerSummaryResult,
type GetLeagueOwnerSummaryErrorCode,
} from './GetLeagueOwnerSummaryUseCase';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Driver } from '../../domain/entities/Driver';
import { League } from '../../domain/entities/League';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
describe('GetLeagueOwnerSummaryUseCase', () => {
let useCase: GetLeagueOwnerSummaryUseCase;
let leagueRepository: {
findById: Mock;
};
let driverRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetLeagueOwnerSummaryResult> & { present: Mock };
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueOwnerSummaryResult> & { present: Mock };
useCase = new GetLeagueOwnerSummaryUseCase(
leagueRepository as unknown as ILeagueRepository,
driverRepository as unknown as IDriverRepository,
output,
);
});
it('should return owner summary when driver exists', async () => {
it('should return owner summary when league and owner exist', async () => {
const leagueId = 'league-1';
const ownerId = 'owner-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'Desc',
ownerId,
settings: {},
});
const driver = Driver.create({
id: ownerId,
iracingId: '123',
@@ -27,30 +57,81 @@ describe('GetLeagueOwnerSummaryUseCase', () => {
country: 'US',
});
leagueRepository.findById.mockResolvedValue(league);
driverRepository.findById.mockResolvedValue(driver);
const result = await useCase.execute({ ownerId });
const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
summary: {
driver: { id: ownerId, name: 'Owner Name' },
rating: 0,
rank: 0,
},
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueOwnerSummaryResult;
expect(presented.league).toBe(league);
expect(presented.owner).toBe(driver);
expect(presented.rating).toBe(0);
expect(presented.rank).toBe(0);
});
it('should return null summary when driver does not exist', async () => {
const ownerId = 'owner-1';
it('should return error when league does not exist', async () => {
const leagueId = 'league-1';
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput);
expect(result.isErr()).toBe(true);
const errorResult = result.unwrapErr() as ApplicationErrorCode<
GetLeagueOwnerSummaryErrorCode,
{ message: string }
>;
expect(errorResult.code).toBe('LEAGUE_NOT_FOUND');
expect(errorResult.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when owner does not exist', async () => {
const leagueId = 'league-1';
const ownerId = 'owner-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'Desc',
ownerId,
settings: {},
});
leagueRepository.findById.mockResolvedValue(league);
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ ownerId });
const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
summary: null,
});
expect(result.isErr()).toBe(true);
const errorResult = result.unwrapErr() as ApplicationErrorCode<
GetLeagueOwnerSummaryErrorCode,
{ message: string }
>;
expect(errorResult.code).toBe('OWNER_NOT_FOUND');
expect(errorResult.details.message).toBe('League owner not found');
expect(output.present).not.toHaveBeenCalled();
});
});
it('should return repository error when repository throws', async () => {
const leagueId = 'league-1';
leagueRepository.findById.mockRejectedValue(new Error('DB failure'));
const result = await useCase.execute({ leagueId } as GetLeagueOwnerSummaryInput);
expect(result.isErr()).toBe(true);
const errorResult = result.unwrapErr() as ApplicationErrorCode<
GetLeagueOwnerSummaryErrorCode,
{ message: string }
>;
expect(errorResult.code).toBe('REPOSITORY_ERROR');
expect(errorResult.details.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,19 +1,65 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueOwnerSummaryOutputPort } from '../ports/output/GetLeagueOwnerSummaryOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Driver } from '../../domain/entities/Driver';
import type { League } from '../../domain/entities/League';
export interface GetLeagueOwnerSummaryUseCaseParams {
ownerId: string;
}
export type GetLeagueOwnerSummaryInput = {
leagueId: string;
};
export class GetLeagueOwnerSummaryUseCase implements AsyncUseCase<GetLeagueOwnerSummaryUseCaseParams, GetLeagueOwnerSummaryOutputPort, 'NO_ERROR'> {
constructor(private readonly driverRepository: IDriverRepository) {}
export type GetLeagueOwnerSummaryResult = {
league: League;
owner: Driver;
rating: number;
rank: number;
};
async execute(params: GetLeagueOwnerSummaryUseCaseParams): Promise<Result<GetLeagueOwnerSummaryOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const driver = await this.driverRepository.findById(params.ownerId);
const summary = driver ? { driver: { id: driver.id, iracingId: driver.iracingId.toString(), name: driver.name.toString(), country: driver.country.toString(), bio: driver.bio?.toString(), joinedAt: driver.joinedAt.toDate().toISOString() }, rating: 0, rank: 0 } : null;
return Result.ok({ summary });
export type GetLeagueOwnerSummaryErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'OWNER_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class GetLeagueOwnerSummaryUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly driverRepository: IDriverRepository,
private readonly output: UseCaseOutputPort<GetLeagueOwnerSummaryResult>,
) {}
async execute(
input: GetLeagueOwnerSummaryInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueOwnerSummaryErrorCode, { message: string }>>> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } });
}
const ownerId = league.ownerId.toString();
const owner = await this.driverRepository.findById(ownerId);
if (!owner) {
return Result.err({ code: 'OWNER_NOT_FOUND', details: { message: 'League owner not found' } });
}
this.output.present({
league,
owner,
rating: 0,
rank: 0,
});
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to fetch league owner summary';
return Result.err({ code: 'REPOSITORY_ERROR', details: { message } });
}
}
}

View File

@@ -1,11 +1,20 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueProtestsUseCase } from './GetLeagueProtestsUseCase';
import {
GetLeagueProtestsUseCase,
GetLeagueProtestsResult,
GetLeagueProtestsInput,
GetLeagueProtestsErrorCode,
} from './GetLeagueProtestsUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Race } from '../../domain/entities/Race';
import { Protest } from '../../domain/entities/Protest';
import { Driver } from '../../domain/entities/Driver';
import { League } from '../../domain/entities/League';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueProtestsUseCase', () => {
let useCase: GetLeagueProtestsUseCase;
@@ -18,6 +27,10 @@ describe('GetLeagueProtestsUseCase', () => {
let driverRepository: {
findById: Mock;
};
let leagueRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetLeagueProtestsResult> & { present: Mock };
beforeEach(() => {
raceRepository = {
@@ -29,15 +42,30 @@ describe('GetLeagueProtestsUseCase', () => {
driverRepository = {
findById: vi.fn(),
};
leagueRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueProtestsResult> & { present: Mock };
useCase = new GetLeagueProtestsUseCase(
raceRepository as unknown as IRaceRepository,
protestRepository as unknown as IProtestRepository,
driverRepository as unknown as IDriverRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('should return protests with races and drivers', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
ownerId: 'owner-1',
description: 'A test league',
});
const race = Race.create({
id: 'race-1',
leagueId,
@@ -67,6 +95,7 @@ describe('GetLeagueProtestsUseCase', () => {
country: 'UK',
});
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockResolvedValue([race]);
protestRepository.findByRaceId.mockResolvedValue([protest]);
driverRepository.findById.mockImplementation((id: string) => {
@@ -75,48 +104,93 @@ describe('GetLeagueProtestsUseCase', () => {
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId });
const input: GetLeagueProtestsInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
protests: [
{
id: 'protest-1',
raceId: 'race-1',
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
submittedAt: expect.any(Date),
description: '',
status: 'pending',
},
],
races: [
{
id: 'race-1',
name: 'Track 1',
date: expect.any(String),
},
],
drivers: [
{ id: 'driver-1', name: 'Driver 1' },
{ id: 'driver-2', name: 'Driver 2' },
],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueProtestsResult;
expect(presented.league).toEqual(league);
expect(presented.protests).toHaveLength(1);
const presentedProtest = presented.protests[0];
expect(presentedProtest.protest).toEqual(protest);
expect(presentedProtest.race).toEqual(race);
expect(presentedProtest.protestingDriver).toEqual(driver1);
expect(presentedProtest.accusedDriver).toEqual(driver2);
});
it('should return empty when no races', async () => {
it('should return empty protests when no races', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
ownerId: 'owner-1',
description: 'A test league',
});
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockResolvedValue([]);
protestRepository.findByRaceId.mockResolvedValue([]);
const result = await useCase.execute({ leagueId });
const input: GetLeagueProtestsInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
protests: [],
races: [],
drivers: [],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueProtestsResult;
expect(presented.league).toEqual(league);
expect(presented.protests).toEqual([]);
});
it('should return LEAGUE_NOT_FOUND when league does not exist', async () => {
const leagueId = 'missing-league';
leagueRepository.findById.mockResolvedValue(null);
const input: GetLeagueProtestsInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueProtestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
ownerId: 'owner-1',
description: 'A test league',
});
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
const input: GetLeagueProtestsInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueProtestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,93 +1,103 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueProtestsOutputPort, ProtestOutputPort, RaceOutputPort, DriverOutputPort } from '../ports/output/GetLeagueProtestsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Race } from '../../domain/entities/Race';
import type { Protest } from '../../domain/entities/Protest';
import type { Driver } from '../../domain/entities/Driver';
import type { League } from '../../domain/entities/League';
export interface GetLeagueProtestsUseCaseParams {
export interface GetLeagueProtestsInput {
leagueId: string;
}
export class GetLeagueProtestsUseCase implements AsyncUseCase<GetLeagueProtestsUseCaseParams, GetLeagueProtestsOutputPort, 'NO_ERROR'> {
export type GetLeagueProtestsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface LeagueProtestWithEntities {
protest: Protest;
race: Race | null;
protestingDriver: Driver | null;
accusedDriver: Driver | null;
}
export interface GetLeagueProtestsResult {
league: League;
protests: LeagueProtestWithEntities[];
}
export class GetLeagueProtestsUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueProtestsResult>,
) {}
async execute(params: GetLeagueProtestsUseCaseParams): Promise<Result<GetLeagueProtestsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const protests: ProtestOutputPort[] = [];
const racesById: Record<string, RaceOutputPort> = {};
const driversById: Record<string, DriverOutputPort> = {};
const driverIds = new Set<string>();
async execute(
input: GetLeagueProtestsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueProtestsErrorCode, { message: string }>>> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
for (const race of races) {
racesById[race.id] = {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt.toISOString(),
track: race.track,
trackId: race.trackId,
car: race.car,
carId: race.carId,
sessionType: race.sessionType.toString(),
status: race.status,
strengthOfField: race.strengthOfField,
registeredCount: race.registeredCount,
maxParticipants: race.maxParticipants,
};
const raceProtests = await this.protestRepository.findByRaceId(race.id);
for (const protest of raceProtests) {
protests.push({
id: protest.id,
raceId: protest.raceId,
protestingDriverId: protest.protestingDriverId,
accusedDriverId: protest.accusedDriverId,
incident: {
lap: protest.incident.lap,
description: protest.incident.description,
timeInRace: protest.incident.timeInRace,
},
comment: protest.comment,
proofVideoUrl: protest.proofVideoUrl,
status: protest.status.toString(),
reviewedBy: protest.reviewedBy,
decisionNotes: protest.decisionNotes,
filedAt: protest.filedAt.toISOString(),
reviewedAt: protest.reviewedAt?.toISOString(),
defense: protest.defense ? {
statement: protest.defense.statement.toString(),
videoUrl: protest.defense.videoUrl?.toString(),
submittedAt: protest.defense.submittedAt.toDate().toISOString(),
} : undefined,
defenseRequestedAt: protest.defenseRequestedAt?.toISOString(),
defenseRequestedBy: protest.defenseRequestedBy,
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
}
}
for (const driverId of driverIds) {
const driver = await this.driverRepository.findById(driverId);
if (driver) {
driversById[driver.id] = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
bio: driver.bio?.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
};
const races = await this.raceRepository.findByLeagueId(input.leagueId);
const protests: Protest[] = [];
const racesById: Record<string, Race> = {};
const driversById: Record<string, Driver> = {};
const driverIds = new Set<string>();
for (const race of races) {
racesById[race.id] = race;
const raceProtests = await this.protestRepository.findByRaceId(race.id);
for (const protest of raceProtests) {
protests.push(protest);
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
}
}
for (const driverId of driverIds) {
const driver = await this.driverRepository.findById(driverId);
if (driver) {
driversById[driver.id] = driver;
}
}
const protestsWithEntities: LeagueProtestWithEntities[] = protests.map(protest => ({
protest,
race: racesById[protest.raceId] ?? null,
protestingDriver: driversById[protest.protestingDriverId] ?? null,
accusedDriver: driversById[protest.accusedDriverId] ?? null,
}));
const result: GetLeagueProtestsResult = {
league,
protests: protestsWithEntities,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string'
? (error as any).message
: 'Failed to load league protests';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
return Result.ok({
protests,
racesById,
driversById,
});
}
}

View File

@@ -1,25 +1,56 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueScheduleUseCase } from './GetLeagueScheduleUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueScheduleUseCase,
type GetLeagueScheduleInput,
type GetLeagueScheduleResult,
type GetLeagueScheduleErrorCode,
} from './GetLeagueScheduleUseCase';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
describe('GetLeagueScheduleUseCase', () => {
let useCase: GetLeagueScheduleUseCase;
let leagueRepository: {
findById: Mock;
};
let raceRepository: {
findByLeagueId: Mock;
};
let logger: Logger;
let output: UseCaseOutputPort<GetLeagueScheduleResult> & { present: Mock };
beforeEach(() => {
leagueRepository = {
findById: vi.fn(),
};
raceRepository = {
findByLeagueId: vi.fn(),
};
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueScheduleResult> & { present: Mock };
useCase = new GetLeagueScheduleUseCase(
leagueRepository as unknown as ILeagueRepository,
raceRepository as unknown as IRaceRepository,
logger,
output,
);
});
it('should return league schedule', async () => {
it('should present league schedule when races exist', async () => {
const leagueId = 'league-1';
const league = { id: leagueId } as unknown as League;
const race = Race.create({
id: 'race-1',
leagueId,
@@ -28,32 +59,83 @@ describe('GetLeagueScheduleUseCase', () => {
car: 'Car 1',
});
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockResolvedValue([race]);
const result = await useCase.execute({ leagueId });
const input: GetLeagueScheduleInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
races: [
{
id: 'race-1',
name: 'Track 1 - Car 1',
scheduledAt: new Date('2023-01-01T10:00:00Z'),
},
],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0]![0] as GetLeagueScheduleResult;
expect(presented.league).toBe(league);
expect(presented.races).toHaveLength(1);
expect(presented.races[0].race).toBe(race);
});
it('should return empty schedule when no races', async () => {
it('should present empty schedule when no races exist', async () => {
const leagueId = 'league-1';
const league = { id: leagueId } as unknown as League;
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockResolvedValue([]);
const result = await useCase.execute({ leagueId });
const input: GetLeagueScheduleInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
races: [],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0]![0] as GetLeagueScheduleResult;
expect(presented.league).toBe(league);
expect(presented.races).toHaveLength(0);
});
it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => {
const leagueId = 'missing-league';
leagueRepository.findById.mockResolvedValue(null);
const input: GetLeagueScheduleInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScheduleErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
const leagueId = 'league-1';
const league = { id: leagueId } as League;
const repositoryError = new Error('DB down');
leagueRepository.findById.mockResolvedValue(league);
raceRepository.findByLeagueId.mockRejectedValue(repositoryError);
const input: GetLeagueScheduleInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScheduleErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB down');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,24 +1,83 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueScheduleOutputPort } from '../ports/output/GetLeagueScheduleOutputPort';
import type { League } from '../../domain/entities/League';
import type { Race } from '../../domain/entities/Race';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
export interface GetLeagueScheduleUseCaseParams {
export type GetLeagueScheduleErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'REPOSITORY_ERROR';
export interface GetLeagueScheduleInput {
leagueId: string;
}
export class GetLeagueScheduleUseCase implements AsyncUseCase<GetLeagueScheduleUseCaseParams, GetLeagueScheduleOutputPort, 'NO_ERROR'> {
constructor(private readonly raceRepository: IRaceRepository) {}
export interface LeagueScheduledRace {
race: Race;
}
async execute(params: GetLeagueScheduleUseCaseParams): Promise<Result<GetLeagueScheduleOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
return Result.ok({
races: races.map(race => ({
id: race.id,
name: `${race.track} - ${race.car}`,
scheduledAt: race.scheduledAt,
})),
});
export interface GetLeagueScheduleResult {
league: League;
races: LeagueScheduledRace[];
}
export class GetLeagueScheduleUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetLeagueScheduleResult>,
) {}
async execute(
input: GetLeagueScheduleInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueScheduleErrorCode, { message: string }>>
> {
this.logger.debug('Fetching league schedule', { input });
const { leagueId } = input;
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
this.logger.warn('League not found when fetching schedule', { leagueId });
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const races = await this.raceRepository.findByLeagueId(leagueId);
const scheduledRaces: LeagueScheduledRace[] = races.map(race => ({
race,
}));
const result: GetLeagueScheduleResult = {
league,
races: scheduledRaces,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
this.logger.error(
'Failed to load league schedule due to an unexpected error',
error instanceof Error ? error : new Error('Unknown error'),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to load league schedule',
},
});
}
}
}

View File

@@ -1,9 +1,16 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueScoringConfigUseCase } from './GetLeagueScoringConfigUseCase';
import {
GetLeagueScoringConfigUseCase,
type GetLeagueScoringConfigResult,
type GetLeagueScoringConfigInput,
type GetLeagueScoringConfigErrorCode,
} from './GetLeagueScoringConfigUseCase';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueScoringConfigUseCase', () => {
let useCase: GetLeagueScoringConfigUseCase;
@@ -11,26 +18,31 @@ describe('GetLeagueScoringConfigUseCase', () => {
let seasonRepository: { findByLeagueId: Mock };
let leagueScoringConfigRepository: { findBySeasonId: Mock };
let gameRepository: { findById: Mock };
let getLeagueScoringPresetById: Mock;
let presetProvider: { getPresetById: Mock };
let output: UseCaseOutputPort<GetLeagueScoringConfigResult> & { present: Mock };
beforeEach(() => {
leagueRepository = { findById: vi.fn() };
seasonRepository = { findByLeagueId: vi.fn() };
leagueScoringConfigRepository = { findBySeasonId: vi.fn() };
gameRepository = { findById: vi.fn() };
getLeagueScoringPresetById = vi.fn();
presetProvider = { getPresetById: vi.fn() };
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetLeagueScoringConfigResult> & {
present: Mock;
};
useCase = new GetLeagueScoringConfigUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository,
gameRepository as unknown as IGameRepository,
getLeagueScoringPresetById,
presetProvider,
output,
);
});
it('should return scoring config for active season', async () => {
const leagueId = 'league-1';
const league = { id: leagueId };
const input: GetLeagueScoringConfigInput = { leagueId: 'league-1' };
const league = { id: input.leagueId };
const season = { id: 'season-1', status: 'active', gameId: 'game-1' };
const scoringConfig = { scoringPresetId: 'preset-1', championships: [] };
const game = { id: 'game-1', name: 'Game 1' };
@@ -40,25 +52,25 @@ describe('GetLeagueScoringConfigUseCase', () => {
seasonRepository.findByLeagueId.mockResolvedValue([season]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig);
gameRepository.findById.mockResolvedValue(game);
getLeagueScoringPresetById.mockResolvedValue(preset);
presetProvider.getPresetById.mockReturnValue(preset);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
leagueId,
seasonId: 'season-1',
gameId: 'game-1',
gameName: 'Game 1',
scoringPresetId: 'preset-1',
preset,
championships: [],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0][0] as GetLeagueScoringConfigResult;
expect(presented.league).toEqual(league);
expect(presented.season).toEqual(season);
expect(presented.scoringConfig).toEqual(scoringConfig);
expect(presented.game).toEqual(game);
expect(presented.preset).toEqual(preset);
});
it('should return scoring config for first season if no active', async () => {
const leagueId = 'league-1';
const league = { id: leagueId };
const input: GetLeagueScoringConfigInput = { leagueId: 'league-1' };
const league = { id: input.leagueId };
const season = { id: 'season-1', status: 'inactive', gameId: 'game-1' };
const scoringConfig = { scoringPresetId: undefined, championships: [] };
const game = { id: 'game-1', name: 'Game 1' };
@@ -68,16 +80,18 @@ describe('GetLeagueScoringConfigUseCase', () => {
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig);
gameRepository.findById.mockResolvedValue(game);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
leagueId,
seasonId: 'season-1',
gameId: 'game-1',
gameName: 'Game 1',
championships: [],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
output.present.mock.calls[0][0] as GetLeagueScoringConfigResult;
expect(presented.league).toEqual(league);
expect(presented.season).toEqual(season);
expect(presented.scoringConfig).toEqual(scoringConfig);
expect(presented.game).toEqual(game);
expect(presented.preset).toBeUndefined();
});
it('should return error if league not found', async () => {
@@ -86,7 +100,13 @@ describe('GetLeagueScoringConfigUseCase', () => {
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'LEAGUE_NOT_FOUND' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error if no seasons', async () => {
@@ -96,7 +116,13 @@ describe('GetLeagueScoringConfigUseCase', () => {
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'NO_SEASONS' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('NO_SEASONS');
expect(err.details.message).toBe('No seasons found for league');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error if no seasons (null)', async () => {
@@ -106,29 +132,69 @@ describe('GetLeagueScoringConfigUseCase', () => {
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'NO_SEASONS' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('NO_SEASONS');
expect(err.details.message).toBe('No seasons found for league');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error if no scoring config', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
seasonRepository.findByLeagueId.mockResolvedValue([{ id: 'season-1', status: 'active', gameId: 'game-1' }]);
seasonRepository.findByLeagueId.mockResolvedValue([
{ id: 'season-1', status: 'active', gameId: 'game-1' },
]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(null);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'NO_SCORING_CONFIG' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('NO_SCORING_CONFIG');
expect(err.details.message).toBe('Scoring configuration not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error if game not found', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
seasonRepository.findByLeagueId.mockResolvedValue([{ id: 'season-1', status: 'active', gameId: 'game-1' }]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue({ scoringPresetId: undefined, championships: [] });
seasonRepository.findByLeagueId.mockResolvedValue([
{ id: 'season-1', status: 'active', gameId: 'game-1' },
]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue({
scoringPresetId: undefined,
championships: [],
});
gameRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'GAME_NOT_FOUND' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('GAME_NOT_FOUND');
expect(err.details.message).toBe('Game not found for season');
expect(output.present).not.toHaveBeenCalled();
});
it('should wrap repository errors', async () => {
leagueRepository.findById.mockRejectedValue(new Error('db down'));
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueScoringConfigErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('db down');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -2,78 +2,129 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { GetLeagueScoringPresetByIdInputPort } from '../ports/input/GetLeagueScoringPresetByIdInputPort';
import type { LeagueScoringPresetOutputPort } from '../ports/output/LeagueScoringPresetOutputPort';
import type { LeagueScoringConfigOutputPort } from '../ports/output/LeagueScoringConfigOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { LeagueScoringPreset } from '../../../bootstrap/LeagueScoringPresets';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/season/Season';
import type { LeagueScoringConfig } from '../../domain/entities/LeagueScoringConfig';
import type { Game } from '../../domain/entities/Game';
type GetLeagueScoringConfigErrorCode =
export type GetLeagueScoringConfigInput = {
leagueId: string;
};
export type GetLeagueScoringConfigResult = {
league: League;
season: Season;
scoringConfig: LeagueScoringConfig;
game: Game;
preset?: LeagueScoringPreset;
};
export type GetLeagueScoringConfigErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'NO_SEASONS'
| 'NO_ACTIVE_SEASON'
| 'NO_SCORING_CONFIG'
| 'GAME_NOT_FOUND';
| 'GAME_NOT_FOUND'
| 'REPOSITORY_ERROR';
/**
* Use Case for retrieving a league's scoring configuration for its active season.
*/
export class GetLeagueScoringConfigUseCase
implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigOutputPort, GetLeagueScoringConfigErrorCode>
{
export class GetLeagueScoringConfigUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueScoringConfigRepository: ILeagueScoringConfigRepository,
private readonly gameRepository: IGameRepository,
private readonly getLeagueScoringPresetById: (input: GetLeagueScoringPresetByIdInputPort) => Promise<LeagueScoringPresetOutputPort | undefined>,
private readonly presetProvider: {
getPresetById(presetId: string): LeagueScoringPreset | undefined;
},
private readonly output: UseCaseOutputPort<GetLeagueScoringConfigResult>,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigOutputPort, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> {
async execute(
params: GetLeagueScoringConfigInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueScoringConfigErrorCode, { message: string }>>
> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err({ code: 'LEAGUE_NOT_FOUND' });
try {
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return Result.err({
code: 'NO_SEASONS',
details: { message: 'No seasons found for league' },
});
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
return Result.err({
code: 'NO_ACTIVE_SEASON',
details: { message: 'No active season found for league' },
});
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(
activeSeason.id,
);
if (!scoringConfig) {
return Result.err({
code: 'NO_SCORING_CONFIG',
details: { message: 'Scoring configuration not found' },
});
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return Result.err({
code: 'GAME_NOT_FOUND',
details: { message: 'Game not found for season' },
});
}
const presetId = scoringConfig.scoringPresetId;
const preset = presetId
? this.presetProvider.getPresetById(presetId)
: undefined;
const result: GetLeagueScoringConfigResult = {
league,
season: activeSeason,
scoringConfig,
game,
...(preset !== undefined ? { preset } : {}),
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to load league scoring config',
},
});
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
return Result.err({ code: 'NO_SEASONS' });
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
return Result.err({ code: 'NO_ACTIVE_SEASON' });
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
return Result.err({ code: 'NO_SCORING_CONFIG' });
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
return Result.err({ code: 'GAME_NOT_FOUND' });
}
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? await this.getLeagueScoringPresetById({ presetId }) : undefined;
const output: LeagueScoringConfigOutputPort = {
leagueId: league.id,
seasonId: activeSeason.id,
gameId: game.id,
gameName: game.name,
...(presetId !== undefined ? { scoringPresetId: presetId } : {}),
...(preset !== undefined ? { preset } : {}),
championships: scoringConfig.championships,
};
return Result.ok(output);
}
}

View File

@@ -1,25 +1,60 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueSeasonsUseCase } from './GetLeagueSeasonsUseCase';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueSeasonsUseCase,
type GetLeagueSeasonsInput,
type GetLeagueSeasonsResult,
type GetLeagueSeasonsErrorCode,
} from './GetLeagueSeasonsUseCase';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
import { League } from '../../domain/entities/League';
describe('GetLeagueSeasonsUseCase', () => {
let useCase: GetLeagueSeasonsUseCase;
let seasonRepository: {
findByLeagueId: Mock;
};
let leagueRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetLeagueSeasonsResult> & {
present: Mock;
};
beforeEach(() => {
seasonRepository = {
findByLeagueId: vi.fn(),
} as unknown as ISeasonRepository as any;
leagueRepository = {
findById: vi.fn(),
} as unknown as ILeagueRepository as any;
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueSeasonsResult> & {
present: Mock;
};
useCase = new GetLeagueSeasonsUseCase(
seasonRepository as unknown as ISeasonRepository,
leagueRepository as unknown as ILeagueRepository,
output,
);
});
it('should return seasons mapped to view model', async () => {
it('should present seasons with correct isParallelActive flags on success', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'A test league',
ownerId: 'owner-1',
});
const seasons = [
Season.create({
id: 'season-1',
@@ -39,37 +74,38 @@ describe('GetLeagueSeasonsUseCase', () => {
}),
];
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue(seasons);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
seasons: [
{
seasonId: 'season-1',
name: 'Season 1',
status: 'active',
startDate: new Date('2023-01-01'),
endDate: new Date('2023-12-31'),
isPrimary: false,
isParallelActive: false, // only one active
},
{
seasonId: 'season-2',
name: 'Season 2',
status: 'planned',
startDate: undefined,
endDate: undefined,
isPrimary: false,
isParallelActive: false,
},
],
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueSeasonsResult;
expect(presented.league).toBe(league);
expect(presented.seasons).toHaveLength(2);
expect(presented.seasons[0]!.season).toBe(seasons[0]);
expect(presented.seasons[0]!.isPrimary).toBe(false);
expect(presented.seasons[0]!.isParallelActive).toBe(false);
expect(presented.seasons[1]!.season).toBe(seasons[1]);
expect(presented.seasons[1]!.isPrimary).toBe(false);
expect(presented.seasons[1]!.isParallelActive).toBe(false);
});
it('should set isParallelActive true for active seasons when multiple active', async () => {
const leagueId = 'league-1';
const league = League.create({
id: leagueId,
name: 'Test League',
description: 'A test league',
ownerId: 'owner-1',
});
const seasons = [
Season.create({
id: 'season-1',
@@ -87,27 +123,56 @@ describe('GetLeagueSeasonsUseCase', () => {
}),
];
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue(seasons);
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.seasons).toHaveLength(2);
expect(viewModel.seasons[0]!.isParallelActive).toBe(true);
expect(viewModel.seasons[1]!.isParallelActive).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetLeagueSeasonsResult;
expect(presented.seasons).toHaveLength(2);
expect(presented.seasons[0]!.isParallelActive).toBe(true);
expect(presented.seasons[1]!.isParallelActive).toBe(true);
});
it('should return error when repository fails', async () => {
const leagueId = 'league-1';
seasonRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
it('should return LEAGUE_NOT_FOUND error when league does not exist', async () => {
const leagueId = 'missing-league';
const result = await useCase.execute({ leagueId });
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch seasons',
});
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueSeasonsErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
const leagueId = 'league-1';
const errorMessage = 'DB error';
leagueRepository.findById.mockRejectedValue(new Error(errorMessage));
const result = await useCase.execute({ leagueId } satisfies GetLeagueSeasonsInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueSeasonsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe(errorMessage);
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,33 +1,77 @@
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { GetLeagueSeasonsOutputPort } from '../ports/output/GetLeagueSeasonsOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { League } from '../../domain/entities/League';
import type { Season } from '../../domain/entities/Season';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
export interface GetLeagueSeasonsUseCaseParams {
export type GetLeagueSeasonsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export interface GetLeagueSeasonsInput {
leagueId: string;
}
export class GetLeagueSeasonsUseCase {
constructor(private readonly seasonRepository: ISeasonRepository) {}
export interface LeagueSeasonSummary {
season: Season;
isPrimary: boolean;
isParallelActive: boolean;
}
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
export interface GetLeagueSeasonsResult {
league: League;
seasons: LeagueSeasonSummary[];
}
export class GetLeagueSeasonsUseCase {
constructor(
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly output: UseCaseOutputPort<GetLeagueSeasonsResult>,
) {}
async execute(
input: GetLeagueSeasonsInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueSeasonsErrorCode, { message: string }>>
> {
try {
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const activeCount = seasons.filter(s => s.status === 'active').length;
const output: GetLeagueSeasonsOutputPort = {
seasons: seasons.map(s => ({
seasonId: s.id,
name: s.name,
status: s.status,
startDate: s.startDate ?? new Date(),
endDate: s.endDate ?? new Date(),
const { leagueId } = input;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
const activeCount = seasons.filter(season => season.status === 'active').length;
const result: GetLeagueSeasonsResult = {
league,
seasons: seasons.map(season => ({
season,
isPrimary: false,
isParallelActive: s.status === 'active' && activeCount > 1
}))
isParallelActive:
season.status === 'active' && activeCount > 1,
})),
};
return Result.ok(output);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: 'Failed to fetch seasons' } });
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to load league seasons',
},
});
}
}
}

View File

@@ -1,7 +1,14 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueStandingsUseCase } from './GetLeagueStandingsUseCase';
import { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import {
GetLeagueStandingsUseCase,
type GetLeagueStandingsInput,
type GetLeagueStandingsResult,
type GetLeagueStandingsErrorCode,
} from './GetLeagueStandingsUseCase';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Standing } from '../../domain/entities/Standing';
import { Driver } from '../../domain/entities/Driver';
@@ -13,6 +20,7 @@ describe('GetLeagueStandingsUseCase', () => {
let driverRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetLeagueStandingsResult> & { present: Mock };
beforeEach(() => {
standingRepository = {
@@ -21,13 +29,18 @@ describe('GetLeagueStandingsUseCase', () => {
driverRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetLeagueStandingsUseCase(
standingRepository as unknown as IStandingRepository,
driverRepository as unknown as IDriverRepository,
output,
);
});
it('should return standings with drivers mapped', async () => {
it('should present standings with drivers mapped and return ok result', async () => {
const leagueId = 'league-1';
const standings = [
Standing.create({
@@ -65,37 +78,43 @@ describe('GetLeagueStandingsUseCase', () => {
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } satisfies GetLeagueStandingsInput);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
standings: [
{
driverId: 'driver-1',
driver: { id: 'driver-1', name: 'Driver One' },
points: 100,
rank: 1,
},
{
driverId: 'driver-2',
driver: { id: 'driver-2', name: 'Driver Two' },
points: 80,
rank: 2,
},
],
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as GetLeagueStandingsResult;
expect(presented.standings).toHaveLength(2);
expect(presented.standings[0]).toEqual({
driverId: 'driver-1',
driver: driver1,
points: 100,
rank: 1,
});
expect(presented.standings[1]).toEqual({
driverId: 'driver-2',
driver: driver2,
points: 80,
rank: 2,
});
});
it('should return error when repository fails', async () => {
it('should return repository error and not call output when repository fails', async () => {
const leagueId = 'league-1';
standingRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
const result = await useCase.execute({ leagueId } satisfies GetLeagueStandingsInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league standings',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetLeagueStandingsErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,12 +1,26 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { LeagueStandingsOutputPort } from '../ports/output/LeagueStandingsOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Driver } from '../../domain/entities/Driver';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
export interface GetLeagueStandingsUseCaseParams {
export type GetLeagueStandingsErrorCode = 'REPOSITORY_ERROR';
export type GetLeagueStandingsInput = {
leagueId: string;
}
};
export type LeagueStandingItem = {
driverId: string;
driver: Driver;
points: number;
rank: number;
};
export type GetLeagueStandingsResult = {
standings: LeagueStandingItem[];
};
/**
* Use Case for retrieving league standings.
@@ -15,29 +29,44 @@ export class GetLeagueStandingsUseCase {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly driverRepository: IDriverRepository,
private readonly output: UseCaseOutputPort<GetLeagueStandingsResult>,
) {}
async execute(
params: GetLeagueStandingsUseCaseParams,
): Promise<Result<LeagueStandingsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
input: GetLeagueStandingsInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueStandingsErrorCode, { message: string }>>
> {
try {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
const driverIds = [...new Set(standings.map(s => s.driverId))];
const standings = await this.standingRepository.findByLeagueId(input.leagueId);
const driverIds = [...new Set(standings.map(s => s.driverId.toString()))];
const driverPromises = driverIds.map(id => this.driverRepository.findById(id));
const driverResults = await Promise.all(driverPromises);
const drivers = driverResults.filter((d): d is NonNullable<typeof d> => d !== null);
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }]));
const viewModel: LeagueStandingsOutputPort = {
standings: standings.map(s => ({
driverId: s.driverId,
driver: driverMap.get(s.driverId)!,
points: s.points,
rank: s.position,
const drivers = driverResults.filter(
(driver): driver is NonNullable<(typeof driverResults)[number]> => driver !== null,
);
const driverMap = new Map(drivers.map(driver => [driver.id, driver]));
const result: GetLeagueStandingsResult = {
standings: standings.map(standing => ({
driverId: standing.driverId.toString(),
driver: driverMap.get(standing.driverId.toString())!,
points: standing.points.toNumber(),
rank: standing.position.toNumber(),
})),
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league standings' });
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error ? error.message : 'Failed to fetch league standings',
},
});
}
}
}

View File

@@ -1,7 +1,14 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueStatsUseCase,
type GetLeagueStatsInput,
type GetLeagueStatsResult,
type GetLeagueStatsErrorCode,
} from './GetLeagueStatsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueStatsUseCase', () => {
let useCase: GetLeagueStatsUseCase;
@@ -12,6 +19,7 @@ describe('GetLeagueStatsUseCase', () => {
findByLeagueId: Mock;
};
let getDriverRating: Mock;
let output: UseCaseOutputPort<GetLeagueStatsResult> & { present: Mock };
beforeEach(() => {
leagueMembershipRepository = {
@@ -21,15 +29,20 @@ describe('GetLeagueStatsUseCase', () => {
findByLeagueId: vi.fn(),
};
getDriverRating = vi.fn();
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueStatsResult> & { present: Mock };
useCase = new GetLeagueStatsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
raceRepository as unknown as IRaceRepository,
getDriverRating,
output,
);
});
it('should return league stats with average rating', async () => {
const leagueId = 'league-1';
const input: GetLeagueStatsInput = { leagueId: 'league-1' };
const memberships = [
{ driverId: 'driver-1' },
{ driverId: 'driver-2' },
@@ -39,25 +52,29 @@ describe('GetLeagueStatsUseCase', () => {
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races);
getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1500, ratingChange: null });
if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
if (input.driverId === 'driver-3') return Promise.resolve({ rating: null, ratingChange: null });
getDriverRating.mockImplementation((driverInput: { driverId: string }) => {
if (driverInput.driverId === 'driver-1') return Promise.resolve({ rating: 1500, ratingChange: null });
if (driverInput.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
if (driverInput.driverId === 'driver-3') return Promise.resolve({ rating: null, ratingChange: null });
return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
totalMembers: 3,
totalRaces: 2,
averageRating: 1550, // (1500 + 1600) / 2
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0]?.[0] as GetLeagueStatsResult;
expect(presented.leagueId).toBe(input.leagueId);
expect(presented.driverCount).toBe(3);
expect(presented.raceCount).toBe(2);
expect(presented.averageRating).toBe(1550); // (1500 + 1600) / 2
});
it('should return 0 average rating when no valid ratings', async () => {
const leagueId = 'league-1';
const input: GetLeagueStatsInput = { leagueId: 'league-1' };
const memberships = [{ driverId: 'driver-1' }];
const races = [{ id: 'race-1' }];
@@ -65,26 +82,50 @@ describe('GetLeagueStatsUseCase', () => {
raceRepository.findByLeagueId.mockResolvedValue(races);
getDriverRating.mockResolvedValue({ rating: null, ratingChange: null });
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
totalMembers: 1,
totalRaces: 1,
averageRating: 0,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0]?.[0] as GetLeagueStatsResult;
expect(presented.leagueId).toBe(input.leagueId);
expect(presented.driverCount).toBe(1);
expect(presented.raceCount).toBe(1);
expect(presented.averageRating).toBe(0);
});
it('should return error when league has no members', async () => {
const input: GetLeagueStatsInput = { leagueId: 'league-1' };
leagueMembershipRepository.getLeagueMembers.mockResolvedValue([]);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetLeagueStatsErrorCode,
{ message: string }
>;
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error when repository fails', async () => {
const leagueId = 'league-1';
const input: GetLeagueStatsInput = { leagueId: 'league-1' };
leagueMembershipRepository.getLeagueMembers.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league stats',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetLeagueStatsErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,48 +1,78 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { LeagueStatsOutputPort } from '../ports/output/LeagueStatsOutputPort';
import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface GetLeagueStatsUseCaseParams {
export interface GetLeagueStatsInput {
leagueId: string;
}
export interface GetLeagueStatsResult {
leagueId: string;
driverCount: number;
raceCount: number;
averageRating: number;
}
export type GetLeagueStatsErrorCode = 'LEAGUE_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetLeagueStatsUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
private readonly getDriverRating: (input: {
driverId: string;
}) => Promise<{ rating: number | null; ratingChange: number | null }>,
private readonly output: UseCaseOutputPort<GetLeagueStatsResult>,
) {}
async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
async execute(
input: GetLeagueStatsInput,
): Promise<Result<void, ApplicationErrorCode<GetLeagueStatsErrorCode, { message: string }>>> {
try {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const driverIds = memberships.map(m => m.driverId);
// Get ratings for all drivers using clean ports
const ratingPromises = driverIds.map(driverId =>
this.getDriverRating({ driverId })
);
const memberships = await this.leagueMembershipRepository.getLeagueMembers(input.leagueId);
if (memberships.length === 0) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
}
const races = await this.raceRepository.findByLeagueId(input.leagueId);
const driverIds = memberships.map(membership => String(membership.driverId));
const ratingPromises = driverIds.map(driverId => this.getDriverRating({ driverId }));
const ratingResults = await Promise.all(ratingPromises);
const validRatings = ratingResults
.map(result => result.rating)
.filter((rating): rating is number => rating !== null);
const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0;
const viewModel: LeagueStatsOutputPort = {
totalMembers: memberships.length,
totalRaces: races.length,
const averageRating =
validRatings.length > 0
? Math.round(validRatings.reduce((sum, rating) => sum + rating, 0) / validRatings.length)
: 0;
const result: GetLeagueStatsResult = {
leagueId: input.leagueId,
driverCount: memberships.length,
raceCount: races.length,
averageRating,
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league stats' });
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to fetch league stats';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}
}

View File

@@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueWalletUseCase } from './GetLeagueWalletUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetLeagueWalletUseCase,
type GetLeagueWalletResult,
type GetLeagueWalletInput,
type GetLeagueWalletErrorCode,
} from './GetLeagueWalletUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
import { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet';
@@ -7,17 +13,27 @@ import { Money } from '../../domain/value-objects/Money';
import { Transaction } from '../../domain/entities/league-wallet/Transaction';
import { TransactionId } from '../../domain/entities/league-wallet/TransactionId';
import { LeagueWalletId } from '../../domain/entities/league-wallet/LeagueWalletId';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetLeagueWalletUseCase', () => {
let leagueRepository: {
exists: Mock;
};
let leagueWalletRepository: {
findByLeagueId: Mock;
};
let transactionRepository: {
findByWalletId: Mock;
};
let output: UseCaseOutputPort<GetLeagueWalletResult> & { present: Mock };
let useCase: GetLeagueWalletUseCase;
beforeEach(() => {
leagueRepository = {
exists: vi.fn(),
};
leagueWalletRepository = {
findByLeagueId: vi.fn(),
};
@@ -26,9 +42,15 @@ describe('GetLeagueWalletUseCase', () => {
findByWalletId: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetLeagueWalletResult> & { present: Mock };
useCase = new GetLeagueWalletUseCase(
leagueRepository as unknown as ILeagueRepository,
leagueWalletRepository as unknown as ILeagueWalletRepository,
transactionRepository as unknown as ITransactionRepository,
output,
);
});
@@ -42,6 +64,7 @@ describe('GetLeagueWalletUseCase', () => {
balance,
});
leagueRepository.exists.mockResolvedValue(true);
leagueWalletRepository.findByLeagueId.mockResolvedValue(wallet);
const sponsorshipTx = Transaction.create({
@@ -99,65 +122,103 @@ describe('GetLeagueWalletUseCase', () => {
transactionRepository.findByWalletId.mockResolvedValue(transactions);
const result = await useCase.execute({ leagueId });
const input: GetLeagueWalletInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
expect(viewModel.balance).toBe(balance.amount);
expect(viewModel.currency).toBe(balance.currency);
const presented = (output.present as Mock).mock.calls[0]![0] as GetLeagueWalletResult;
expect(presented.wallet).toBe(wallet);
expect(presented.transactions).toHaveLength(transactions.length);
expect(presented.transactions[0]!.id).toEqual(
transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!
.id,
);
const { aggregates } = presented;
const expectedTotalRevenue =
sponsorshipTx.amount.amount +
membershipTx.amount.amount +
pendingPrizeTx.amount.amount;
sponsorshipTx.amount.add(membershipTx.amount).add(pendingPrizeTx.amount);
const expectedTotalFees =
sponsorshipTx.platformFee.amount +
membershipTx.platformFee.amount +
pendingPrizeTx.platformFee.amount;
sponsorshipTx.platformFee
.add(membershipTx.platformFee)
.add(pendingPrizeTx.platformFee);
const expectedTotalWithdrawals = withdrawalTx.netAmount.amount;
const expectedPendingPayouts = pendingPrizeTx.netAmount.amount;
const expectedTotalWithdrawals = withdrawalTx.netAmount;
const expectedPendingPayouts = pendingPrizeTx.netAmount;
expect(viewModel.totalRevenue).toBe(expectedTotalRevenue);
expect(viewModel.totalFees).toBe(expectedTotalFees);
expect(viewModel.totalWithdrawals).toBe(expectedTotalWithdrawals);
expect(viewModel.pendingPayouts).toBe(expectedPendingPayouts);
expect(viewModel.transactions).toHaveLength(transactions.length);
expect(viewModel.transactions[0]!.id).toBe(transactions.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!.id.toString());
expect(viewModel.transactions.find(t => t.type === 'sponsorship')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'membership')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'withdrawal')).toBeTruthy();
expect(viewModel.transactions.find(t => t.type === 'prize')).toBeTruthy();
expect(aggregates.balance).toBe(balance);
expect(aggregates.totalRevenue.amount).toBe(expectedTotalRevenue.amount);
expect(aggregates.totalFees.amount).toBe(expectedTotalFees.amount);
expect(aggregates.totalWithdrawals.amount).toBe(
expectedTotalWithdrawals.amount,
);
expect(aggregates.pendingPayouts.amount).toBe(expectedPendingPayouts.amount);
});
it('returns error result when wallet is missing', async () => {
const leagueId = 'league-missing';
leagueRepository.exists.mockResolvedValue(true);
leagueWalletRepository.findByLeagueId.mockResolvedValue(null);
const result = await useCase.execute({ leagueId });
const input: GetLeagueWalletInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Wallet not found',
});
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueWalletErrorCode,
{ message: string }
>;
expect(err.code).toBe('WALLET_NOT_FOUND');
expect(err.details.message).toBe('League wallet not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns league not found when league does not exist', async () => {
const leagueId = 'league-missing';
leagueRepository.exists.mockResolvedValue(false);
const input: GetLeagueWalletInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueWalletErrorCode,
{ message: string }
>;
expect(err.code).toBe('LEAGUE_NOT_FOUND');
expect(err.details.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns repository error when repository throws', async () => {
const leagueId = 'league-1';
leagueWalletRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
leagueRepository.exists.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
const input: GetLeagueWalletInput = { leagueId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league wallet',
});
const err = result.unwrapErr() as ApplicationErrorCode<
GetLeagueWalletErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,105 +1,136 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import type { ITransactionRepository } from '../../domain/repositories/ITransactionRepository';
import type { GetLeagueWalletOutputPort, WalletTransactionOutputPort } from '../ports/output/GetLeagueWalletOutputPort';
import type { TransactionType } from '../../domain/entities/league-wallet/Transaction';
import type { Transaction } from '../../domain/entities/league-wallet/Transaction';
import type { LeagueWallet } from '../../domain/entities/league-wallet/LeagueWallet';
import type { UseCaseOutputPort } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetLeagueWalletUseCaseParams {
import { Money } from '../../domain/value-objects/Money';
export type GetLeagueWalletErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'WALLET_NOT_FOUND'
| 'REPOSITORY_ERROR';
export interface GetLeagueWalletInput {
leagueId: string;
}
export interface GetLeagueWalletAggregates {
balance: Money;
totalRevenue: Money;
totalFees: Money;
totalWithdrawals: Money;
pendingPayouts: Money;
}
export interface GetLeagueWalletResult {
wallet: LeagueWallet;
transactions: Transaction[];
aggregates: GetLeagueWalletAggregates;
}
/**
* Use Case for retrieving league wallet information.
*/
export class GetLeagueWalletUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueWalletRepository: ILeagueWalletRepository,
private readonly transactionRepository: ITransactionRepository,
private readonly output: UseCaseOutputPort<GetLeagueWalletResult>,
) {}
async execute(
params: GetLeagueWalletUseCaseParams,
): Promise<Result<GetLeagueWalletOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
input: GetLeagueWalletInput,
): Promise<
Result<void, ApplicationErrorCode<GetLeagueWalletErrorCode, { message: string }>>
> {
try {
const wallet = await this.leagueWalletRepository.findByLeagueId(params.leagueId);
if (!wallet) {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Wallet not found' });
}
const transactions = await this.transactionRepository.findByWalletId(wallet.id.toString());
let totalRevenue = 0;
let totalFees = 0;
let totalWithdrawals = 0;
let pendingPayouts = 0;
const transactionViewModels: WalletTransactionOutputPort[] = transactions
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.map(transaction => {
const amount = transaction.amount.amount;
const fee = transaction.platformFee.amount;
const netAmount = transaction.netAmount.amount;
if (
transaction.type === 'sponsorship_payment' ||
transaction.type === 'membership_payment' ||
transaction.type === 'prize_payout'
) {
totalRevenue += amount;
totalFees += fee;
}
if (transaction.type === 'withdrawal' && transaction.status === 'completed') {
totalWithdrawals += netAmount;
}
if (transaction.type === 'prize_payout' && transaction.status === 'pending') {
pendingPayouts += netAmount;
}
return {
id: transaction.id.toString(),
type: this.mapTransactionType(transaction.type),
description: transaction.description ?? '',
amount,
fee,
netAmount,
date: transaction.createdAt.toISOString(),
status: transaction.status === 'cancelled' ? 'failed' : transaction.status,
};
const leagueExists = await this.leagueRepository.exists(input.leagueId);
if (!leagueExists) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found' },
});
const output: GetLeagueWalletOutputPort = {
balance: wallet.balance.amount,
currency: wallet.balance.currency,
}
const wallet = await this.leagueWalletRepository.findByLeagueId(input.leagueId);
if (!wallet) {
return Result.err({
code: 'WALLET_NOT_FOUND',
details: { message: 'League wallet not found' },
});
}
const transactions = await this.transactionRepository.findByWalletId(
wallet.id.toString(),
);
const { aggregates } = this.computeAggregates(wallet.balance, transactions);
const result: GetLeagueWalletResult = {
wallet,
transactions: transactions
.slice()
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()),
aggregates,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to fetch league wallet',
},
});
}
}
private computeAggregates(
balance: Money,
transactions: Transaction[],
): { aggregates: GetLeagueWalletAggregates } {
let totalRevenue = Money.create(0, balance.currency);
let totalFees = Money.create(0, balance.currency);
let totalWithdrawals = Money.create(0, balance.currency);
let pendingPayouts = Money.create(0, balance.currency);
for (const transaction of transactions) {
if (
transaction.type === 'sponsorship_payment' ||
transaction.type === 'membership_payment' ||
transaction.type === 'prize_payout'
) {
totalRevenue = totalRevenue.add(transaction.amount);
totalFees = totalFees.add(transaction.platformFee);
}
if (transaction.type === 'withdrawal' && transaction.status === 'completed') {
totalWithdrawals = totalWithdrawals.add(transaction.netAmount);
}
if (transaction.type === 'prize_payout' && transaction.status === 'pending') {
pendingPayouts = pendingPayouts.add(transaction.netAmount);
}
}
return {
aggregates: {
balance,
totalRevenue,
totalFees,
totalWithdrawals,
pendingPayouts,
canWithdraw: true,
transactions: transactionViewModels,
};
return Result.ok(output);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league wallet' });
}
}
private mapTransactionType(type: TransactionType): WalletTransactionOutputPort['type'] {
switch (type) {
case 'sponsorship_payment':
return 'sponsorship';
case 'membership_payment':
return 'membership';
case 'prize_payout':
return 'prize';
case 'withdrawal':
return 'withdrawal';
case 'refund':
return 'sponsorship';
}
},
};
}
}

View File

@@ -1,10 +1,17 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetPendingSponsorshipRequestsUseCase } from './GetPendingSponsorshipRequestsUseCase';
import {
GetPendingSponsorshipRequestsUseCase,
type GetPendingSponsorshipRequestsResult,
type GetPendingSponsorshipRequestsInput,
type GetPendingSponsorshipRequestsErrorCode,
} from './GetPendingSponsorshipRequestsUseCase';
import { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import { Sponsor } from '../../domain/entities/Sponsor';
import { Money } from '../../domain/value-objects/Money';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetPendingSponsorshipRequestsUseCase', () => {
let useCase: GetPendingSponsorshipRequestsUseCase;
@@ -14,6 +21,9 @@ describe('GetPendingSponsorshipRequestsUseCase', () => {
let sponsorRepo: {
findById: Mock;
};
let output: UseCaseOutputPort<GetPendingSponsorshipRequestsResult> & {
present: Mock;
};
beforeEach(() => {
sponsorshipRequestRepo = {
@@ -22,14 +32,21 @@ describe('GetPendingSponsorshipRequestsUseCase', () => {
sponsorRepo = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as typeof output;
useCase = new GetPendingSponsorshipRequestsUseCase(
sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
sponsorRepo as unknown as ISponsorRepository,
output,
);
});
it('should return pending sponsorship requests', async () => {
const dto = { entityType: 'season' as const, entityId: 'entity-1' };
it('should present pending sponsorship requests', async () => {
const input: GetPendingSponsorshipRequestsInput = {
entityType: 'season',
entityId: 'entity-1',
};
const request = SponsorshipRequest.create({
id: 'req-1',
sponsorId: 'sponsor-1',
@@ -49,42 +66,41 @@ describe('GetPendingSponsorshipRequestsUseCase', () => {
sponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([request]);
sponsorRepo.findById.mockResolvedValue(sponsor);
const result = await useCase.execute(dto);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
entityType: 'season',
entityId: 'entity-1',
requests: [
{
id: 'req-1',
sponsorId: 'sponsor-1',
sponsorName: 'Test Sponsor',
sponsorLogo: 'logo.png',
tier: 'main',
offeredAmount: 10000,
currency: 'USD',
formattedAmount: '$100.00',
message: 'Test message',
createdAt: expect.any(Date),
platformFee: 1000,
netAmount: 9000,
},
],
totalCount: 1,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0][0] as GetPendingSponsorshipRequestsResult;
expect(presented.entityType).toBe('season');
expect(presented.entityId).toBe('entity-1');
expect(presented.totalCount).toBe(1);
expect(presented.requests).toHaveLength(1);
const summary = presented.requests[0];
expect(summary.sponsor?.name).toBe('Test Sponsor');
expect(summary.financials.offeredAmount.amount).toBe(10000);
expect(summary.financials.offeredAmount.currency).toBe('USD');
});
it('should return error when repository fails', async () => {
const dto = { entityType: 'season' as const, entityId: 'entity-1' };
sponsorshipRequestRepo.findPendingByEntity.mockRejectedValue(new Error('DB error'));
const input: GetPendingSponsorshipRequestsInput = {
entityType: 'season',
entityId: 'entity-1',
};
const error = new Error('DB error');
sponsorshipRequestRepo.findPendingByEntity.mockRejectedValue(error);
const result = await useCase.execute(dto);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch pending sponsorship requests',
});
const err = result.unwrapErr() as ApplicationErrorCode<
GetPendingSponsorshipRequestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -7,86 +7,103 @@
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { PendingSponsorshipRequestsOutputPort } from '../ports/output/PendingSponsorshipRequestsOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { SponsorshipRequest } from '../../domain/entities/SponsorshipRequest';
import type { Sponsor } from '../../domain/entities/Sponsor';
import { Money } from '../../domain/value-objects/Money';
export interface GetPendingSponsorshipRequestsDTO {
export interface GetPendingSponsorshipRequestsInput {
entityType: SponsorableEntityType;
entityId: string;
}
export interface PendingSponsorshipRequestDTO {
id: string;
sponsorId: string;
sponsorName: string;
sponsorLogo?: string;
tier: SponsorshipTier;
offeredAmount: number;
currency: string;
formattedAmount: string;
message?: string;
createdAt: Date;
platformFee: number;
netAmount: number;
export interface PendingSponsorshipRequestFinancials {
offeredAmount: Money;
platformFee: Money;
netAmount: Money;
}
export interface GetPendingSponsorshipRequestsResultDTO {
export interface PendingSponsorshipRequestSummary {
request: SponsorshipRequest;
sponsor: Sponsor | null;
financials: PendingSponsorshipRequestFinancials;
}
export interface GetPendingSponsorshipRequestsResult {
entityType: SponsorableEntityType;
entityId: string;
requests: PendingSponsorshipRequestDTO[];
requests: PendingSponsorshipRequestSummary[];
totalCount: number;
}
export type GetPendingSponsorshipRequestsErrorCode = 'REPOSITORY_ERROR';
export class GetPendingSponsorshipRequestsUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
private readonly output: UseCaseOutputPort<GetPendingSponsorshipRequestsResult>,
) {}
async execute(
dto: GetPendingSponsorshipRequestsDTO,
): Promise<Result<PendingSponsorshipRequestsOutputPort, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
input: GetPendingSponsorshipRequestsInput,
): Promise<
Result<void, ApplicationErrorCode<GetPendingSponsorshipRequestsErrorCode, { message: string }>>
> {
try {
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
input.entityType,
input.entityId,
);
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
const summaries: PendingSponsorshipRequestSummary[] = [];
for (const request of requests) {
const sponsor = await this.sponsorRepo.findById(request.sponsorId);
requestDTOs.push({
id: request.id,
sponsorId: request.sponsorId,
sponsorName: sponsor?.name ?? 'Unknown Sponsor',
...(sponsor?.logoUrl !== undefined ? { sponsorLogo: sponsor.logoUrl } : {}),
tier: request.tier,
offeredAmount: request.offeredAmount.amount,
currency: request.offeredAmount.currency,
formattedAmount: request.offeredAmount.format(),
...(request.message !== undefined ? { message: request.message } : {}),
createdAt: request.createdAt,
platformFee: request.getPlatformFee().amount,
netAmount: request.getNetAmount().amount,
const offeredAmount = Money.create(
request.offeredAmount.amount,
request.offeredAmount.currency,
);
const platformFee = request.getPlatformFee();
const netAmount = request.getNetAmount();
summaries.push({
request,
sponsor: sponsor ?? null,
financials: {
offeredAmount,
platformFee,
netAmount,
},
});
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
summaries.sort((a, b) => b.request.createdAt.getTime() - a.request.createdAt.getTime());
const outputPort: PendingSponsorshipRequestsOutputPort = {
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
const result: GetPendingSponsorshipRequestsResult = {
entityType: input.entityType,
entityId: input.entityId,
requests: summaries,
totalCount: summaries.length,
};
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch pending sponsorship requests' });
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to fetch pending sponsorship requests',
},
});
}
}
}
}

View File

@@ -1,12 +1,19 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetProfileOverviewUseCase } from './GetProfileOverviewUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetProfileOverviewUseCase,
type GetProfileOverviewInput,
type GetProfileOverviewResult,
type GetProfileOverviewErrorCode,
} from './GetProfileOverviewUseCase';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { IImageServicePort } from '../ports/IImageServicePort';
import type { IImageServicePort } from '../ports/IImageServicePort';
import { Driver } from '../../domain/entities/Driver';
import { Team } from '../../domain/entities/Team';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetProfileOverviewUseCase', () => {
let useCase: GetProfileOverviewUseCase;
@@ -27,6 +34,10 @@ describe('GetProfileOverviewUseCase', () => {
};
let getDriverStats: Mock;
let getAllDriverRankings: Mock;
let driverExtendedProfileProvider: {
getExtendedProfile: Mock;
};
let output: UseCaseOutputPort<GetProfileOverviewResult> & { present: Mock };
beforeEach(() => {
driverRepository = {
@@ -46,14 +57,23 @@ describe('GetProfileOverviewUseCase', () => {
};
getDriverStats = vi.fn();
getAllDriverRankings = vi.fn();
driverExtendedProfileProvider = {
getExtendedProfile: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetProfileOverviewResult> & { present: Mock };
useCase = new GetProfileOverviewUseCase(
driverRepository as unknown as IDriverRepository,
teamRepository as unknown as ITeamRepository,
teamMembershipRepository as unknown as ITeamMembershipRepository,
socialRepository as unknown as ISocialGraphRepository,
imageService as unknown as IImageServicePort,
driverExtendedProfileProvider,
getDriverStats,
getAllDriverRankings,
output,
);
});
@@ -65,15 +85,33 @@ describe('GetProfileOverviewUseCase', () => {
name: 'Test Driver',
country: 'US',
});
const teams = [Team.create({ id: 'team-1', name: 'Test Team', tag: 'TT', description: 'Test', ownerId: 'owner-1', leagues: [] })];
const friends = [Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' })];
const teams = [
Team.create({
id: 'team-1',
name: 'Test Team',
tag: 'TT',
description: 'Test',
ownerId: 'owner-1',
leagues: [],
}),
];
const friends = [
Driver.create({ id: 'friend-1', iracingId: '456', name: 'Friend', country: 'US' }),
];
const statsAdapter = {
rating: 1500,
wins: 5,
podiums: 2,
dnfs: 1,
totalRaces: 10,
avgFinish: 3.5,
bestFinish: 1,
worstFinish: 10,
overallRank: 10,
consistency: 90,
percentile: 75,
};
const rankings = [{ driverId, rating: 1500, overallRank: 1 }];
const rankings = [{ driverId, rating: 1500, overallRank: 10 }];
driverRepository.findById.mockResolvedValue(driver);
teamRepository.findAll.mockResolvedValue(teams);
@@ -82,38 +120,48 @@ describe('GetProfileOverviewUseCase', () => {
imageService.getDriverAvatar.mockReturnValue('avatar-url');
getDriverStats.mockReturnValue(statsAdapter);
getAllDriverRankings.mockReturnValue(rankings);
driverExtendedProfileProvider.getExtendedProfile.mockReturnValue(null);
const result = await useCase.execute({ driverId });
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.currentDriver?.id).toBe(driverId);
expect(viewModel.extendedProfile).toBe(null);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as unknown as Mock).mock
.calls[0][0] as GetProfileOverviewResult;
expect(presented.driverInfo.driver.id).toBe(driverId);
expect(presented.extendedProfile).toBeNull();
});
it('should return error for non-existing driver', async () => {
const driverId = 'driver-1';
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'DRIVER_NOT_FOUND',
message: 'Driver not found',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetProfileOverviewErrorCode,
{ message: string }
>;
expect(error.code).toBe('DRIVER_NOT_FOUND');
expect(error.details.message).toBe('Driver not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error on repository failure', async () => {
const driverId = 'driver-1';
driverRepository.findById.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ driverId });
const result = await useCase.execute({ driverId } as GetProfileOverviewInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch profile overview',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetProfileOverviewErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,9 +6,10 @@ import type { ISocialGraphRepository } from '@core/social/domain/repositories/IS
import type { DriverExtendedProfileProvider } from '../ports/DriverExtendedProfileProvider';
import type { Driver } from '../../domain/entities/Driver';
import type { Team } from '../../domain/entities/Team';
import type { ProfileOverviewOutputPort } from '../ports/output/ProfileOverviewOutputPort';
import type { TeamMembership } from '../../domain/types/TeamMembership';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
interface ProfileDriverStatsAdapter {
rating: number | null;
@@ -30,10 +31,67 @@ interface DriverRankingEntry {
overallRank: number | null;
}
export interface GetProfileOverviewParams {
export type GetProfileOverviewInput = {
driverId: string;
};
export interface ProfileOverviewStats {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface ProfileOverviewFinishDistribution {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface ProfileOverviewTeamMembership {
team: Team;
membership: TeamMembership;
}
export interface ProfileOverviewSocialSummary {
friendsCount: number;
friends: Driver[];
}
export interface ProfileOverviewDriverInfo {
driver: Driver;
totalDrivers: number;
globalRank: number | null;
consistency: number | null;
rating: number | null;
}
export type GetProfileOverviewResult = {
driverInfo: ProfileOverviewDriverInfo;
stats: ProfileOverviewStats | null;
finishDistribution: ProfileOverviewFinishDistribution | null;
teamMemberships: ProfileOverviewTeamMembership[];
socialSummary: ProfileOverviewSocialSummary;
extendedProfile: unknown;
};
export type GetProfileOverviewErrorCode =
| 'DRIVER_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class GetProfileOverviewUseCase {
constructor(
private readonly driverRepository: IDriverRepository,
@@ -44,16 +102,24 @@ export class GetProfileOverviewUseCase {
private readonly driverExtendedProfileProvider: DriverExtendedProfileProvider,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
private readonly output: UseCaseOutputPort<GetProfileOverviewResult>,
) {}
async execute(params: GetProfileOverviewParams): Promise<Result<ProfileOverviewOutputPort, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
async execute(
input: GetProfileOverviewInput,
): Promise<
Result<void, ApplicationErrorCode<GetProfileOverviewErrorCode, { message: string }>>
> {
try {
const { driverId } = params;
const { driverId } = input;
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
return Result.err({ code: 'DRIVER_NOT_FOUND', message: 'Driver not found' });
return Result.err({
code: 'DRIVER_NOT_FOUND',
details: { message: 'Driver not found' },
});
}
const [statsAdapter, teams, friends] = await Promise.all([
@@ -62,15 +128,15 @@ export class GetProfileOverviewUseCase {
this.socialRepository.getFriends(driverId),
]);
const driverSummary = this.buildDriverSummary(driver, statsAdapter);
const driverInfo = this.buildDriverInfo(driver, statsAdapter);
const stats = this.buildStats(statsAdapter);
const finishDistribution = this.buildFinishDistribution(statsAdapter);
const teamMemberships = await this.buildTeamMemberships(driver.id, teams as Team[]);
const socialSummary = this.buildSocialSummary(friends as Driver[]);
const extendedProfile = this.driverExtendedProfileProvider.getExtendedProfile(driverId);
const outputPort: ProfileOverviewOutputPort = {
driver: driverSummary,
const result: GetProfileOverviewResult = {
driverInfo,
stats,
finishDistribution,
teamMemberships,
@@ -78,35 +144,36 @@ export class GetProfileOverviewUseCase {
extendedProfile,
};
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch profile overview' });
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message:
error instanceof Error
? error.message
: 'Failed to load profile overview',
},
});
}
}
private buildDriverSummary(
private buildDriverInfo(
driver: Driver,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewOutputPort['driver'] {
): ProfileOverviewDriverInfo {
const rankings = this.getAllDriverRankings();
const fallbackRank = this.computeFallbackRank(driver.id, rankings);
const totalDrivers = rankings.length;
return {
id: driver.id,
name: driver.name.value,
country: driver.country.value,
avatarUrl: this.imageService.getDriverAvatar(driver.id),
iracingId: driver.iracingId?.value ?? null,
joinedAt:
driver.joinedAt instanceof Date
? driver.joinedAt
: new Date(driver.joinedAt.value),
rating: stats?.rating ?? null,
driver,
totalDrivers,
globalRank: stats?.overallRank ?? fallbackRank,
consistency: stats?.consistency ?? null,
bio: driver.bio?.value ?? null,
totalDrivers,
rating: stats?.rating ?? null,
};
}
@@ -123,7 +190,7 @@ export class GetProfileOverviewUseCase {
private buildStats(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewStatsViewModel | null {
): ProfileOverviewStats | null {
if (!stats) {
return null;
}
@@ -132,12 +199,9 @@ export class GetProfileOverviewUseCase {
const dnfs = stats.dnfs;
const finishedRaces = Math.max(totalRaces - dnfs, 0);
const finishRate =
totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null;
const winRate =
totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null;
const podiumRate =
totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null;
const finishRate = totalRaces > 0 ? (finishedRaces / totalRaces) * 100 : null;
const winRate = totalRaces > 0 ? (stats.wins / totalRaces) * 100 : null;
const podiumRate = totalRaces > 0 ? (stats.podiums / totalRaces) * 100 : null;
return {
totalRaces,
@@ -159,7 +223,7 @@ export class GetProfileOverviewUseCase {
private buildFinishDistribution(
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewFinishDistributionViewModel | null {
): ProfileOverviewFinishDistribution | null {
if (!stats || stats.totalRaces <= 0) {
return null;
}
@@ -189,8 +253,8 @@ export class GetProfileOverviewUseCase {
private async buildTeamMemberships(
driverId: string,
teams: Team[],
): Promise<ProfileOverviewOutputPort['teamMemberships']> {
const memberships: ProfileOverviewOutputPort['teamMemberships'] = [];
): Promise<ProfileOverviewTeamMembership[]> {
const memberships: ProfileOverviewTeamMembership[] = [];
for (const team of teams) {
const membership = await this.teamMembershipRepository.getMembership(
@@ -200,33 +264,22 @@ export class GetProfileOverviewUseCase {
if (!membership) continue;
memberships.push({
teamId: team.id,
teamName: team.name.value,
teamTag: team.tag?.value ?? null,
role: membership.role,
joinedAt:
membership.joinedAt instanceof Date
? membership.joinedAt
: new Date(membership.joinedAt),
isCurrent: membership.status === 'active',
team,
membership,
});
}
memberships.sort((a, b) => a.joinedAt.getTime() - b.joinedAt.getTime());
memberships.sort(
(a, b) => a.membership.joinedAt.getTime() - b.membership.joinedAt.getTime(),
);
return memberships;
}
private buildSocialSummary(friends: Driver[]): ProfileOverviewOutputPort['socialSummary'] {
private buildSocialSummary(friends: Driver[]): ProfileOverviewSocialSummary {
return {
friendsCount: friends.length,
friends: friends.map(friend => ({
id: friend.id,
name: friend.name.value,
country: friend.country.value,
avatarUrl: this.imageService.getDriverAvatar(friend.id),
})),
friends,
};
}
}
}

View File

@@ -1,34 +1,38 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceDetailUseCase } from './GetRaceDetailUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetRaceDetailUseCase,
type GetRaceDetailInput,
type GetRaceDetailResult,
type GetRaceDetailErrorCode,
} from './GetRaceDetailUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetRaceDetailUseCase', () => {
let useCase: GetRaceDetailUseCase;
let raceRepository: { findById: Mock };
let leagueRepository: { findById: Mock };
let driverRepository: { findById: Mock };
let raceRegistrationRepository: { getRegisteredDrivers: Mock };
let raceRegistrationRepository: { findByRaceId: Mock };
let resultRepository: { findByRaceId: Mock };
let leagueMembershipRepository: { getMembership: Mock };
let driverRatingProvider: { getRating: Mock; getRatings: Mock };
let imageService: { getDriverAvatar: Mock; getTeamLogo: Mock; getLeagueCover: Mock; getLeagueLogo: Mock };
let output: UseCaseOutputPort<GetRaceDetailResult> & { present: Mock };
beforeEach(() => {
raceRepository = { findById: vi.fn() };
leagueRepository = { findById: vi.fn() };
driverRepository = { findById: vi.fn() };
raceRegistrationRepository = { getRegisteredDrivers: vi.fn() };
raceRegistrationRepository = { findByRaceId: vi.fn() };
resultRepository = { findByRaceId: vi.fn() };
leagueMembershipRepository = { getMembership: vi.fn() };
driverRatingProvider = { getRating: vi.fn(), getRatings: vi.fn() };
imageService = { getDriverAvatar: vi.fn(), getTeamLogo: vi.fn(), getLeagueCover: vi.fn(), getLeagueLogo: vi.fn() };
output = { present: vi.fn() } as UseCaseOutputPort<GetRaceDetailResult> & { present: Mock };
useCase = new GetRaceDetailUseCase(
raceRepository as unknown as IRaceRepository,
leagueRepository as unknown as ILeagueRepository,
@@ -36,12 +40,11 @@ describe('GetRaceDetailUseCase', () => {
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository,
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
driverRatingProvider as DriverRatingProvider,
imageService as IImageServicePort,
output,
);
});
it('should return race detail when race exists', async () => {
it('should present race detail when race exists', async () => {
const raceId = 'race-1';
const driverId = 'driver-1';
const race = {
@@ -62,9 +65,11 @@ describe('GetRaceDetailUseCase', () => {
description: 'Description',
settings: { maxDrivers: 20, qualifyingFormat: 'ladder' },
};
const registeredDriverIds = ['driver-1', 'driver-2'];
const registrations = [
{ driverId: { toString: () => 'driver-1' } },
{ driverId: { toString: () => 'driver-2' } },
];
const membership = { status: 'active' as const };
const ratings = new Map([['driver-1', 1600], ['driver-2', 1400]]);
const drivers = [
{ id: 'driver-1', name: 'Driver 1', country: 'US' },
{ id: 'driver-2', name: 'Driver 2', country: 'UK' },
@@ -72,46 +77,41 @@ describe('GetRaceDetailUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
leagueRepository.findById.mockResolvedValue(league);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(registeredDriverIds);
raceRegistrationRepository.findByRaceId.mockResolvedValue(registrations);
leagueMembershipRepository.getMembership.mockResolvedValue(membership);
driverRatingProvider.getRatings.mockReturnValue(ratings);
driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id) || null));
imageService.getDriverAvatar.mockImplementation((id) => `avatar-${id}`);
driverRepository.findById.mockImplementation((id: string) =>
Promise.resolve(drivers.find(d => d.id === id) || null),
);
resultRepository.findByRaceId.mockResolvedValue([]);
const result = await useCase.execute({ raceId, driverId });
const input: GetRaceDetailInput = { raceId, driverId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.race).toEqual({
id: raceId,
leagueId: 'league-1',
track: 'Track 1',
car: 'Car 1',
scheduledAt: '2023-01-01T10:00:00.000Z',
sessionType: 'race',
status: 'scheduled',
strengthOfField: 1500,
registeredCount: 10,
maxParticipants: 20,
});
expect(viewModel.league).toEqual({
id: 'league-1',
name: 'League 1',
description: 'Description',
settings: { maxDrivers: 20, qualifyingFormat: 'ladder' },
});
expect(viewModel.entryList).toHaveLength(2);
expect(viewModel.registration).toEqual({ isUserRegistered: true, canRegister: false });
expect(viewModel.userResult).toBeNull();
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetRaceDetailResult;
expect(presented.race).toEqual(race);
expect(presented.league).toEqual(league);
expect(presented.registrations).toEqual(registrations);
expect(presented.drivers).toHaveLength(2);
expect(presented.isUserRegistered).toBe(true);
expect(presented.canRegister).toBe(true);
expect(presented.userResult).toBeNull();
});
it('should return error when race not found', async () => {
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1', driverId: 'driver-1' });
const input: GetRaceDetailInput = { raceId: 'race-1', driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' });
const err = result.unwrapErr() as ApplicationErrorCode<GetRaceDetailErrorCode, { message: string }>;
expect(err.code).toBe('RACE_NOT_FOUND');
expect(err.details?.message).toBe('Race not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should include user result when race is completed', async () => {
@@ -126,37 +126,43 @@ describe('GetRaceDetailUseCase', () => {
sessionType: 'race' as const,
status: 'completed' as const,
};
const results = [{
driverId: 'driver-1',
position: 2,
startPosition: 1,
incidents: 0,
fastestLap: 120,
getPositionChange: () => -1,
isPodium: () => true,
isClean: () => true,
}];
const registrations: Array<{ driverId: { toString: () => string } }> = [];
const userDomainResult = {
driverId: { toString: () => driverId },
} as unknown as { driverId: { toString: () => string } };
raceRepository.findById.mockResolvedValue(race);
leagueRepository.findById.mockResolvedValue(null);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]);
raceRegistrationRepository.findByRaceId.mockResolvedValue(registrations);
leagueMembershipRepository.getMembership.mockResolvedValue(null);
driverRatingProvider.getRatings.mockReturnValue(new Map());
resultRepository.findByRaceId.mockResolvedValue(results);
driverRepository.findById.mockResolvedValue(null);
resultRepository.findByRaceId.mockResolvedValue([userDomainResult]);
const result = await useCase.execute({ raceId, driverId });
const input: GetRaceDetailInput = { raceId, driverId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.userResult).toEqual({
position: 2,
startPosition: 1,
incidents: 0,
fastestLap: 120,
positionChange: -1,
isPodium: true,
isClean: true,
ratingChange: 61, // based on calculateRatingChange
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetRaceDetailResult;
expect(presented.userResult).toBe(userDomainResult);
expect(presented.race).toEqual(race);
expect(presented.league).toBeNull();
expect(presented.registrations).toEqual(registrations);
});
it('should wrap repository errors', async () => {
const error = new Error('db down');
raceRepository.findById.mockRejectedValue(error);
const input: GetRaceDetailInput = { raceId: 'race-1', driverId: 'driver-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetRaceDetailErrorCode, { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('db down');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -4,31 +4,35 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { RaceDetailOutputPort } from '../ports/output/RaceDetailOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { League } from '../../domain/entities/League';
import type { Race } from '../../domain/entities/Race';
import type { RaceRegistration } from '../../domain/entities/RaceRegistration';
import type { Result as DomainResult } from '../../domain/entities/Result';
/**
* Use Case: GetRaceDetailUseCase
*
* Given a race id and current driver id:
* - When the race exists, it builds a view model with race, league, entry list, registration flags and user result.
* - When the race does not exist, it presents a view model with an error and no race data.
*
* Given a completed race with a result for the driver:
* - When computing rating change, it applies the same position-based formula used in the legacy UI.
*/
export interface GetRaceDetailQueryParams {
export type GetRaceDetailInput = {
raceId: string;
driverId: string;
}
};
type GetRaceDetailErrorCode = 'RACE_NOT_FOUND';
// Backwards-compatible alias for older callers
export type GetRaceDetailQueryParams = GetRaceDetailInput;
export class GetRaceDetailUseCase
implements AsyncUseCase<GetRaceDetailQueryParams, RaceDetailOutputPort, GetRaceDetailErrorCode>
{
export type GetRaceDetailErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR';
export type GetRaceDetailResult = {
race: Race;
league: League | null;
registrations: RaceRegistration[];
drivers: NonNullable<Awaited<ReturnType<IDriverRepository['findById']>>>[];
userResult: DomainResult | null;
isUserRegistered: boolean;
canRegister: boolean;
};
export class GetRaceDetailUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
@@ -36,50 +40,69 @@ export class GetRaceDetailUseCase
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly output: UseCaseOutputPort<GetRaceDetailResult>,
) {}
async execute(params: GetRaceDetailQueryParams): Promise<Result<RaceDetailOutputPort, ApplicationErrorCode<GetRaceDetailErrorCode>>> {
const { raceId, driverId } = params;
async execute(
input: GetRaceDetailInput,
): Promise<Result<void, ApplicationErrorCode<GetRaceDetailErrorCode, { message: string }>>> {
const { raceId, driverId } = input;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' });
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
}
const [league, registrations, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.findByRaceId(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
]);
const drivers = await Promise.all(
registrations.map(registration => this.driverRepository.findById(registration.driverId.toString())),
);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId);
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister = !!membership && membership.status === 'active' && isUpcoming;
let userResult: DomainResult | null = null;
if (race.status === 'completed') {
const results = await this.resultRepository.findByRaceId(race.id);
userResult = results.find(r => r.driverId.toString() === driverId) ?? null;
}
const result: GetRaceDetailResult = {
race,
league: league ?? null,
registrations,
drivers: validDrivers,
userResult,
isUserRegistered,
canRegister,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load race detail';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
const [league, registrations, membership] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.raceRegistrationRepository.findByRaceId(race.id),
this.leagueMembershipRepository.getMembership(race.leagueId, driverId),
]);
const drivers = await Promise.all(
registrations.map(registration => this.driverRepository.findById(registration.driverId.toString())),
);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const isUserRegistered = registrations.some(reg => reg.driverId.toString() === driverId);
const isUpcoming = race.status === 'scheduled' && race.scheduledAt > new Date();
const canRegister = !!membership && membership.status === 'active' && isUpcoming;
let userResult: Result | null = null;
if (race.status === 'completed') {
const results = await this.resultRepository.findByRaceId(race.id);
userResult = results.find(r => r.driverId.toString() === driverId) ?? null;
}
const outputPort: RaceDetailOutputPort = {
race,
league,
registrations,
drivers: validDrivers,
userResult,
isUserRegistered,
canRegister,
};
return Result.ok(outputPort);
}
}

View File

@@ -1,28 +1,38 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRacePenaltiesUseCase } from './GetRacePenaltiesUseCase';
import {
GetRacePenaltiesUseCase,
type GetRacePenaltiesInput,
type GetRacePenaltiesResult,
type GetRacePenaltiesErrorCode,
} from './GetRacePenaltiesUseCase';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetRacePenaltiesUseCase', () => {
let useCase: GetRacePenaltiesUseCase;
let penaltyRepository: { findByRaceId: Mock };
let driverRepository: { findById: Mock };
let output: UseCaseOutputPort<GetRacePenaltiesResult> & { present: Mock };
beforeEach(() => {
penaltyRepository = { findByRaceId: vi.fn() };
driverRepository = { findById: vi.fn() };
output = { present: vi.fn() } as UseCaseOutputPort<GetRacePenaltiesResult> & { present: Mock };
useCase = new GetRacePenaltiesUseCase(
penaltyRepository as unknown as IPenaltyRepository,
driverRepository as unknown as IDriverRepository,
output,
);
});
it('should return penalties with driver map', async () => {
const raceId = 'race-1';
it('should return penalties with drivers', async () => {
const input: GetRacePenaltiesInput = { raceId: 'race-1' };
const penalties = [
{
id: 'penalty-1',
raceId,
raceId: input.raceId,
driverId: 'driver-1',
issuedBy: 'driver-2',
type: 'time' as const,
@@ -38,26 +48,47 @@ describe('GetRacePenaltiesUseCase', () => {
];
penaltyRepository.findByRaceId.mockResolvedValue(penalties);
driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id)));
driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find((d) => d.id === id)));
const result = await useCase.execute({ raceId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.penalties).toEqual(penalties);
expect(dto.driverMap.get('driver-1')).toBe('Driver 1');
expect(dto.driverMap.get('driver-2')).toBe('Driver 2');
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as GetRacePenaltiesResult;
expect(presented.penalties).toEqual(penalties);
expect(presented.drivers).toEqual(drivers);
});
it('should return empty when no penalties', async () => {
penaltyRepository.findByRaceId.mockResolvedValue([]);
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1' });
const input: GetRacePenaltiesInput = { raceId: 'race-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.penalties).toEqual([]);
expect(dto.driverMap.size).toBe(0);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0]![0] as GetRacePenaltiesResult;
expect(presented.penalties).toEqual([]);
expect(presented.drivers).toEqual([]);
});
it('should return repository error when repository throws', async () => {
const input: GetRacePenaltiesInput = { raceId: 'race-1' };
const repositoryError = new Error('Repository failure');
penaltyRepository.findByRaceId.mockRejectedValue(repositoryError);
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetRacePenaltiesErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -7,40 +7,60 @@
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { RacePenaltiesOutputPort } from '../ports/output/RacePenaltiesOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Driver } from '../../domain/entities/Driver';
export interface GetRacePenaltiesInput {
export type GetRacePenaltiesInput = {
raceId: string;
}
};
export class GetRacePenaltiesUseCase implements AsyncUseCase<GetRacePenaltiesInput, RacePenaltiesOutputPort, 'NO_ERROR'> {
export type GetRacePenaltiesResult = {
penalties: unknown[];
drivers: Driver[];
};
export type GetRacePenaltiesErrorCode = 'REPOSITORY_ERROR';
export class GetRacePenaltiesUseCase {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly driverRepository: IDriverRepository,
private readonly output: UseCaseOutputPort<GetRacePenaltiesResult>,
) {}
async execute(input: GetRacePenaltiesInput): Promise<Result<RacePenaltiesOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
async execute(
input: GetRacePenaltiesInput,
): Promise<Result<void, ApplicationErrorCode<GetRacePenaltiesErrorCode, { message: string }>>> {
try {
const penalties = await this.penaltyRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
penalties.forEach((penalty) => {
driverIds.add(penalty.driverId);
driverIds.add(penalty.issuedBy);
});
const driverIds = new Set<string>();
penalties.forEach((penalty: any) => {
driverIds.add(penalty.driverId);
driverIds.add(penalty.issuedBy);
});
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const outputPort: RacePenaltiesOutputPort = {
penalties,
drivers: validDrivers,
};
return Result.ok(outputPort);
this.output.present({ penalties, drivers: validDrivers });
return Result.ok(undefined);
} catch (error) {
const message =
error instanceof Error && error.message ? error.message : 'Failed to load race penalties';
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message,
},
} as ApplicationErrorCode<GetRacePenaltiesErrorCode, { message: string }>);
}
}
}

View File

@@ -1,64 +1,122 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceProtestsUseCase } from './GetRaceProtestsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetRaceProtestsUseCase,
type GetRaceProtestsInput,
type GetRaceProtestsResult,
type GetRaceProtestsErrorCode,
} from './GetRaceProtestsUseCase';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Protest } from '../../domain/entities/Protest';
import { Driver } from '../../domain/entities/Driver';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetRaceProtestsUseCase', () => {
let useCase: GetRaceProtestsUseCase;
let protestRepository: { findByRaceId: Mock };
let driverRepository: { findById: Mock };
let output: UseCaseOutputPort<GetRaceProtestsResult> & { present: Mock };
beforeEach(() => {
protestRepository = { findByRaceId: vi.fn() };
driverRepository = { findById: vi.fn() };
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetRaceProtestsResult> & { present: Mock };
useCase = new GetRaceProtestsUseCase(
protestRepository as unknown as IProtestRepository,
driverRepository as unknown as IDriverRepository,
output,
);
});
it('should return protests with driver map', async () => {
it('should return protests with drivers', async () => {
const raceId = 'race-1';
const protests = [
{
id: 'protest-1',
raceId,
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
reviewedBy: 'driver-3',
filedAt: new Date(),
comment: 'Comment',
status: 'pending' as const,
},
];
const drivers = [
{ id: 'driver-1', name: 'Driver 1' },
{ id: 'driver-2', name: 'Driver 2' },
{ id: 'driver-3', name: 'Driver 3' },
];
const protest = Protest.create({
id: 'protest-1',
raceId,
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
incident: { lap: 1, description: 'Incident' },
status: 'pending',
filedAt: new Date(),
reviewedBy: 'driver-3',
});
protestRepository.findByRaceId.mockResolvedValue(protests);
driverRepository.findById.mockImplementation((id) => Promise.resolve(drivers.find(d => d.id === id)));
const driver1 = Driver.create({
id: 'driver-1',
iracingId: 'ir-1',
name: 'Driver 1',
country: 'US',
});
const driver2 = Driver.create({
id: 'driver-2',
iracingId: 'ir-2',
name: 'Driver 2',
country: 'UK',
});
const driver3 = Driver.create({
id: 'driver-3',
iracingId: 'ir-3',
name: 'Driver 3',
country: 'DE',
});
const result = await useCase.execute({ raceId });
protestRepository.findByRaceId.mockResolvedValue([protest]);
driverRepository.findById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(driver1);
if (id === 'driver-2') return Promise.resolve(driver2);
if (id === 'driver-3') return Promise.resolve(driver3);
return Promise.resolve(null);
});
const input: GetRaceProtestsInput = { raceId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.protests).toEqual(protests);
expect(dto.driverMap.get('driver-1')).toBe('Driver 1');
expect(dto.driverMap.get('driver-2')).toBe('Driver 2');
expect(dto.driverMap.get('driver-3')).toBe('Driver 3');
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetRaceProtestsResult;
expect(presented.protests).toHaveLength(1);
expect(presented.protests[0]).toEqual(protest);
expect(presented.drivers).toHaveLength(3);
expect(presented.drivers).toEqual(expect.arrayContaining([driver1, driver2, driver3]));
});
it('should return empty when no protests', async () => {
protestRepository.findByRaceId.mockResolvedValue([]);
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1' });
const input: GetRaceProtestsInput = { raceId: 'race-1' };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.protests).toEqual([]);
expect(dto.driverMap.size).toBe(0);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetRaceProtestsResult;
expect(presented.protests).toEqual([]);
expect(presented.drivers).toEqual([]);
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
protestRepository.findByRaceId.mockRejectedValue(new Error('DB error'));
const input: GetRaceProtestsInput = { raceId: 'race-1' };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetRaceProtestsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -7,43 +7,69 @@
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { RaceProtestsOutputPort } from '../ports/output/RaceProtestsOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { Protest } from '../../domain/entities/Protest';
import type { Driver } from '../../domain/entities/Driver';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
export interface GetRaceProtestsInput {
raceId: string;
}
export class GetRaceProtestsUseCase implements AsyncUseCase<GetRaceProtestsInput, RaceProtestsOutputPort, 'NO_ERROR'> {
export type GetRaceProtestsErrorCode = 'REPOSITORY_ERROR';
export interface GetRaceProtestsResult {
protests: Protest[];
drivers: Driver[];
}
export class GetRaceProtestsUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
private readonly output: UseCaseOutputPort<GetRaceProtestsResult>,
) {}
async execute(input: GetRaceProtestsInput): Promise<Result<RaceProtestsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const protests = await this.protestRepository.findByRaceId(input.raceId);
async execute(
input: GetRaceProtestsInput,
): Promise<Result<void, ApplicationErrorCode<GetRaceProtestsErrorCode, { message: string }>>> {
try {
const protests = await this.protestRepository.findByRaceId(input.raceId);
const driverIds = new Set<string>();
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
driverIds.add(protest.reviewedBy);
}
});
const driverIds = new Set<string>();
protests.forEach((protest) => {
driverIds.add(protest.protestingDriverId);
driverIds.add(protest.accusedDriverId);
if (protest.reviewedBy) {
driverIds.add(protest.reviewedBy);
}
});
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const drivers = await Promise.all(
Array.from(driverIds).map((id) => this.driverRepository.findById(id)),
);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const validDrivers = drivers.filter((driver): driver is NonNullable<typeof driver> => driver !== null);
const outputPort: RaceProtestsOutputPort = {
protests,
drivers: validDrivers,
};
return Result.ok(outputPort);
const result: GetRaceProtestsResult = {
protests,
drivers: validDrivers,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error && typeof error === 'object' && 'message' in error && typeof (error as any).message === 'string'
? (error as any).message
: 'Failed to load race protests';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -1,42 +1,105 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceRegistrationsUseCase } from './GetRaceRegistrationsUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetRaceRegistrationsUseCase,
type GetRaceRegistrationsInput,
type GetRaceRegistrationsResult,
type GetRaceRegistrationsErrorCode,
} from './GetRaceRegistrationsUseCase';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { Race } from '@core/racing/domain/entities/Race';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
describe('GetRaceRegistrationsUseCase', () => {
let useCase: GetRaceRegistrationsUseCase;
let raceRepository: { findById: Mock };
let registrationRepository: { findByRaceId: Mock };
let output: UseCaseOutputPort<GetRaceRegistrationsResult> & { present: Mock };
beforeEach(() => {
raceRepository = { findById: vi.fn() };
registrationRepository = { findByRaceId: vi.fn() };
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetRaceRegistrationsResult> & { present: Mock };
useCase = new GetRaceRegistrationsUseCase(
raceRepository as unknown as IRaceRepository,
registrationRepository as unknown as IRaceRegistrationRepository,
output,
);
});
it('should return registrations', async () => {
const raceId = 'race-1';
it('should present race and registrations on success', async () => {
const input: GetRaceRegistrationsInput = { raceId: 'race-1' };
const race = Race.create({
id: input.raceId,
leagueId: 'league-1',
scheduledAt: new Date(),
track: 'Track',
car: 'Car',
});
const registrations = [
RaceRegistration.create({ raceId, driverId: 'driver-1' }),
RaceRegistration.create({ raceId, driverId: 'driver-2' }),
RaceRegistration.create({ raceId: input.raceId, driverId: 'driver-1' }),
RaceRegistration.create({ raceId: input.raceId, driverId: 'driver-2' }),
];
raceRepository.findById.mockResolvedValue(race);
registrationRepository.findByRaceId.mockResolvedValue(registrations);
const result = await useCase.execute({ raceId });
const result: Result<void, ApplicationErrorCode<GetRaceRegistrationsErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
const outputPort = result.unwrap();
expect(outputPort.registrations).toEqual(registrations);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as GetRaceRegistrationsResult;
expect(presented.race).toEqual(race);
expect(presented.registrations).toHaveLength(2);
expect(presented.registrations[0].registration).toEqual(registrations[0]);
expect(presented.registrations[1].registration).toEqual(registrations[1]);
});
it('should return empty array when no registrations', async () => {
registrationRepository.findByRaceId.mockResolvedValue([]);
it('should return RACE_NOT_FOUND error when race does not exist', async () => {
const input: GetRaceRegistrationsInput = { raceId: 'non-existent-race' };
const result = await useCase.execute({ raceId: 'race-1' });
raceRepository.findById.mockResolvedValue(null);
expect(result.isOk()).toBe(true);
const outputPort = result.unwrap();
expect(outputPort.registrations).toEqual([]);
const result: Result<void, ApplicationErrorCode<GetRaceRegistrationsErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetRaceRegistrationsErrorCode,
{ message: string }
>;
expect(err.code).toBe('RACE_NOT_FOUND');
expect(err.details?.message).toBe('Race not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return REPOSITORY_ERROR when repository throws', async () => {
const input: GetRaceRegistrationsInput = { raceId: 'race-1' };
raceRepository.findById.mockRejectedValue(new Error('DB failure'));
const result: Result<void, ApplicationErrorCode<GetRaceRegistrationsErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetRaceRegistrationsErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('DB failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,29 +1,72 @@
import type { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import type { GetRaceRegistrationsQueryParamsDTO } from '../dto/RaceRegistrationQueryDTO';
import type { RaceRegistrationsOutputPort } from '../ports/output/RaceRegistrationsOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
import type { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import type { Race } from '@core/racing/domain/entities/Race';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
/**
* Use Case: GetRaceRegistrationsUseCase
*
* Returns registered driver IDs for a race.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetRaceRegistrationsUseCase implements AsyncUseCase<GetRaceRegistrationsQueryParamsDTO, RaceRegistrationsOutputPort, 'NO_ERROR'> {
export type GetRaceRegistrationsInput = {
raceId: string;
};
export type RaceRegistrationWithContext = {
registration: RaceRegistration;
};
export type GetRaceRegistrationsResult = {
race: Race;
registrations: RaceRegistrationWithContext[];
};
export type GetRaceRegistrationsErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetRaceRegistrationsUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly output: UseCaseOutputPort<GetRaceRegistrationsResult>,
) {}
async execute(params: GetRaceRegistrationsQueryParamsDTO): Promise<Result<RaceRegistrationsOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const { raceId } = params;
const registrations = await this.registrationRepository.findByRaceId(raceId);
async execute(
input: GetRaceRegistrationsInput,
): Promise<Result<void, ApplicationErrorCode<GetRaceRegistrationsErrorCode, { message: string }>>> {
const { raceId } = input;
const outputPort: RaceRegistrationsOutputPort = {
registrations,
};
try {
const race = await this.raceRepository.findById(raceId);
return Result.ok(outputPort);
if (!race) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
}
const registrations = await this.registrationRepository.findByRaceId(raceId);
const registrationsWithContext: RaceRegistrationWithContext[] = registrations.map(registration => ({
registration,
}));
const result: GetRaceRegistrationsResult = {
race,
registrations: registrationsWithContext,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load race registrations';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}

View File

@@ -1,10 +1,17 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceResultsDetailUseCase } from './GetRaceResultsDetailUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetRaceResultsDetailUseCase,
type GetRaceResultsDetailInput,
type GetRaceResultsDetailResult,
type GetRaceResultsDetailErrorCode,
} from './GetRaceResultsDetailUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetRaceResultsDetailUseCase', () => {
let useCase: GetRaceResultsDetailUseCase;
@@ -13,6 +20,7 @@ describe('GetRaceResultsDetailUseCase', () => {
let resultRepository: { findByRaceId: Mock };
let driverRepository: { findAll: Mock };
let penaltyRepository: { findByRaceId: Mock };
let output: UseCaseOutputPort<GetRaceResultsDetailResult> & { present: Mock };
beforeEach(() => {
raceRepository = { findById: vi.fn() };
@@ -20,39 +28,79 @@ describe('GetRaceResultsDetailUseCase', () => {
resultRepository = { findByRaceId: vi.fn() };
driverRepository = { findAll: vi.fn() };
penaltyRepository = { findByRaceId: vi.fn() };
output = { present: vi.fn() } as unknown as UseCaseOutputPort<GetRaceResultsDetailResult> & {
present: Mock;
};
useCase = new GetRaceResultsDetailUseCase(
raceRepository as unknown as IRaceRepository,
leagueRepository as unknown as ILeagueRepository,
resultRepository as unknown as IResultRepository,
driverRepository as unknown as IDriverRepository,
penaltyRepository as unknown as IPenaltyRepository,
output,
);
});
it('should return race results detail when race exists', async () => {
const raceId = 'race-1';
it('presents race results detail when race exists', async () => {
const input: GetRaceResultsDetailInput = { raceId: 'race-1' };
const race = {
id: raceId,
id: 'race-1',
leagueId: 'league-1',
track: 'Track 1',
scheduledAt: new Date('2023-01-01T10:00:00Z'),
status: 'completed' as const,
};
const league = {
id: 'league-1',
name: 'League 1',
settings: { pointsSystem: 'f1-2024' },
};
const results = [
{ driverId: 'driver-1', position: 1, fastestLap: 120 },
{ driverId: 'driver-2', position: 2, fastestLap: 125 },
{
id: 'res-1',
raceId: 'race-1',
driverId: 'driver-1',
position: { toNumber: () => 1 },
fastestLap: { toNumber: () => 120 },
incidents: { toNumber: () => 0 },
startPosition: { toNumber: () => 1 },
},
{
id: 'res-2',
raceId: 'race-1',
driverId: 'driver-2',
position: { toNumber: () => 2 },
fastestLap: { toNumber: () => 125 },
incidents: { toNumber: () => 1 },
startPosition: { toNumber: () => 2 },
},
];
const drivers = [
{ id: 'driver-1', name: 'Driver 1' },
{ id: 'driver-2', name: 'Driver 2' },
];
const penalties = [
{ driverId: 'driver-1', type: 'time' as const, value: 10 },
{
id: 'pen-1',
leagueId: 'league-1',
raceId: 'race-1',
driverId: 'driver-1',
type: 'time_penalty',
value: 5,
reason: 'cut track',
protestId: undefined,
issuedBy: 'steward-1',
status: 'pending',
issuedAt: new Date(),
appliedAt: undefined,
notes: undefined,
},
];
raceRepository.findById.mockResolvedValue(race);
@@ -61,32 +109,57 @@ describe('GetRaceResultsDetailUseCase', () => {
driverRepository.findAll.mockResolvedValue(drivers);
penaltyRepository.findByRaceId.mockResolvedValue(penalties);
const result = await useCase.execute({ raceId });
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.race).toEqual({
id: raceId,
leagueId: 'league-1',
track: 'Track 1',
scheduledAt: new Date('2023-01-01T10:00:00Z'),
status: 'completed',
});
expect(viewModel.league).toEqual({ id: 'league-1', name: 'League 1' });
expect(viewModel.results).toEqual(results);
expect(viewModel.drivers).toEqual(drivers);
expect(viewModel.penalties).toEqual([{ driverId: 'driver-1', type: 'time', value: 10 }]);
expect(viewModel.pointsSystem).toBeDefined();
expect(viewModel.fastestLapTime).toBe(120);
expect(viewModel.currentDriverId).toBe('driver-1');
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0]![0] as GetRaceResultsDetailResult;
expect(presented.race).toEqual(race);
expect(presented.league).toEqual(league);
expect(presented.results).toEqual(results);
expect(presented.drivers).toEqual(drivers);
expect(presented.penalties).toEqual(penalties);
expect(presented.pointsSystem).toBeDefined();
expect(presented.fastestLapTime).toBe(120);
expect(presented.currentDriverId).toBe('driver-1');
});
it('should return error when race not found', async () => {
it('returns error when race not found and does not present data', async () => {
const input: GetRaceResultsDetailInput = { raceId: 'race-1' };
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'RACE_NOT_FOUND' });
const error = result.unwrapErr() as ApplicationErrorCode<
GetRaceResultsDetailErrorCode,
{ message: string }
>;
expect(error.code).toBe('RACE_NOT_FOUND');
expect(error.details.message).toBe('Race not found');
expect(output.present).not.toHaveBeenCalled();
});
});
it('returns repository error when an unexpected error occurs', async () => {
const input: GetRaceResultsDetailInput = { raceId: 'race-1' };
raceRepository.findById.mockRejectedValue(new Error('Database failure'));
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetRaceResultsDetailErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('Database failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,67 +1,102 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { RaceResultsDetailOutputPort } from '../ports/output/RaceResultsDetailOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { Driver } from '../../domain/entities/Driver';
import type { League } from '../../domain/entities/League';
import type { Result as DomainResult } from '../../domain/entities/Result';
import type { Penalty } from '../../domain/entities/Penalty';
import type { Race } from '../../domain/entities/Race';
import type { Penalty } from '../../domain/entities/penalty/Penalty';
import type { Result as DomainResult } from '../../domain/entities/result/Result';
export interface GetRaceResultsDetailParams {
export type GetRaceResultsDetailInput = {
raceId: string;
driverId?: string;
}
};
export type GetRaceResultsDetailErrorCode =
| 'RACE_NOT_FOUND'
| 'REPOSITORY_ERROR';
type GetRaceResultsDetailErrorCode = 'RACE_NOT_FOUND';
export type GetRaceResultsDetailResult = {
race: Race;
league: League | null;
results: DomainResult[];
drivers: Driver[];
penalties: Penalty[];
pointsSystem?: Record<number, number>;
fastestLapTime?: number;
currentDriverId?: string;
};
export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsDetailParams, RaceResultsDetailOutputPort, GetRaceResultsDetailErrorCode> {
export class GetRaceResultsDetailUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly resultRepository: IResultRepository,
private readonly driverRepository: IDriverRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly output: UseCaseOutputPort<GetRaceResultsDetailResult>,
) {}
async execute(params: GetRaceResultsDetailParams): Promise<Result<RaceResultsDetailOutputPort, ApplicationErrorCode<GetRaceResultsDetailErrorCode>>> {
const { raceId, driverId } = params;
async execute(
params: GetRaceResultsDetailInput,
): Promise<
Result<void, ApplicationErrorCode<GetRaceResultsDetailErrorCode, { message: string }>>
> {
try {
const { raceId, driverId } = params;
const race = await this.raceRepository.findById(raceId);
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' });
if (!race) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: 'Race not found' },
});
}
const [league, results, drivers, penalties] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.resultRepository.findByRaceId(raceId),
this.driverRepository.findAll(),
this.penaltyRepository.findByRaceId(raceId),
]);
const effectiveCurrentDriverId =
driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined);
const pointsSystem = this.buildPointsSystem(league);
const fastestLapTime = this.getFastestLapTime(results);
const result: GetRaceResultsDetailResult = {
race,
league,
results,
drivers,
penalties,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && typeof error.message === 'string'
? error.message
: 'Failed to load race results detail';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
const [league, results, drivers, penalties] = await Promise.all([
this.leagueRepository.findById(race.leagueId),
this.resultRepository.findByRaceId(raceId),
this.driverRepository.findAll(),
this.penaltyRepository.findByRaceId(raceId),
]);
const effectiveCurrentDriverId =
driverId ?? (drivers.length > 0 ? drivers[0]!.id : undefined);
const pointsSystem = this.buildPointsSystem(league);
const fastestLapTime = this.getFastestLapTime(results);
const outputPort: RaceResultsDetailOutputPort = {
race,
league,
results,
drivers,
penalties,
...(pointsSystem ? { pointsSystem } : {}),
...(fastestLapTime !== undefined ? { fastestLapTime } : {}),
...(effectiveCurrentDriverId ? { currentDriverId: effectiveCurrentDriverId } : {}),
};
return Result.ok(outputPort);
}
private buildPointsSystem(league: League | null): Record<number, number> | undefined {
@@ -114,7 +149,6 @@ export class GetRaceResultsDetailUseCase implements AsyncUseCase<GetRaceResultsD
private getFastestLapTime(results: DomainResult[]): number | undefined {
if (results.length === 0) return undefined;
return Math.min(...results.map((r) => r.fastestLap));
return Math.min(...results.map(r => r.fastestLap.toNumber()));
}
}
}

View File

@@ -1,10 +1,17 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRaceWithSOFUseCase } from './GetRaceWithSOFUseCase';
import {
GetRaceWithSOFUseCase,
type GetRaceWithSOFInput,
type GetRaceWithSOFResult,
type GetRaceWithSOFErrorCode,
} from './GetRaceWithSOFUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import { IResultRepository } from '../../domain/repositories/IResultRepository';
import { Race } from '../../domain/entities/Race';
import { SessionType } from '../../domain/value-objects/SessionType';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetRaceWithSOFUseCase', () => {
let useCase: GetRaceWithSOFUseCase;
@@ -18,6 +25,7 @@ describe('GetRaceWithSOFUseCase', () => {
findByRaceId: Mock;
};
let getDriverRating: Mock;
let output: UseCaseOutputPort<GetRaceWithSOFResult> & { present: Mock };
beforeEach(() => {
raceRepository = {
@@ -30,21 +38,31 @@ describe('GetRaceWithSOFUseCase', () => {
findByRaceId: vi.fn(),
};
getDriverRating = vi.fn();
output = {
present: vi.fn(),
};
useCase = new GetRaceWithSOFUseCase(
raceRepository as unknown as IRaceRepository,
registrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository,
getDriverRating,
output,
);
});
it('should return error when race not found', async () => {
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({ code: 'RACE_NOT_FOUND' });
const err = result.unwrapErr() as ApplicationErrorCode<
GetRaceWithSOFErrorCode,
{ message: string }
>;
expect(err.code).toBe('RACE_NOT_FOUND');
expect(err.details?.message).toBe('Race with id race-1 not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return race with stored SOF when available', async () => {
@@ -62,20 +80,31 @@ describe('GetRaceWithSOFUseCase', () => {
});
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2', 'driver-3', 'driver-4', 'driver-5', 'driver-6', 'driver-7', 'driver-8', 'driver-9', 'driver-10']);
registrationRepository.getRegisteredDrivers.mockResolvedValue([
'driver-1',
'driver-2',
'driver-3',
'driver-4',
'driver-5',
'driver-6',
'driver-7',
'driver-8',
'driver-9',
'driver-10',
]);
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.raceId).toBe('race-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.strengthOfField).toBe(1500);
expect(dto.registeredCount).toBe(10);
expect(dto.maxParticipants).toBe(20);
expect(dto.participantCount).toBe(10);
expect(dto.sessionType).toBe('main');
expect(dto.status).toBe('scheduled');
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]];
expect(presented.race.id).toBe('race-1');
expect(presented.race.leagueId).toBe('league-1');
expect(presented.strengthOfField).toBe(1500);
expect(presented.registeredCount).toBe(10);
expect(presented.maxParticipants).toBe(20);
expect(presented.participantCount).toBe(10);
});
it('should calculate SOF for upcoming race using registrations', async () => {
@@ -92,17 +121,23 @@ describe('GetRaceWithSOFUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
if (input.driverId === 'driver-1') {
return Promise.resolve({ rating: 1400, ratingChange: null });
}
if (input.driverId === 'driver-2') {
return Promise.resolve({ rating: 1600, ratingChange: null });
}
return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1500); // average
expect(dto.participantCount).toBe(2);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]];
expect(presented.strengthOfField).toBe(1500); // average
expect(presented.participantCount).toBe(2);
expect(registrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1');
expect(resultRepository.findByRaceId).not.toHaveBeenCalled();
});
@@ -124,17 +159,23 @@ describe('GetRaceWithSOFUseCase', () => {
{ driverId: 'driver-2' },
]);
getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
if (input.driverId === 'driver-2') return Promise.resolve({ rating: 1600, ratingChange: null });
if (input.driverId === 'driver-1') {
return Promise.resolve({ rating: 1400, ratingChange: null });
}
if (input.driverId === 'driver-2') {
return Promise.resolve({ rating: 1600, ratingChange: null });
}
return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1500);
expect(dto.participantCount).toBe(2);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]];
expect(presented.strengthOfField).toBe(1500);
expect(presented.participantCount).toBe(2);
expect(resultRepository.findByRaceId).toHaveBeenCalledWith('race-1');
expect(registrationRepository.getRegisteredDrivers).not.toHaveBeenCalled();
});
@@ -153,17 +194,21 @@ describe('GetRaceWithSOFUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
getDriverRating.mockImplementation((input) => {
if (input.driverId === 'driver-1') return Promise.resolve({ rating: 1400, ratingChange: null });
if (input.driverId === 'driver-1') {
return Promise.resolve({ rating: 1400, ratingChange: null });
}
// driver-2 missing
return Promise.resolve({ rating: null, ratingChange: null });
});
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(1400); // only one rating
expect(dto.participantCount).toBe(2);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]];
expect(presented.strengthOfField).toBe(1400); // only one rating
expect(presented.participantCount).toBe(2);
});
it('should return null SOF when no participants', async () => {
@@ -180,11 +225,28 @@ describe('GetRaceWithSOFUseCase', () => {
raceRepository.findById.mockResolvedValue(race);
registrationRepository.getRegisteredDrivers.mockResolvedValue([]);
const result = await useCase.execute({ raceId: 'race-1' });
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.strengthOfField).toBe(null);
expect(dto.participantCount).toBe(0);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const [[presented]] = output.present.mock.calls as [[GetRaceWithSOFResult]];
expect(presented.strengthOfField).toBe(null);
expect(presented.participantCount).toBe(0);
});
});
it('should wrap repository errors in REPOSITORY_ERROR and not call output', async () => {
raceRepository.findById.mockRejectedValue(new Error('boom'));
const result = await useCase.execute({ raceId: 'race-1' } as GetRaceWithSOFInput);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<
GetRaceWithSOFErrorCode,
{ message: string }
>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details?.message).toBe('boom');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -7,90 +7,114 @@
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { GetDriverRatingInputPort } from '../ports/input/GetDriverRatingInputPort';
import type { GetDriverRatingOutputPort } from '../ports/output/GetDriverRatingOutputPort';
import type { RaceWithSOFOutputPort } from '../ports/output/RaceWithSOFOutputPort';
import {
AverageStrengthOfFieldCalculator,
type StrengthOfFieldCalculator,
} from '../../domain/services/StrengthOfFieldCalculator';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Race } from '../../domain/entities/Race';
export interface GetRaceWithSOFQueryParams {
export interface GetRaceWithSOFInput {
raceId: string;
}
type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND';
export type GetRaceWithSOFErrorCode = 'RACE_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetRaceWithSOFUseCase implements AsyncUseCase<GetRaceWithSOFQueryParams, RaceWithSOFOutputPort, GetRaceWithSOFErrorCode> {
export type GetRaceWithSOFResult = {
race: Race;
strengthOfField: number | null;
participantCount: number;
registeredCount: number;
maxParticipants: number;
};
type GetDriverRating = (input: { driverId: string }) => Promise<{ rating: number | null }>;
export class GetRaceWithSOFUseCase {
private readonly sofCalculator: StrengthOfFieldCalculator;
constructor(
private readonly raceRepository: IRaceRepository,
private readonly registrationRepository: IRaceRegistrationRepository,
private readonly resultRepository: IResultRepository,
private readonly getDriverRating: (input: GetDriverRatingInputPort) => Promise<GetDriverRatingOutputPort>,
private readonly getDriverRating: GetDriverRating,
private readonly output: UseCaseOutputPort<GetRaceWithSOFResult>,
sofCalculator?: StrengthOfFieldCalculator,
) {
this.sofCalculator = sofCalculator ?? new AverageStrengthOfFieldCalculator();
}
async execute(params: GetRaceWithSOFQueryParams): Promise<Result<RaceWithSOFOutputPort, ApplicationErrorCode<GetRaceWithSOFErrorCode>>> {
async execute(
params: GetRaceWithSOFInput,
): Promise<Result<void, ApplicationErrorCode<GetRaceWithSOFErrorCode, { message: string }>>> {
const { raceId } = params;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({ code: 'RACE_NOT_FOUND' });
try {
const race = await this.raceRepository.findById(raceId);
if (!race) {
return Result.err({
code: 'RACE_NOT_FOUND',
details: { message: `Race with id ${raceId} not found` },
});
}
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId.toString());
} else {
// For upcoming/running races, use registrations
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
}
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) {
// Get ratings for all participants using clean ports
const ratingPromises = participantIds.map(driverId =>
this.getDriverRating({ driverId }),
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = participantIds.reduce<{ driverId: string; rating: number }[]>(
(acc, driverId, index) => {
const ratingResult = ratingResults[index];
if (ratingResult && ratingResult.rating !== null) {
acc.push({ driverId, rating: ratingResult.rating });
}
return acc;
},
[],
);
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
const result: GetRaceWithSOFResult = {
race,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
participantCount: participantIds.length,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error) {
const message =
(error as Error)?.message ?? 'Failed to load race with SOF';
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
// Get participant IDs based on race status
let participantIds: string[] = [];
if (race.status === 'completed') {
// For completed races, use results
const results = await this.resultRepository.findByRaceId(raceId);
participantIds = results.map(r => r.driverId);
} else {
// For upcoming/running races, use registrations
participantIds = await this.registrationRepository.getRegisteredDrivers(raceId);
}
// Use stored SOF if available, otherwise calculate
let strengthOfField = race.strengthOfField ?? null;
if (strengthOfField === null && participantIds.length > 0) {
// Get ratings for all participants using clean ports
const ratingPromises = participantIds.map(driverId =>
this.getDriverRating({ driverId })
);
const ratingResults = await Promise.all(ratingPromises);
const driverRatings = participantIds
.filter((_, index) => ratingResults[index].rating !== null)
.map((driverId, index) => ({
driverId,
rating: ratingResults[index].rating!
}));
strengthOfField = this.sofCalculator.calculate(driverRatings);
}
const outputPort: RaceWithSOFOutputPort = {
id: race.id,
leagueId: race.leagueId,
scheduledAt: race.scheduledAt,
track: race.track ?? '',
car: race.car ?? '',
status: race.status,
strengthOfField,
registeredCount: race.registeredCount ?? participantIds.length,
maxParticipants: race.maxParticipants ?? participantIds.length,
participantCount: participantIds.length,
};
return Result.ok(outputPort);
}
}

View File

@@ -1,23 +1,67 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetRacesPageDataUseCase } from './GetRacesPageDataUseCase';
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import {
GetRacesPageDataUseCase,
type GetRacesPageDataResult,
type GetRacesPageDataInput,
type GetRacesPageDataErrorCode,
} from './GetRacesPageDataUseCase';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { Result } from '@core/shared/application/Result';
describe('GetRacesPageDataUseCase', () => {
let useCase: GetRacesPageDataUseCase;
let raceRepository: { findAll: Mock };
let leagueRepository: { findAll: Mock };
let raceRepository: IRaceRepository;
let leagueRepository: ILeagueRepository;
let logger: Logger;
let output: UseCaseOutputPort<GetRacesPageDataResult> & { present: Mock };
beforeEach(() => {
raceRepository = { findAll: vi.fn() };
leagueRepository = { findAll: vi.fn() };
useCase = new GetRacesPageDataUseCase(
raceRepository as unknown as IRaceRepository,
leagueRepository as unknown as ILeagueRepository,
);
const raceFindAll = vi.fn();
const leagueFindAll = vi.fn();
raceRepository = {
findById: vi.fn(),
findAll: raceFindAll,
findByLeagueId: vi.fn(),
findUpcomingByLeagueId: vi.fn(),
findCompletedByLeagueId: vi.fn(),
findByStatus: vi.fn(),
findByDateRange: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
} as unknown as IRaceRepository;
leagueRepository = {
findById: vi.fn(),
findAll: leagueFindAll,
findByOwnerId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
searchByName: vi.fn(),
} as unknown as ILeagueRepository;
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetRacesPageDataResult> & { present: Mock };
useCase = new GetRacesPageDataUseCase(raceRepository, leagueRepository, logger, output);
});
it('should return races page data', async () => {
it('should present races page data for a league', async () => {
const races = [
{
id: 'race-1',
@@ -43,32 +87,48 @@ describe('GetRacesPageDataUseCase', () => {
isLive: () => false,
isPast: () => true,
},
];
const leagues = [
{ id: 'league-1', name: 'League 1' },
];
] as any[];
raceRepository.findAll.mockResolvedValue(races);
leagueRepository.findAll.mockResolvedValue(leagues);
const leagues = [{ id: 'league-1', name: 'League 1' }] as any[];
const result = await useCase.execute();
(raceRepository.findAll as Mock).mockResolvedValue(races);
(leagueRepository.findAll as Mock).mockResolvedValue(leagues);
const input: GetRacesPageDataInput = { leagueId: 'league-1' };
const result: Result<void, ApplicationErrorCode<GetRacesPageDataErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.races).toHaveLength(2);
expect(dto.races[0]).toEqual({
id: 'race-1',
track: 'Track 1',
car: 'Car 1',
scheduledAt: '2023-01-01T10:00:00.000Z',
status: 'scheduled',
leagueId: 'league-1',
leagueName: 'League 1',
strengthOfField: 1500,
isUpcoming: true,
isLive: false,
isPast: false,
});
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0]! as GetRacesPageDataResult;
expect(presented.leagueId).toBe('league-1');
expect(presented.races).toHaveLength(2);
expect(presented.races[0].race.id).toBe('race-1');
expect(presented.races[0].leagueName).toBe('League 1');
expect(presented.races[1].race.id).toBe('race-2');
});
});
it('should return repository error when repositories throw and not present data', async () => {
const error = new Error('Repository error');
(raceRepository.findAll as Mock).mockRejectedValue(error);
const input: GetRacesPageDataInput = { leagueId: 'league-1' };
const result: Result<void, ApplicationErrorCode<GetRacesPageDataErrorCode, { message: string }>> =
await useCase.execute(input);
expect(result.isErr()).toBe(true);
const err = result.unwrapErr() as ApplicationErrorCode<GetRacesPageDataErrorCode, { message: string }>;
expect(err.code).toBe('REPOSITORY_ERROR');
expect(err.details.message).toBe('Repository error');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -1,43 +1,80 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { RacesPageOutputPort } from '../ports/output/RacesPageOutputPort';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { Race } from '../../domain/entities/Race';
export class GetRacesPageDataUseCase implements AsyncUseCase<void, RacesPageOutputPort, 'NO_ERROR'> {
export type GetRacesPageDataInput = {
leagueId: string;
};
export type GetRacesPageRaceItem = {
race: Race;
leagueName: string;
};
export type GetRacesPageDataResult = {
leagueId: string;
races: GetRacesPageRaceItem[];
};
export type GetRacesPageDataErrorCode = 'REPOSITORY_ERROR';
export class GetRacesPageDataUseCase {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
private readonly output: UseCaseOutputPort<GetRacesPageDataResult>,
) {}
async execute(): Promise<Result<RacesPageOutputPort, ApplicationErrorCode<'NO_ERROR'>>> {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
async execute(
input: GetRacesPageDataInput,
): Promise<Result<void, ApplicationErrorCode<GetRacesPageDataErrorCode, { message: string }>>> {
this.logger.debug('GetRacesPageDataUseCase:execute', { input });
const leagueMap = new Map(allLeagues.map(l => [l.id, l.name]));
try {
const [allRaces, allLeagues] = await Promise.all([
this.raceRepository.findAll(),
this.leagueRepository.findAll(),
]);
const races = allRaces
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime())
.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status,
leagueId: race.leagueId,
strengthOfField: race.strengthOfField,
const leagueMap = new Map(allLeagues.map(league => [league.id, league.name]));
const filteredRaces = allRaces
.filter(race => race.leagueId === input.leagueId)
.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
const races: GetRacesPageRaceItem[] = filteredRaces.map(race => ({
race,
leagueName: leagueMap.get(race.leagueId) ?? 'Unknown League',
}));
const outputPort: RacesPageOutputPort = {
page: 1,
pageSize: races.length,
totalCount: races.length,
races,
};
const result: GetRacesPageDataResult = {
leagueId: input.leagueId,
races,
};
return Result.ok(outputPort);
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error instanceof Error && error.message
? error.message
: 'Failed to load races page data';
this.logger.error(
'GetRacesPageDataUseCase:execution error',
error instanceof Error ? error : new Error(message),
);
return Result.err({
code: 'REPOSITORY_ERROR',
details: { message },
});
}
}
}
}

View File

@@ -1,8 +1,15 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetSeasonDetailsUseCase } from './GetSeasonDetailsUseCase';
import {
GetSeasonDetailsUseCase,
type GetSeasonDetailsInput,
type GetSeasonDetailsResult,
type GetSeasonDetailsErrorCode,
} from './GetSeasonDetailsUseCase';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Season } from '../../domain/entities/Season';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetSeasonDetailsUseCase', () => {
let useCase: GetSeasonDetailsUseCase;
@@ -12,6 +19,9 @@ describe('GetSeasonDetailsUseCase', () => {
let seasonRepository: {
findById: Mock;
};
let output: UseCaseOutputPort<GetSeasonDetailsResult> & {
present: Mock;
};
beforeEach(() => {
leagueRepository = {
@@ -20,9 +30,14 @@ describe('GetSeasonDetailsUseCase', () => {
seasonRepository = {
findById: vi.fn(),
};
output = {
present: vi.fn(),
};
useCase = new GetSeasonDetailsUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
output,
);
});
@@ -39,34 +54,51 @@ describe('GetSeasonDetailsUseCase', () => {
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(season);
const result = await useCase.execute({
const input: GetSeasonDetailsInput = {
leagueId: 'league-1',
seasonId: 'season-1',
});
};
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dto = result.unwrap();
expect(dto.seasonId).toBe('season-1');
expect(dto.leagueId).toBe('league-1');
expect(dto.gameId).toBe('iracing');
expect(dto.name).toBe('Detailed Season');
expect(dto.status).toBe('planned');
expect(dto.maxDrivers).toBe(24);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented =
(output.present.mock.calls[0]?.[0] as GetSeasonDetailsResult | undefined) ??
undefined;
expect(presented).toBeDefined();
expect(presented?.leagueId).toBe('league-1');
expect(presented?.season.id).toBe('season-1');
expect(presented?.season.leagueId).toBe('league-1');
expect(presented?.season.gameId).toBe('iracing');
expect(presented?.season.name).toBe('Detailed Season');
expect(presented?.season.status).toBe('planned');
expect(presented?.season.maxDrivers).toBe(24);
});
it('returns error when league not found', async () => {
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
const input: GetSeasonDetailsInput = {
leagueId: 'league-1',
seasonId: 'season-1',
});
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'LEAGUE_NOT_FOUND',
details: { message: 'League not found: league-1' },
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetSeasonDetailsErrorCode,
{ message: string }
>;
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details.message).toBe('League not found: league-1');
expect(output.present).not.toHaveBeenCalled();
});
it('returns error when season not found', async () => {
@@ -74,16 +106,25 @@ describe('GetSeasonDetailsUseCase', () => {
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({
const input: GetSeasonDetailsInput = {
leagueId: 'league-1',
seasonId: 'season-1',
});
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season season-1 does not belong to league league-1' },
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetSeasonDetailsErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(error.details.message).toBe(
'Season season-1 does not belong to league league-1',
);
expect(output.present).not.toHaveBeenCalled();
});
it('returns error when season belongs to different league', async () => {
@@ -99,15 +140,48 @@ describe('GetSeasonDetailsUseCase', () => {
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findById.mockResolvedValue(season);
const result = await useCase.execute({
const input: GetSeasonDetailsInput = {
leagueId: 'league-1',
seasonId: 'season-1',
});
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'SEASON_NOT_FOUND',
details: { message: 'Season season-1 does not belong to league league-1' },
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetSeasonDetailsErrorCode,
{ message: string }
>;
expect(error.code).toBe('SEASON_NOT_FOUND');
expect(error.details.message).toBe(
'Season season-1 does not belong to league league-1',
);
expect(output.present).not.toHaveBeenCalled();
});
it('returns repository error when an unexpected exception occurs', async () => {
leagueRepository.findById.mockRejectedValue(
new Error('Unexpected repository failure'),
);
const input: GetSeasonDetailsInput = {
leagueId: 'league-1',
seasonId: 'season-1',
};
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetSeasonDetailsErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('Unexpected repository failure');
expect(output.present).not.toHaveBeenCalled();
});
});

View File

@@ -2,47 +2,23 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { Season } from '../../domain/entities/Season';
export interface GetSeasonDetailsQuery {
export type GetSeasonDetailsInput = {
leagueId: string;
seasonId: string;
}
};
export interface SeasonDetailsDTO {
seasonId: string;
leagueId: string;
gameId: string;
name: string;
status: import('../../domain/entities/Season').SeasonStatus;
startDate?: Date;
endDate?: Date;
maxDrivers?: number;
schedule?: {
startDate: Date;
plannedRounds: number;
};
scoring?: {
scoringPresetId: string;
customScoringEnabled: boolean;
};
dropPolicy?: {
strategy: import('../../domain/value-objects/SeasonDropPolicy').SeasonDropStrategy;
n?: number;
};
stewarding?: {
decisionMode: import('../../domain/entities/League').StewardingDecisionMode;
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}
export type GetSeasonDetailsResult = {
leagueId: Season['leagueId'];
season: Season;
};
type GetSeasonDetailsErrorCode = 'LEAGUE_NOT_FOUND' | 'SEASON_NOT_FOUND';
export type GetSeasonDetailsErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'SEASON_NOT_FOUND'
| 'REPOSITORY_ERROR';
/**
* GetSeasonDetailsUseCase
@@ -51,82 +27,51 @@ export class GetSeasonDetailsUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly output: UseCaseOutputPort<GetSeasonDetailsResult>,
) {}
async execute(query: GetSeasonDetailsQuery): Promise<Result<SeasonDetailsDTO, ApplicationErrorCode<GetSeasonDetailsErrorCode>>> {
const league = await this.leagueRepository.findById(query.leagueId);
if (!league) {
async execute(
input: GetSeasonDetailsInput,
): Promise<
Result<void, ApplicationErrorCode<GetSeasonDetailsErrorCode, { message: string }>>
> {
try {
const league = await this.leagueRepository.findById(input.leagueId);
if (!league) {
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: `League not found: ${input.leagueId}` },
});
}
const season = await this.seasonRepository.findById(input.seasonId);
if (!season || season.leagueId !== league.id) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: {
message: `Season ${input.seasonId} does not belong to league ${league.id}`,
},
});
}
const result: GetSeasonDetailsResult = {
leagueId: league.id,
season,
};
this.output.present(result);
return Result.ok(undefined);
} catch (error: unknown) {
const message =
error && typeof (error as Error).message === 'string'
? (error as Error).message
: 'Failed to load season details';
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: { message: `League not found: ${query.leagueId}` },
code: 'REPOSITORY_ERROR',
details: { message },
});
}
const season = await this.seasonRepository.findById(query.seasonId);
if (!season || season.leagueId !== league.id) {
return Result.err({
code: 'SEASON_NOT_FOUND',
details: { message: `Season ${query.seasonId} does not belong to league ${league.id}` },
});
}
return Result.ok({
seasonId: season.id,
leagueId: season.leagueId,
gameId: season.gameId,
name: season.name,
status: season.status,
...(season.startDate !== undefined ? { startDate: season.startDate } : {}),
...(season.endDate !== undefined ? { endDate: season.endDate } : {}),
...(season.maxDrivers !== undefined ? { maxDrivers: season.maxDrivers } : {}),
...(season.schedule
? {
schedule: {
startDate: season.schedule.startDate,
plannedRounds: season.schedule.plannedRounds,
},
}
: {}),
...(season.scoringConfig
? {
scoring: {
scoringPresetId: season.scoringConfig.scoringPresetId,
customScoringEnabled:
season.scoringConfig.customScoringEnabled ?? false,
},
}
: {}),
...(season.dropPolicy
? {
dropPolicy: {
strategy: season.dropPolicy.strategy,
...(season.dropPolicy.n !== undefined
? { n: season.dropPolicy.n }
: {}),
},
}
: {}),
...(season.stewardingConfig
? {
stewarding: {
decisionMode: season.stewardingConfig.decisionMode,
...(season.stewardingConfig.requiredVotes !== undefined
? { requiredVotes: season.stewardingConfig.requiredVotes }
: {}),
requireDefense: season.stewardingConfig.requireDefense,
defenseTimeLimit: season.stewardingConfig.defenseTimeLimit,
voteTimeLimit: season.stewardingConfig.voteTimeLimit,
protestDeadlineHours:
season.stewardingConfig.protestDeadlineHours,
stewardingClosesHours:
season.stewardingConfig.stewardingClosesHours,
notifyAccusedOnProtest:
season.stewardingConfig.notifyAccusedOnProtest,
notifyOnVoteRequired:
season.stewardingConfig.notifyOnVoteRequired,
},
}
: {}),
});
}
}

View File

@@ -3,45 +3,91 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { SponsorshipDetailOutput } from '../ports/output/SponsorSponsorshipsOutputPort';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export interface GetSeasonSponsorshipsParams {
export type GetSeasonSponsorshipsInput = {
seasonId: string;
}
};
export interface GetSeasonSponsorshipsOutputPort {
export type SeasonSponsorshipMetrics = {
drivers: number;
races: number;
completedRaces: number;
impressions: number;
};
export type SeasonSponsorshipFinancials = {
amount: number;
currency: string;
};
import type { LeagueId } from '../../domain/entities/LeagueId';
import type { LeagueName } from '../../domain/entities/LeagueName';
export type SeasonSponsorshipDetail = {
id: string;
leagueId: LeagueId;
leagueName: LeagueName;
seasonId: string;
sponsorships: SponsorshipDetailOutput[];
}
seasonName: string;
seasonStartDate?: Date;
seasonEndDate?: Date;
tier: string;
status: string;
pricing: SeasonSponsorshipFinancials;
platformFee: SeasonSponsorshipFinancials;
netAmount: SeasonSponsorshipFinancials;
metrics: SeasonSponsorshipMetrics;
createdAt: Date;
activatedAt?: Date;
};
export class GetSeasonSponsorshipsUseCase
implements AsyncUseCase<GetSeasonSponsorshipsParams, GetSeasonSponsorshipsOutputPort | null, 'REPOSITORY_ERROR'>
{
export type GetSeasonSponsorshipsResult = {
seasonId: string;
sponsorships: SeasonSponsorshipDetail[];
};
export type GetSeasonSponsorshipsErrorCode =
| 'SEASON_NOT_FOUND'
| 'LEAGUE_NOT_FOUND'
| 'REPOSITORY_ERROR';
export class GetSeasonSponsorshipsUseCase {
constructor(
private readonly seasonSponsorshipRepository: ISeasonSponsorshipRepository,
private readonly seasonRepository: ISeasonRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly output: UseCaseOutputPort<GetSeasonSponsorshipsResult>,
) {}
async execute(
params: GetSeasonSponsorshipsParams,
): Promise<Result<GetSeasonSponsorshipsOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
input: GetSeasonSponsorshipsInput,
): Promise<Result<void, ApplicationErrorCode<GetSeasonSponsorshipsErrorCode, { message: string }>>> {
try {
const { seasonId } = params;
const { seasonId } = input;
const season = await this.seasonRepository.findById(seasonId);
if (!season) {
return Result.ok(null);
return Result.err({
code: 'SEASON_NOT_FOUND',
details: {
message: 'Season not found',
},
});
}
const league = await this.leagueRepository.findById(season.leagueId);
if (!league) {
return Result.ok(null);
return Result.err({
code: 'LEAGUE_NOT_FOUND',
details: {
message: 'League not found for season',
},
});
}
const sponsorships = await this.seasonSponsorshipRepository.findBySeasonId(seasonId);
@@ -55,7 +101,7 @@ export class GetSeasonSponsorshipsUseCase
const completedRaces = races.filter(r => r.status === 'completed').length;
const impressions = completedRaces * driverCount * 100;
const sponsorshipDetails: SponsorshipDetailOutput[] = sponsorships.map(sponsorship => {
const sponsorshipDetails: SeasonSponsorshipDetail[] = sponsorships.map(sponsorship => {
const platformFee = sponsorship.getPlatformFee();
const netAmount = sponsorship.getNetAmount();
@@ -65,8 +111,8 @@ export class GetSeasonSponsorshipsUseCase
leagueName: league.name,
seasonId: season.id,
seasonName: season.name,
...(season.startDate !== undefined ? { seasonStartDate: season.startDate } : {}),
...(season.endDate !== undefined ? { seasonEndDate: season.endDate } : {}),
seasonStartDate: season.startDate,
seasonEndDate: season.endDate,
tier: sponsorship.tier,
status: sponsorship.status,
pricing: {
@@ -88,16 +134,25 @@ export class GetSeasonSponsorshipsUseCase
impressions,
},
createdAt: sponsorship.createdAt,
...(sponsorship.activatedAt !== undefined ? { activatedAt: sponsorship.activatedAt } : {}),
activatedAt: sponsorship.activatedAt,
};
});
return Result.ok({
this.output.present({
seasonId,
sponsorships: sponsorshipDetails,
});
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch season sponsorships' });
return Result.ok(undefined);
} catch (err) {
const error = err as { message?: string } | undefined;
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error?.message ?? 'Failed to fetch season sponsorships',
},
});
}
}
}

View File

@@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetSponsorDashboardUseCase } from './GetSponsorDashboardUseCase';
import {
GetSponsorDashboardUseCase,
type GetSponsorDashboardInput,
type GetSponsorDashboardResult,
type GetSponsorDashboardErrorCode,
} from './GetSponsorDashboardUseCase';
import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
@@ -11,6 +16,8 @@ import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import { Season } from '../../domain/entities/Season';
import { League } from '../../domain/entities/League';
import { Money } from '../../domain/value-objects/Money';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetSponsorDashboardUseCase', () => {
let useCase: GetSponsorDashboardUseCase;
@@ -32,6 +39,7 @@ describe('GetSponsorDashboardUseCase', () => {
let raceRepository: {
findByLeagueId: Mock;
};
let output: UseCaseOutputPort<GetSponsorDashboardResult> & { present: Mock };
beforeEach(() => {
sponsorRepository = {
@@ -52,6 +60,10 @@ describe('GetSponsorDashboardUseCase', () => {
raceRepository = {
findByLeagueId: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetSponsorDashboardResult> & { present: Mock };
useCase = new GetSponsorDashboardUseCase(
sponsorRepository as unknown as ISponsorRepository,
seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository,
@@ -59,10 +71,11 @@ describe('GetSponsorDashboardUseCase', () => {
leagueRepository as unknown as ILeagueRepository,
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
raceRepository as unknown as IRaceRepository,
output,
);
});
it('should return sponsor dashboard for existing sponsor', async () => {
it('should present sponsor dashboard for existing sponsor', async () => {
const sponsorId = 'sponsor-1';
const sponsor = Sponsor.create({
id: sponsorId,
@@ -99,34 +112,56 @@ describe('GetSponsorDashboardUseCase', () => {
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races);
const result = await useCase.execute({ sponsorId });
const input: GetSponsorDashboardInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const dashboard = result.unwrap();
expect(dashboard?.sponsorId).toBe(sponsorId);
expect(dashboard?.metrics.impressions).toBe(100); // 1 completed race * 1 driver * 100
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const dashboard = (output.present as Mock).mock.calls[0][0] as GetSponsorDashboardResult;
expect(dashboard.sponsorId).toBe(sponsorId);
expect(dashboard.metrics.impressions).toBe(100); // 1 completed race * 1 driver * 100
expect(dashboard.investment.totalInvestment.amount).toBe(10000);
expect(dashboard.investment.totalInvestment.currency).toBe('USD');
});
it('should return null for non-existing sponsor', async () => {
it('should return error when sponsor does not exist', async () => {
const sponsorId = 'sponsor-1';
sponsorRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ sponsorId });
const input: GetSponsorDashboardInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBe(null);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetSponsorDashboardErrorCode,
{ message: string }
>;
expect(error.code).toBe('SPONSOR_NOT_FOUND');
expect(error.details.message).toBe('Sponsor not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error on repository failure', async () => {
const sponsorId = 'sponsor-1';
sponsorRepository.findById.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ sponsorId });
const input: GetSponsorDashboardInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch sponsor dashboard',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetSponsorDashboardErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

View File

@@ -10,45 +10,58 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { SponsorDashboardOutputPort } from '../ports/output/SponsorDashboardOutputPort';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import { Money } from '../../domain/value-objects/Money';
export interface GetSponsorDashboardQueryParams {
export interface GetSponsorDashboardInput {
sponsorId: string;
}
export interface SponsoredLeagueDTO {
id: string;
name: string;
tier: 'main' | 'secondary';
export interface SponsoredLeagueMetrics {
drivers: number;
races: number;
impressions: number;
status: 'active' | 'upcoming' | 'completed';
}
export interface SponsorDashboardDTO {
export type SponsoredLeagueStatus = 'active' | 'upcoming' | 'completed';
export interface SponsoredLeagueSummary {
leagueId: string;
leagueName: string;
tier: 'main' | 'secondary';
metrics: SponsoredLeagueMetrics;
status: SponsoredLeagueStatus;
}
export interface SponsorDashboardMetrics {
impressions: number;
impressionsChange: number;
uniqueViewers: number;
viewersChange: number;
races: number;
drivers: number;
exposure: number;
exposureChange: number;
}
export interface SponsorInvestmentSummary {
activeSponsorships: number;
totalInvestment: Money;
costPerThousandViews: number;
}
export interface GetSponsorDashboardResult {
sponsorId: string;
sponsorName: string;
metrics: {
impressions: number;
impressionsChange: number;
uniqueViewers: number;
viewersChange: number;
races: number;
drivers: number;
exposure: number;
exposureChange: number;
};
sponsoredLeagues: SponsoredLeagueDTO[];
investment: {
activeSponsorships: number;
totalInvestment: number;
costPerThousandViews: number;
};
metrics: SponsorDashboardMetrics;
sponsoredLeagues: SponsoredLeagueSummary[];
investment: SponsorInvestmentSummary;
}
export type GetSponsorDashboardErrorCode = 'SPONSOR_NOT_FOUND' | 'REPOSITORY_ERROR';
export class GetSponsorDashboardUseCase {
constructor(
private readonly sponsorRepository: ISponsorRepository,
@@ -57,17 +70,23 @@ export class GetSponsorDashboardUseCase {
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly output: UseCaseOutputPort<GetSponsorDashboardResult>,
) {}
async execute(
params: GetSponsorDashboardQueryParams,
): Promise<Result<SponsorDashboardOutputPort | null, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
params: GetSponsorDashboardInput,
): Promise<Result<void, ApplicationErrorCode<GetSponsorDashboardErrorCode, { message: string }>>> {
try {
const { sponsorId } = params;
const sponsor = await this.sponsorRepository.findById(sponsorId);
if (!sponsor) {
return Result.ok(null);
return Result.err({
code: 'SPONSOR_NOT_FOUND',
details: {
message: 'Sponsor not found',
},
});
}
// Get all sponsorships for this sponsor
@@ -77,8 +96,8 @@ export class GetSponsorDashboardUseCase {
let totalImpressions = 0;
let totalDrivers = 0;
let totalRaces = 0;
let totalInvestment = 0;
const sponsoredLeagues: SponsoredLeagueDTO[] = [];
let totalInvestmentMoney = Money.create(0, 'USD');
const sponsoredLeagues: SponsoredLeagueSummary[] = [];
const seenLeagues = new Set<string>();
for (const sponsorship of sponsorships) {
@@ -104,14 +123,13 @@ export class GetSponsorDashboardUseCase {
totalRaces += raceCount;
// Calculate impressions based on completed races and drivers
// This is a simplified calculation - in production would come from analytics
const completedRaces = races.filter(r => r.status === 'completed').length;
const leagueImpressions = completedRaces * driverCount * 100; // Simplified: 100 views per driver per race
totalImpressions += leagueImpressions;
// Determine status based on season dates
const now = new Date();
let status: 'active' | 'upcoming' | 'completed' = 'active';
let status: SponsoredLeagueStatus = 'active';
if (season.endDate && season.endDate < now) {
status = 'completed';
} else if (season.startDate && season.startDate > now) {
@@ -119,22 +137,26 @@ export class GetSponsorDashboardUseCase {
}
// Add investment
totalInvestment += sponsorship.pricing.amount;
totalInvestmentMoney = totalInvestmentMoney.add(
Money.create(sponsorship.pricing.amount, sponsorship.pricing.currency),
);
sponsoredLeagues.push({
id: league.id,
name: league.name,
leagueId: league.id,
leagueName: league.name,
tier: sponsorship.tier,
drivers: driverCount,
races: raceCount,
impressions: leagueImpressions,
metrics: {
drivers: driverCount,
races: raceCount,
impressions: leagueImpressions,
},
status,
});
}
const activeSponsorships = sponsorships.filter(s => s.status === 'active').length;
const costPerThousandViews = totalImpressions > 0
? (totalInvestment / (totalImpressions / 1000))
? totalInvestmentMoney.amount / (totalImpressions / 1000)
: 0;
// Calculate unique viewers (simplified: assume 70% of impressions are unique)
@@ -146,7 +168,7 @@ export class GetSponsorDashboardUseCase {
? Math.min(100, (mainSponsorships * 30) + (sponsorships.length * 10))
: 0;
const outputPort: SponsorDashboardOutputPort = {
const result: GetSponsorDashboardResult = {
sponsorId,
sponsorName: sponsor.name,
metrics: {
@@ -162,14 +184,23 @@ export class GetSponsorDashboardUseCase {
sponsoredLeagues,
investment: {
activeSponsorships,
totalInvestment,
totalInvestment: totalInvestmentMoney,
costPerThousandViews: Math.round(costPerThousandViews * 100) / 100,
},
};
return Result.ok(outputPort);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch sponsor dashboard' });
this.output.present(result);
return Result.ok(undefined);
} catch (err) {
const error = err as { message?: string } | undefined;
return Result.err({
code: 'REPOSITORY_ERROR',
details: {
message: error?.message ?? 'Failed to fetch sponsor dashboard',
},
});
}
}
}
}

View File

@@ -1,16 +1,23 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetSponsorSponsorshipsUseCase } from './GetSponsorSponsorshipsUseCase';
import {
GetSponsorSponsorshipsUseCase,
type GetSponsorSponsorshipsInput,
type GetSponsorSponsorshipsResult,
type GetSponsorSponsorshipsErrorCode,
} from './GetSponsorSponsorshipsUseCase';
import { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { Sponsor } from '../../domain/entities/Sponsor';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import { Season } from '../../domain/entities/Season';
import { Sponsor } from '../../domain/entities/sponsor/Sponsor';
import { SeasonSponsorship } from '../../domain/entities/season/SeasonSponsorship';
import { Season } from '../../domain/entities/season/Season';
import { League } from '../../domain/entities/League';
import { Money } from '../../domain/value-objects/Money';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
describe('GetSponsorSponsorshipsUseCase', () => {
let useCase: GetSponsorSponsorshipsUseCase;
@@ -32,6 +39,7 @@ describe('GetSponsorSponsorshipsUseCase', () => {
let raceRepository: {
findByLeagueId: Mock;
};
let output: UseCaseOutputPort<GetSponsorSponsorshipsResult> & { present: Mock };
beforeEach(() => {
sponsorRepository = {
@@ -52,6 +60,10 @@ describe('GetSponsorSponsorshipsUseCase', () => {
raceRepository = {
findByLeagueId: vi.fn(),
};
output = {
present: vi.fn(),
} as unknown as UseCaseOutputPort<GetSponsorSponsorshipsResult> & { present: Mock };
useCase = new GetSponsorSponsorshipsUseCase(
sponsorRepository as unknown as ISponsorRepository,
seasonSponsorshipRepository as unknown as ISeasonSponsorshipRepository,
@@ -59,10 +71,11 @@ describe('GetSponsorSponsorshipsUseCase', () => {
leagueRepository as unknown as ILeagueRepository,
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
raceRepository as unknown as IRaceRepository,
output,
);
});
it('should return sponsor sponsorships for existing sponsor', async () => {
it('should present sponsor sponsorships for existing sponsor', async () => {
const sponsorId = 'sponsor-1';
const sponsor = Sponsor.create({
id: sponsorId,
@@ -99,34 +112,55 @@ describe('GetSponsorSponsorshipsUseCase', () => {
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races);
const result = await useCase.execute({ sponsorId });
const input: GetSponsorSponsorshipsInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
const data = result.unwrap();
expect(data?.sponsorId).toBe(sponsorId);
expect(data?.sponsorships).toHaveLength(1);
expect(result.unwrap()).toBeUndefined();
expect(output.present).toHaveBeenCalledTimes(1);
const presented = (output.present as Mock).mock.calls[0][0] as GetSponsorSponsorshipsResult;
expect(presented.sponsor).toBe(sponsor);
expect(presented.sponsorships).toHaveLength(1);
const summary = presented.summary;
expect(summary.totalSponsorships).toBe(1);
expect(summary.activeSponsorships).toBe(0); // status default may not be 'active'
expect(summary.totalInvestment.amount).toBe(10000);
expect(summary.totalInvestment.currency).toBe('USD');
});
it('should return null for non-existing sponsor', async () => {
it('should return SPONSOR_NOT_FOUND error for non-existing sponsor', async () => {
const sponsorId = 'sponsor-1';
sponsorRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ sponsorId });
const input: GetSponsorSponsorshipsInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBe(null);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr() as ApplicationErrorCode<
GetSponsorSponsorshipsErrorCode,
{ message: string }
>;
expect(error.code).toBe('SPONSOR_NOT_FOUND');
expect(error.details.message).toBe('Sponsor not found');
expect(output.present).not.toHaveBeenCalled();
});
it('should return error on repository failure', async () => {
it('should return REPOSITORY_ERROR on repository failure', async () => {
const sponsorId = 'sponsor-1';
sponsorRepository.findById.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ sponsorId });
const input: GetSponsorSponsorshipsInput = { sponsorId };
const result = await useCase.execute(input);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch sponsor sponsorships',
});
const error = result.unwrapErr() as ApplicationErrorCode<
GetSponsorSponsorshipsErrorCode,
{ message: string }
>;
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
});
});

Some files were not shown because too many files have changed in this diff Show More