refactor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface GetLeagueOwnerSummaryResultDTO {
|
||||
summary: { driver: { id: string; name: string }; rating: number; rank: number } | null;
|
||||
}
|
||||
26
core/racing/application/dto/GetLeagueProtestsResultDTO.ts
Normal file
26
core/racing/application/dto/GetLeagueProtestsResultDTO.ts
Normal 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[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface GetLeagueScheduleResultDTO {
|
||||
races: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
scheduledAt: Date;
|
||||
}>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface GetLeagueAdminPermissionsUseCaseParams {
|
||||
leagueId: string;
|
||||
performerDriverId: string;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface GetLeagueAdminUseCaseParams {
|
||||
leagueId: string;
|
||||
}
|
||||
@@ -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' }];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface GetLeagueDriverSeasonStatsUseCaseParams {
|
||||
leagueId: string;
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user