refactor racing use cases
This commit is contained in:
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }>);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user