This commit is contained in:
2025-12-16 21:05:01 +01:00
parent f61e3a4e5a
commit 7532c7ed6d
207 changed files with 7861 additions and 2606 deletions

View File

@@ -1,6 +1,6 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
/**
* Port for authentication services implementing zero-knowledge login.

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { CheckoutConfirmation } from '../../domain/value-objects/CheckoutConfirmation';
import type { CheckoutConfirmationRequestDTO } from '../dto/CheckoutConfirmationRequestDTO';

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { CheckoutInfoDTO } from '../dto/CheckoutInfoDTO';
export interface CheckoutServicePort {

View File

@@ -1,4 +1,4 @@
import type { Result } from '@gridpilot/shared/result/Result';
import type { Result } from '@gridpilot/shared/application/Result';
export interface SessionValidatorPort {
validateSession(): Promise<Result<boolean>>;

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CheckAuthenticationUseCase } from 'apps/companion/main/automation/application/use-cases/CheckAuthenticationUseCase';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { Result } from '@core/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { AuthenticationServicePort } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort';
interface ISessionValidator {

View File

@@ -1,6 +1,6 @@
import { AuthenticationState } from '../../domain/value-objects/AuthenticationState';
import type { Logger } from '@core/shared/application';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import { SessionLifetime } from '../../domain/value-objects/SessionLifetime';
import type { SessionValidatorPort } from '../ports/SessionValidatorPort';

View File

@@ -2,7 +2,7 @@ import { vi, Mock } from 'vitest';
import { ClearSessionUseCase } from './ClearSessionUseCase';
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import type { Logger } from '@core/shared/application';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
describe('ClearSessionUseCase', () => {
let useCase: ClearSessionUseCase;

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import type { Logger } from '@core/shared/application';

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CompleteRaceCreationUseCase } from 'apps/companion/main/automation/application/use-cases/CompleteRaceCreationUseCase';
import { Result } from '@core/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { RaceCreationResult } from 'apps/companion/main/automation/domain/value-objects/RaceCreationResult';
import { CheckoutPrice } from 'apps/companion/main/automation/domain/value-objects/CheckoutPrice';
import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort';

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { RaceCreationResult } from '../../domain/value-objects/RaceCreationResult';
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
import type { Logger } from '@core/shared/application';

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { Result } from '@core/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { ConfirmCheckoutUseCase } from 'apps/companion/main/automation/application/use-cases/ConfirmCheckoutUseCase';
import type { CheckoutServicePort } from 'apps/companion/main/automation/application/ports/CheckoutServicePort';
import type { CheckoutConfirmationPort } from 'apps/companion/main/automation/application/ports/CheckoutConfirmationPort';

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { Logger } from '@core/shared/application';
import type { CheckoutServicePort } from '../ports/CheckoutServicePort';
import type { CheckoutConfirmationPort } from '../ports/CheckoutConfirmationPort';

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import type { Logger } from '@core/shared/application/Logger';

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { VerifyAuthenticatedPageUseCase } from 'apps/companion/main/automation/application/use-cases/VerifyAuthenticatedPageUseCase';
import { AuthenticationServicePort as IAuthenticationService } from 'apps/companion/main/automation/application/ports/AuthenticationServicePort';
import { Result } from '@core/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';

View File

@@ -1,5 +1,5 @@
import type { AuthenticationServicePort } from '../ports/AuthenticationServicePort';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { BrowserAuthenticationState } from '../../domain/value-objects/BrowserAuthenticationState';
import type { Logger } from '@core/shared/application';

View File

@@ -1,5 +1,5 @@
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
/**
* Configuration for page state validation.

View File

@@ -1,7 +1,7 @@
import { StepId } from '../value-objects/StepId';
import type { IDomainValidationService } from '@core/shared/domain';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { SessionState } from '../value-objects/SessionState';
export interface ValidationResult {

View File

@@ -1,4 +1,4 @@
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { CheckoutPrice } from '../../../domain/value-objects/CheckoutPrice';
import { CheckoutState } from '../../../domain/value-objects/CheckoutState';
import type { CheckoutInfoDTO } from '../../../application/dto/CheckoutInfoDTO';

View File

@@ -5,7 +5,7 @@ import type { AuthenticationServicePort } from '../../../../application/ports/Au
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { SessionCookieStore } from './SessionCookieStore';
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';

View File

@@ -3,7 +3,7 @@ import type { Page, Locator } from 'playwright';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import type { LoggerPort as Logger } from 'apps/companion/main/automation/application/ports/LoggerPort';
import type { Result } from '@core/shared/result/Result';
import type { Result } from '@gridpilot/shared/application/Result';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { PlaywrightAuthSessionService } from './PlaywrightAuthSessionService';

View File

@@ -2,7 +2,7 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
interface Cookie {

View File

@@ -16,7 +16,7 @@ import type { ModalResultDTO } from '../../../../application/dto/ModalResultDTO'
import type { AutomationResultDTO } from '../../../../application/dto/AutomationResultDTO';
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import { IRACING_SELECTORS, IRACING_URLS, IRACING_TIMEOUTS, BLOCKED_KEYWORDS } from '../dom/IRacingSelectors';
import { SessionCookieStore } from '../auth/SessionCookieStore';
import { PlaywrightBrowserSession } from './PlaywrightBrowserSession';

View File

@@ -18,7 +18,7 @@ import type {
PageStateValidation,
PageStateValidationResult,
} from '../../../../domain/services/PageStateValidator';
import type { Result } from '@gridpilot/shared/result/Result';
import type { Result } from '@gridpilot/shared/application/Result';
interface WizardStepOrchestratorDeps {
config: Required<PlaywrightConfig>;

View File

@@ -5,7 +5,7 @@
import type { BrowserWindow } from 'electron';
import { ipcMain } from 'electron';
import { Result } from '@gridpilot/shared/result/Result';
import { Result } from '@gridpilot/shared/application/Result';
import type { CheckoutConfirmationPort } from '../../../application/ports/CheckoutConfirmationPort';
import type { CheckoutConfirmationRequestDTO } from '../../../application/dto/CheckoutConfirmationRequestDTO';
import { CheckoutConfirmation } from '../../../domain/value-objects/CheckoutConfirmation';

View File

@@ -0,0 +1,3 @@
export interface GetLeagueOwnerSummaryResultDTO {
summary: { driver: { id: string; name: string }; rating: number; rank: number } | null;
}

View File

@@ -0,0 +1,26 @@
export interface ProtestDTO {
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
submittedAt: Date;
description: string;
status: string;
}
export interface RaceDTO {
id: string;
name: string;
date: string;
}
export interface DriverDTO {
id: string;
name: string;
}
export interface GetLeagueProtestsResultDTO {
protests: ProtestDTO[];
races: RaceDTO[];
drivers: DriverDTO[];
}

View File

@@ -0,0 +1,7 @@
export interface GetLeagueScheduleResultDTO {
races: Array<{
id: string;
name: string;
scheduledAt: Date;
}>;
}

View File

@@ -1,8 +1,8 @@
import type { IApplicationError, CommonApplicationErrorKind } from '@core/shared/errors';
import type { ApplicationError, CommonApplicationErrorKind } from '@core/shared/errors';
export abstract class RacingApplicationError
extends Error
implements IApplicationError<CommonApplicationErrorKind | string, unknown>
implements ApplicationError<CommonApplicationErrorKind | string, unknown>
{
readonly type = 'application' as const;
readonly context = 'racing-application';
@@ -33,7 +33,7 @@ export interface EntityNotFoundDetails {
export class EntityNotFoundError
extends RacingApplicationError
implements IApplicationError<'not_found', EntityNotFoundDetails>
implements ApplicationError<'not_found', EntityNotFoundDetails>
{
readonly kind = 'not_found' as const;
readonly details: EntityNotFoundDetails;
@@ -55,7 +55,7 @@ export type PermissionDeniedReason =
export class PermissionDeniedError
extends RacingApplicationError
implements IApplicationError<'forbidden', PermissionDeniedReason>
implements ApplicationError<'forbidden', PermissionDeniedReason>
{
readonly kind = 'forbidden' as const;
@@ -70,7 +70,7 @@ export class PermissionDeniedError
export class BusinessRuleViolationError
extends RacingApplicationError
implements IApplicationError<'conflict', undefined>
implements ApplicationError<'conflict', undefined>
{
readonly kind = 'conflict' as const;

View File

@@ -15,16 +15,13 @@ import type { IWalletRepository } from '@core/payments/domain/repositories/IWall
import type { ILeagueWalletRepository } from '../../domain/repositories/ILeagueWalletRepository';
import { SeasonSponsorship } from '../../domain/entities/SeasonSponsorship';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import {
RacingDomainValidationError,
RacingDomainInvariantError,
} from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { AcceptSponsorshipRequestDTO } from '../dto/AcceptSponsorshipRequestDTO';
import type { AcceptSponsorshipRequestResultDTO } from '../dto/AcceptSponsorshipRequestResultDTO';
export class AcceptSponsorshipRequestUseCase
implements AsyncUseCase<AcceptSponsorshipRequestDTO, Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
implements AsyncUseCase<AcceptSponsorshipRequestDTO, AcceptSponsorshipRequestResultDTO, string> {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly seasonSponsorshipRepo: ISeasonSponsorshipRepository,
@@ -36,19 +33,19 @@ export class AcceptSponsorshipRequestUseCase
private readonly logger: Logger,
) {}
async execute(dto: AcceptSponsorshipRequestDTO): Promise<Result<AcceptSponsorshipRequestResultDTO, RacingDomainValidationError | RacingDomainInvariantError>> {
async execute(dto: AcceptSponsorshipRequestDTO): Promise<Result<AcceptSponsorshipRequestResultDTO, ApplicationErrorCode<string>>> {
this.logger.debug(`Attempting to accept sponsorship request: ${dto.requestId}`, { requestId: dto.requestId, respondedBy: dto.respondedBy });
// Find the request
const request = await this.sponsorshipRequestRepo.findById(dto.requestId);
if (!request) {
this.logger.warn(`Sponsorship request not found: ${dto.requestId}`, { requestId: dto.requestId });
return Result.err(new RacingDomainValidationError('Sponsorship request not found'));
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 });
return Result.err(new RacingDomainValidationError(`Cannot accept a ${request.status} sponsorship request`));
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 });
@@ -66,7 +63,7 @@ export class AcceptSponsorshipRequestUseCase
const season = await this.seasonRepository.findById(request.entityId);
if (!season) {
this.logger.warn(`Season not found for sponsorship request ${dto.requestId} and entityId ${request.entityId}`, { requestId: dto.requestId, entityId: request.entityId });
return Result.err(new RacingDomainValidationError('Season not found for sponsorship request'));
return Result.err({ code: 'SEASON_NOT_FOUND' });
}
const sponsorship = SeasonSponsorship.create({
@@ -104,20 +101,20 @@ export class AcceptSponsorshipRequestUseCase
);
if (!paymentResult.success) {
this.logger.error(`Payment failed for sponsorship request ${request.id}: ${paymentResult.error}`, undefined, { requestId: request.id });
return Result.err(new RacingDomainInvariantError('Payment processing failed'));
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 });
return Result.err(new RacingDomainInvariantError('Sponsor wallet not found'));
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 });
return Result.err(new RacingDomainInvariantError('League wallet not found'));
return Result.err({ code: 'LEAGUE_WALLET_NOT_FOUND' });
}
const netAmount = acceptedRequest.getNetAmount();

View File

@@ -60,7 +60,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Sponsor not found');
expect(result.error!.code).toBe('SPONSOR_NOT_FOUND');
});
it('should return error when sponsorship pricing is not set up', async () => {
@@ -83,7 +83,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('This entity has not set up sponsorship pricing');
expect(result.error!.code).toBe('SPONSORSHIP_PRICING_NOT_SETUP');
});
it('should return error when entity is not accepting applications', async () => {
@@ -110,7 +110,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('This entity is not currently accepting sponsorship applications');
expect(result.error!.code).toBe('ENTITY_NOT_ACCEPTING_APPLICATIONS');
});
it('should return error when no slots are available', async () => {
@@ -137,7 +137,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('No main sponsorship slots are available');
expect(result.error!.code).toBe('NO_SLOTS_AVAILABLE');
});
it('should return error when sponsor has pending request', async () => {
@@ -165,7 +165,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('You already have a pending sponsorship request for this entity');
expect(result.error!.code).toBe('PENDING_REQUEST_EXISTS');
});
it('should return error when offered amount is less than minimum', async () => {
@@ -193,7 +193,7 @@ describe('ApplyForSponsorshipUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Offered amount must be at least $15.00');
expect(result.error!.code).toBe('OFFERED_AMOUNT_TOO_LOW');
});
it('should create sponsorship request and return result on success', async () => {

View File

@@ -11,14 +11,14 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/IS
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import { Money } from '../../domain/value-objects/Money';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ApplyForSponsorshipDTO } from '../dto/ApplyForSponsorshipDTO';
import type { ApplyForSponsorshipResultDTO } from '../dto/ApplyForSponsorshipResultDTO';
export class ApplyForSponsorshipUseCase
implements AsyncUseCase<ApplyForSponsorshipDTO, Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<ApplyForSponsorshipDTO, ApplyForSponsorshipResultDTO, string>
{
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
@@ -27,33 +27,33 @@ export class ApplyForSponsorshipUseCase
private readonly logger: Logger,
) {}
async execute(dto: ApplyForSponsorshipDTO): Promise<Result<ApplyForSponsorshipResultDTO, RacingDomainValidationError>> {
async execute(dto: ApplyForSponsorshipDTO): Promise<Result<ApplyForSponsorshipResultDTO, ApplicationErrorCode<string>>> {
this.logger.debug('Attempting to apply for sponsorship', { dto });
// Validate sponsor exists
const sponsor = await this.sponsorRepo.findById(dto.sponsorId);
if (!sponsor) {
this.logger.error('Sponsor not found', undefined, { sponsorId: dto.sponsorId });
return Result.err(new RacingDomainValidationError('Sponsor not found'));
return Result.err({ code: 'SPONSOR_NOT_FOUND' });
}
// Check if entity accepts sponsorship applications
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
if (!pricing) {
this.logger.warn('Sponsorship pricing not set up for this entity', { entityType: dto.entityType, entityId: dto.entityId });
return Result.err(new RacingDomainValidationError('This entity has not set up sponsorship pricing'));
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 });
return Result.err(new RacingDomainValidationError('This entity is not currently accepting sponsorship applications'));
return Result.err({ code: 'ENTITY_NOT_ACCEPTING_APPLICATIONS' });
}
// Check if the requested tier slot is available
const slotAvailable = pricing.isSlotAvailable(dto.tier);
if (!slotAvailable) {
this.logger.warn(`No ${dto.tier} sponsorship slots are available for entity ${dto.entityId}`);
return Result.err(new RacingDomainValidationError(`No ${dto.tier} sponsorship slots are available`));
return Result.err({ code: 'NO_SLOTS_AVAILABLE' });
}
// Check if sponsor already has a pending request for this entity
@@ -64,14 +64,14 @@ export class ApplyForSponsorshipUseCase
);
if (hasPending) {
this.logger.warn('Sponsor already has a pending request for this entity', { sponsorId: dto.sponsorId, entityType: dto.entityType, entityId: dto.entityId });
return Result.err(new RacingDomainValidationError('You already have a pending sponsorship request for this entity'));
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}`);
return Result.err(new RacingDomainValidationError(`Offered amount must be at least ${minPrice.format()}`));
return Result.err({ code: 'OFFERED_AMOUNT_TOO_LOW' });
}
// Create the sponsorship request

View File

@@ -68,7 +68,7 @@ describe('ApplyPenaltyUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Race not found');
expect(result.error!.code).toBe('RACE_NOT_FOUND');
});
it('should return error when steward does not have authority', async () => {
@@ -95,7 +95,7 @@ describe('ApplyPenaltyUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Only league owners and admins can apply penalties');
expect(result.error!.code).toBe('INSUFFICIENT_AUTHORITY');
});
it('should return error when protest does not exist', async () => {
@@ -124,7 +124,7 @@ describe('ApplyPenaltyUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Protest not found');
expect(result.error!.code).toBe('PROTEST_NOT_FOUND');
});
it('should return error when protest is not upheld', async () => {
@@ -153,7 +153,7 @@ describe('ApplyPenaltyUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Can only create penalties for upheld protests');
expect(result.error!.code).toBe('PROTEST_NOT_UPHELD');
});
it('should return error when protest is not for this race', async () => {
@@ -182,7 +182,7 @@ describe('ApplyPenaltyUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Protest is not for this race');
expect(result.error!.code).toBe('PROTEST_NOT_FOR_RACE');
});
it('should create penalty and return result on success', async () => {

View File

@@ -12,13 +12,13 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { randomUUID } from 'crypto';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ApplyPenaltyCommand } from './ApplyPenaltyCommand';
export class ApplyPenaltyUseCase
implements AsyncUseCase<ApplyPenaltyCommand, Result<{ penaltyId: string }, RacingDomainValidationError>> {
implements AsyncUseCase<ApplyPenaltyCommand, { penaltyId: string }, string> {
constructor(
private readonly penaltyRepository: IPenaltyRepository,
private readonly protestRepository: IProtestRepository,
@@ -27,14 +27,14 @@ export class ApplyPenaltyUseCase
private readonly logger: Logger,
) {}
async execute(command: ApplyPenaltyCommand): Promise<Result<{ penaltyId: string }, RacingDomainValidationError>> {
async execute(command: ApplyPenaltyCommand): Promise<Result<{ penaltyId: string }, ApplicationErrorCode<string>>> {
this.logger.debug('ApplyPenaltyUseCase: Executing with command', command);
// Validate race exists
const race = await this.raceRepository.findById(command.raceId);
if (!race) {
this.logger.warn(`ApplyPenaltyUseCase: Race with ID ${command.raceId} not found.`);
return Result.err(new RacingDomainValidationError('Race not found'));
return Result.err({ code: 'RACE_NOT_FOUND' });
}
this.logger.debug(`ApplyPenaltyUseCase: Race ${race.id} found.`);
@@ -46,7 +46,7 @@ export class ApplyPenaltyUseCase
if (!stewardMembership || (stewardMembership.role !== 'owner' && stewardMembership.role !== 'admin')) {
this.logger.warn(`ApplyPenaltyUseCase: Steward ${command.stewardId} does not have authority for league ${race.leagueId}.`);
return Result.err(new RacingDomainValidationError('Only league owners and admins can apply penalties'));
return Result.err({ code: 'INSUFFICIENT_AUTHORITY' });
}
this.logger.debug(`ApplyPenaltyUseCase: Steward ${command.stewardId} has authority.`);
@@ -55,15 +55,15 @@ export class ApplyPenaltyUseCase
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
this.logger.warn(`ApplyPenaltyUseCase: Protest with ID ${command.protestId} not found.`);
return Result.err(new RacingDomainValidationError('Protest not found'));
return Result.err({ code: 'PROTEST_NOT_FOUND' });
}
if (protest.status !== 'upheld') {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is not upheld. Status: ${protest.status}`);
return Result.err(new RacingDomainValidationError('Can only create penalties for upheld protests'));
return Result.err({ code: 'PROTEST_NOT_UPHELD' });
}
if (protest.raceId !== command.raceId) {
this.logger.warn(`ApplyPenaltyUseCase: Protest ${protest.id} is for race ${protest.raceId}, not ${command.raceId}.`);
return Result.err(new RacingDomainValidationError('Protest is not for this race'));
return Result.err({ code: 'PROTEST_NOT_FOR_RACE' });
}
this.logger.debug(`ApplyPenaltyUseCase: Protest ${protest.id} is valid and upheld.`);
}

View File

@@ -1,45 +1,54 @@
import { ApproveLeagueJoinRequestUseCase } from '@core/racing/application/use-cases/ApproveLeagueJoinRequestUseCase';
import { ApproveLeagueJoinRequestPresenter } from '@apps/api/src/modules/league/presenters/ApproveLeagueJoinRequestPresenter';
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { ApproveLeagueJoinRequestUseCase } from './ApproveLeagueJoinRequestUseCase';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
describe('ApproveLeagueJoinRequestUseCase', () => {
let useCase: ApproveLeagueJoinRequestUseCase;
let leagueMembershipRepository: jest.Mocked<ILeagueMembershipRepository>;
let presenter: ApproveLeagueJoinRequestPresenter;
let mockLeagueMembershipRepo: {
getJoinRequests: Mock;
removeJoinRequest: Mock;
saveMembership: Mock;
};
beforeEach(() => {
leagueMembershipRepository = {
getJoinRequests: jest.fn(),
removeJoinRequest: jest.fn(),
saveMembership: jest.fn(),
} as unknown;
presenter = new ApproveLeagueJoinRequestPresenter();
useCase = new ApproveLeagueJoinRequestUseCase(leagueMembershipRepository);
mockLeagueMembershipRepo = {
getJoinRequests: vi.fn(),
removeJoinRequest: vi.fn(),
saveMembership: vi.fn(),
};
});
it('should approve join request and save membership', async () => {
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository);
const leagueId = 'league-1';
const requestId = 'req-1';
const joinRequests = [{ id: requestId, leagueId, driverId: 'driver-1', requestedAt: new Date(), message: 'msg' }];
leagueMembershipRepository.getJoinRequests.mockResolvedValue(joinRequests);
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue(joinRequests);
await useCase.execute({ leagueId, requestId }, presenter);
const result = await useCase.execute({ leagueId, requestId });
expect(leagueMembershipRepository.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(leagueMembershipRepository.saveMembership).toHaveBeenCalledWith({
expect(result.isOk()).toBe(true);
expect(result.value).toEqual({ success: true, message: 'Join request approved.' });
expect(mockLeagueMembershipRepo.removeJoinRequest).toHaveBeenCalledWith(requestId);
expect(mockLeagueMembershipRepo.saveMembership).toHaveBeenCalledWith({
id: expect.any(String),
leagueId,
driverId: 'driver-1',
role: 'member',
status: 'active',
joinedAt: expect.any(Date),
});
expect(presenter.viewModel).toEqual({ success: true, message: 'Join request approved.' });
});
it('should throw error if request not found', async () => {
leagueMembershipRepository.getJoinRequests.mockResolvedValue([]);
it('should return error if request not found', async () => {
const useCase = new ApproveLeagueJoinRequestUseCase(mockLeagueMembershipRepo as unknown as ILeagueMembershipRepository);
await expect(useCase.execute({ leagueId: 'league-1', requestId: 'req-1' }, presenter)).rejects.toThrow('Join request not found');
mockLeagueMembershipRepo.getJoinRequests.mockResolvedValue([]);
const result = await useCase.execute({ leagueId: 'league-1', requestId: 'req-1' });
expect(result.isOk()).toBe(false);
expect(result.error!.code).toBe('JOIN_REQUEST_NOT_FOUND');
});
});

View File

@@ -1,19 +1,19 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
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 './ApproveLeagueJoinRequestUseCaseParams';
import type { ApproveLeagueJoinRequestResultDTO } from '../dto/ApproveLeagueJoinRequestResultDTO';
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
export class ApproveLeagueJoinRequestUseCase implements AsyncUseCase<ApproveLeagueJoinRequestUseCaseParams, ApproveLeagueJoinRequestResultDTO, string> {
constructor(private readonly leagueMembershipRepository: ILeagueMembershipRepository) {}
async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise<Result<ApproveLeagueJoinRequestResultDTO, RacingDomainValidationError>> {
async execute(params: ApproveLeagueJoinRequestUseCaseParams): Promise<Result<ApproveLeagueJoinRequestResultDTO, ApplicationErrorCode<string>>> {
const requests = await this.leagueMembershipRepository.getJoinRequests(params.leagueId);
const request = requests.find(r => r.id === params.requestId);
if (!request) {
return Result.err(new RacingDomainValidationError('Join request not found'));
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
}
await this.leagueMembershipRepository.removeJoinRequest(params.requestId);
await this.leagueMembershipRepository.saveMembership({

View File

@@ -45,6 +45,6 @@ describe('ApproveTeamJoinRequestUseCase', () => {
const result = await useCase.execute({ teamId: 'team-1', requestId: 'req-1' });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Join request not found');
expect(result.unwrapErr().code).toBe('JOIN_REQUEST_NOT_FOUND');
});
});

View File

@@ -1,36 +1,34 @@
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type {
TeamMembership,
TeamMembershipStatus,
TeamRole,
TeamJoinRequest,
} from '../../domain/types/TeamMembership';
import type { ApproveTeamJoinRequestCommandDTO } from '../dto/TeamCommandAndQueryDTO';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class ApproveTeamJoinRequestUseCase
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, Result<void, RacingDomainValidationError>> {
implements AsyncUseCase<ApproveTeamJoinRequestCommandDTO, void, string> {
constructor(
private readonly membershipRepository: ITeamMembershipRepository,
) {}
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<Result<void, RacingDomainValidationError>> {
async execute(command: ApproveTeamJoinRequestCommandDTO): Promise<Result<void, ApplicationErrorCode<string>>> {
const { teamId, requestId } = command;
const allRequests: TeamJoinRequest[] = await this.membershipRepository.getJoinRequests(teamId);
const request = allRequests.find((r) => r.id === requestId);
if (!request) {
return Result.err(new RacingDomainValidationError('Join request not found'));
return Result.err({ code: 'JOIN_REQUEST_NOT_FOUND' });
}
const membership: TeamMembership = {
teamId: request.teamId,
driverId: request.driverId,
role: 'driver' as TeamRole,
status: 'active' as TeamMembershipStatus,
role: 'driver',
status: 'active',
joinedAt: new Date(),
};

View File

@@ -4,7 +4,6 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { Logger } from '@core/shared/application';
import { Race } from '../../domain/entities/Race';
import { SessionType } from '../../domain/value-objects/SessionType';
import { RacingDomainInvariantError, RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
describe('CancelRaceUseCase', () => {
let useCase: CancelRaceUseCase;
@@ -52,7 +51,6 @@ describe('CancelRaceUseCase', () => {
expect(result.isOk()).toBe(true);
expect(raceRepository.findById).toHaveBeenCalledWith(raceId);
expect(raceRepository.update).toHaveBeenCalledWith(expect.objectContaining({ id: raceId, status: 'cancelled' }));
expect(logger.info).toHaveBeenCalledWith(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
});
it('should return error if race not found', async () => {
@@ -62,9 +60,7 @@ describe('CancelRaceUseCase', () => {
const result = await useCase.execute({ raceId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Race not found');
expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
});
it('should return domain error if race is already cancelled', async () => {
@@ -84,9 +80,7 @@ describe('CancelRaceUseCase', () => {
const result = await useCase.execute({ raceId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainInvariantError);
expect(result.unwrapErr().message).toBe('Race is already cancelled');
expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: Race is already cancelled`);
expect(result.unwrapErr().code).toBe('RACE_ALREADY_CANCELLED');
});
it('should return domain error if race is completed', async () => {
@@ -106,8 +100,6 @@ describe('CancelRaceUseCase', () => {
const result = await useCase.execute({ raceId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainInvariantError);
expect(result.unwrapErr().message).toBe('Cannot cancel a completed race');
expect(logger.warn).toHaveBeenCalledWith(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: Cannot cancel a completed race`);
expect(result.unwrapErr().code).toBe('CANNOT_CANCEL_COMPLETED_RACE');
});
});

View File

@@ -1,8 +1,8 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError, RacingDomainInvariantError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
/**
@@ -15,13 +15,13 @@ import type { CancelRaceCommandDTO } from '../dto/CancelRaceCommandDTO';
* - persists the updated race via the repository.
*/
export class CancelRaceUseCase
implements AsyncUseCase<CancelRaceCommandDTO, Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
implements AsyncUseCase<CancelRaceCommandDTO, void, string> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly logger: Logger,
) {}
async execute(command: CancelRaceCommandDTO): Promise<Result<void, RacingDomainValidationError | RacingDomainInvariantError>> {
async execute(command: CancelRaceCommandDTO): Promise<Result<void, ApplicationErrorCode<string>>> {
const { raceId } = command;
this.logger.debug(`[CancelRaceUseCase] Executing for raceId: ${raceId}`);
@@ -29,7 +29,7 @@ export class CancelRaceUseCase
const race = await this.raceRepository.findById(raceId);
if (!race) {
this.logger.warn(`[CancelRaceUseCase] Race with ID ${raceId} not found.`);
return Result.err(new RacingDomainValidationError('Race not found'));
return Result.err({ code: 'RACE_NOT_FOUND' });
}
const cancelledRace = race.cancel();
@@ -37,12 +37,16 @@ export class CancelRaceUseCase
this.logger.info(`[CancelRaceUseCase] Race ${raceId} cancelled successfully.`);
return Result.ok(undefined);
} catch (error) {
if (error instanceof RacingDomainInvariantError || error instanceof RacingDomainValidationError) {
if (error instanceof Error && error.message.includes('already cancelled')) {
this.logger.warn(`[CancelRaceUseCase] Domain error cancelling race ${raceId}: ${error.message}`);
return Result.err(error);
return Result.err({ code: 'RACE_ALREADY_CANCELLED' });
}
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' });
}
this.logger.error(`[CancelRaceUseCase] Unexpected error cancelling race ${raceId}`, error instanceof Error ? error : new Error(String(error)));
throw error;
return Result.err({ code: 'UNEXPECTED_ERROR' });
}
}
}

View File

@@ -1,12 +1,13 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CloseRaceEventStewardingUseCase } from './CloseRaceEventStewardingUseCase';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import type { Logger } from '@core/shared/application';
import { RaceEvent } from '../../domain/entities/RaceEvent';
import { Session } from '../../domain/entities/Session';
import { SessionType } from '../../domain/value-objects/SessionType';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
describe('CloseRaceEventStewardingUseCase', () => {
let useCase: CloseRaceEventStewardingUseCase;
@@ -14,6 +15,12 @@ describe('CloseRaceEventStewardingUseCase', () => {
findAwaitingStewardingClose: Mock;
update: Mock;
};
let raceRegistrationRepository: {
getRegisteredDrivers: Mock;
};
let penaltyRepository: {
findByRaceId: Mock;
};
let domainEventPublisher: {
publish: Mock;
};
@@ -26,6 +33,12 @@ describe('CloseRaceEventStewardingUseCase', () => {
findAwaitingStewardingClose: vi.fn(),
update: vi.fn(),
};
raceRegistrationRepository = {
getRegisteredDrivers: vi.fn(),
};
penaltyRepository = {
findByRaceId: vi.fn(),
};
domainEventPublisher = {
publish: vi.fn(),
};
@@ -35,7 +48,9 @@ describe('CloseRaceEventStewardingUseCase', () => {
useCase = new CloseRaceEventStewardingUseCase(
logger as unknown as Logger,
raceEventRepository as unknown as IRaceEventRepository,
domainEventPublisher as unknown as IDomainEventPublisher,
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
penaltyRepository as unknown as IPenaltyRepository,
domainEventPublisher as unknown as DomainEventPublisher,
);
});
@@ -61,6 +76,8 @@ describe('CloseRaceEventStewardingUseCase', () => {
});
raceEventRepository.findAwaitingStewardingClose.mockResolvedValue([raceEvent]);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
penaltyRepository.findByRaceId.mockResolvedValue([]);
domainEventPublisher.publish.mockResolvedValue(undefined);
const result = await useCase.execute({});
@@ -89,6 +106,6 @@ describe('CloseRaceEventStewardingUseCase', () => {
const result = await useCase.execute({});
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().code).toBe('FAILED_TO_CLOSE_STEWARDING');
});
});

View File

@@ -1,10 +1,12 @@
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IDomainEventPublisher } from '@core/shared/domain/IDomainEvent';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { DomainEventPublisher } from '@/shared/domain/DomainEvent';
import { RaceEventStewardingClosedEvent } from '../../domain/events/RaceEventStewardingClosed';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CloseRaceEventStewardingCommand } from './CloseRaceEventStewardingCommand';
import type { RaceEvent } from '../../domain/entities/RaceEvent';
@@ -18,17 +20,19 @@ import type { RaceEvent } from '../../domain/entities/RaceEvent';
* to automatically close stewarding windows based on league configuration.
*/
export class CloseRaceEventStewardingUseCase
implements AsyncUseCase<CloseRaceEventStewardingCommand, Result<void, RacingDomainValidationError>>
implements AsyncUseCase<CloseRaceEventStewardingCommand, void, string>
{
constructor(
private readonly logger: Logger,
private readonly raceEventRepository: IRaceEventRepository,
private readonly domainEventPublisher: IDomainEventPublisher,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
private readonly penaltyRepository: IPenaltyRepository,
private readonly domainEventPublisher: DomainEventPublisher,
) {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async execute(_command: CloseRaceEventStewardingCommand): Promise<Result<void, RacingDomainValidationError>> {
async execute(_command: CloseRaceEventStewardingCommand): Promise<Result<void, ApplicationErrorCode<string>>> {
try {
// Find all race events awaiting stewarding that have expired windows
const expiredEvents = await this.raceEventRepository.findAwaitingStewardingClose();
@@ -40,7 +44,7 @@ export class CloseRaceEventStewardingUseCase
return Result.ok(undefined);
} catch (error) {
this.logger.error('Failed to close race event stewarding', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError('Failed to close stewarding for race events'));
return Result.err({ code: 'FAILED_TO_CLOSE_STEWARDING' });
}
}
@@ -50,11 +54,11 @@ export class CloseRaceEventStewardingUseCase
const closedRaceEvent = raceEvent.closeStewarding();
await this.raceEventRepository.update(closedRaceEvent);
// Get list of participating drivers (would need to be implemented)
const driverIds = await this.getParticipatingDriverIds();
// Get list of participating drivers
const driverIds = await this.getParticipatingDriverIds(raceEvent);
// Check if any penalties were applied during stewarding
const hadPenaltiesApplied = await this.checkForAppliedPenalties();
const hadPenaltiesApplied = await this.checkForAppliedPenalties(raceEvent);
// Publish domain event to trigger final results notifications
const event = new RaceEventStewardingClosedEvent({
@@ -70,19 +74,17 @@ export class CloseRaceEventStewardingUseCase
} catch (error) {
this.logger.error(`Failed to close stewarding for race event ${raceEvent.id}`, error instanceof Error ? error : new Error(String(error)));
// TODO: In production, this would trigger alerts/monitoring
// In production, this would trigger alerts/monitoring
throw error;
}
}
private async getParticipatingDriverIds(): Promise<string[]> {
// TODO: Implement query for participating driver IDs from race event registrations
// This would typically involve querying race registrations for the event
return [];
private async getParticipatingDriverIds(raceEvent: RaceEvent): Promise<string[]> {
return await this.raceRegistrationRepository.getRegisteredDrivers(raceEvent.id);
}
private async checkForAppliedPenalties(): Promise<boolean> {
// TODO: Implement check for applied penalties during stewarding window
// This would query the penalty repository for penalties related to this race event
return false;
private async checkForAppliedPenalties(raceEvent: RaceEvent): Promise<boolean> {
const penalties = await this.penaltyRepository.findByRaceId(raceEvent.id);
return penalties.length > 0;
}
}

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CompleteDriverOnboardingUseCase } from './CompleteDriverOnboardingUseCase';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
describe('CompleteDriverOnboardingUseCase', () => {
@@ -78,8 +77,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Driver already exists');
expect(result.unwrapErr().code).toBe('DRIVER_ALREADY_EXISTS');
expect(driverRepository.create).not.toHaveBeenCalled();
});
@@ -98,8 +96,7 @@ describe('CompleteDriverOnboardingUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
});
it('should handle bio being undefined', async () => {

View File

@@ -1,24 +1,24 @@
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/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteDriverOnboardingCommand } from './CompleteDriverOnboardingCommand';
/**
* Use Case for completing driver onboarding.
*/
export class CompleteDriverOnboardingUseCase
implements AsyncUseCase<CompleteDriverOnboardingCommand, Result<{ driverId: string }, RacingDomainValidationError>>
implements AsyncUseCase<CompleteDriverOnboardingCommand, { driverId: string }, string>
{
constructor(private readonly driverRepository: IDriverRepository) {}
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<{ driverId: string }, RacingDomainValidationError>> {
async execute(command: CompleteDriverOnboardingCommand): Promise<Result<{ driverId: string }, ApplicationErrorCode<string>>> {
try {
// Check if driver already exists
const existing = await this.driverRepository.findById(command.userId);
if (existing) {
return Result.err(new RacingDomainValidationError('Driver already exists'));
return Result.err({ code: 'DRIVER_ALREADY_EXISTS' });
}
// Create new driver
@@ -33,8 +33,8 @@ export class CompleteDriverOnboardingUseCase
await this.driverRepository.create(driver);
return Result.ok({ driverId: driver.id });
} catch (error) {
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
} catch {
return Result.err({ code: 'UNKNOWN_ERROR' });
}
}
}

View File

@@ -5,7 +5,6 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
describe('CompleteRaceUseCase', () => {
@@ -97,8 +96,7 @@ describe('CompleteRaceUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Race not found');
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
});
it('should return error when no registered drivers', async () => {
@@ -118,8 +116,7 @@ describe('CompleteRaceUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Cannot complete race with no registered drivers');
expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS');
});
it('should return error when repository throws', async () => {
@@ -141,7 +138,6 @@ describe('CompleteRaceUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
});
});

View File

@@ -6,8 +6,8 @@ import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '../../domain/entities/Result';
import { Standing } from '../../domain/entities/Standing';
import type { AsyncUseCase } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result as SharedResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
/**
@@ -22,7 +22,7 @@ import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
* - persists all changes via repositories.
*/
export class CompleteRaceUseCase
implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<{}, RacingDomainValidationError>> {
implements AsyncUseCase<CompleteRaceCommandDTO, {}, string> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
@@ -31,19 +31,19 @@ export class CompleteRaceUseCase
private readonly driverRatingProvider: DriverRatingProvider,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, RacingDomainValidationError>> {
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<{}, ApplicationErrorCode<string>>> {
try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return SharedResult.err(new RacingDomainValidationError('Race not found'));
return SharedResult.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(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' });
}
// Get driver ratings
@@ -65,8 +65,8 @@ export class CompleteRaceUseCase
await this.raceRepository.update(completedRace);
return SharedResult.ok({});
} catch (error) {
return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
} catch {
return SharedResult.err({ code: 'UNKNOWN_ERROR' });
}
}

View File

@@ -6,7 +6,6 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
describe('CompleteRaceUseCaseWithRatings', () => {
@@ -107,8 +106,7 @@ describe('CompleteRaceUseCaseWithRatings', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Race not found');
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
});
it('should return error when no registered drivers', async () => {
@@ -128,8 +126,7 @@ describe('CompleteRaceUseCaseWithRatings', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('Cannot complete race with no registered drivers');
expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS');
});
it('should return error when repository throws', async () => {
@@ -151,7 +148,6 @@ describe('CompleteRaceUseCaseWithRatings', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().code).toBe('UNKNOWN_ERROR');
});
});

View File

@@ -8,15 +8,15 @@ import { Standing } from '../../domain/entities/Standing';
import { RaceResultGenerator } from '../utils/RaceResultGenerator';
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
import type { AsyncUseCase } from '@core/shared/application';
import { Result as SharedResult } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result as SharedResult } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CompleteRaceCommandDTO } from '../dto/CompleteRaceCommandDTO';
/**
* Enhanced CompleteRaceUseCase that includes rating updates
*/
export class CompleteRaceUseCaseWithRatings
implements AsyncUseCase<CompleteRaceCommandDTO, SharedResult<void, RacingDomainValidationError>> {
implements AsyncUseCase<CompleteRaceCommandDTO, void, string> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly raceRegistrationRepository: IRaceRegistrationRepository,
@@ -26,19 +26,19 @@ export class CompleteRaceUseCaseWithRatings
private readonly ratingUpdateService: RatingUpdateService,
) {}
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<void, RacingDomainValidationError>> {
async execute(command: CompleteRaceCommandDTO): Promise<SharedResult<void, ApplicationErrorCode<string>>> {
try {
const { raceId } = command;
const race = await this.raceRepository.findById(raceId);
if (!race) {
return SharedResult.err(new RacingDomainValidationError('Race not found'));
return SharedResult.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(new RacingDomainValidationError('Cannot complete race with no registered drivers'));
return SharedResult.err({ code: 'NO_REGISTERED_DRIVERS' });
}
// Get driver ratings
@@ -63,8 +63,8 @@ export class CompleteRaceUseCaseWithRatings
await this.raceRepository.update(completedRace);
return SharedResult.ok(undefined);
} catch (error) {
return SharedResult.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
} catch {
return SharedResult.err({ code: 'UNKNOWN_ERROR' });
}
}

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateLeagueWithSeasonAndScoringUseCase } from './CreateLeagueWithSeasonAndScoringUseCase';
import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand';
import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
@@ -114,7 +114,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('League name is required');
expect(result.unwrapErr().details.message).toBe('League name is required');
});
it('should return error when ownerId is empty', async () => {
@@ -133,7 +133,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('League ownerId is required');
expect(result.unwrapErr().details.message).toBe('League ownerId is required');
});
it('should return error when gameId is empty', async () => {
@@ -152,7 +152,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('gameId is required');
expect(result.unwrapErr().details.message).toBe('gameId is required');
});
it('should return error when visibility is missing', async () => {
@@ -166,10 +166,10 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
enableTrophyChampionship: false,
};
const result = await useCase.execute(command);
const result = await useCase.execute(command as CreateLeagueWithSeasonAndScoringCommand);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('visibility is required');
expect(result.unwrapErr().details.message).toBe('visibility is required');
});
it('should return error when maxDrivers is invalid', async () => {
@@ -189,7 +189,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('maxDrivers must be greater than 0 when provided');
expect(result.unwrapErr().details.message).toBe('maxDrivers must be greater than 0 when provided');
});
it('should return error when ranked league has insufficient drivers', async () => {
@@ -209,7 +209,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toContain('Ranked leagues require at least 10 drivers');
expect(result.unwrapErr().details.message).toContain('Ranked leagues require at least 10 drivers');
});
it('should return error when scoring preset is unknown', async () => {
@@ -231,7 +231,7 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Unknown scoring preset: unknown-preset');
expect(result.unwrapErr().details.message).toBe('Unknown scoring preset: unknown-preset');
});
it('should return error when repository throws', async () => {
@@ -259,6 +259,6 @@ describe('CreateLeagueWithSeasonAndScoringUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().details.message).toBe('DB error');
});
});

View File

@@ -14,13 +14,33 @@ import {
LeagueVisibility,
MIN_RANKED_LEAGUE_DRIVERS,
} from '../../domain/value-objects/LeagueVisibility';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CreateLeagueWithSeasonAndScoringCommand } from './CreateLeagueWithSeasonAndScoringCommand';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { LeagueVisibilityInput } from './LeagueVisibilityInput';
import type { CreateLeagueWithSeasonAndScoringResultDTO } from '../dto/CreateLeagueWithSeasonAndScoringResultDTO';
export interface CreateLeagueWithSeasonAndScoringCommand {
name: string;
description?: string;
/**
* League visibility/ranking mode.
* - 'ranked' (or legacy 'public'): Competitive, public, affects ratings. Requires min 10 drivers.
* - 'unranked' (or legacy 'private'): Casual with friends, no rating impact.
*/
visibility: LeagueVisibilityInput;
ownerId: string;
gameId: string;
maxDrivers?: number;
maxTeams?: number;
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
scoringPresetId?: string;
}
export class CreateLeagueWithSeasonAndScoringUseCase
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
implements AsyncUseCase<CreateLeagueWithSeasonAndScoringCommand, CreateLeagueWithSeasonAndScoringResultDTO, 'VALIDATION_ERROR' | 'UNKNOWN_PRESET' | 'REPOSITORY_ERROR'> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
@@ -31,7 +51,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
async execute(
command: CreateLeagueWithSeasonAndScoringCommand,
): Promise<Result<CreateLeagueWithSeasonAndScoringResultDTO, RacingDomainValidationError>> {
): Promise<Result<CreateLeagueWithSeasonAndScoringResultDTO, ApplicationErrorCode<'VALIDATION_ERROR' | 'UNKNOWN_PRESET' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateLeagueWithSeasonAndScoringUseCase', { command });
const validation = this.validate(command);
if (validation.isErr()) {
@@ -81,7 +101,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
if (!preset) {
this.logger.error(`Unknown scoring preset: ${presetId}`);
return Result.err(new RacingDomainValidationError(`Unknown scoring preset: ${presetId}`));
return Result.err({ code: 'UNKNOWN_PRESET', details: { message: `Unknown scoring preset: ${presetId}` } });
}
this.logger.info(`Scoring preset ${preset.name} (${preset.id}) retrieved.`);
@@ -101,31 +121,31 @@ export class CreateLeagueWithSeasonAndScoringUseCase
this.logger.debug('CreateLeagueWithSeasonAndScoringUseCase completed successfully.', { result });
return Result.ok(result);
} catch (error) {
return Result.err(new RacingDomainValidationError(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, RacingDomainValidationError> {
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 });
return Result.err(new RacingDomainValidationError('League name is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'League name is required' } });
}
if (!command.ownerId || command.ownerId.trim().length === 0) {
this.logger.warn('Validation failed: League ownerId is required', { command });
return Result.err(new RacingDomainValidationError('League ownerId is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'League ownerId is required' } });
}
if (!command.gameId || command.gameId.trim().length === 0) {
this.logger.warn('Validation failed: gameId is required', { command });
return Result.err(new RacingDomainValidationError('gameId is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'gameId is required' } });
}
if (!command.visibility) {
this.logger.warn('Validation failed: visibility is required', { command });
return Result.err(new RacingDomainValidationError('visibility is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'visibility is required' } });
}
if (command.maxDrivers !== undefined && command.maxDrivers <= 0) {
this.logger.warn('Validation failed: maxDrivers must be greater than 0 when provided', { command });
return Result.err(new RacingDomainValidationError('maxDrivers must be greater than 0 when provided'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'maxDrivers must be greater than 0 when provided' } });
}
const visibility = LeagueVisibility.fromString(command.visibility);
@@ -137,11 +157,11 @@ export class CreateLeagueWithSeasonAndScoringUseCase
`Validation failed: Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. Current setting: ${driverCount}.`,
{ command }
);
return Result.err(new RacingDomainValidationError(
return Result.err({ code: 'VALIDATION_ERROR', details: { message:
`Ranked leagues require at least ${MIN_RANKED_LEAGUE_DRIVERS} drivers. ` +
`Current setting: ${driverCount}. ` +
`For smaller groups, consider creating an Unranked (Friends) league instead.`
));
} });
}
}
this.logger.debug('Validation successful.');

View File

@@ -0,0 +1,311 @@
import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {
CreateSeasonForLeagueUseCase,
type CreateSeasonForLeagueCommand,
} from '@core/racing/application/use-cases/CreateSeasonForLeagueUseCase';
import type { LeagueConfigFormModel } from '@core/racing/application/dto/LeagueConfigFormDTO';
import type { Logger } from '@core/shared/application';
const logger: Logger = {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
function createFakeLeagueRepository(seed: Array<{ id: string }>): ILeagueRepository {
return {
findById: async (id: string) => seed.find((l) => l.id === id) ?? null,
findAll: async () => seed,
create: async (league: any) => league,
update: async (league: any) => league,
} as unknown as ILeagueRepository;
}
function createLeagueConfigFormModel(overrides?: Partial<LeagueConfigFormModel>): LeagueConfigFormModel {
return {
basics: {
name: 'Test League',
visibility: 'ranked',
gameId: 'iracing',
...overrides?.basics,
},
structure: {
mode: 'solo',
maxDrivers: 30,
...overrides?.structure,
},
championships: {
enableDriverChampionship: true,
enableTeamChampionship: false,
enableNationsChampionship: false,
enableTrophyChampionship: false,
...overrides?.championships,
},
scoring: {
patternId: 'sprint-main-driver',
customScoringEnabled: false,
...overrides?.scoring,
},
dropPolicy: {
strategy: 'bestNResults',
n: 3,
...overrides?.dropPolicy,
},
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
seasonStartDate: '2025-01-01',
raceStartTime: '20:00',
timezoneId: 'UTC',
recurrenceStrategy: 'weekly',
weekdays: ['Mon'],
...overrides?.timings,
},
stewarding: {
decisionMode: 'steward_vote',
requiredVotes: 3,
requireDefense: true,
defenseTimeLimit: 24,
voteTimeLimit: 24,
protestDeadlineHours: 48,
stewardingClosesHours: 72,
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
...overrides?.stewarding,
},
...overrides,
};
}
describe('InMemorySeasonRepository', () => {
it('add and findById provide a roundtrip for Season', async () => {
const repo = new InMemorySeasonRepository(logger);
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Test Season',
status: 'planned',
});
await repo.add(season);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.id).toBe(season.id);
expect(loaded!.leagueId).toBe(season.leagueId);
expect(loaded!.status).toBe('planned');
});
it('update persists changed Season state', async () => {
const repo = new InMemorySeasonRepository(logger);
const season = Season.create({
id: 'season-1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Initial Season',
status: 'planned',
});
await repo.add(season);
const activated = season.activate();
await repo.update(activated);
const loaded = await repo.findById(season.id);
expect(loaded).not.toBeNull();
expect(loaded!.status).toBe('active');
});
it('listByLeague returns only seasons for that league', async () => {
const repo = new InMemorySeasonRepository(logger);
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S1',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'L1 S2',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-2',
gameId: 'iracing',
name: 'L2 S1',
status: 'planned',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
const league1Seasons = await repo.listByLeague('league-1');
const league2Seasons = await repo.listByLeague('league-2');
expect(league1Seasons.map((s: Season) => s.id).sort()).toEqual(['s1', 's2']);
expect(league2Seasons.map((s: Season) => s.id)).toEqual(['s3']);
});
it('listActiveByLeague returns only active seasons for a league', async () => {
const repo = new InMemorySeasonRepository(logger);
const s1 = Season.create({
id: 's1',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Planned',
status: 'planned',
});
const s2 = Season.create({
id: 's2',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Active',
status: 'active',
});
const s3 = Season.create({
id: 's3',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Completed',
status: 'completed',
});
const s4 = Season.create({
id: 's4',
leagueId: 'league-2',
gameId: 'iracing',
name: 'Other League Active',
status: 'active',
});
await repo.add(s1);
await repo.add(s2);
await repo.add(s3);
await repo.add(s4);
const activeInLeague1 = await repo.listActiveByLeague('league-1');
expect(activeInLeague1.map((s: Season) => s.id)).toEqual(['s2']);
});
});
describe('CreateSeasonForLeagueUseCase', () => {
it('creates a planned Season for an existing league with config-derived props', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const config = createLeagueConfigFormModel({
basics: {
name: 'League With Config',
visibility: 'ranked',
gameId: 'iracing',
},
scoring: {
patternId: 'club-default',
customScoringEnabled: true,
},
dropPolicy: {
strategy: 'dropWorstN',
n: 2,
},
// Intentionally omit seasonStartDate / raceStartTime to avoid schedule derivation,
// focusing this test on scoring/drop/stewarding/maxDrivers mapping.
timings: {
qualifyingMinutes: 10,
mainRaceMinutes: 30,
sessionCount: 8,
},
});
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Season from Config',
gameId: 'iracing',
config,
};
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.value.seasonId).toBeDefined();
const created = await seasonRepo.findById(result.value.seasonId);
expect(created).not.toBeNull();
const season = created!;
expect(season.leagueId).toBe('league-1');
expect(season.gameId).toBe('iracing');
expect(season.name).toBe('Season from Config');
expect(season.status).toBe('planned');
// Schedule is optional when timings lack seasonStartDate / raceStartTime.
expect(season.schedule).toBeUndefined();
expect(season.scoringConfig).toBeDefined();
expect(season.scoringConfig!.scoringPresetId).toBe('club-default');
expect(season.scoringConfig!.customScoringEnabled).toBe(true);
expect(season.dropPolicy).toBeDefined();
expect(season.dropPolicy!.strategy).toBe('dropWorstN');
expect(season.dropPolicy!.n).toBe(2);
expect(season.stewardingConfig).toBeDefined();
expect(season.maxDrivers).toBe(30);
});
it('clones configuration from a source season when sourceSeasonId is provided', async () => {
const leagueRepo = createFakeLeagueRepository([{ id: 'league-1' }]);
const seasonRepo = new InMemorySeasonRepository(logger);
const sourceSeason = Season.create({
id: 'source-season',
leagueId: 'league-1',
gameId: 'iracing',
name: 'Source Season',
status: 'planned',
}).withMaxDrivers(40);
await seasonRepo.add(sourceSeason);
const useCase = new CreateSeasonForLeagueUseCase(leagueRepo, seasonRepo);
const command: CreateSeasonForLeagueCommand = {
leagueId: 'league-1',
name: 'Cloned Season',
gameId: 'iracing',
sourceSeasonId: 'source-season',
};
const result = await useCase.execute(command);
const created = await seasonRepo.findById(result.value.seasonId);
expect(result.isOk()).toBe(true);
expect(created).not.toBeNull();
const season = created!;
expect(season.id).not.toBe(sourceSeason.id);
expect(season.leagueId).toBe(sourceSeason.leagueId);
expect(season.gameId).toBe(sourceSeason.gameId);
expect(season.status).toBe('planned');
expect(season.maxDrivers).toBe(sourceSeason.maxDrivers);
expect(season.schedule).toBe(sourceSeason.schedule);
expect(season.scoringConfig).toBe(sourceSeason.scoringConfig);
expect(season.dropPolicy).toBe(sourceSeason.dropPolicy);
expect(season.stewardingConfig).toBe(sourceSeason.stewardingConfig);
});
});

View File

@@ -0,0 +1,219 @@
import { Season } from '../../domain/entities/Season';
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { LeagueConfigFormModel } from '../dto/LeagueConfigFormDTO';
import { SeasonSchedule } from '../../domain/value-objects/SeasonSchedule';
import { SeasonScoringConfig } from '../../domain/value-objects/SeasonScoringConfig';
import { SeasonDropPolicy } from '../../domain/value-objects/SeasonDropPolicy';
import { SeasonStewardingConfig } from '../../domain/value-objects/SeasonStewardingConfig';
import { RaceTimeOfDay } from '../../domain/value-objects/RaceTimeOfDay';
import { LeagueTimezone } from '../../domain/value-objects/LeagueTimezone';
import { RecurrenceStrategyFactory } from '../../domain/value-objects/RecurrenceStrategy';
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 { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CreateSeasonForLeagueCommand {
leagueId: string;
name: string;
gameId: string;
sourceSeasonId?: string;
/**
* Optional high-level wizard config used to derive schedule/scoring/drop/stewarding.
* When omitted, the Season will be created with minimal metadata only.
*/
config?: LeagueConfigFormModel;
}
export interface CreateSeasonForLeagueResultDTO {
seasonId: string;
}
type CreateSeasonForLeagueErrorCode = 'LEAGUE_NOT_FOUND' | 'SOURCE_SEASON_NOT_FOUND';
/**
* CreateSeasonForLeagueUseCase
*
* Creates a new Season for an existing League, optionally cloning or deriving
* configuration from a source Season or a league config form.
*/
export class CreateSeasonForLeagueUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
) {}
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) {
return Result.err({
code: 'SOURCE_SEASON_NOT_FOUND',
details: { message: `Source Season not found: ${command.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 (command.config) {
baseSeasonProps = this.deriveSeasonPropsFromConfig(command.config);
}
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): {
schedule?: SeasonSchedule;
scoringConfig?: SeasonScoringConfig;
dropPolicy?: SeasonDropPolicy;
stewardingConfig?: SeasonStewardingConfig;
maxDrivers?: number;
} {
const schedule = this.buildScheduleFromTimings(config);
const scoringConfig = new SeasonScoringConfig({
scoringPresetId: config.scoring.patternId ?? 'custom',
customScoringEnabled: config.scoring.customScoringEnabled ?? false,
});
const dropPolicy = new SeasonDropPolicy({
strategy: config.dropPolicy.strategy,
...(config.dropPolicy.n !== undefined ? { n: config.dropPolicy.n } : {}),
});
const stewardingConfig = new SeasonStewardingConfig({
decisionMode: config.stewarding.decisionMode,
...(config.stewarding.requiredVotes !== undefined
? { requiredVotes: config.stewarding.requiredVotes }
: {}),
requireDefense: config.stewarding.requireDefense,
defenseTimeLimit: config.stewarding.defenseTimeLimit,
voteTimeLimit: config.stewarding.voteTimeLimit,
protestDeadlineHours: config.stewarding.protestDeadlineHours,
stewardingClosesHours: config.stewarding.stewardingClosesHours,
notifyAccusedOnProtest: config.stewarding.notifyAccusedOnProtest,
notifyOnVoteRequired: config.stewarding.notifyOnVoteRequired,
});
const structure = config.structure;
const maxDrivers =
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
? structure.maxDrivers
: undefined;
return {
...(schedule !== undefined ? { schedule } : {}),
scoringConfig,
dropPolicy,
stewardingConfig,
...(maxDrivers !== undefined ? { maxDrivers } : {}),
};
}
private buildScheduleFromTimings(
config: LeagueConfigFormModel,
): SeasonSchedule | undefined {
const { timings } = config;
if (!timings.seasonStartDate || !timings.raceStartTime) {
return undefined;
}
const startDate = new Date(timings.seasonStartDate);
const timeOfDay = RaceTimeOfDay.fromString(timings.raceStartTime);
const timezoneId = timings.timezoneId ?? 'UTC';
const timezone = new LeagueTimezone(timezoneId);
const plannedRounds =
typeof timings.roundsPlanned === 'number' && timings.roundsPlanned > 0
? timings.roundsPlanned
: timings.sessionCount;
const recurrence = (() => {
const weekdays: WeekdaySet =
timings.weekdays && timings.weekdays.length > 0
? WeekdaySet.fromArray(
timings.weekdays as unknown as Weekday[],
)
: WeekdaySet.fromArray(['Mon']);
switch (timings.recurrenceStrategy) {
case 'everyNWeeks':
return RecurrenceStrategyFactory.everyNWeeks(
timings.intervalWeeks ?? 2,
weekdays,
);
case 'monthlyNthWeekday': {
const pattern = new MonthlyRecurrencePattern({
ordinal: (timings.monthlyOrdinal ?? 1) as 1 | 2 | 3 | 4,
weekday: (timings.monthlyWeekday ?? 'Mon') as Weekday,
});
return RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
}
case 'weekly':
default:
return RecurrenceStrategyFactory.weekly(weekdays);
}
})();
return new SeasonSchedule({
startDate,
timeOfDay,
timezone,
recurrence,
plannedRounds,
});
}
}

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateSponsorUseCase } from './CreateSponsorUseCase';
import type { CreateSponsorCommand } from './CreateSponsorCommand';
import { CreateSponsorUseCase, type CreateSponsorCommand } from './CreateSponsorUseCase';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { Logger } from '@core/shared/application';
@@ -80,7 +79,7 @@ describe('CreateSponsorUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Sponsor name is required');
expect(result.unwrapErr().details.message).toBe('Sponsor name is required');
});
it('should return error when contactEmail is empty', async () => {
@@ -92,7 +91,7 @@ describe('CreateSponsorUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Sponsor contact email is required');
expect(result.unwrapErr().details.message).toBe('Sponsor contact email is required');
});
it('should return error when contactEmail is invalid', async () => {
@@ -104,7 +103,7 @@ describe('CreateSponsorUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Invalid sponsor contact email format');
expect(result.unwrapErr().details.message).toBe('Invalid sponsor contact email format');
});
it('should return error when websiteUrl is invalid', async () => {
@@ -117,7 +116,7 @@ describe('CreateSponsorUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Invalid sponsor website URL');
expect(result.unwrapErr().details.message).toBe('Invalid sponsor website URL');
});
it('should return error when repository throws', async () => {
@@ -131,6 +130,6 @@ describe('CreateSponsorUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().details.message).toBe('DB error');
});
});

View File

@@ -8,13 +8,19 @@ import { Sponsor } from '../../domain/entities/Sponsor';
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { CreateSponsorCommand } from './CreateSponsorCommand';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { CreateSponsorResultDTO } from '../dto/CreateSponsorResultDTO';
export interface CreateSponsorCommand {
name: string;
contactEmail: string;
websiteUrl?: string;
logoUrl?: string;
}
export class CreateSponsorUseCase
implements AsyncUseCase<CreateSponsorCommand, Result<CreateSponsorResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<CreateSponsorCommand, CreateSponsorResultDTO, 'VALIDATION_ERROR' | 'REPOSITORY_ERROR'>
{
constructor(
private readonly sponsorRepository: ISponsorRepository,
@@ -23,7 +29,7 @@ export class CreateSponsorUseCase
async execute(
command: CreateSponsorCommand,
): Promise<Result<CreateSponsorResultDTO, RacingDomainValidationError>> {
): Promise<Result<CreateSponsorResultDTO, ApplicationErrorCode<'VALIDATION_ERROR' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateSponsorUseCase', { command });
const validation = this.validate(command);
if (validation.isErr()) {
@@ -50,40 +56,40 @@ export class CreateSponsorUseCase
id: sponsor.id,
name: sponsor.name,
contactEmail: sponsor.contactEmail,
websiteUrl: sponsor.websiteUrl,
logoUrl: sponsor.logoUrl,
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);
} catch (error) {
return Result.err(new RacingDomainValidationError(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: CreateSponsorCommand): Result<void, RacingDomainValidationError> {
private validate(command: CreateSponsorCommand): Result<void, ApplicationErrorCode<'VALIDATION_ERROR', { message: string }>> {
this.logger.debug('Validating CreateSponsorCommand', { command });
if (!command.name || command.name.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor name is required', { command });
return Result.err(new RacingDomainValidationError('Sponsor name is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor name is required' } });
}
if (!command.contactEmail || command.contactEmail.trim().length === 0) {
this.logger.warn('Validation failed: Sponsor contact email is required', { command });
return Result.err(new RacingDomainValidationError('Sponsor contact email is required'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Sponsor contact email is required' } });
}
// Basic email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(command.contactEmail)) {
this.logger.warn('Validation failed: Invalid sponsor contact email format', { command });
return Result.err(new RacingDomainValidationError('Invalid sponsor contact email format'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor contact email format' } });
}
if (command.websiteUrl && command.websiteUrl.trim().length > 0) {
try {
new URL(command.websiteUrl);
} catch {
this.logger.warn('Validation failed: Invalid sponsor website URL', { command });
return Result.err(new RacingDomainValidationError('Invalid sponsor website URL'));
return Result.err({ code: 'VALIDATION_ERROR', details: { message: 'Invalid sponsor website URL' } });
}
}
this.logger.debug('Validation successful.');

View File

@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { CreateTeamUseCase } from './CreateTeamUseCase';
import type { CreateTeamCommandDTO } from '../dto/CreateTeamCommandDTO';
import { CreateTeamUseCase, type CreateTeamCommandDTO } from './CreateTeamUseCase';
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { Logger } from '@core/shared/application';
@@ -98,7 +97,7 @@ describe('CreateTeamUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Driver already belongs to a team');
expect(result.unwrapErr().details.message).toBe('Driver already belongs to a team');
expect(teamRepository.create).not.toHaveBeenCalled();
expect(membershipRepository.saveMembership).not.toHaveBeenCalled();
});
@@ -118,6 +117,6 @@ describe('CreateTeamUseCase', () => {
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('DB error');
expect(result.unwrapErr().details.message).toBe('DB error');
});
});

View File

@@ -12,17 +12,25 @@ import type {
TeamMembershipStatus,
TeamRole,
} from '../../domain/types/TeamMembership';
import type {
CreateTeamCommandDTO,
CreateTeamResultDTO,
} from '../dto/CreateTeamCommandDTO';
import type { AsyncUseCase } from '@core/shared/application';
import type { Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface CreateTeamCommandDTO {
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}
export interface CreateTeamResultDTO {
team: Team;
}
export class CreateTeamUseCase
implements AsyncUseCase<CreateTeamCommandDTO, Result<CreateTeamResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<CreateTeamCommandDTO, CreateTeamResultDTO, 'ALREADY_IN_TEAM' | 'REPOSITORY_ERROR'>
{
constructor(
private readonly teamRepository: ITeamRepository,
@@ -32,7 +40,7 @@ export class CreateTeamUseCase
async execute(
command: CreateTeamCommandDTO,
): Promise<Result<CreateTeamResultDTO, RacingDomainValidationError>> {
): Promise<Result<CreateTeamResultDTO, ApplicationErrorCode<'ALREADY_IN_TEAM' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug('Executing CreateTeamUseCase', { command });
const { name, tag, description, ownerId, leagues } = command;
@@ -41,7 +49,7 @@ export class CreateTeamUseCase
);
if (existingMembership) {
this.logger.warn('Validation failed: Driver already belongs to a team', { ownerId });
return Result.err(new RacingDomainValidationError('Driver already belongs to a team'));
return Result.err({ code: 'ALREADY_IN_TEAM', details: { message: 'Driver already belongs to a team' } });
}
this.logger.info('Command validated successfully.');
@@ -76,7 +84,7 @@ export class CreateTeamUseCase
this.logger.debug('CreateTeamUseCase completed successfully.', { result });
return Result.ok(result);
} catch (error) {
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error'));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error' } });
}
}
}

View File

@@ -8,15 +8,13 @@ import type { IRaceRegistrationRepository } from '../../domain/repositories/IRac
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { Result } from '@core/shared/result/Result';
import { RacingDomainError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import { League } from '../../domain/entities/League';
import { Race } from '../../domain/entities/Race';
import { Result as RaceResult } from '../../domain/entities/Result';
import { Driver } from '../../domain/entities/Driver';
import { Standing } from '../../domain/entities/Standing';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type { DashboardOverviewParams } from './DashboardOverviewParams';
import type {
DashboardOverviewViewModel,
DashboardDriverSummaryViewModel,
@@ -28,6 +26,10 @@ import type {
DashboardFriendSummaryViewModel,
} from '../presenters/IDashboardOverviewPresenter';
interface DashboardOverviewParams {
driverId: string;
}
interface DashboardDriverStatsAdapter {
rating: number | null;
wins: number;
@@ -52,7 +54,7 @@ export class DashboardOverviewUseCase {
private readonly getDriverStats: (driverId: string) => DashboardDriverStatsAdapter | null,
) {}
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewViewModel, RacingDomainError>> {
async execute(params: DashboardOverviewParams): Promise<Result<DashboardOverviewViewModel>> {
const { driverId } = params;
const [driver, allLeagues, allRaces, allResults, feedItems, friends] = await Promise.all([
@@ -285,13 +287,13 @@ export class DashboardOverviewUseCase {
id: item.id,
type: item.type,
headline: item.headline,
body: item.body,
timestamp:
item.timestamp instanceof Date
? item.timestamp.toISOString()
: new Date(item.timestamp).toISOString(),
ctaLabel: item.ctaLabel,
ctaHref: item.ctaHref,
...(item.body !== undefined ? { body: item.body } : {}),
...(item.ctaLabel !== undefined ? { ctaLabel: item.ctaLabel } : {}),
...(item.ctaHref !== undefined ? { ctaHref: item.ctaHref } : {}),
}));
return {

View File

@@ -44,7 +44,7 @@ describe('FileProtestUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Race not found');
expect(result.error!.details.message).toBe('Race not found');
});
it('should return error when protesting against self', async () => {
@@ -64,7 +64,7 @@ describe('FileProtestUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Cannot file a protest against yourself');
expect(result.error!.details.message).toBe('Cannot file a protest against yourself');
});
it('should return error when protesting driver is not an active member', async () => {
@@ -87,7 +87,7 @@ describe('FileProtestUseCase', () => {
});
expect(result.isOk()).toBe(false);
expect(result.error!.message).toBe('Protesting driver is not an active member of this league');
expect(result.error!.details.message).toBe('Protesting driver is not an active member of this league');
});
it('should create protest and return protestId on success', async () => {

View File

@@ -8,11 +8,20 @@ import { Protest } from '../../domain/entities/Protest';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { FileProtestCommand } from './FileProtestCommand';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { ProtestIncident } from '../../domain/entities/Protest';
import { randomUUID } from 'crypto';
export interface FileProtestCommand {
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: ProtestIncident;
comment?: string;
proofVideoUrl?: string;
}
export class FileProtestUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
@@ -20,16 +29,16 @@ export class FileProtestUseCase {
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: FileProtestCommand): Promise<Result<{ protestId: string }, RacingDomainValidationError>> {
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(new RacingDomainValidationError('Race not found'));
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(new RacingDomainValidationError('Cannot file a protest against yourself'));
return Result.err({ code: 'SELF_PROTEST', details: { message: 'Cannot file a protest against yourself' } });
}
// Validate protesting driver is a member of the league
@@ -39,7 +48,7 @@ export class FileProtestUseCase {
);
if (!protestingDriverMembership) {
return Result.err(new RacingDomainValidationError('Protesting driver is not an active member of this league'));
return Result.err({ code: 'NOT_MEMBER', details: { message: 'Protesting driver is not an active member of this league' } });
}
// Create the protest

View File

@@ -5,16 +5,13 @@ import type { ILeagueScoringConfigRepository } from '../../domain/repositories/I
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type { LeagueEnrichedData } from '../presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
/**
* Use Case for retrieving all leagues with capacity and scoring information.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetAllLeaguesWithCapacityAndScoringUseCase
implements AsyncUseCase<void, Result<LeagueEnrichedData[], RacingDomainValidationError>>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
@@ -25,7 +22,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(): Promise<Result<LeagueEnrichedData[], RacingDomainValidationError>> {
async execute(): Promise<Result<LeagueEnrichedData[]>> {
const leagues = await this.leagueRepository.findAll();
const enrichedLeagues: LeagueEnrichedData[] = [];

View File

@@ -2,22 +2,22 @@ import type { ILeagueRepository } from '../../domain/repositories/ILeagueReposit
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { AllLeaguesWithCapacityResultDTO } from '../presenters/IAllLeaguesWithCapacityPresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving all leagues with capacity information.
* Orchestrates domain logic and returns result.
*/
export class GetAllLeaguesWithCapacityUseCase
implements AsyncUseCase<void, Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<void, AllLeaguesWithCapacityResultDTO, string>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(): Promise<Result<AllLeaguesWithCapacityResultDTO, RacingDomainValidationError>> {
async execute(): Promise<Result<AllLeaguesWithCapacityResultDTO, ApplicationErrorCode<string>>> {
const leagues = await this.leagueRepository.findAll();
const memberCounts = new Map<string, number>();

View File

@@ -8,18 +8,18 @@ import type {
AllRacesFilterOptionsViewModel,
} from '../presenters/IAllRacesPagePresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export class GetAllRacesPageDataUseCase
implements AsyncUseCase<void, Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
implements AsyncUseCase<void, AllRacesPageResultDTO, string> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly leagueRepository: ILeagueRepository,
private readonly logger: Logger,
) {}
async execute(): Promise<Result<AllRacesPageResultDTO, RacingDomainValidationError>> {
async execute(): Promise<Result<AllRacesPageResultDTO, ApplicationErrorCode<string>>> {
this.logger.debug('Executing GetAllRacesPageDataUseCase');
try {
const [allRaces, allLeagues] = await Promise.all([

View File

@@ -2,7 +2,7 @@ import type { IRaceRepository } from '../../domain/repositories/IRaceRepository'
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetAllRacesResultDTO } from '../presenters/IGetAllRacesPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
export class GetAllRacesUseCase implements AsyncUseCase<void, Result<GetAllRacesResultDTO, RacingDomainValidationError>> {

View File

@@ -2,7 +2,7 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { AllTeamsResultDTO } from '../presenters/IAllTeamsPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
/**

View File

@@ -5,13 +5,36 @@ import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamM
import type { Logger } from '@core/shared/application';
describe('GetDriverTeamUseCase', () => {
let mockTeamRepo: { findById: Mock };
let mockMembershipRepo: { getActiveMembershipForDriver: Mock };
let mockTeamRepo: ITeamRepository;
let mockMembershipRepo: ITeamMembershipRepository;
let mockLogger: Logger;
let mockFindById: Mock;
let mockGetActiveMembershipForDriver: Mock;
beforeEach(() => {
mockTeamRepo = { findById: vi.fn() };
mockMembershipRepo = { getActiveMembershipForDriver: vi.fn() };
mockFindById = vi.fn();
mockGetActiveMembershipForDriver = vi.fn();
mockTeamRepo = {
findById: mockFindById,
findAll: vi.fn(),
findByLeagueId: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
} as ITeamRepository;
mockMembershipRepo = {
getActiveMembershipForDriver: mockGetActiveMembershipForDriver,
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
saveMembership: vi.fn(),
removeMembership: vi.fn(),
getJoinRequests: vi.fn(),
getMembershipsForDriver: vi.fn(),
countByTeamId: vi.fn(),
saveJoinRequest: vi.fn(),
removeJoinRequest: vi.fn(),
} as ITeamMembershipRepository;
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
@@ -22,8 +45,8 @@ describe('GetDriverTeamUseCase', () => {
it('should return driver team data when membership and team exist', async () => {
const useCase = new GetDriverTeamUseCase(
mockTeamRepo as unknown as ITeamRepository,
mockMembershipRepo as unknown as ITeamMembershipRepository,
mockTeamRepo,
mockMembershipRepo,
mockLogger,
);
@@ -31,8 +54,8 @@ describe('GetDriverTeamUseCase', () => {
const membership = { id: 'membership1', driverId, teamId: 'team1' };
const team = { id: 'team1', name: 'Team One' };
mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership);
mockTeamRepo.findById.mockResolvedValue(team);
mockGetActiveMembershipForDriver.mockResolvedValue(membership);
mockFindById.mockResolvedValue(team);
const result = await useCase.execute({ driverId });
@@ -53,12 +76,13 @@ describe('GetDriverTeamUseCase', () => {
const driverId = 'driver1';
mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(null);
mockGetActiveMembershipForDriver.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('No active membership found for driver driver1');
expect(result.unwrapErr().code).toBe('MEMBERSHIP_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('No active membership found for driver driver1');
});
it('should return error when team not found', async () => {
@@ -71,13 +95,14 @@ describe('GetDriverTeamUseCase', () => {
const driverId = 'driver1';
const membership = { id: 'membership1', driverId, teamId: 'team1' };
mockMembershipRepo.getActiveMembershipForDriver.mockResolvedValue(membership);
mockTeamRepo.findById.mockResolvedValue(null);
mockGetActiveMembershipForDriver.mockResolvedValue(membership);
mockFindById.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Team not found for teamId team1');
expect(result.unwrapErr().code).toBe('TEAM_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('Team not found for teamId team1');
});
it('should return error when repository throws', async () => {
@@ -90,11 +115,12 @@ describe('GetDriverTeamUseCase', () => {
const driverId = 'driver1';
const error = new Error('Repository error');
mockMembershipRepo.getActiveMembershipForDriver.mockRejectedValue(error);
mockGetActiveMembershipForDriver.mockRejectedValue(error);
const result = await useCase.execute({ driverId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -2,15 +2,15 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { DriverTeamResultDTO } from '../presenters/IDriverTeamPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving a driver's team.
* Orchestrates domain logic and returns result.
*/
export class GetDriverTeamUseCase
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamResultDTO, RacingDomainValidationError>>
implements AsyncUseCase<{ driverId: string }, Result<DriverTeamResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>>
{
constructor(
private readonly teamRepository: ITeamRepository,
@@ -18,20 +18,20 @@ export class GetDriverTeamUseCase
private readonly logger: Logger,
) {}
async execute(input: { driverId: string }): Promise<Result<DriverTeamResultDTO, RacingDomainValidationError>> {
async execute(input: { driverId: string }): Promise<Result<DriverTeamResultDTO, ApplicationErrorCode<'MEMBERSHIP_NOT_FOUND' | 'TEAM_NOT_FOUND' | 'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug(`Executing GetDriverTeamUseCase for driverId: ${input.driverId}`);
try {
const membership = await this.membershipRepository.getActiveMembershipForDriver(input.driverId);
if (!membership) {
this.logger.warn(`No active membership found for driverId: ${input.driverId}`);
return Result.err(new RacingDomainValidationError(`No active membership found for driver ${input.driverId}`));
return Result.err({ code: 'MEMBERSHIP_NOT_FOUND', details: { message: `No active membership found for driver ${input.driverId}` } });
}
this.logger.debug(`Found membership for driverId: ${input.driverId}, teamId: ${membership.teamId}`);
const team = await this.teamRepository.findById(membership.teamId);
if (!team) {
this.logger.error(`Team not found for teamId: ${membership.teamId}`);
return Result.err(new RacingDomainValidationError(`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}`);
@@ -45,7 +45,7 @@ export class GetDriverTeamUseCase
return Result.ok(dto);
} catch (error) {
this.logger.error('Error executing GetDriverTeamUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } });
}
}
}

View File

@@ -4,7 +4,7 @@ import type { IDriverStatsService } from '../../domain/services/IDriverStatsServ
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { DriversLeaderboardResultDTO } from '../presenters/IDriversLeaderboardPresenter';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@/shared/application/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
/**

View File

@@ -6,15 +6,59 @@ import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISe
import type { Logger } from '@core/shared/application';
describe('GetEntitySponsorshipPricingUseCase', () => {
let mockSponsorshipPricingRepo: { findByEntity: Mock };
let mockSponsorshipRequestRepo: { findPendingByEntity: Mock };
let mockSeasonSponsorshipRepo: { findBySeasonId: Mock };
let mockSponsorshipPricingRepo: ISponsorshipPricingRepository;
let mockSponsorshipRequestRepo: ISponsorshipRequestRepository;
let mockSeasonSponsorshipRepo: ISeasonSponsorshipRepository;
let mockLogger: Logger;
let mockFindByEntity: Mock;
let mockFindPendingByEntity: Mock;
let mockFindBySeasonId: Mock;
beforeEach(() => {
mockSponsorshipPricingRepo = { findByEntity: vi.fn() };
mockSponsorshipRequestRepo = { findPendingByEntity: vi.fn() };
mockSeasonSponsorshipRepo = { findBySeasonId: vi.fn() };
mockFindByEntity = vi.fn();
mockFindPendingByEntity = vi.fn();
mockFindBySeasonId = vi.fn();
mockSponsorshipPricingRepo = {
findByEntity: mockFindByEntity,
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
save: vi.fn(),
exists: vi.fn(),
findAcceptingApplications: vi.fn(),
} as ISponsorshipPricingRepository;
mockSponsorshipRequestRepo = {
findPendingByEntity: mockFindPendingByEntity,
findByEntity: vi.fn(),
findById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findBySponsorId: vi.fn(),
findByStatus: vi.fn(),
findBySponsorIdAndStatus: vi.fn(),
hasPendingRequest: vi.fn(),
findPendingBySponsor: vi.fn(),
findApprovedByEntity: vi.fn(),
findRejectedByEntity: vi.fn(),
countPendingByEntity: vi.fn(),
create: vi.fn(),
exists: vi.fn(),
} as ISponsorshipRequestRepository;
mockSeasonSponsorshipRepo = {
findBySeasonId: mockFindBySeasonId,
findById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findAll: vi.fn(),
findByLeagueId: vi.fn(),
findBySponsorId: vi.fn(),
findBySeasonAndTier: vi.fn(),
create: vi.fn(),
exists: vi.fn(),
} as ISeasonSponsorshipRepository;
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
@@ -33,7 +77,7 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
const dto = { entityType: 'season' as const, entityId: 'season1' };
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(null);
mockFindByEntity.mockResolvedValue(null);
const result = await useCase.execute(dto);
@@ -67,9 +111,9 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
},
};
mockSponsorshipPricingRepo.findByEntity.mockResolvedValue(pricing);
mockSponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([]);
mockSeasonSponsorshipRepo.findBySeasonId.mockResolvedValue([]);
mockFindByEntity.mockResolvedValue(pricing);
mockFindPendingByEntity.mockResolvedValue([]);
mockFindBySeasonId.mockResolvedValue([]);
const result = await useCase.execute(dto);
@@ -115,11 +159,12 @@ describe('GetEntitySponsorshipPricingUseCase', () => {
const dto = { entityType: 'season' as const, entityId: 'season1' };
const error = new Error('Repository error');
mockSponsorshipPricingRepo.findByEntity.mockRejectedValue(error);
mockFindByEntity.mockRejectedValue(error);
const result = await useCase.execute(dto);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toBe('Repository error');
expect(result.unwrapErr().code).toBe('REPOSITORY_ERROR');
expect(result.unwrapErr().details.message).toBe('Repository error');
});
});

View File

@@ -9,13 +9,13 @@ import type { ISponsorshipPricingRepository } from '../../domain/repositories/IS
import type { ISponsorshipRequestRepository } from '../../domain/repositories/ISponsorshipRequestRepository';
import type { ISeasonSponsorshipRepository } from '../../domain/repositories/ISeasonSponsorshipRepository';
import type { AsyncUseCase, Logger } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetEntitySponsorshipPricingDTO } from '../dto/GetEntitySponsorshipPricingDTO';
import type { GetEntitySponsorshipPricingResultDTO } from '../dto/GetEntitySponsorshipPricingResultDTO';
export class GetEntitySponsorshipPricingUseCase
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>>
implements AsyncUseCase<GetEntitySponsorshipPricingDTO, GetEntitySponsorshipPricingResultDTO | null, 'REPOSITORY_ERROR'>
{
constructor(
private readonly sponsorshipPricingRepo: ISponsorshipPricingRepository,
@@ -24,7 +24,7 @@ export class GetEntitySponsorshipPricingUseCase
private readonly logger: Logger,
) {}
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<Result<GetEntitySponsorshipPricingResultDTO | null, RacingDomainValidationError>> {
async execute(dto: GetEntitySponsorshipPricingDTO): Promise<Result<GetEntitySponsorshipPricingResultDTO | null, ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>>> {
this.logger.debug(`Executing GetEntitySponsorshipPricingUseCase for entityType: ${dto.entityType}, entityId: ${dto.entityId}`);
try {
const pricing = await this.sponsorshipPricingRepo.findByEntity(dto.entityType, dto.entityId);
@@ -97,7 +97,7 @@ export class GetEntitySponsorshipPricingUseCase
return Result.ok(result);
} catch (error) {
this.logger.error('Error executing GetEntitySponsorshipPricingUseCase', error instanceof Error ? error : new Error(String(error)));
return Result.err(new RacingDomainValidationError(error instanceof Error ? error.message : 'Unknown error occurred'));
return Result.err({ code: 'REPOSITORY_ERROR', details: { message: error instanceof Error ? error.message : 'Unknown error occurred' } });
}
}
}

View File

@@ -2,29 +2,51 @@ import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueAdminPermissionsUseCase } from './GetLeagueAdminPermissionsUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams';
describe('GetLeagueAdminPermissionsUseCase', () => {
let mockLeagueRepo: { findById: Mock };
let mockMembershipRepo: { getMembership: Mock };
let mockLeagueRepo: ILeagueRepository;
let mockMembershipRepo: ILeagueMembershipRepository;
let mockFindById: Mock;
let mockGetMembership: Mock;
beforeEach(() => {
mockLeagueRepo = { findById: vi.fn() };
mockMembershipRepo = { getMembership: vi.fn() };
mockFindById = vi.fn();
mockGetMembership = vi.fn();
mockLeagueRepo = {
findById: mockFindById,
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
findByOwnerId: vi.fn(),
searchByName: vi.fn(),
} as ILeagueRepository;
mockMembershipRepo = {
getMembership: mockGetMembership,
getMembershipsForDriver: vi.fn(),
saveMembership: vi.fn(),
removeMembership: vi.fn(),
getJoinRequests: vi.fn(),
saveJoinRequest: vi.fn(),
removeJoinRequest: vi.fn(),
countByLeagueId: vi.fn(),
getLeagueMembers: vi.fn(),
} as ILeagueMembershipRepository;
});
const createUseCase = () => new GetLeagueAdminPermissionsUseCase(
mockLeagueRepo as unknown as ILeagueRepository,
mockMembershipRepo as unknown as ILeagueMembershipRepository,
mockLeagueRepo,
mockMembershipRepo,
);
const params: GetLeagueAdminPermissionsUseCaseParams = {
const params = {
leagueId: 'league1',
performerDriverId: 'driver1',
};
it('should return no permissions when league not found', async () => {
mockLeagueRepo.findById.mockResolvedValue(null);
mockFindById.mockResolvedValue(null);
const useCase = createUseCase();
const result = await useCase.execute(params);
@@ -34,8 +56,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
});
it('should return no permissions when membership not found', async () => {
mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' });
mockMembershipRepo.getMembership.mockResolvedValue(null);
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue(null);
const useCase = createUseCase();
const result = await useCase.execute(params);
@@ -45,8 +67,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
});
it('should return no permissions when membership not active', async () => {
mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' });
mockMembershipRepo.getMembership.mockResolvedValue({ status: 'inactive', role: 'admin' });
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'inactive', role: 'admin' });
const useCase = createUseCase();
const result = await useCase.execute(params);
@@ -56,8 +78,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
});
it('should return no permissions when role is member', async () => {
mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' });
mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'member' });
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'active', role: 'member' });
const useCase = createUseCase();
const result = await useCase.execute(params);
@@ -67,8 +89,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
});
it('should return permissions when role is admin', async () => {
mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' });
mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'admin' });
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'active', role: 'admin' });
const useCase = createUseCase();
const result = await useCase.execute(params);
@@ -78,8 +100,8 @@ describe('GetLeagueAdminPermissionsUseCase', () => {
});
it('should return permissions when role is owner', async () => {
mockLeagueRepo.findById.mockResolvedValue({ id: 'league1' });
mockMembershipRepo.getMembership.mockResolvedValue({ status: 'active', role: 'owner' });
mockFindById.mockResolvedValue({ id: 'league1' });
mockGetMembership.mockResolvedValue({ status: 'active', role: 'owner' });
const useCase = createUseCase();
const result = await useCase.execute(params);

View File

@@ -1,17 +1,16 @@
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/result/Result';
import type { GetLeagueAdminPermissionsUseCaseParams } from './GetLeagueAdminPermissionsUseCaseParams';
import { Result } from '@core/shared/application/Result';
import type { GetLeagueAdminPermissionsResultDTO } from '../dto/GetLeagueAdminPermissionsResultDTO';
export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<GetLeagueAdminPermissionsUseCaseParams, Result<GetLeagueAdminPermissionsResultDTO, never>> {
export class GetLeagueAdminPermissionsUseCase implements AsyncUseCase<{ leagueId: string; performerDriverId: string }, GetLeagueAdminPermissionsResultDTO, 'NO_ERROR'> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
) {}
async execute(params: GetLeagueAdminPermissionsUseCaseParams): Promise<Result<GetLeagueAdminPermissionsResultDTO, never>> {
async execute(params: { leagueId: string; performerDriverId: string }): Promise<Result<GetLeagueAdminPermissionsResultDTO, never>> {
const league = await this.leagueRepository.findById(params.leagueId);
if (!league) {
return Result.ok({ canRemoveMember: false, canUpdateRoles: false });

View File

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

View File

@@ -1,38 +1,47 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueAdminUseCase } from './GetLeagueAdminUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
describe('GetLeagueAdminUseCase', () => {
let mockLeagueRepo: { findById: Mock };
let mockLeagueRepo: ILeagueRepository;
let mockFindById: Mock;
beforeEach(() => {
mockLeagueRepo = { findById: vi.fn() };
mockFindById = vi.fn();
mockLeagueRepo = {
findById: mockFindById,
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
exists: vi.fn(),
findByOwnerId: vi.fn(),
searchByName: vi.fn(),
} as ILeagueRepository;
});
const createUseCase = () => new GetLeagueAdminUseCase(
mockLeagueRepo as unknown as ILeagueRepository,
mockLeagueRepo,
);
const params: GetLeagueAdminUseCaseParams = {
const params = {
leagueId: 'league1',
};
it('should return error when league not found', async () => {
mockLeagueRepo.findById.mockResolvedValue(null);
mockFindById.mockResolvedValue(null);
const useCase = createUseCase();
const result = await useCase.execute(params);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toBeInstanceOf(RacingDomainValidationError);
expect(result.unwrapErr().message).toBe('League not found');
expect(result.unwrapErr().code).toBe('LEAGUE_NOT_FOUND');
expect(result.unwrapErr().details.message).toBe('League not found');
});
it('should return league data when league found', async () => {
const league = { id: 'league1', ownerId: 'owner1' };
mockLeagueRepo.findById.mockResolvedValue(league);
mockFindById.mockResolvedValue(league);
const useCase = createUseCase();
const result = await useCase.execute(params);

View File

@@ -1,19 +1,18 @@
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { GetLeagueAdminUseCaseParams } from './GetLeagueAdminUseCaseParams';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueAdminResultDTO } from '../dto/GetLeagueAdminResultDTO';
export class GetLeagueAdminUseCase implements AsyncUseCase<GetLeagueAdminUseCaseParams, Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
export class GetLeagueAdminUseCase implements AsyncUseCase<{ leagueId: string }, GetLeagueAdminResultDTO, 'LEAGUE_NOT_FOUND'> {
constructor(
private readonly leagueRepository: ILeagueRepository,
) {}
async execute(params: GetLeagueAdminUseCaseParams): Promise<Result<GetLeagueAdminResultDTO, RacingDomainValidationError>> {
async execute(params: { leagueId: string }): Promise<Result<GetLeagueAdminResultDTO, ApplicationErrorCode<'LEAGUE_NOT_FOUND', { message: string }>>> {
const league = await this.leagueRepository.findById(params.leagueId);
if (!league) {
return Result.err(new RacingDomainValidationError('League not found'));
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: 'League not found' } });
}
const dto: GetLeagueAdminResultDTO = {

View File

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

View File

@@ -4,8 +4,7 @@ import type { IStandingRepository } from '../../domain/repositories/IStandingRep
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { DriverRatingPort } from './DriverRatingPort';
import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
describe('GetLeagueDriverSeasonStatsUseCase', () => {
let useCase: GetLeagueDriverSeasonStatsUseCase;
@@ -42,7 +41,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
});
it('should return league driver season stats for given league id', async () => {
const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' };
const params = { leagueId: 'league-1' };
const mockStandings = [
{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 },
@@ -82,7 +81,7 @@ describe('GetLeagueDriverSeasonStatsUseCase', () => {
});
it('should handle no penalties', async () => {
const params: GetLeagueDriverSeasonStatsUseCaseParams = { leagueId: 'league-1' };
const params = { leagueId: 'league-1' };
const mockStandings = [{ driverId: 'driver-1', position: 1, points: 100, racesCompleted: 5 }];
const mockRaces = [{ id: 'race-1' }];

View File

@@ -4,16 +4,14 @@ import type { IPenaltyRepository } from '../../domain/repositories/IPenaltyRepos
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { LeagueDriverSeasonStatsResultDTO } from '../presenters/ILeagueDriverSeasonStatsPresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import type { GetLeagueDriverSeasonStatsUseCaseParams } from './GetLeagueDriverSeasonStatsUseCaseParams';
import type { DriverRatingPort } from './DriverRatingPort';
import { Result } from '@core/shared/application/Result';
import type { DriverRatingPort } from '../ports/DriverRatingPort';
/**
* Use Case for retrieving league driver season statistics.
* Orchestrates domain logic and returns the result.
*/
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<GetLeagueDriverSeasonStatsUseCaseParams, Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<{ leagueId: string }, LeagueDriverSeasonStatsResultDTO, 'NO_ERROR'> {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly resultRepository: IResultRepository,
@@ -22,7 +20,7 @@ export class GetLeagueDriverSeasonStatsUseCase implements AsyncUseCase<GetLeague
private readonly driverRatingPort: DriverRatingPort,
) {}
async execute(params: GetLeagueDriverSeasonStatsUseCaseParams): Promise<Result<LeagueDriverSeasonStatsResultDTO, RacingDomainValidationError>> {
async execute(params: { leagueId: string }): Promise<Result<LeagueDriverSeasonStatsResultDTO, never>> {
const { leagueId } = params;
// Get standings and races for the league

View File

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

View File

@@ -144,7 +144,8 @@ describe('GetLeagueFullConfigUseCase', () => {
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.message).toBe('League with id league-1 not found');
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details.message).toBe('League with id league-1 not found');
});
it('should handle no active season', async () => {

View File

@@ -8,14 +8,14 @@ import type {
LeagueConfigFormViewModel,
} from '../presenters/ILeagueFullConfigPresenter';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { RacingDomainValidationError } from '../../domain/errors/RacingDomainError';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
/**
* Use Case for retrieving a league's full configuration.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: string }, LeagueConfigFormViewModel, 'LEAGUE_NOT_FOUND' | 'PRESENTATION_FAILED'> {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly seasonRepository: ISeasonRepository,
@@ -24,12 +24,12 @@ export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: stri
private readonly presenter: ILeagueFullConfigPresenter,
) {}
async execute(params: { leagueId: string }): Promise<Result<LeagueConfigFormViewModel, RacingDomainValidationError>> {
async execute(params: { leagueId: string }): Promise<Result<LeagueConfigFormViewModel, ApplicationErrorCode<'LEAGUE_NOT_FOUND' | 'PRESENTATION_FAILED', { message: string }>>> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
return Result.err(new RacingDomainValidationError(`League with id ${leagueId} not found`));
return Result.err({ code: 'LEAGUE_NOT_FOUND', details: { message: `League with id ${leagueId} not found` } });
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
@@ -58,7 +58,7 @@ export class GetLeagueFullConfigUseCase implements AsyncUseCase<{ leagueId: stri
this.presenter.present(data);
const viewModel = this.presenter.getViewModel();
if (!viewModel) {
return Result.err(new RacingDomainValidationError('Failed to present league config'));
return Result.err({ code: 'PRESENTATION_FAILED', details: { message: 'Failed to present league config' } });
}
return Result.ok(viewModel);

View File

@@ -1,25 +1,31 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import type { GetLeagueJoinRequestsUseCaseParams } from '../dto/GetLeagueJoinRequestsUseCaseParams';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueJoinRequestsResultDTO } from '../dto/GetLeagueJoinRequestsResultDTO';
export class GetLeagueJoinRequestsUseCase implements AsyncUseCase<GetLeagueJoinRequestsUseCaseParams, Result<GetLeagueJoinRequestsResultDTO, never>> {
export interface GetLeagueJoinRequestsUseCaseParams {
leagueId: string;
}
export class GetLeagueJoinRequestsUseCase implements AsyncUseCase<GetLeagueJoinRequestsUseCaseParams, GetLeagueJoinRequestsResultDTO, 'NO_ERROR'> {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise<Result<GetLeagueJoinRequestsResultDTO, never>> {
async execute(params: GetLeagueJoinRequestsUseCaseParams): Promise<Result<GetLeagueJoinRequestsResultDTO, 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.map(request => ({
...request,
driver: driverMap.get(request.driverId)!,
}));
const enrichedJoinRequests = joinRequests
.filter(request => driverMap.has(request.driverId))
.map(request => ({
...request,
driver: driverMap.get(request.driverId)!,
}));
return Result.ok({
joinRequests: enrichedJoinRequests,
});

View File

@@ -1,20 +1,21 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/result/Result';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueMembershipsResultDTO } from '../dto/GetLeagueMembershipsResultDTO';
export interface GetLeagueMembershipsUseCaseParams {
leagueId: string;
}
export class GetLeagueMembershipsUseCase implements AsyncUseCase<GetLeagueMembershipsUseCaseParams, Result<GetLeagueMembershipsResultDTO, never>> {
export class GetLeagueMembershipsUseCase implements AsyncUseCase<GetLeagueMembershipsUseCaseParams, GetLeagueMembershipsResultDTO, 'NO_ERROR'> {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(params: GetLeagueMembershipsUseCaseParams): Promise<Result<GetLeagueMembershipsResultDTO, never>> {
async execute(params: GetLeagueMembershipsUseCaseParams): Promise<Result<GetLeagueMembershipsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const drivers: { id: string; name: string }[] = [];

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueOwnerSummaryUseCase } from './GetLeagueOwnerSummaryUseCase';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Driver } from '../../domain/entities/Driver';
describe('GetLeagueOwnerSummaryUseCase', () => {
let useCase: GetLeagueOwnerSummaryUseCase;
let driverRepository: {
findById: Mock;
};
beforeEach(() => {
driverRepository = {
findById: vi.fn(),
};
useCase = new GetLeagueOwnerSummaryUseCase(
driverRepository as unknown as IDriverRepository,
);
});
it('should return owner summary when driver exists', async () => {
const ownerId = 'owner-1';
const driver = Driver.create({
id: ownerId,
iracingId: '123',
name: 'Owner Name',
country: 'US',
});
driverRepository.findById.mockResolvedValue(driver);
const result = await useCase.execute({ ownerId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
summary: {
driver: { id: ownerId, name: 'Owner Name' },
rating: 0,
rank: 0,
},
});
});
it('should return null summary when driver does not exist', async () => {
const ownerId = 'owner-1';
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ ownerId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
summary: null,
});
});
});

View File

@@ -1,23 +1,19 @@
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IGetLeagueOwnerSummaryPresenter, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel } from '../presenters/IGetLeagueOwnerSummaryPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueOwnerSummaryResultDTO } from '../dto/GetLeagueOwnerSummaryResultDTO';
export interface GetLeagueOwnerSummaryUseCaseParams {
ownerId: string;
}
export interface GetLeagueOwnerSummaryResultDTO {
summary: { driver: { id: string; name: string }; rating: number; rank: number } | null;
}
export class GetLeagueOwnerSummaryUseCase implements UseCase<GetLeagueOwnerSummaryUseCaseParams, GetLeagueOwnerSummaryResultDTO, GetLeagueOwnerSummaryViewModel, IGetLeagueOwnerSummaryPresenter> {
export class GetLeagueOwnerSummaryUseCase implements AsyncUseCase<GetLeagueOwnerSummaryUseCaseParams, GetLeagueOwnerSummaryResultDTO, 'NO_ERROR'> {
constructor(private readonly driverRepository: IDriverRepository) {}
async execute(params: GetLeagueOwnerSummaryUseCaseParams, presenter: IGetLeagueOwnerSummaryPresenter): Promise<void> {
async execute(params: GetLeagueOwnerSummaryUseCaseParams): Promise<Result<GetLeagueOwnerSummaryResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
const driver = await this.driverRepository.findById(params.ownerId);
const summary = driver ? { driver: { id: driver.id, name: driver.name }, rating: 0, rank: 0 } : null;
const dto: GetLeagueOwnerSummaryResultDTO = { summary };
presenter.reset();
presenter.present(dto);
return Result.ok({ summary });
}
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueProtestsUseCase } from './GetLeagueProtestsUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import { Race } from '../../domain/entities/Race';
import { Protest } from '../../domain/entities/Protest';
import { Driver } from '../../domain/entities/Driver';
describe('GetLeagueProtestsUseCase', () => {
let useCase: GetLeagueProtestsUseCase;
let raceRepository: {
findByLeagueId: Mock;
};
let protestRepository: {
findByRaceId: Mock;
};
let driverRepository: {
findById: Mock;
};
beforeEach(() => {
raceRepository = {
findByLeagueId: vi.fn(),
};
protestRepository = {
findByRaceId: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
useCase = new GetLeagueProtestsUseCase(
raceRepository as unknown as IRaceRepository,
protestRepository as unknown as IProtestRepository,
driverRepository as unknown as IDriverRepository,
);
});
it('should return protests with races and drivers', async () => {
const leagueId = 'league-1';
const race = Race.create({
id: 'race-1',
leagueId,
scheduledAt: new Date(),
track: 'Track 1',
car: 'Car 1',
});
const protest = Protest.create({
id: 'protest-1',
raceId: 'race-1',
protestingDriverId: 'driver-1',
accusedDriverId: 'driver-2',
incident: { lap: 1, description: 'Incident' },
status: 'pending',
filedAt: new Date(),
});
const driver1 = Driver.create({
id: 'driver-1',
iracingId: '123',
name: 'Driver 1',
country: 'US',
});
const driver2 = Driver.create({
id: 'driver-2',
iracingId: '456',
name: 'Driver 2',
country: 'UK',
});
raceRepository.findByLeagueId.mockResolvedValue([race]);
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);
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId });
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' },
],
});
});
it('should return empty when no races', async () => {
const leagueId = 'league-1';
raceRepository.findByLeagueId.mockResolvedValue([]);
protestRepository.findByRaceId.mockResolvedValue([]);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
protests: [],
races: [],
drivers: [],
});
});
});

View File

@@ -1,34 +1,30 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { IGetLeagueProtestsPresenter, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel } from '../presenters/IGetLeagueProtestsPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueProtestsResultDTO, ProtestDTO } from '../dto/GetLeagueProtestsResultDTO';
export interface GetLeagueProtestsUseCaseParams {
leagueId: string;
}
export interface GetLeagueProtestsResultDTO {
protests: unknown[];
races: unknown[];
drivers: { id: string; name: string }[];
}
export class GetLeagueProtestsUseCase implements UseCase<GetLeagueProtestsUseCaseParams, GetLeagueProtestsResultDTO, GetLeagueProtestsViewModel, IGetLeagueProtestsPresenter> {
export class GetLeagueProtestsUseCase implements AsyncUseCase<GetLeagueProtestsUseCaseParams, GetLeagueProtestsResultDTO, 'NO_ERROR'> {
constructor(
private readonly raceRepository: IRaceRepository,
private readonly protestRepository: IProtestRepository,
private readonly driverRepository: IDriverRepository,
) {}
async execute(params: GetLeagueProtestsUseCaseParams, presenter: IGetLeagueProtestsPresenter): Promise<void> {
async execute(params: GetLeagueProtestsUseCaseParams): Promise<Result<GetLeagueProtestsResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const protests = [];
const protests: ProtestDTO[] = [];
const raceMap = new Map();
const driverIds = new Set<string>();
for (const race of races) {
raceMap.set(race.id, { id: race.id, name: race.name, date: race.scheduledAt.toISOString() });
raceMap.set(race.id, { id: race.id, name: race.track, date: race.scheduledAt.toISOString() });
const raceProtests = await this.protestRepository.findByRaceId(race.id);
for (const protest of raceProtests) {
protests.push({
@@ -45,14 +41,17 @@ export class GetLeagueProtestsUseCase implements UseCase<GetLeagueProtestsUseCas
}
}
const drivers = await this.driverRepository.findByIds(Array.from(driverIds));
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }]));
const dto: GetLeagueProtestsResultDTO = {
const drivers: { id: string; name: string }[] = [];
for (const driverId of driverIds) {
const driver = await this.driverRepository.findById(driverId);
if (driver) {
drivers.push({ id: driver.id, name: driver.name });
}
}
return Result.ok({
protests,
races: Array.from(raceMap.values()),
drivers: Array.from(driverMap.values()),
};
presenter.reset();
presenter.present(dto);
drivers,
});
}
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueScheduleUseCase } from './GetLeagueScheduleUseCase';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { Race } from '../../domain/entities/Race';
describe('GetLeagueScheduleUseCase', () => {
let useCase: GetLeagueScheduleUseCase;
let raceRepository: {
findByLeagueId: Mock;
};
beforeEach(() => {
raceRepository = {
findByLeagueId: vi.fn(),
};
useCase = new GetLeagueScheduleUseCase(
raceRepository as unknown as IRaceRepository,
);
});
it('should return league schedule', async () => {
const leagueId = 'league-1';
const race = Race.create({
id: 'race-1',
leagueId,
scheduledAt: new Date('2023-01-01T10:00:00Z'),
track: 'Track 1',
car: 'Car 1',
});
raceRepository.findByLeagueId.mockResolvedValue([race]);
const result = await useCase.execute({ leagueId });
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'),
},
],
});
});
it('should return empty schedule when no races', async () => {
const leagueId = 'league-1';
raceRepository.findByLeagueId.mockResolvedValue([]);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
races: [],
});
});
});

View File

@@ -1,32 +1,24 @@
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IGetLeagueSchedulePresenter, GetLeagueScheduleResultDTO, GetLeagueScheduleViewModel } from '../presenters/IGetLeagueSchedulePresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { AsyncUseCase } from '@core/shared/application';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { GetLeagueScheduleResultDTO } from '../dto/GetLeagueScheduleResultDTO';
export interface GetLeagueScheduleUseCaseParams {
leagueId: string;
}
export interface GetLeagueScheduleResultDTO {
races: Array<{
id: string;
name: string;
scheduledAt: Date;
}>;
}
export class GetLeagueScheduleUseCase implements UseCase<GetLeagueScheduleUseCaseParams, GetLeagueScheduleResultDTO, GetLeagueScheduleViewModel, IGetLeagueSchedulePresenter> {
export class GetLeagueScheduleUseCase implements AsyncUseCase<GetLeagueScheduleUseCaseParams, GetLeagueScheduleResultDTO, 'NO_ERROR'> {
constructor(private readonly raceRepository: IRaceRepository) {}
async execute(params: GetLeagueScheduleUseCaseParams, presenter: IGetLeagueSchedulePresenter): Promise<void> {
async execute(params: GetLeagueScheduleUseCaseParams): Promise<Result<GetLeagueScheduleResultDTO, ApplicationErrorCode<'NO_ERROR'>>> {
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const dto: GetLeagueScheduleResultDTO = {
return Result.ok({
races: races.map(race => ({
id: race.id,
name: `${race.track} - ${race.car}`,
scheduledAt: race.scheduledAt,
})),
};
presenter.reset();
presenter.present(dto);
});
}
}

View File

@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueScoringConfigUseCase } 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 { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
describe('GetLeagueScoringConfigUseCase', () => {
let useCase: GetLeagueScoringConfigUseCase;
let leagueRepository: { findById: Mock };
let seasonRepository: { findByLeagueId: Mock };
let leagueScoringConfigRepository: { findBySeasonId: Mock };
let gameRepository: { findById: Mock };
let presetProvider: { getPresetById: Mock; listPresets: Mock; createScoringConfigFromPreset: Mock };
beforeEach(() => {
leagueRepository = { findById: vi.fn() };
seasonRepository = { findByLeagueId: vi.fn() };
leagueScoringConfigRepository = { findBySeasonId: vi.fn() };
gameRepository = { findById: vi.fn() };
presetProvider = { getPresetById: vi.fn(), listPresets: vi.fn(), createScoringConfigFromPreset: vi.fn() };
useCase = new GetLeagueScoringConfigUseCase(
leagueRepository as unknown as ILeagueRepository,
seasonRepository as unknown as ISeasonRepository,
leagueScoringConfigRepository as unknown as ILeagueScoringConfigRepository,
gameRepository as unknown as IGameRepository,
presetProvider as LeagueScoringPresetProvider,
);
});
it('should return scoring config for active season', async () => {
const leagueId = 'league-1';
const league = { id: 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' };
const preset = { id: 'preset-1', name: 'Preset 1' };
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue([season]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig);
gameRepository.findById.mockResolvedValue(game);
presetProvider.getPresetById.mockReturnValue(preset);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
leagueId,
seasonId: 'season-1',
gameId: 'game-1',
gameName: 'Game 1',
scoringPresetId: 'preset-1',
preset,
championships: [],
});
});
it('should return scoring config for first season if no active', async () => {
const leagueId = 'league-1';
const league = { id: leagueId };
const season = { id: 'season-1', status: 'inactive', gameId: 'game-1' };
const scoringConfig = { scoringPresetId: undefined, championships: [] };
const game = { id: 'game-1', name: 'Game 1' };
leagueRepository.findById.mockResolvedValue(league);
seasonRepository.findByLeagueId.mockResolvedValue([season]);
leagueScoringConfigRepository.findBySeasonId.mockResolvedValue(scoringConfig);
gameRepository.findById.mockResolvedValue(game);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
leagueId,
seasonId: 'season-1',
gameId: 'game-1',
gameName: 'Game 1',
championships: [],
});
});
it('should return error if league not found', async () => {
leagueRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'LEAGUE_NOT_FOUND' });
});
it('should return error if no seasons', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
seasonRepository.findByLeagueId.mockResolvedValue([]);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'NO_SEASONS' });
});
it('should return error if no seasons (null)', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
seasonRepository.findByLeagueId.mockResolvedValue(null);
const result = await useCase.execute({ leagueId: 'league-1' });
expect(result.isErr()).toBe(true);
expect(result.error).toEqual({ code: 'NO_SEASONS' });
});
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' }]);
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' });
});
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: [] });
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' });
});
});

View File

@@ -3,19 +3,23 @@ import type { ISeasonRepository } from '../../domain/repositories/ISeasonReposit
import type { ILeagueScoringConfigRepository } from '../../domain/repositories/ILeagueScoringConfigRepository';
import type { IGameRepository } from '../../domain/repositories/IGameRepository';
import type { LeagueScoringPresetProvider } from '../ports/LeagueScoringPresetProvider';
import type {
ILeagueScoringConfigPresenter,
LeagueScoringConfigData,
LeagueScoringConfigViewModel,
} from '../presenters/ILeagueScoringConfigPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { LeagueScoringConfigData } from '../presenters/ILeagueScoringConfigPresenter';
import type { AsyncUseCase } from '@core/shared/application/AsyncUseCase';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
type GetLeagueScoringConfigErrorCode =
| 'LEAGUE_NOT_FOUND'
| 'NO_SEASONS'
| 'NO_ACTIVE_SEASON'
| 'NO_SCORING_CONFIG'
| 'GAME_NOT_FOUND';
/**
* Use Case for retrieving a league's scoring configuration for its active season.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueScoringConfigUseCase
implements UseCase<{ leagueId: string }, LeagueScoringConfigData, LeagueScoringConfigViewModel, ILeagueScoringConfigPresenter>
implements AsyncUseCase<{ leagueId: string }, LeagueScoringConfigData, GetLeagueScoringConfigErrorCode>
{
constructor(
private readonly leagueRepository: ILeagueRepository,
@@ -25,40 +29,40 @@ export class GetLeagueScoringConfigUseCase
private readonly presetProvider: LeagueScoringPresetProvider,
) {}
async execute(params: { leagueId: string }, presenter: ILeagueScoringConfigPresenter): Promise<void> {
async execute(params: { leagueId: string }): Promise<Result<LeagueScoringConfigData, ApplicationErrorCode<GetLeagueScoringConfigErrorCode>>> {
const { leagueId } = params;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error(`League ${leagueId} not found`);
return Result.err({ code: 'LEAGUE_NOT_FOUND' });
}
const seasons = await this.seasonRepository.findByLeagueId(leagueId);
if (!seasons || seasons.length === 0) {
throw new Error(`No seasons found for league ${leagueId}`);
return Result.err({ code: 'NO_SEASONS' });
}
const activeSeason =
seasons.find((s) => s.status === 'active') ?? seasons[0];
if (!activeSeason) {
throw new Error(`No active season could be determined for league ${leagueId}`);
return Result.err({ code: 'NO_ACTIVE_SEASON' });
}
const scoringConfig =
await this.leagueScoringConfigRepository.findBySeasonId(activeSeason.id);
if (!scoringConfig) {
throw new Error(`No scoring config found for season ${activeSeason.id}`);
return Result.err({ code: 'NO_SCORING_CONFIG' });
}
const game = await this.gameRepository.findById(activeSeason.gameId);
if (!game) {
throw new Error(`Game ${activeSeason.gameId} not found`);
return Result.err({ code: 'GAME_NOT_FOUND' });
}
const presetId = scoringConfig.scoringPresetId;
const preset = presetId ? this.presetProvider.getPresetById(presetId) : undefined;
const data: LeagueScoringConfigData = {
leagueId: league.id,
seasonId: activeSeason.id,
@@ -69,7 +73,6 @@ export class GetLeagueScoringConfigUseCase
championships: scoringConfig.championships,
};
presenter.reset();
presenter.present(data);
return Result.ok(data);
}
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueSeasonsUseCase } from './GetLeagueSeasonsUseCase';
import { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import { Season } from '../../domain/entities/Season';
describe('GetLeagueSeasonsUseCase', () => {
let useCase: GetLeagueSeasonsUseCase;
let seasonRepository: {
findByLeagueId: Mock;
};
beforeEach(() => {
seasonRepository = {
findByLeagueId: vi.fn(),
};
useCase = new GetLeagueSeasonsUseCase(
seasonRepository as unknown as ISeasonRepository,
);
});
it('should return seasons mapped to view model', async () => {
const leagueId = 'league-1';
const seasons = [
Season.create({
id: 'season-1',
leagueId,
gameId: 'game-1',
name: 'Season 1',
status: 'active',
startDate: new Date('2023-01-01'),
endDate: new Date('2023-12-31'),
}),
Season.create({
id: 'season-2',
leagueId,
gameId: 'game-1',
name: 'Season 2',
status: 'planned',
}),
];
seasonRepository.findByLeagueId.mockResolvedValue(seasons);
const result = await useCase.execute({ leagueId });
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,
},
],
});
});
it('should set isParallelActive true for active seasons when multiple active', async () => {
const leagueId = 'league-1';
const seasons = [
Season.create({
id: 'season-1',
leagueId,
gameId: 'game-1',
name: 'Season 1',
status: 'active',
}),
Season.create({
id: 'season-2',
leagueId,
gameId: 'game-1',
name: 'Season 2',
status: 'active',
}),
];
seasonRepository.findByLeagueId.mockResolvedValue(seasons);
const result = await useCase.execute({ leagueId });
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);
});
it('should return error when repository fails', async () => {
const leagueId = 'league-1';
seasonRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch seasons',
});
});
});

View File

@@ -1,22 +1,33 @@
import type { ISeasonRepository } from '../../domain/repositories/ISeasonRepository';
import type { IGetLeagueSeasonsPresenter, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { GetLeagueSeasonsViewModel } from '../presenters/IGetLeagueSeasonsPresenter';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetLeagueSeasonsUseCaseParams {
leagueId: string;
}
export interface GetLeagueSeasonsResultDTO {
seasons: unknown[];
}
export class GetLeagueSeasonsUseCase implements UseCase<GetLeagueSeasonsUseCaseParams, GetLeagueSeasonsResultDTO, GetLeagueSeasonsViewModel, IGetLeagueSeasonsPresenter> {
export class GetLeagueSeasonsUseCase {
constructor(private readonly seasonRepository: ISeasonRepository) {}
async execute(params: GetLeagueSeasonsUseCaseParams, presenter: IGetLeagueSeasonsPresenter): Promise<void> {
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const dto: GetLeagueSeasonsResultDTO = { seasons };
presenter.reset();
presenter.present(dto);
async execute(params: GetLeagueSeasonsUseCaseParams): Promise<Result<GetLeagueSeasonsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const seasons = await this.seasonRepository.findByLeagueId(params.leagueId);
const activeCount = seasons.filter(s => s.status === 'active').length;
const viewModel: GetLeagueSeasonsViewModel = {
seasons: seasons.map(s => ({
seasonId: s.id,
name: s.name,
status: s.status,
startDate: s.startDate,
endDate: s.endDate,
isPrimary: false,
isParallelActive: s.status === 'active' && activeCount > 1
}))
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch seasons' });
}
}
}

View File

@@ -0,0 +1,101 @@
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 { Standing } from '../../domain/entities/Standing';
import { Driver } from '../../domain/entities/Driver';
describe('GetLeagueStandingsUseCase', () => {
let useCase: GetLeagueStandingsUseCase;
let standingRepository: {
findByLeagueId: Mock;
};
let driverRepository: {
findById: Mock;
};
beforeEach(() => {
standingRepository = {
findByLeagueId: vi.fn(),
};
driverRepository = {
findById: vi.fn(),
};
useCase = new GetLeagueStandingsUseCase(
standingRepository as unknown as IStandingRepository,
driverRepository as unknown as IDriverRepository,
);
});
it('should return standings with drivers mapped', async () => {
const leagueId = 'league-1';
const standings = [
Standing.create({
id: 'standing-1',
leagueId,
driverId: 'driver-1',
points: 100,
position: 1,
}),
Standing.create({
id: 'standing-2',
leagueId,
driverId: 'driver-2',
points: 80,
position: 2,
}),
];
const driver1 = Driver.create({
id: 'driver-1',
iracingId: '123',
name: 'Driver One',
country: 'US',
});
const driver2 = Driver.create({
id: 'driver-2',
iracingId: '456',
name: 'Driver Two',
country: 'US',
});
standingRepository.findByLeagueId.mockResolvedValue(standings);
driverRepository.findById.mockImplementation((id: string) => {
if (id === 'driver-1') return Promise.resolve(driver1);
if (id === 'driver-2') return Promise.resolve(driver2);
return Promise.resolve(null);
});
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
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,
},
],
});
});
it('should return error when repository fails', async () => {
const leagueId = 'league-1';
standingRepository.findByLeagueId.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league standings',
});
});
});

View File

@@ -1,11 +1,8 @@
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type {
ILeagueStandingsPresenter,
LeagueStandingsResultDTO,
LeagueStandingsViewModel,
} from '../presenters/ILeagueStandingsPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { LeagueStandingsViewModel } from '../presenters/ILeagueStandingsPresenter';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetLeagueStandingsUseCaseParams {
leagueId: string;
@@ -13,12 +10,8 @@ export interface GetLeagueStandingsUseCaseParams {
/**
* Use Case for retrieving league standings.
* Orchestrates domain logic and delegates presentation to the presenter.
*/
export class GetLeagueStandingsUseCase
implements
UseCase<GetLeagueStandingsUseCaseParams, LeagueStandingsResultDTO, LeagueStandingsViewModel, ILeagueStandingsPresenter>
{
export class GetLeagueStandingsUseCase {
constructor(
private readonly standingRepository: IStandingRepository,
private readonly driverRepository: IDriverRepository,
@@ -26,17 +19,25 @@ export class GetLeagueStandingsUseCase
async execute(
params: GetLeagueStandingsUseCaseParams,
presenter: ILeagueStandingsPresenter,
): Promise<void> {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
const driverIds = [...new Set(standings.map(s => s.driverId))];
const drivers = await this.driverRepository.findByIds(driverIds);
const driverMap = new Map(drivers.map(d => [d.id, { id: d.id, name: d.name }]));
const dto: LeagueStandingsResultDTO = {
standings,
drivers: Array.from(driverMap.values()),
};
presenter.reset();
presenter.present(dto);
): Promise<Result<LeagueStandingsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const standings = await this.standingRepository.findByLeagueId(params.leagueId);
const driverIds = [...new Set(standings.map(s => s.driverId))];
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: LeagueStandingsViewModel = {
standings: standings.map(s => ({
driverId: s.driverId,
driver: driverMap.get(s.driverId)!,
points: s.points,
rank: s.position,
})),
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league standings' });
}
}
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetLeagueStatsUseCase } from './GetLeagueStatsUseCase';
import { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import { DriverRatingProvider } from '../ports/DriverRatingProvider';
describe('GetLeagueStatsUseCase', () => {
let useCase: GetLeagueStatsUseCase;
let leagueMembershipRepository: {
getLeagueMembers: Mock;
};
let raceRepository: {
findByLeagueId: Mock;
};
let driverRatingProvider: {
getRatings: Mock;
};
beforeEach(() => {
leagueMembershipRepository = {
getLeagueMembers: vi.fn(),
};
raceRepository = {
findByLeagueId: vi.fn(),
};
driverRatingProvider = {
getRatings: vi.fn(),
};
useCase = new GetLeagueStatsUseCase(
leagueMembershipRepository as unknown as ILeagueMembershipRepository,
raceRepository as unknown as IRaceRepository,
driverRatingProvider as unknown as DriverRatingProvider,
);
});
it('should return league stats with average rating', async () => {
const leagueId = 'league-1';
const memberships = [
{ driverId: 'driver-1' },
{ driverId: 'driver-2' },
{ driverId: 'driver-3' },
];
const races = [{ id: 'race-1' }, { id: 'race-2' }];
const ratings = new Map([
['driver-1', 1500],
['driver-2', 1600],
['driver-3', null],
]);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races);
driverRatingProvider.getRatings.mockReturnValue(ratings);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
totalMembers: 3,
totalRaces: 2,
averageRating: 1550, // (1500 + 1600) / 2
});
});
it('should return 0 average rating when no valid ratings', async () => {
const leagueId = 'league-1';
const memberships = [{ driverId: 'driver-1' }];
const races = [{ id: 'race-1' }];
const ratings = new Map([['driver-1', null]]);
leagueMembershipRepository.getLeagueMembers.mockResolvedValue(memberships);
raceRepository.findByLeagueId.mockResolvedValue(races);
driverRatingProvider.getRatings.mockReturnValue(ratings);
const result = await useCase.execute({ leagueId });
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toEqual({
totalMembers: 1,
totalRaces: 1,
averageRating: 0,
});
});
it('should return error when repository fails', async () => {
const leagueId = 'league-1';
leagueMembershipRepository.getLeagueMembers.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute({ leagueId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch league stats',
});
});
});

View File

@@ -1,28 +1,37 @@
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueStatsPresenter, LeagueStatsResultDTO, LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter';
import type { UseCase } from '@core/shared/application/UseCase';
import type { LeagueStatsViewModel } from '../presenters/ILeagueStatsPresenter';
import type { DriverRatingProvider } from '../ports/DriverRatingProvider';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetLeagueStatsUseCaseParams {
leagueId: string;
}
export class GetLeagueStatsUseCase implements UseCase<GetLeagueStatsUseCaseParams, LeagueStatsResultDTO, LeagueStatsViewModel, ILeagueStatsPresenter> {
export class GetLeagueStatsUseCase {
constructor(
private readonly leagueMembershipRepository: ILeagueMembershipRepository,
private readonly raceRepository: IRaceRepository,
private readonly driverRatingProvider: DriverRatingProvider,
) {}
async execute(params: GetLeagueStatsUseCaseParams, presenter: ILeagueStatsPresenter): Promise<void> {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const races = await this.raceRepository.findByLeagueId(params.leagueId);
// TODO: Implement average rating calculation from driver ratings
const dto: LeagueStatsResultDTO = {
totalMembers: memberships.length,
totalRaces: races.length,
averageRating: 0,
};
presenter.reset();
presenter.present(dto);
async execute(params: GetLeagueStatsUseCaseParams): Promise<Result<LeagueStatsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const memberships = await this.leagueMembershipRepository.getLeagueMembers(params.leagueId);
const races = await this.raceRepository.findByLeagueId(params.leagueId);
const driverIds = memberships.map(m => m.driverId);
const ratings = this.driverRatingProvider.getRatings(driverIds);
const validRatings = Array.from(ratings.values()).filter(r => r !== null) as number[];
const averageRating = validRatings.length > 0 ? Math.round(validRatings.reduce((sum, r) => sum + r, 0) / validRatings.length) : 0;
const viewModel: LeagueStatsViewModel = {
totalMembers: memberships.length,
totalRaces: races.length,
averageRating,
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch league stats' });
}
}
}

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetPendingSponsorshipRequestsUseCase } 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';
describe('GetPendingSponsorshipRequestsUseCase', () => {
let useCase: GetPendingSponsorshipRequestsUseCase;
let sponsorshipRequestRepo: {
findPendingByEntity: Mock;
};
let sponsorRepo: {
findById: Mock;
};
beforeEach(() => {
sponsorshipRequestRepo = {
findPendingByEntity: vi.fn(),
};
sponsorRepo = {
findById: vi.fn(),
};
useCase = new GetPendingSponsorshipRequestsUseCase(
sponsorshipRequestRepo as unknown as ISponsorshipRequestRepository,
sponsorRepo as unknown as ISponsorRepository,
);
});
it('should return pending sponsorship requests', async () => {
const dto = { entityType: 'season' as const, entityId: 'entity-1' };
const request = SponsorshipRequest.create({
id: 'req-1',
sponsorId: 'sponsor-1',
entityType: 'season',
entityId: 'entity-1',
tier: 'main',
offeredAmount: Money.create(10000, 'USD'),
message: 'Test message',
});
const sponsor = Sponsor.create({
id: 'sponsor-1',
name: 'Test Sponsor',
contactEmail: 'test@example.com',
logoUrl: 'logo.png',
});
sponsorshipRequestRepo.findPendingByEntity.mockResolvedValue([request]);
sponsorRepo.findById.mockResolvedValue(sponsor);
const result = await useCase.execute(dto);
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,
});
});
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 result = await useCase.execute(dto);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch pending sponsorship requests',
});
});
});

View File

@@ -8,11 +8,9 @@ import type { ISponsorshipRequestRepository } from '../../domain/repositories/IS
import type { ISponsorRepository } from '../../domain/repositories/ISponsorRepository';
import type { SponsorableEntityType } from '../../domain/entities/SponsorshipRequest';
import type { SponsorshipTier } from '../../domain/entities/SeasonSponsorship';
import type { UseCase } from '@core/shared/application/UseCase';
import type {
IPendingSponsorshipRequestsPresenter,
PendingSponsorshipRequestsViewModel,
} from '../presenters/IPendingSponsorshipRequestsPresenter';
import type { PendingSponsorshipRequestsViewModel } from '../presenters/IPendingSponsorshipRequestsPresenter';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
export interface GetPendingSponsorshipRequestsDTO {
entityType: SponsorableEntityType;
@@ -41,13 +39,7 @@ export interface GetPendingSponsorshipRequestsResultDTO {
totalCount: number;
}
export class GetPendingSponsorshipRequestsUseCase
implements UseCase<
GetPendingSponsorshipRequestsDTO,
GetPendingSponsorshipRequestsResultDTO,
PendingSponsorshipRequestsViewModel,
IPendingSponsorshipRequestsPresenter
> {
export class GetPendingSponsorshipRequestsUseCase {
constructor(
private readonly sponsorshipRequestRepo: ISponsorshipRequestRepository,
private readonly sponsorRepo: ISponsorRepository,
@@ -55,43 +47,46 @@ export class GetPendingSponsorshipRequestsUseCase
async execute(
dto: GetPendingSponsorshipRequestsDTO,
presenter: IPendingSponsorshipRequestsPresenter,
): Promise<void> {
presenter.reset();
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
): Promise<Result<PendingSponsorshipRequestsViewModel, ApplicationErrorCode<'REPOSITORY_ERROR'>>> {
try {
const requests = await this.sponsorshipRequestRepo.findPendingByEntity(
dto.entityType,
dto.entityId
);
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
const requestDTOs: PendingSponsorshipRequestDTO[] = [];
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,
});
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,
});
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
const viewModel: PendingSponsorshipRequestsViewModel = {
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
};
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch pending sponsorship requests' });
}
// Sort by creation date (newest first)
requestDTOs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
presenter.present({
entityType: dto.entityType,
entityId: dto.entityId,
requests: requestDTOs,
totalCount: requestDTOs.length,
});
}
}

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import { GetProfileOverviewUseCase } 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 { Driver } from '../../domain/entities/Driver';
import { Team } from '../../domain/entities/Team';
describe('GetProfileOverviewUseCase', () => {
let useCase: GetProfileOverviewUseCase;
let driverRepository: {
findById: Mock;
};
let teamRepository: {
findAll: Mock;
};
let teamMembershipRepository: {
getMembership: Mock;
};
let socialRepository: {
getFriends: Mock;
};
let imageService: {
getDriverAvatar: Mock;
};
let getDriverStats: Mock;
let getAllDriverRankings: Mock;
beforeEach(() => {
driverRepository = {
findById: vi.fn(),
};
teamRepository = {
findAll: vi.fn(),
};
teamMembershipRepository = {
getMembership: vi.fn(),
};
socialRepository = {
getFriends: vi.fn(),
};
imageService = {
getDriverAvatar: vi.fn(),
};
getDriverStats = vi.fn();
getAllDriverRankings = vi.fn();
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,
getDriverStats,
getAllDriverRankings,
);
});
it('should return profile overview for existing driver', async () => {
const driverId = 'driver-1';
const driver = Driver.create({
id: driverId,
iracingId: '123',
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 statsAdapter = {
rating: 1500,
wins: 5,
totalRaces: 10,
avgFinish: 3.5,
};
const rankings = [{ driverId, rating: 1500, overallRank: 1 }];
driverRepository.findById.mockResolvedValue(driver);
teamRepository.findAll.mockResolvedValue(teams);
teamMembershipRepository.getMembership.mockResolvedValue(null);
socialRepository.getFriends.mockResolvedValue(friends);
imageService.getDriverAvatar.mockReturnValue('avatar-url');
getDriverStats.mockReturnValue(statsAdapter);
getAllDriverRankings.mockReturnValue(rankings);
const result = await useCase.execute({ driverId });
expect(result.isOk()).toBe(true);
const viewModel = result.unwrap();
expect(viewModel.currentDriver?.id).toBe(driverId);
expect(viewModel.extendedProfile).toBe(null);
});
it('should return error for non-existing driver', async () => {
const driverId = 'driver-1';
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute({ driverId });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'DRIVER_NOT_FOUND',
message: 'Driver not found',
});
});
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 });
expect(result.isErr()).toBe(true);
expect(result.unwrapErr()).toEqual({
code: 'REPOSITORY_ERROR',
message: 'Failed to fetch profile overview',
});
});
});

View File

@@ -3,16 +3,18 @@ import type { ITeamRepository } from '../../domain/repositories/ITeamRepository'
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
import type { IImageServicePort } from '../ports/IImageServicePort';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { Driver } from '../../domain/entities/Driver';
import type { Team } from '../../domain/entities/Team';
import type {
IProfileOverviewPresenter,
ProfileOverviewViewModel,
ProfileOverviewDriverSummaryViewModel,
ProfileOverviewStatsViewModel,
ProfileOverviewFinishDistributionViewModel,
ProfileOverviewTeamMembershipViewModel,
ProfileOverviewSocialSummaryViewModel,
ProfileOverviewExtendedProfileViewModel,
} from '../presenters/IProfileOverviewPresenter';
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
interface ProfileDriverStatsAdapter {
rating: number | null;
@@ -47,59 +49,47 @@ export class GetProfileOverviewUseCase {
private readonly imageService: IImageServicePort,
private readonly getDriverStats: (driverId: string) => ProfileDriverStatsAdapter | null,
private readonly getAllDriverRankings: () => DriverRankingEntry[],
public readonly presenter: IProfileOverviewPresenter,
) {}
async execute(params: GetProfileOverviewParams): Promise<ProfileOverviewViewModel | null> {
const { driverId } = params;
async execute(params: GetProfileOverviewParams): Promise<Result<ProfileOverviewViewModel, ApplicationErrorCode<'DRIVER_NOT_FOUND' | 'REPOSITORY_ERROR'>>> {
try {
const { driverId } = params;
const driver = await this.driverRepository.findById(driverId);
const driver = await this.driverRepository.findById(driverId);
if (!driver) {
const emptyViewModel: ProfileOverviewViewModel = {
currentDriver: null,
stats: null,
finishDistribution: null,
teamMemberships: [],
socialSummary: {
friendsCount: 0,
friends: [],
},
if (!driver) {
return Result.err({ code: 'DRIVER_NOT_FOUND', message: 'Driver not found' });
}
const [statsAdapter, teams, friends] = await Promise.all([
Promise.resolve(this.getDriverStats(driverId)),
this.teamRepository.findAll(),
this.socialRepository.getFriends(driverId),
]);
const driverSummary = this.buildDriverSummary(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 viewModel: ProfileOverviewViewModel = {
currentDriver: driverSummary,
stats,
finishDistribution,
teamMemberships,
socialSummary,
extendedProfile: null,
};
this.presenter.present(emptyViewModel);
return emptyViewModel;
return Result.ok(viewModel);
} catch {
return Result.err({ code: 'REPOSITORY_ERROR', message: 'Failed to fetch profile overview' });
}
const [statsAdapter, teams, friends] = await Promise.all([
Promise.resolve(this.getDriverStats(driverId)),
this.teamRepository.findAll(),
this.socialRepository.getFriends(driverId),
]);
const driverSummary = this.buildDriverSummary(driver, statsAdapter);
const stats = this.buildStats(statsAdapter);
const finishDistribution = this.buildFinishDistribution(statsAdapter);
const teamMemberships = await this.buildTeamMemberships(driver.id, teams);
const socialSummary = this.buildSocialSummary(friends);
const extendedProfile = this.buildExtendedProfile(driver.id);
const viewModel: ProfileOverviewViewModel = {
currentDriver: driverSummary,
stats,
finishDistribution,
teamMemberships,
socialSummary,
extendedProfile,
};
this.presenter.present(viewModel);
return viewModel;
}
private buildDriverSummary(
driver: any,
driver: Driver,
stats: ProfileDriverStatsAdapter | null,
): ProfileOverviewDriverSummaryViewModel {
const rankings = this.getAllDriverRankings();
@@ -202,7 +192,7 @@ export class GetProfileOverviewUseCase {
private async buildTeamMemberships(
driverId: string,
teams: unknown[],
teams: Team[],
): Promise<ProfileOverviewTeamMembershipViewModel[]> {
const memberships: ProfileOverviewTeamMembershipViewModel[] = [];
@@ -231,7 +221,7 @@ export class GetProfileOverviewUseCase {
return memberships;
}
private buildSocialSummary(friends: unknown[]): ProfileOverviewSocialSummaryViewModel {
private buildSocialSummary(friends: Driver[]): ProfileOverviewSocialSummaryViewModel {
return {
friendsCount: friends.length,
friends: friends.map(friend => ({
@@ -243,209 +233,4 @@ export class GetProfileOverviewUseCase {
};
}
private buildExtendedProfile(driverId: string): ProfileOverviewExtendedProfileViewModel {
const hash = driverId
.split('')
.reduce((acc: number, char: string) => acc + char.charCodeAt(0), 0);
const socialOptions: Array<
Array<{
platform: 'twitter' | 'youtube' | 'twitch' | 'discord';
handle: string;
url: string;
}>
> = [
[
{
platform: 'twitter',
handle: '@speedracer',
url: 'https://twitter.com/speedracer',
},
{
platform: 'youtube',
handle: 'SpeedRacer Racing',
url: 'https://youtube.com/@speedracer',
},
{
platform: 'twitch',
handle: 'speedracer_live',
url: 'https://twitch.tv/speedracer_live',
},
],
[
{
platform: 'twitter',
handle: '@racingpro',
url: 'https://twitter.com/racingpro',
},
{
platform: 'discord',
handle: 'RacingPro#1234',
url: '#',
},
],
[
{
platform: 'twitch',
handle: 'simracer_elite',
url: 'https://twitch.tv/simracer_elite',
},
{
platform: 'youtube',
handle: 'SimRacer Elite',
url: 'https://youtube.com/@simracerelite',
},
],
];
const achievementSets: Array<
Array<{
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: 'common' | 'rare' | 'epic' | 'legendary';
earnedAt: Date;
}>
> = [
[
{
id: '1',
title: 'First Victory',
description: 'Win your first race',
icon: 'trophy',
rarity: 'common',
earnedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Clean Racer',
description: '10 races without incidents',
icon: 'star',
rarity: 'rare',
earnedAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Podium Streak',
description: '5 consecutive podium finishes',
icon: 'medal',
rarity: 'epic',
earnedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
},
{
id: '4',
title: 'Championship Glory',
description: 'Win a league championship',
icon: 'crown',
rarity: 'legendary',
earnedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Rookie No More',
description: 'Complete 25 races',
icon: 'target',
rarity: 'common',
earnedAt: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Consistent Performer',
description: 'Maintain 80%+ consistency rating',
icon: 'zap',
rarity: 'rare',
earnedAt: new Date(Date.now() - 45 * 24 * 60 * 60 * 1000),
},
{
id: '3',
title: 'Endurance Master',
description: 'Complete a 24-hour race',
icon: 'star',
rarity: 'epic',
earnedAt: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000),
},
],
[
{
id: '1',
title: 'Welcome Racer',
description: 'Join GridPilot',
icon: 'star',
rarity: 'common',
earnedAt: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
},
{
id: '2',
title: 'Team Player',
description: 'Join a racing team',
icon: 'medal',
rarity: 'rare',
earnedAt: new Date(Date.now() - 80 * 24 * 60 * 60 * 1000),
},
],
];
const tracks = [
'Spa-Francorchamps',
'Nürburgring Nordschleife',
'Suzuka',
'Monza',
'Interlagos',
'Silverstone',
];
const cars = [
'Porsche 911 GT3 R',
'Ferrari 488 GT3',
'Mercedes-AMG GT3',
'BMW M4 GT3',
'Audi R8 LMS',
];
const styles = [
'Aggressive Overtaker',
'Consistent Pacer',
'Strategic Calculator',
'Late Braker',
'Smooth Operator',
];
const timezones = [
'EST (UTC-5)',
'CET (UTC+1)',
'PST (UTC-8)',
'GMT (UTC+0)',
'JST (UTC+9)',
];
const hours = [
'Evenings (18:00-23:00)',
'Weekends only',
'Late nights (22:00-02:00)',
'Flexible schedule',
];
const socialHandles =
socialOptions[hash % socialOptions.length] ?? [];
const achievementsSource =
achievementSets[hash % achievementSets.length] ?? [];
return {
socialHandles,
achievements: achievementsSource.map(achievement => ({
id: achievement.id,
title: achievement.title,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
earnedAt: achievement.earnedAt.toISOString(),
})),
racingStyle: styles[hash % styles.length] ?? 'Consistent Pacer',
favoriteTrack: tracks[hash % tracks.length] ?? 'Unknown Track',
favoriteCar: cars[hash % cars.length] ?? 'Unknown Car',
timezone: timezones[hash % timezones.length] ?? 'UTC',
availableHours: hours[hash % hours.length] ?? 'Flexible schedule',
lookingForTeam: hash % 3 === 0,
openToRequests: hash % 2 === 0,
};
}
}

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