diff --git a/adapters/racing/persistence/inmemory/InMemoryScoringRepositories.ts b/adapters/racing/persistence/inmemory/InMemoryScoringRepositories.ts index 3e60701f3..3fc744d8d 100644 --- a/adapters/racing/persistence/inmemory/InMemoryScoringRepositories.ts +++ b/adapters/racing/persistence/inmemory/InMemoryScoringRepositories.ts @@ -1,5 +1,5 @@ import { Game } from '@core/racing/domain/entities/Game'; -import { Season } from '@core/racing/domain/entities/Season'; +import { Season } from '@core/racing/domain/entities/season/Season'; import type { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig'; import { PointsTable } from '@core/racing/domain/value-objects/PointsTable'; import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; @@ -10,7 +10,7 @@ import type { IGameRepository } from '@core/racing/domain/repositories/IGameRepo import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository'; import type { IChampionshipStandingRepository } from '@core/racing/domain/repositories/IChampionshipStandingRepository'; -import { ChampionshipStanding } from '@core/racing/domain/entities/ChampionshipStanding'; +import { ChampionshipStanding } from '@core/racing/domain/entities/championship/ChampionshipStanding'; import type { ChampionshipType } from '@core/racing/domain/types/ChampionshipType'; import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef'; import type { Logger } from '@core/shared/application'; diff --git a/adapters/racing/persistence/inmemory/InMemorySeasonRepository.ts b/adapters/racing/persistence/inmemory/InMemorySeasonRepository.ts index d6f84349a..5344dd1cd 100644 --- a/adapters/racing/persistence/inmemory/InMemorySeasonRepository.ts +++ b/adapters/racing/persistence/inmemory/InMemorySeasonRepository.ts @@ -1,5 +1,5 @@ import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; -import { Season } from '@core/racing/domain/entities/Season'; +import { Season } from '@core/racing/domain/entities/season/Season'; import { Logger } from '@core/shared/application'; export class InMemorySeasonRepository implements ISeasonRepository { diff --git a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts index 66b5fac5c..834913d8e 100644 --- a/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts +++ b/core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts @@ -81,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase this.logger.debug(`Generated seasonId: ${seasonId}`); const season = Season.create({ id: seasonId, - leagueId: league.id, + leagueId: league.id.toString(), gameId: command.gameId, name: `${command.name} Season 1`, year: new Date().getFullYear(), @@ -113,7 +113,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase this.logger.info(`Scoring configuration saved for season ${seasonId}.`); const result: CreateLeagueWithSeasonAndScoringResultDTO = { - leagueId: league.id, + leagueId: league.id.toString(), seasonId, scoringPresetId: preset.id, scoringPresetName: preset.name, diff --git a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts index 4e583ea2f..bbdde5f46 100644 --- a/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts +++ b/core/racing/application/use-cases/CreateSeasonForLeagueUseCase.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { Season } from '@core/racing/domain/entities/Season'; +import { Season } from '@core/racing/domain/entities/season/Season'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import { diff --git a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts index 84fcfc3a7..d5abd589c 100644 --- a/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts +++ b/core/racing/application/use-cases/GetAllRacesPageDataUseCase.ts @@ -28,7 +28,7 @@ export class GetAllRacesPageDataUseCase ]); this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`); - const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name])); + const leagueMap = new Map(allLeagues.map((league) => [league.id.toString(), league.name.toString()])); const races: AllRacesListItemViewModel[] = allRaces .slice() @@ -46,7 +46,7 @@ export class GetAllRacesPageDataUseCase const uniqueLeagues = new Map(); for (const league of allLeagues) { - uniqueLeagues.set(league.id, { id: league.id, name: league.name }); + uniqueLeagues.set(league.id.toString(), { id: league.id.toString(), name: league.name.toString() }); } const filters: AllRacesFilterOptionsViewModel = { diff --git a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts index d8a8968d6..b80dbf68c 100644 --- a/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts +++ b/core/racing/application/use-cases/RecalculateChampionshipStandingsUseCase.ts @@ -7,7 +7,7 @@ import type { IChampionshipStandingRepository } from '@core/racing/domain/reposi import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig'; import type { SessionType } from '@core/racing/domain/types/SessionType'; -import type { ChampionshipStanding } from '@core/racing/domain/entities/ChampionshipStanding'; +import type { ChampionshipStanding } from '@core/racing/domain/entities/championship/ChampionshipStanding'; import { EventScoringService } from '@core/racing/domain/services/EventScoringService'; import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator'; @@ -101,10 +101,10 @@ export class RecalculateChampionshipStandingsUseCase const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({ participant: s.participant, - position: s.position, - totalPoints: s.totalPoints, - resultsCounted: s.resultsCounted, - resultsDropped: s.resultsDropped, + position: s.position.toNumber(), + totalPoints: s.totalPoints.toNumber(), + resultsCounted: s.resultsCounted.toNumber(), + resultsDropped: s.resultsDropped.toNumber(), })); const dto: ChampionshipStandingsDTO = { diff --git a/core/racing/application/use-cases/SeasonUseCases.test.ts b/core/racing/application/use-cases/SeasonUseCases.test.ts index 8e45697b1..e4b57e8b1 100644 --- a/core/racing/application/use-cases/SeasonUseCases.test.ts +++ b/core/racing/application/use-cases/SeasonUseCases.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest'; import { InMemorySeasonRepository, } from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories'; -import { Season } from '@core/racing/domain/entities/Season'; +import { Season } from '@core/racing/domain/entities/season/Season'; import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository'; import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository'; import { diff --git a/core/racing/domain/entities/AppliedAt.test.ts b/core/racing/domain/entities/AppliedAt.test.ts new file mode 100644 index 000000000..e68ce120a --- /dev/null +++ b/core/racing/domain/entities/AppliedAt.test.ts @@ -0,0 +1,43 @@ +import { AppliedAt } from './AppliedAt'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('AppliedAt', () => { + describe('create', () => { + it('should create an AppliedAt with valid date', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const appliedAt = AppliedAt.create(date); + expect(appliedAt.toDate().getTime()).toBe(date.getTime()); + }); + + it('should create a copy of the date', () => { + const date = new Date(); + const appliedAt = AppliedAt.create(date); + date.setFullYear(2000); // modify original + expect(appliedAt.toDate().getFullYear()).not.toBe(2000); + }); + + it('should throw error for invalid date', () => { + expect(() => AppliedAt.create(new Date('invalid'))).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-Date', () => { + expect(() => AppliedAt.create('2023-01-01' as unknown as Date)).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal dates', () => { + const date1 = new Date('2023-01-01T00:00:00Z'); + const date2 = new Date('2023-01-01T00:00:00Z'); + const appliedAt1 = AppliedAt.create(date1); + const appliedAt2 = AppliedAt.create(date2); + expect(appliedAt1.equals(appliedAt2)).toBe(true); + }); + + it('should return false for different dates', () => { + const appliedAt1 = AppliedAt.create(new Date('2023-01-01')); + const appliedAt2 = AppliedAt.create(new Date('2023-01-02')); + expect(appliedAt1.equals(appliedAt2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/AppliedAt.ts b/core/racing/domain/entities/AppliedAt.ts new file mode 100644 index 000000000..cb6635fa8 --- /dev/null +++ b/core/racing/domain/entities/AppliedAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class AppliedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): AppliedAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('AppliedAt must be a valid Date'); + } + return new AppliedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: AppliedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Car.test.ts b/core/racing/domain/entities/Car.test.ts new file mode 100644 index 000000000..c62e82774 --- /dev/null +++ b/core/racing/domain/entities/Car.test.ts @@ -0,0 +1,118 @@ +import { Car } from './Car'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('Car', () => { + describe('create', () => { + it('should create a car with required fields', () => { + const car = Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: 'iracing', + }); + + expect(car.id.toString()).toBe('1'); + expect(car.name.toString()).toBe('Ferrari 488'); + expect(car.manufacturer.toString()).toBe('Ferrari'); + expect(car.gameId.toString()).toBe('iracing'); + }); + + it('should create a car with all fields', () => { + const car = Car.create({ + id: '1', + name: 'Ferrari 488', + shortName: '488', + manufacturer: 'Ferrari', + carClass: 'gt', + license: 'Pro', + year: 2018, + horsepower: 661, + weight: 1320, + imageUrl: 'http://example.com/car.jpg', + gameId: 'iracing', + }); + + expect(car.id.toString()).toBe('1'); + expect(car.name.toString()).toBe('Ferrari 488'); + expect(car.shortName).toBe('488'); + expect(car.manufacturer.toString()).toBe('Ferrari'); + expect(car.carClass.toString()).toBe('gt'); + expect(car.license.toString()).toBe('Pro'); + expect(car.year.toNumber()).toBe(2018); + expect(car.horsepower?.toNumber()).toBe(661); + expect(car.weight?.toNumber()).toBe(1320); + expect(car.imageUrl?.toString()).toBe('http://example.com/car.jpg'); + expect(car.gameId.toString()).toBe('iracing'); + }); + + it('should use defaults for optional fields', () => { + const car = Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: 'iracing', + }); + + expect(car.carClass.toString()).toBe('gt'); + expect(car.license.toString()).toBe('D'); + expect(car.year.toNumber()).toBe(new Date().getFullYear()); + expect(car.shortName).toBe('Ferrari 48'); + }); + + it('should throw error for invalid id', () => { + expect(() => Car.create({ + id: '', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: 'iracing', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid name', () => { + expect(() => Car.create({ + id: '1', + name: '', + manufacturer: 'Ferrari', + gameId: 'iracing', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid manufacturer', () => { + expect(() => Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: '', + gameId: 'iracing', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid gameId', () => { + expect(() => Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: '', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid horsepower', () => { + expect(() => Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: 'iracing', + horsepower: 0, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid weight', () => { + expect(() => Car.create({ + id: '1', + name: 'Ferrari 488', + manufacturer: 'Ferrari', + gameId: 'iracing', + weight: -1, + })).toThrow(RacingDomainValidationError); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Car.ts b/core/racing/domain/entities/Car.ts index a2fdd7b41..33c9418aa 100644 --- a/core/racing/domain/entities/Car.ts +++ b/core/racing/domain/entities/Car.ts @@ -7,35 +7,42 @@ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { CarName } from './CarName'; +import { Manufacturer } from './Manufacturer'; +import { CarClass, CarClassType } from './CarClass'; +import { CarLicense, CarLicenseType } from './CarLicense'; +import { Year } from './Year'; +import { Horsepower } from './Horsepower'; +import { Weight } from './Weight'; +import { GameId } from './GameId'; +import { CarId } from './CarId'; +import { ImageUrl } from './ImageUrl'; -export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt'; -export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro'; - -export class Car implements IEntity { - readonly id: string; - readonly name: string; +export class Car implements IEntity { + readonly id: CarId; + readonly name: CarName; readonly shortName: string; - readonly manufacturer: string; + readonly manufacturer: Manufacturer; readonly carClass: CarClass; readonly license: CarLicense; - readonly year: number; - readonly horsepower: number | undefined; - readonly weight: number | undefined; - readonly imageUrl: string | undefined; - readonly gameId: string; + readonly year: Year; + readonly horsepower: Horsepower | undefined; + readonly weight: Weight | undefined; + readonly imageUrl: ImageUrl | undefined; + readonly gameId: GameId; private constructor(props: { - id: string; - name: string; + id: CarId; + name: CarName; shortName: string; - manufacturer: string; + manufacturer: Manufacturer; carClass: CarClass; license: CarLicense; - year: number; - horsepower?: number; - weight?: number; - imageUrl?: string; - gameId: string; + year: Year; + horsepower?: Horsepower; + weight?: Weight; + imageUrl?: ImageUrl; + gameId: GameId; }) { this.id = props.id; this.name = props.name; @@ -58,8 +65,8 @@ export class Car implements IEntity { name: string; shortName?: string; manufacturer: string; - carClass?: CarClass; - license?: CarLicense; + carClass?: CarClassType; + license?: CarLicenseType; year?: number; horsepower?: number; weight?: number; @@ -69,17 +76,17 @@ export class Car implements IEntity { this.validate(props); return new Car({ - id: props.id, - name: props.name, + id: CarId.create(props.id), + name: CarName.create(props.name), shortName: props.shortName ?? props.name.slice(0, 10), - manufacturer: props.manufacturer, - carClass: props.carClass ?? 'gt', - license: props.license ?? 'D', - year: props.year ?? new Date().getFullYear(), - ...(props.horsepower !== undefined ? { horsepower: props.horsepower } : {}), - ...(props.weight !== undefined ? { weight: props.weight } : {}), - ...(props.imageUrl !== undefined ? { imageUrl: props.imageUrl } : {}), - gameId: props.gameId, + manufacturer: Manufacturer.create(props.manufacturer), + carClass: CarClass.create(props.carClass ?? 'gt'), + license: CarLicense.create(props.license ?? 'D'), + year: Year.create(props.year ?? new Date().getFullYear()), + ...(props.horsepower !== undefined ? { horsepower: Horsepower.create(props.horsepower) } : {}), + ...(props.weight !== undefined ? { weight: Weight.create(props.weight) } : {}), + ...(props.imageUrl !== undefined ? { imageUrl: ImageUrl.create(props.imageUrl) } : {}), + gameId: GameId.create(props.gameId), }); } @@ -109,25 +116,4 @@ export class Car implements IEntity { } } - /** - * Get formatted car display name - */ - getDisplayName(): string { - return `${this.manufacturer} ${this.name}`; - } - - /** - * Get license badge color - */ - getLicenseColor(): string { - const colors: Record = { - 'R': '#FF6B6B', - 'D': '#FFB347', - 'C': '#FFD700', - 'B': '#7FFF00', - 'A': '#00BFFF', - 'Pro': '#9370DB', - }; - return colors[this.license]; - } } \ No newline at end of file diff --git a/core/racing/domain/entities/CarClass.ts b/core/racing/domain/entities/CarClass.ts new file mode 100644 index 000000000..2c742393e --- /dev/null +++ b/core/racing/domain/entities/CarClass.ts @@ -0,0 +1,17 @@ +export type CarClassType = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt'; + +export class CarClass { + private constructor(private readonly value: CarClassType) {} + + static create(value: CarClassType): CarClass { + return new CarClass(value); + } + + toString(): CarClassType { + return this.value; + } + + equals(other: CarClass): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/CarId.ts b/core/racing/domain/entities/CarId.ts new file mode 100644 index 000000000..a4b70c7d3 --- /dev/null +++ b/core/racing/domain/entities/CarId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class CarId { + private constructor(private readonly value: string) {} + + static create(value: string): CarId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Car ID cannot be empty'); + } + return new CarId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: CarId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/CarLicense.ts b/core/racing/domain/entities/CarLicense.ts new file mode 100644 index 000000000..2603fc188 --- /dev/null +++ b/core/racing/domain/entities/CarLicense.ts @@ -0,0 +1,17 @@ +export type CarLicenseType = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro'; + +export class CarLicense { + private constructor(private readonly value: CarLicenseType) {} + + static create(value: CarLicenseType): CarLicense { + return new CarLicense(value); + } + + toString(): CarLicenseType { + return this.value; + } + + equals(other: CarLicense): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/CarName.ts b/core/racing/domain/entities/CarName.ts new file mode 100644 index 000000000..0003ae426 --- /dev/null +++ b/core/racing/domain/entities/CarName.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class CarName { + private constructor(private readonly value: string) {} + + static create(value: string): CarName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Car name cannot be empty'); + } + if (value.length > 100) { + throw new RacingDomainValidationError('Car name cannot exceed 100 characters'); + } + return new CarName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: CarName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ChampionshipStanding.ts b/core/racing/domain/entities/ChampionshipStanding.ts deleted file mode 100644 index 44dca5a00..000000000 --- a/core/racing/domain/entities/ChampionshipStanding.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ParticipantRef } from '../types/ParticipantRef'; - -export class ChampionshipStanding { - readonly seasonId: string; - readonly championshipId: string; - readonly participant: ParticipantRef; - readonly totalPoints: number; - readonly resultsCounted: number; - readonly resultsDropped: number; - readonly position: number; - - constructor(props: { - seasonId: string; - championshipId: string; - participant: ParticipantRef; - totalPoints: number; - resultsCounted: number; - resultsDropped: number; - position: number; - }) { - this.seasonId = props.seasonId; - this.championshipId = props.championshipId; - this.participant = props.participant; - this.totalPoints = props.totalPoints; - this.resultsCounted = props.resultsCounted; - this.resultsDropped = props.resultsDropped; - this.position = props.position; - } - - withPosition(position: number): ChampionshipStanding { - return new ChampionshipStanding({ - seasonId: this.seasonId, - championshipId: this.championshipId, - participant: this.participant, - totalPoints: this.totalPoints, - resultsCounted: this.resultsCounted, - resultsDropped: this.resultsDropped, - position, - }); - } -} \ No newline at end of file diff --git a/core/racing/domain/entities/DecisionNotes.ts b/core/racing/domain/entities/DecisionNotes.ts new file mode 100644 index 000000000..0c73b208b --- /dev/null +++ b/core/racing/domain/entities/DecisionNotes.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DecisionNotes { + private constructor(private readonly value: string) {} + + static create(value: string): DecisionNotes { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Decision notes cannot be empty'); + } + return new DecisionNotes(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: DecisionNotes): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/DefenseRequestedAt.ts b/core/racing/domain/entities/DefenseRequestedAt.ts new file mode 100644 index 000000000..e93cf5881 --- /dev/null +++ b/core/racing/domain/entities/DefenseRequestedAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DefenseRequestedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): DefenseRequestedAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('DefenseRequestedAt must be a valid Date'); + } + return new DefenseRequestedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: DefenseRequestedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/DefenseStatement.ts b/core/racing/domain/entities/DefenseStatement.ts new file mode 100644 index 000000000..c32b905c7 --- /dev/null +++ b/core/racing/domain/entities/DefenseStatement.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DefenseStatement { + private constructor(private readonly value: string) {} + + static create(value: string): DefenseStatement { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Defense statement cannot be empty'); + } + return new DefenseStatement(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: DefenseStatement): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Driver.test.ts b/core/racing/domain/entities/Driver.test.ts new file mode 100644 index 000000000..c1778f99f --- /dev/null +++ b/core/racing/domain/entities/Driver.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { Driver } from './Driver'; + +describe('Driver', () => { + it('should create a driver', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + bio: 'A passionate racer.', + joinedAt: new Date('2020-01-01'), + }); + + expect(driver.id).toBe('driver1'); + expect(driver.iracingId.toString()).toBe('12345'); + expect(driver.name.toString()).toBe('John Doe'); + expect(driver.country.toString()).toBe('US'); + expect(driver.bio?.toString()).toBe('A passionate racer.'); + expect(driver.joinedAt.toDate()).toEqual(new Date('2020-01-01')); + }); + + it('should create driver without bio', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + }); + + expect(driver.bio).toBeUndefined(); + }); + + it('should create driver with default joinedAt', () => { + const before = new Date(); + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + }); + const after = new Date(); + + expect(driver.joinedAt.toDate().getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(driver.joinedAt.toDate().getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should update name', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + }); + + const updated = driver.update({ name: 'Jane Doe' }); + expect(updated.name.toString()).toBe('Jane Doe'); + expect(updated.id).toBe('driver1'); + }); + + it('should update country', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + }); + + const updated = driver.update({ country: 'CA' }); + expect(updated.country.toString()).toBe('CA'); + }); + + it('should update bio', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + bio: 'Old bio', + }); + + const updated = driver.update({ bio: 'New bio' }); + expect(updated.bio?.toString()).toBe('New bio'); + }); + + it('should remove bio', () => { + const driver = Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + bio: 'Old bio', + }); + + const updated = driver.update({ bio: undefined }); + expect(updated.bio).toBeUndefined(); + }); + + it('should throw on invalid id', () => { + expect(() => Driver.create({ + id: '', + iracingId: '12345', + name: 'John Doe', + country: 'US', + })).toThrow('Driver ID is required'); + }); + + it('should throw on invalid iracingId', () => { + expect(() => Driver.create({ + id: 'driver1', + iracingId: '', + name: 'John Doe', + country: 'US', + })).toThrow('iRacing ID is required'); + }); + + it('should throw on invalid name', () => { + expect(() => Driver.create({ + id: 'driver1', + iracingId: '12345', + name: '', + country: 'US', + })).toThrow('Driver name is required'); + }); + + it('should throw on invalid country', () => { + expect(() => Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: '', + })).toThrow('Country code is required'); + }); + + it('should throw on invalid country format', () => { + expect(() => Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'U', + })).toThrow('Country must be a valid ISO code (2-3 letters)'); + }); + + it('should throw on future joinedAt', () => { + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + expect(() => Driver.create({ + id: 'driver1', + iracingId: '12345', + name: 'John Doe', + country: 'US', + joinedAt: future, + })).toThrow('Joined date cannot be in the future'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Driver.ts b/core/racing/domain/entities/Driver.ts index e7d257e27..55ec85eec 100644 --- a/core/racing/domain/entities/Driver.ts +++ b/core/racing/domain/entities/Driver.ts @@ -4,25 +4,30 @@ * Represents a driver profile in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - + import { RacingDomainValidationError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; - +import { IRacingId } from '../value-objects/IRacingId'; +import { DriverName } from '../value-objects/DriverName'; +import { CountryCode } from '../value-objects/CountryCode'; +import { DriverBio } from '../value-objects/DriverBio'; +import { JoinedAt } from '../value-objects/JoinedAt'; + export class Driver implements IEntity { readonly id: string; - readonly iracingId: string; - readonly name: string; - readonly country: string; - readonly bio: string | undefined; - readonly joinedAt: Date; + readonly iracingId: IRacingId; + readonly name: DriverName; + readonly country: CountryCode; + readonly bio: DriverBio | undefined; + readonly joinedAt: JoinedAt; private constructor(props: { id: string; - iracingId: string; - name: string; - country: string; - bio?: string; - joinedAt: Date; + iracingId: IRacingId; + name: DriverName; + country: CountryCode; + bio?: DriverBio; + joinedAt: JoinedAt; }) { this.id = props.id; this.iracingId = props.iracingId; @@ -43,47 +48,18 @@ export class Driver implements IEntity { bio?: string; joinedAt?: Date; }): Driver { - this.validate(props); - - return new Driver({ - id: props.id, - iracingId: props.iracingId, - name: props.name, - country: props.country, - ...(props.bio !== undefined ? { bio: props.bio } : {}), - joinedAt: props.joinedAt ?? new Date(), - }); - } - - /** - * Domain validation logic - */ - private static validate(props: { - id: string; - iracingId: string; - name: string; - country: string; - }): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Driver ID is required'); } - if (!props.iracingId || props.iracingId.trim().length === 0) { - throw new RacingDomainValidationError('iRacing ID is required'); - } - - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('Driver name is required'); - } - - if (!props.country || props.country.trim().length === 0) { - throw new RacingDomainValidationError('Country code is required'); - } - - // Validate ISO country code format (2-3 letters) - if (!/^[A-Z]{2,3}$/i.test(props.country)) { - throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)'); - } + return new Driver({ + id: props.id, + iracingId: IRacingId.create(props.iracingId), + name: DriverName.create(props.name), + country: CountryCode.create(props.country), + ...(props.bio !== undefined ? { bio: DriverBio.create(props.bio) } : {}), + joinedAt: JoinedAt.create(props.joinedAt ?? new Date()), + }); } /** @@ -92,11 +68,11 @@ export class Driver implements IEntity { update(props: Partial<{ name: string; country: string; - bio?: string; + bio: string | undefined; }>): Driver { - const nextName = props.name ?? this.name; - const nextCountry = props.country ?? this.country; - const nextBio = props.bio ?? this.bio; + const nextName = 'name' in props ? DriverName.create(props.name!) : this.name; + const nextCountry = 'country' in props ? CountryCode.create(props.country!) : this.country; + const nextBio = 'bio' in props ? (props.bio ? DriverBio.create(props.bio) : undefined) : this.bio; return new Driver({ id: this.id, diff --git a/core/racing/domain/entities/DriverId.test.ts b/core/racing/domain/entities/DriverId.test.ts new file mode 100644 index 000000000..91312c678 --- /dev/null +++ b/core/racing/domain/entities/DriverId.test.ts @@ -0,0 +1,38 @@ +import { DriverId } from './DriverId'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('DriverId', () => { + describe('create', () => { + it('should create a DriverId with valid value', () => { + const id = DriverId.create('driver-123'); + expect(id.toString()).toBe('driver-123'); + }); + + it('should trim whitespace', () => { + const id = DriverId.create(' driver-123 '); + expect(id.toString()).toBe('driver-123'); + }); + + it('should throw error for empty string', () => { + expect(() => DriverId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => DriverId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = DriverId.create('driver-123'); + const id2 = DriverId.create('driver-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = DriverId.create('driver-123'); + const id2 = DriverId.create('driver-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/DriverId.ts b/core/racing/domain/entities/DriverId.ts new file mode 100644 index 000000000..ee8918818 --- /dev/null +++ b/core/racing/domain/entities/DriverId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DriverId { + private constructor(private readonly value: string) {} + + static create(value: string): DriverId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Driver ID cannot be empty'); + } + return new DriverId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: DriverId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/DriverLivery.test.ts b/core/racing/domain/entities/DriverLivery.test.ts new file mode 100644 index 000000000..fabaf978b --- /dev/null +++ b/core/racing/domain/entities/DriverLivery.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from 'vitest'; +import { DriverLivery } from './DriverLivery'; +import { LiveryDecal } from '../value-objects/LiveryDecal'; +import { DecalOverride } from '../value-objects/DecalOverride'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('DriverLivery', () => { + const validProps = { + id: 'livery-1', + driverId: 'driver-1', + gameId: 'game-1', + carId: 'car-1', + uploadedImageUrl: 'https://example.com/image.png', + }; + + describe('create', () => { + it('should create a valid DriverLivery', () => { + const livery = DriverLivery.create(validProps); + + expect(livery.id).toBe('livery-1'); + expect(livery.driverId.toString()).toBe('driver-1'); + expect(livery.gameId.toString()).toBe('game-1'); + expect(livery.carId.toString()).toBe('car-1'); + expect(livery.uploadedImageUrl.toString()).toBe('https://example.com/image.png'); + expect(livery.userDecals).toEqual([]); + expect(livery.leagueOverrides).toEqual([]); + expect(livery.isValidated()).toBe(false); + }); + + it('should throw error for invalid id', () => { + expect(() => DriverLivery.create({ ...validProps, id: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid driverId', () => { + expect(() => DriverLivery.create({ ...validProps, driverId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid gameId', () => { + expect(() => DriverLivery.create({ ...validProps, gameId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid carId', () => { + expect(() => DriverLivery.create({ ...validProps, carId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid uploadedImageUrl', () => { + expect(() => DriverLivery.create({ ...validProps, uploadedImageUrl: '' })).toThrow(RacingDomainValidationError); + }); + }); + + describe('addDecal', () => { + it('should add a user decal', () => { + const livery = DriverLivery.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal-1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + + const updatedLivery = livery.addDecal(decal); + + expect(updatedLivery.userDecals).toHaveLength(1); + expect(updatedLivery.userDecals[0]).toBe(decal); + expect(updatedLivery.updatedAt).toBeInstanceOf(Date); + }); + + it('should throw error for non-user decal', () => { + const livery = DriverLivery.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal-1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + + expect(() => livery.addDecal(decal)).toThrow(RacingDomainInvariantError); + }); + }); + + describe('removeDecal', () => { + it('should remove a decal', () => { + const decal = LiveryDecal.create({ + id: 'decal-1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + const livery = DriverLivery.create({ ...validProps, userDecals: [decal] }); + + const updatedLivery = livery.removeDecal('decal-1'); + + expect(updatedLivery.userDecals).toHaveLength(0); + }); + + it('should throw error if decal not found', () => { + const livery = DriverLivery.create(validProps); + + expect(() => livery.removeDecal('nonexistent')).toThrow(RacingDomainValidationError); + }); + }); + + describe('updateDecal', () => { + it('should update a decal', () => { + const decal = LiveryDecal.create({ + id: 'decal-1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + const livery = DriverLivery.create({ ...validProps, userDecals: [decal] }); + const updatedDecal = decal.moveTo(0.6, 0.6); + + const updatedLivery = livery.updateDecal('decal-1', updatedDecal); + + expect(updatedLivery.userDecals[0]).toBe(updatedDecal); + }); + + it('should throw error if decal not found', () => { + const livery = DriverLivery.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal-1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + + expect(() => livery.updateDecal('nonexistent', decal)).toThrow(RacingDomainValidationError); + }); + }); + + describe('setLeagueOverride', () => { + it('should add a league override', () => { + const livery = DriverLivery.create(validProps); + + const updatedLivery = livery.setLeagueOverride('league-1', 'season-1', 'decal-1', 0.5, 0.5); + + expect(updatedLivery.leagueOverrides).toHaveLength(1); + expect(updatedLivery.leagueOverrides[0].leagueId).toBe('league-1'); + }); + + it('should update existing override', () => { + const override = DecalOverride.create({ + leagueId: 'league-1', + seasonId: 'season-1', + decalId: 'decal-1', + newX: 0.5, + newY: 0.5, + }); + const livery = DriverLivery.create({ ...validProps, leagueOverrides: [override] }); + + const updatedLivery = livery.setLeagueOverride('league-1', 'season-1', 'decal-1', 0.6, 0.6); + + expect(updatedLivery.leagueOverrides).toHaveLength(1); + expect(updatedLivery.leagueOverrides[0].newX).toBe(0.6); + }); + }); + + describe('removeLeagueOverride', () => { + it('should remove a league override', () => { + const override = DecalOverride.create({ + leagueId: 'league-1', + seasonId: 'season-1', + decalId: 'decal-1', + newX: 0.5, + newY: 0.5, + }); + const livery = DriverLivery.create({ ...validProps, leagueOverrides: [override] }); + + const updatedLivery = livery.removeLeagueOverride('league-1', 'season-1', 'decal-1'); + + expect(updatedLivery.leagueOverrides).toHaveLength(0); + }); + }); + + describe('getOverridesFor', () => { + it('should return overrides for league and season', () => { + const override1 = DecalOverride.create({ + leagueId: 'league-1', + seasonId: 'season-1', + decalId: 'decal-1', + newX: 0.5, + newY: 0.5, + }); + const override2 = DecalOverride.create({ + leagueId: 'league-1', + seasonId: 'season-2', + decalId: 'decal-2', + newX: 0.6, + newY: 0.6, + }); + const livery = DriverLivery.create({ ...validProps, leagueOverrides: [override1, override2] }); + + const overrides = livery.getOverridesFor('league-1', 'season-1'); + + expect(overrides).toHaveLength(1); + expect(overrides[0]).toBe(override1); + }); + }); + + describe('markAsValidated', () => { + it('should mark livery as validated', () => { + const livery = DriverLivery.create(validProps); + + const validatedLivery = livery.markAsValidated(); + + expect(validatedLivery.isValidated()).toBe(true); + expect(validatedLivery.validatedAt).toBeInstanceOf(Date); + }); + }); + + describe('isValidated', () => { + it('should return false when not validated', () => { + const livery = DriverLivery.create(validProps); + + expect(livery.isValidated()).toBe(false); + }); + + it('should return true when validated', () => { + const livery = DriverLivery.create(validProps).markAsValidated(); + + expect(livery.isValidated()).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/DriverLivery.ts b/core/racing/domain/entities/DriverLivery.ts index 8860eadc8..0e379a5a9 100644 --- a/core/racing/domain/entities/DriverLivery.ts +++ b/core/racing/domain/entities/DriverLivery.ts @@ -7,22 +7,20 @@ import type { IEntity } from '@core/shared/domain'; -import type { LiveryDecal } from '../value-objects/LiveryDecal'; - -export interface DecalOverride { - leagueId: string; - seasonId: string; - decalId: string; - newX: number; - newY: number; -} +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; +import { LiveryDecal } from '../value-objects/LiveryDecal'; +import { DecalOverride } from '../value-objects/DecalOverride'; +import { DriverId } from '../value-objects/DriverId'; +import { GameId } from './GameId'; +import { CarId } from '../value-objects/CarId'; +import { ImageUrl } from '../value-objects/ImageUrl'; export interface DriverLiveryProps { id: string; - driverId: string; - gameId: string; - carId: string; - uploadedImageUrl: string; + driverId: DriverId; + gameId: GameId; + carId: CarId; + uploadedImageUrl: ImageUrl; userDecals: LiveryDecal[]; leagueOverrides: DecalOverride[]; createdAt: Date; @@ -32,10 +30,10 @@ export interface DriverLiveryProps { export class DriverLivery implements IEntity { readonly id: string; - readonly driverId: string; - readonly gameId: string; - readonly carId: string; - readonly uploadedImageUrl: string; + readonly driverId: DriverId; + readonly gameId: GameId; + readonly carId: CarId; + readonly uploadedImageUrl: ImageUrl; readonly userDecals: LiveryDecal[]; readonly leagueOverrides: DecalOverride[]; readonly createdAt: Date; @@ -55,7 +53,12 @@ export class DriverLivery implements IEntity { this.validatedAt = props.validatedAt; } - static create(props: Omit & { + static create(props: { + id: string; + driverId: string; + gameId: string; + carId: string; + uploadedImageUrl: string; createdAt?: Date; userDecals?: LiveryDecal[]; leagueOverrides?: DecalOverride[]; @@ -63,14 +66,26 @@ export class DriverLivery implements IEntity { this.validate(props); return new DriverLivery({ - ...props, - createdAt: props.createdAt ?? new Date(), + id: props.id, + driverId: DriverId.create(props.driverId), + gameId: GameId.create(props.gameId), + carId: CarId.create(props.carId), + uploadedImageUrl: ImageUrl.create(props.uploadedImageUrl), userDecals: props.userDecals ?? [], leagueOverrides: props.leagueOverrides ?? [], + createdAt: props.createdAt ?? new Date(), + updatedAt: undefined, + validatedAt: undefined, }); } - private static validate(props: Omit): void { + private static validate(props: { + id: string; + driverId: string; + gameId: string; + carId: string; + uploadedImageUrl: string; + }): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('DriverLivery ID is required'); } @@ -173,8 +188,8 @@ export class DriverLivery implements IEntity { o => o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId ); - const override: DecalOverride = { leagueId, seasonId, decalId, newX, newY }; - + const override = DecalOverride.create({ leagueId, seasonId, decalId, newX, newY }); + let updatedOverrides: DecalOverride[]; if (existingIndex >= 0) { updatedOverrides = [...this.leagueOverrides]; diff --git a/core/racing/domain/entities/FiledAt.ts b/core/racing/domain/entities/FiledAt.ts new file mode 100644 index 000000000..813ba5876 --- /dev/null +++ b/core/racing/domain/entities/FiledAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class FiledAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): FiledAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('FiledAt must be a valid Date'); + } + return new FiledAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: FiledAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Game.test.ts b/core/racing/domain/entities/Game.test.ts new file mode 100644 index 000000000..c80251d5d --- /dev/null +++ b/core/racing/domain/entities/Game.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { Game } from './Game'; + +describe('Game', () => { + it('should create a game', () => { + const game = Game.create({ + id: 'game1', + name: 'iRacing', + }); + + expect(game.id.toString()).toBe('game1'); + expect(game.name.toString()).toBe('iRacing'); + }); + + it('should throw on invalid id', () => { + expect(() => Game.create({ + id: '', + name: 'iRacing', + })).toThrow('Game ID cannot be empty'); + }); + + it('should throw on invalid name', () => { + expect(() => Game.create({ + id: 'game1', + name: '', + })).toThrow('Game name cannot be empty'); + }); + + it('should throw on name too long', () => { + const longName = 'a'.repeat(51); + expect(() => Game.create({ + id: 'game1', + name: longName, + })).toThrow('Game name cannot exceed 50 characters'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Game.ts b/core/racing/domain/entities/Game.ts index 84be8c23b..ed70d7b77 100644 --- a/core/racing/domain/entities/Game.ts +++ b/core/racing/domain/entities/Game.ts @@ -1,27 +1,23 @@ -import { RacingDomainValidationError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; - -export class Game implements IEntity { - readonly id: string; - readonly name: string; +import { GameId } from './GameId'; +import { GameName } from './GameName'; - private constructor(props: { id: string; name: string }) { +export class Game implements IEntity { + readonly id: GameId; + readonly name: GameName; + + private constructor(props: { id: GameId; name: GameName }) { this.id = props.id; this.name = props.name; } static create(props: { id: string; name: string }): Game { - if (!props.id || props.id.trim().length === 0) { - throw new RacingDomainValidationError('Game ID is required'); - } - - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('Game name is required'); - } + const id = GameId.create(props.id); + const name = GameName.create(props.name); return new Game({ - id: props.id, - name: props.name, + id, + name, }); } } \ No newline at end of file diff --git a/core/racing/domain/entities/GameId.ts b/core/racing/domain/entities/GameId.ts new file mode 100644 index 000000000..47e019bd6 --- /dev/null +++ b/core/racing/domain/entities/GameId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class GameId { + private constructor(private readonly value: string) {} + + static create(value: string): GameId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Game ID cannot be empty'); + } + return new GameId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: GameId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/GameName.ts b/core/racing/domain/entities/GameName.ts new file mode 100644 index 000000000..c2dc2d466 --- /dev/null +++ b/core/racing/domain/entities/GameName.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class GameName { + private constructor(private readonly value: string) {} + + static create(value: string): GameName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Game name cannot be empty'); + } + if (value.length > 50) { + throw new RacingDomainValidationError('Game name cannot exceed 50 characters'); + } + return new GameName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: GameName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Horsepower.ts b/core/racing/domain/entities/Horsepower.ts new file mode 100644 index 000000000..eaa7708e5 --- /dev/null +++ b/core/racing/domain/entities/Horsepower.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class Horsepower { + private constructor(private readonly value: number) {} + + static create(value: number): Horsepower { + if (value <= 0) { + throw new RacingDomainValidationError('Horsepower must be positive'); + } + return new Horsepower(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Horsepower): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ImageUrl.ts b/core/racing/domain/entities/ImageUrl.ts new file mode 100644 index 000000000..ce5f72ba3 --- /dev/null +++ b/core/racing/domain/entities/ImageUrl.ts @@ -0,0 +1,26 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class ImageUrl { + private constructor(private readonly value: string) {} + + static create(value: string): ImageUrl { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Image URL cannot be empty'); + } + // Basic URL validation + try { + new URL(value); + } catch { + throw new RacingDomainValidationError('Invalid image URL format'); + } + return new ImageUrl(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: ImageUrl): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/IncidentDescription.ts b/core/racing/domain/entities/IncidentDescription.ts new file mode 100644 index 000000000..7d8fe5fc4 --- /dev/null +++ b/core/racing/domain/entities/IncidentDescription.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class IncidentDescription { + private constructor(private readonly value: string) {} + + static create(value: string): IncidentDescription { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Incident description cannot be empty'); + } + return new IncidentDescription(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: IncidentDescription): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/IssuedAt.test.ts b/core/racing/domain/entities/IssuedAt.test.ts new file mode 100644 index 000000000..07073fa4f --- /dev/null +++ b/core/racing/domain/entities/IssuedAt.test.ts @@ -0,0 +1,43 @@ +import { IssuedAt } from './IssuedAt'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('IssuedAt', () => { + describe('create', () => { + it('should create an IssuedAt with valid date', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const issuedAt = IssuedAt.create(date); + expect(issuedAt.toDate().getTime()).toBe(date.getTime()); + }); + + it('should create a copy of the date', () => { + const date = new Date(); + const issuedAt = IssuedAt.create(date); + date.setFullYear(2000); // modify original + expect(issuedAt.toDate().getFullYear()).not.toBe(2000); + }); + + it('should throw error for invalid date', () => { + expect(() => IssuedAt.create(new Date('invalid'))).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-Date', () => { + expect(() => IssuedAt.create('2023-01-01' as unknown as Date)).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal dates', () => { + const date1 = new Date('2023-01-01T00:00:00Z'); + const date2 = new Date('2023-01-01T00:00:00Z'); + const issuedAt1 = IssuedAt.create(date1); + const issuedAt2 = IssuedAt.create(date2); + expect(issuedAt1.equals(issuedAt2)).toBe(true); + }); + + it('should return false for different dates', () => { + const issuedAt1 = IssuedAt.create(new Date('2023-01-01')); + const issuedAt2 = IssuedAt.create(new Date('2023-01-02')); + expect(issuedAt1.equals(issuedAt2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/IssuedAt.ts b/core/racing/domain/entities/IssuedAt.ts new file mode 100644 index 000000000..bd09b75a9 --- /dev/null +++ b/core/racing/domain/entities/IssuedAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class IssuedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): IssuedAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('IssuedAt must be a valid Date'); + } + return new IssuedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: IssuedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/JoinRequest.ts b/core/racing/domain/entities/JoinRequest.ts new file mode 100644 index 000000000..f1d8880a8 --- /dev/null +++ b/core/racing/domain/entities/JoinRequest.ts @@ -0,0 +1,60 @@ +/** + * Domain Entity: JoinRequest + * + * Represents a request to join a league. + */ + +import type { IEntity } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { LeagueId } from './LeagueId'; +import { LeagueOwnerId } from './LeagueOwnerId'; +import { JoinedAt } from '../value-objects/JoinedAt'; + +export interface JoinRequestProps { + id?: string; + leagueId: string; + driverId: string; + requestedAt?: Date; + message?: string; +} + +export class JoinRequest implements IEntity { + readonly id: string; + readonly leagueId: LeagueId; + readonly driverId: LeagueOwnerId; + readonly requestedAt: JoinedAt; + readonly message: string | undefined; + + private constructor(props: { id: string; leagueId: string; driverId: string; requestedAt: Date; message?: string }) { + this.id = props.id; + this.leagueId = LeagueId.create(props.leagueId); + this.driverId = LeagueOwnerId.create(props.driverId); + this.requestedAt = JoinedAt.create(props.requestedAt); + this.message = props.message; + } + + static create(props: JoinRequestProps): JoinRequest { + this.validate(props); + + const id = props.id && props.id.trim().length > 0 ? props.id : `${props.leagueId}:${props.driverId}`; + const requestedAt = props.requestedAt ?? new Date(); + + return new JoinRequest({ + id, + leagueId: props.leagueId, + driverId: props.driverId, + requestedAt, + message: props.message, + }); + } + + private static validate(props: JoinRequestProps): void { + if (!props.leagueId || props.leagueId.trim().length === 0) { + throw new RacingDomainValidationError('League ID is required'); + } + + if (!props.driverId || props.driverId.trim().length === 0) { + throw new RacingDomainValidationError('Driver ID is required'); + } + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LapNumber.ts b/core/racing/domain/entities/LapNumber.ts new file mode 100644 index 000000000..a007cbb34 --- /dev/null +++ b/core/racing/domain/entities/LapNumber.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LapNumber { + private constructor(private readonly value: number) {} + + static create(value: number): LapNumber { + if (!Number.isInteger(value) || value < 0) { + throw new RacingDomainValidationError('Lap number must be a non-negative integer'); + } + return new LapNumber(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: LapNumber): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/League.test.ts b/core/racing/domain/entities/League.test.ts new file mode 100644 index 000000000..b526dc6d7 --- /dev/null +++ b/core/racing/domain/entities/League.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { League } from './League'; + +describe('League', () => { + it('should create a league', () => { + const league = League.create({ + id: 'league1', + name: 'Test League', + description: 'A test league', + ownerId: 'owner1', + }); + + expect(league.id.toString()).toBe('league1'); + expect(league.name.toString()).toBe('Test League'); + expect(league.description.toString()).toBe('A test league'); + expect(league.ownerId.toString()).toBe('owner1'); + }); + + it('should throw on invalid id', () => { + expect(() => League.create({ + id: '', + name: 'Test League', + description: 'A test league', + ownerId: 'owner1', + })).toThrow('League ID cannot be empty'); + }); + + it('should throw on invalid name', () => { + expect(() => League.create({ + id: 'league1', + name: '', + description: 'A test league', + ownerId: 'owner1', + })).toThrow('League name cannot be empty'); + }); + + it('should throw on name too long', () => { + const longName = 'a'.repeat(101); + expect(() => League.create({ + id: 'league1', + name: longName, + description: 'A test league', + ownerId: 'owner1', + })).toThrow('League name cannot exceed 100 characters'); + }); + + it('should throw on invalid description', () => { + expect(() => League.create({ + id: 'league1', + name: 'Test League', + description: '', + ownerId: 'owner1', + })).toThrow('League description cannot be empty'); + }); + + it('should throw on description too long', () => { + const longDesc = 'a'.repeat(501); + expect(() => League.create({ + id: 'league1', + name: 'Test League', + description: longDesc, + ownerId: 'owner1', + })).toThrow('League description cannot exceed 500 characters'); + }); + + it('should throw on invalid ownerId', () => { + expect(() => League.create({ + id: 'league1', + name: 'Test League', + description: 'A test league', + ownerId: '', + })).toThrow('League owner ID cannot be empty'); + }); + + it('should create with social links', () => { + const league = League.create({ + id: 'league1', + name: 'Test League', + description: 'A test league', + ownerId: 'owner1', + socialLinks: { + discordUrl: 'https://discord.gg/test', + }, + }); + + expect(league.socialLinks?.discordUrl).toBe('https://discord.gg/test'); + }); + + it('should throw on invalid social links', () => { + expect(() => League.create({ + id: 'league1', + name: 'Test League', + description: 'A test league', + ownerId: 'owner1', + socialLinks: { + discordUrl: 'invalid-url', + }, + })).toThrow('Invalid Discord URL'); + }); + + it('should update league', () => { + const league = League.create({ + id: 'league1', + name: 'Test League', + description: 'A test league', + ownerId: 'owner1', + }); + + const updated = league.update({ + name: 'Updated League', + }); + + expect(updated.name.toString()).toBe('Updated League'); + expect(updated.id).toBe(league.id); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/League.ts b/core/racing/domain/entities/League.ts index f11398551..b92ac51e9 100644 --- a/core/racing/domain/entities/League.ts +++ b/core/racing/domain/entities/League.ts @@ -4,9 +4,14 @@ * Represents a league in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - -import { RacingDomainValidationError } from '../errors/RacingDomainError'; + import type { IEntity } from '@core/shared/domain'; +import { LeagueId } from './LeagueId'; +import { LeagueName } from './LeagueName'; +import { LeagueDescription } from './LeagueDescription'; +import { LeagueOwnerId } from './LeagueOwnerId'; +import { LeagueCreatedAt } from './LeagueCreatedAt'; +import { LeagueSocialLinks } from './LeagueSocialLinks'; /** * Stewarding decision mode for protests @@ -21,41 +26,41 @@ export type StewardingDecisionMode = export interface StewardingSettings { /** - * How protest decisions are made - */ + * How protest decisions are made + */ decisionMode: StewardingDecisionMode; /** - * Number of votes required to uphold/reject a protest - * Used with steward_vote, member_vote, steward_veto, member_veto modes - */ + * Number of votes required to uphold/reject a protest + * Used with steward_vote, member_vote, steward_veto, member_veto modes + */ requiredVotes?: number; /** - * Whether to require a defense from the accused before deciding - */ + * Whether to require a defense from the accused before deciding + */ requireDefense?: boolean; /** - * Time limit (hours) for accused to submit defense - */ + * Time limit (hours) for accused to submit defense + */ defenseTimeLimit?: number; /** - * Time limit (hours) for voting to complete - */ + * Time limit (hours) for voting to complete + */ voteTimeLimit?: number; /** - * Time limit (hours) after race ends when protests can be filed - */ + * Time limit (hours) after race ends when protests can be filed + */ protestDeadlineHours?: number; /** - * Time limit (hours) after race ends when stewarding is closed (no more decisions) - */ + * Time limit (hours) after race ends when stewarding is closed (no more decisions) + */ stewardingClosesHours?: number; /** - * Whether to notify the accused when a protest is filed - */ + * Whether to notify the accused when a protest is filed + */ notifyAccusedOnProtest?: boolean; /** - * Whether to notify eligible voters when a vote is required - */ + * Whether to notify eligible voters when a vote is required + */ notifyOnVoteRequired?: boolean; } @@ -65,38 +70,32 @@ export interface LeagueSettings { qualifyingFormat?: 'single-lap' | 'open'; customPoints?: Record; /** - * Maximum number of drivers allowed in the league. - * Used for simple capacity display on the website. - */ + * Maximum number of drivers allowed in the league. + * Used for simple capacity display on the website. + */ maxDrivers?: number; /** - * Stewarding settings for protest handling - */ + * Stewarding settings for protest handling + */ stewarding?: StewardingSettings; } -export interface LeagueSocialLinks { - discordUrl?: string; - youtubeUrl?: string; - websiteUrl?: string; -} - -export class League implements IEntity { - readonly id: string; - readonly name: string; - readonly description: string; - readonly ownerId: string; +export class League implements IEntity { + readonly id: LeagueId; + readonly name: LeagueName; + readonly description: LeagueDescription; + readonly ownerId: LeagueOwnerId; readonly settings: LeagueSettings; - readonly createdAt: Date; + readonly createdAt: LeagueCreatedAt; readonly socialLinks: LeagueSocialLinks | undefined; private constructor(props: { - id: string; - name: string; - description: string; - ownerId: string; + id: LeagueId; + name: LeagueName; + description: LeagueDescription; + ownerId: LeagueOwnerId; settings: LeagueSettings; - createdAt: Date; + createdAt: LeagueCreatedAt; socialLinks?: LeagueSocialLinks; }) { this.id = props.id; @@ -118,9 +117,17 @@ export class League implements IEntity { ownerId: string; settings?: Partial; createdAt?: Date; - socialLinks?: LeagueSocialLinks; + socialLinks?: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }; }): League { - this.validate(props); + const id = LeagueId.create(props.id); + const name = LeagueName.create(props.name); + const description = LeagueDescription.create(props.description); + const ownerId = LeagueOwnerId.create(props.ownerId); + const createdAt = LeagueCreatedAt.create(props.createdAt ?? new Date()); const defaultStewardingSettings: StewardingSettings = { decisionMode: 'admin_only', @@ -141,48 +148,19 @@ export class League implements IEntity { stewarding: defaultStewardingSettings, }; - const socialLinks = props.socialLinks; + const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined; return new League({ - id: props.id, - name: props.name, - description: props.description, - ownerId: props.ownerId, + id, + name, + description, + ownerId, settings: { ...defaultSettings, ...props.settings }, - createdAt: props.createdAt ?? new Date(), + createdAt, ...(socialLinks !== undefined ? { socialLinks } : {}), }); } - /** - * Domain validation logic - */ - private static validate(props: { - id: string; - name: string; - description: string; - ownerId: string; - }): void { - if (!props.id || props.id.trim().length === 0) { - throw new RacingDomainValidationError('League ID is required'); - } - - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('League name is required'); - } - - if (props.name.length > 100) { - throw new RacingDomainValidationError('League name must be 100 characters or less'); - } - - if (!props.description || props.description.trim().length === 0) { - throw new RacingDomainValidationError('League description is required'); - } - - if (!props.ownerId || props.ownerId.trim().length === 0) { - throw new RacingDomainValidationError('League owner ID is required'); - } - } /** * Create a copy with updated properties @@ -192,20 +170,25 @@ export class League implements IEntity { description: string; ownerId: string; settings: LeagueSettings; - socialLinks?: LeagueSocialLinks; + socialLinks?: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }; }>): League { + const name = props.name ? LeagueName.create(props.name) : this.name; + const description = props.description ? LeagueDescription.create(props.description) : this.description; + const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId; + const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks; + return new League({ id: this.id, - name: props.name ?? this.name, - description: props.description ?? this.description, - ownerId: props.ownerId ?? this.ownerId, + name, + description, + ownerId, settings: props.settings ?? this.settings, createdAt: this.createdAt, - ...(props.socialLinks !== undefined - ? { socialLinks: props.socialLinks } - : this.socialLinks !== undefined - ? { socialLinks: this.socialLinks } - : {}), + ...(socialLinks !== undefined ? { socialLinks } : {}), }); } } \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueCreatedAt.ts b/core/racing/domain/entities/LeagueCreatedAt.ts new file mode 100644 index 000000000..63fb78fe3 --- /dev/null +++ b/core/racing/domain/entities/LeagueCreatedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueCreatedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): LeagueCreatedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Created date cannot be in the future'); + } + return new LeagueCreatedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: LeagueCreatedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueDescription.ts b/core/racing/domain/entities/LeagueDescription.ts new file mode 100644 index 000000000..33fbac1ae --- /dev/null +++ b/core/racing/domain/entities/LeagueDescription.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueDescription { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueDescription { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('League description cannot be empty'); + } + if (value.length > 500) { + throw new RacingDomainValidationError('League description cannot exceed 500 characters'); + } + return new LeagueDescription(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueDescription): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueId.ts b/core/racing/domain/entities/LeagueId.ts new file mode 100644 index 000000000..3a68b3c4c --- /dev/null +++ b/core/racing/domain/entities/LeagueId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueId { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('League ID cannot be empty'); + } + return new LeagueId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueMembership.test.ts b/core/racing/domain/entities/LeagueMembership.test.ts new file mode 100644 index 000000000..d8ac40ec0 --- /dev/null +++ b/core/racing/domain/entities/LeagueMembership.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueMembership } from './LeagueMembership'; + +describe('LeagueMembership', () => { + it('should create a league membership', () => { + const membership = LeagueMembership.create({ + leagueId: 'league1', + driverId: 'driver1', + role: 'member', + }); + + expect(membership.id).toBe('league1:driver1'); + expect(membership.leagueId.toString()).toBe('league1'); + expect(membership.driverId.toString()).toBe('driver1'); + expect(membership.role.toString()).toBe('member'); + expect(membership.status.toString()).toBe('pending'); + }); + + it('should create with custom id', () => { + const membership = LeagueMembership.create({ + id: 'custom-id', + leagueId: 'league1', + driverId: 'driver1', + role: 'admin', + status: 'active', + joinedAt: new Date('2023-01-01'), + }); + + expect(membership.id).toBe('custom-id'); + expect(membership.role.toString()).toBe('admin'); + expect(membership.status.toString()).toBe('active'); + expect(membership.joinedAt.toDate()).toEqual(new Date('2023-01-01')); + }); + + it('should throw on invalid leagueId', () => { + expect(() => LeagueMembership.create({ + leagueId: '', + driverId: 'driver1', + role: 'member', + })).toThrow('League ID is required'); + }); + + it('should throw on invalid driverId', () => { + expect(() => LeagueMembership.create({ + leagueId: 'league1', + driverId: '', + role: 'member', + })).toThrow('Driver ID is required'); + }); + + it('should throw on invalid role', () => { + expect(() => LeagueMembership.create({ + leagueId: 'league1', + driverId: 'driver1', + role: '', + })).toThrow('Membership role is required'); + }); + + it('should create with valid role', () => { + const membership = LeagueMembership.create({ + leagueId: 'league1', + driverId: 'driver1', + role: 'owner', + }); + + expect(membership.role.toString()).toBe('owner'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueMembership.ts b/core/racing/domain/entities/LeagueMembership.ts index e21f76163..ffdaff56d 100644 --- a/core/racing/domain/entities/LeagueMembership.ts +++ b/core/racing/domain/entities/LeagueMembership.ts @@ -1,33 +1,42 @@ /** - * Domain Entity: LeagueMembership and JoinRequest + * Domain Entity: LeagueMembership * - * Represents a driver's membership in a league and join requests. + * Represents a driver's membership in a league. */ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; - -export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member'; -export type MembershipStatus = 'active' | 'inactive' | 'pending'; +import { LeagueId } from './LeagueId'; +import { DriverId } from '../value-objects/DriverId'; +import { MembershipRole, MembershipRoleValue } from './MembershipRole'; +import { MembershipStatus, MembershipStatusValue } from './MembershipStatus'; +import { JoinedAt } from '../value-objects/JoinedAt'; export interface LeagueMembershipProps { id?: string; leagueId: string; driverId: string; - role: MembershipRole; - status?: MembershipStatus; + role: string; + status?: string; joinedAt?: Date; } export class LeagueMembership implements IEntity { readonly id: string; - readonly leagueId: string; - readonly driverId: string; + readonly leagueId: LeagueId; + readonly driverId: DriverId; readonly role: MembershipRole; readonly status: MembershipStatus; - readonly joinedAt: Date; + readonly joinedAt: JoinedAt; - private constructor(props: Required) { + private constructor(props: { + id: string; + leagueId: LeagueId; + driverId: DriverId; + role: MembershipRole; + status: MembershipStatus; + joinedAt: JoinedAt; + }) { this.id = props.id; this.leagueId = props.leagueId; this.driverId = props.driverId; @@ -44,14 +53,17 @@ export class LeagueMembership implements IEntity { ? props.id : `${props.leagueId}:${props.driverId}`; - const status = props.status ?? 'pending'; - const joinedAt = props.joinedAt ?? new Date(); + const leagueId = LeagueId.create(props.leagueId); + const driverId = DriverId.create(props.driverId); + const role = MembershipRole.create(props.role as MembershipRoleValue); + const status = MembershipStatus.create((props.status ?? 'pending') as MembershipStatusValue); + const joinedAt = JoinedAt.create(props.joinedAt ?? new Date()); return new LeagueMembership({ id, - leagueId: props.leagueId, - driverId: props.driverId, - role: props.role, + leagueId, + driverId, + role, status, joinedAt, }); @@ -70,12 +82,4 @@ export class LeagueMembership implements IEntity { throw new RacingDomainValidationError('Membership role is required'); } } -} - -export interface JoinRequest { - id: string; - leagueId: string; - driverId: string; - requestedAt: Date; - message?: string; } \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueName.ts b/core/racing/domain/entities/LeagueName.ts new file mode 100644 index 000000000..0fdd31991 --- /dev/null +++ b/core/racing/domain/entities/LeagueName.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueName { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('League name cannot be empty'); + } + if (value.length > 100) { + throw new RacingDomainValidationError('League name cannot exceed 100 characters'); + } + return new LeagueName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueOwnerId.ts b/core/racing/domain/entities/LeagueOwnerId.ts new file mode 100644 index 000000000..9881004e2 --- /dev/null +++ b/core/racing/domain/entities/LeagueOwnerId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueOwnerId { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueOwnerId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('League owner ID cannot be empty'); + } + return new LeagueOwnerId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueOwnerId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueScoringConfig.test.ts b/core/racing/domain/entities/LeagueScoringConfig.test.ts new file mode 100644 index 000000000..eb29826f2 --- /dev/null +++ b/core/racing/domain/entities/LeagueScoringConfig.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueScoringConfig } from './LeagueScoringConfig'; +import type { ChampionshipConfig } from '../types/ChampionshipConfig'; +import { PointsTable } from '../value-objects/PointsTable'; + +const mockPointsTable = new PointsTable({ 1: 25, 2: 18, 3: 15 }); + +const mockChampionshipConfig: ChampionshipConfig = { + id: 'champ1', + name: 'Championship 1', + type: 'driver', + sessionTypes: ['main'], + pointsTableBySessionType: { + practice: mockPointsTable, + qualifying: mockPointsTable, + q1: mockPointsTable, + q2: mockPointsTable, + q3: mockPointsTable, + sprint: mockPointsTable, + main: mockPointsTable, + timeTrial: mockPointsTable, + }, + dropScorePolicy: { strategy: 'none' }, +}; + +describe('LeagueScoringConfig', () => { + it('should create a league scoring config', () => { + const config = LeagueScoringConfig.create({ + seasonId: 'season1', + championships: [mockChampionshipConfig], + }); + + expect(config.id.toString()).toBe('scoring-config-season1'); + expect(config.seasonId.toString()).toBe('season1'); + expect(config.scoringPresetId).toBeUndefined(); + expect(config.championships).toEqual([mockChampionshipConfig]); + }); + + it('should create with custom id', () => { + const config = LeagueScoringConfig.create({ + id: 'custom-id', + seasonId: 'season1', + scoringPresetId: 'preset1', + championships: [mockChampionshipConfig], + }); + + expect(config.id.toString()).toBe('custom-id'); + expect(config.scoringPresetId?.toString()).toBe('preset1'); + }); + + it('should throw on invalid seasonId', () => { + expect(() => LeagueScoringConfig.create({ + seasonId: '', + championships: [mockChampionshipConfig], + })).toThrow('Season ID is required'); + }); + + it('should throw on empty championships', () => { + expect(() => LeagueScoringConfig.create({ + seasonId: 'season1', + championships: [], + })).toThrow('At least one championship is required'); + }); + + it('should create with multiple championships', () => { + const config = LeagueScoringConfig.create({ + seasonId: 'season1', + championships: [mockChampionshipConfig, { ...mockChampionshipConfig, id: 'champ2' }], + }); + + expect(config.championships).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueScoringConfig.ts b/core/racing/domain/entities/LeagueScoringConfig.ts index fa7b1cc97..dafd17e65 100644 --- a/core/racing/domain/entities/LeagueScoringConfig.ts +++ b/core/racing/domain/entities/LeagueScoringConfig.ts @@ -1,13 +1,73 @@ -import type { ChampionshipConfig } from '../types/ChampionshipConfig'; +/** + * Domain Entity: LeagueScoringConfig + * + * Represents the scoring configuration for a league season. + */ -export interface LeagueScoringConfig { - id: string; +import type { IEntity } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { ChampionshipConfig } from '../types/ChampionshipConfig'; +import { SeasonId } from './SeasonId'; +import { ScoringPresetId } from './ScoringPresetId'; +import { LeagueScoringConfigId } from './LeagueScoringConfigId'; + +export interface LeagueScoringConfigProps { + id?: string; seasonId: string; - /** - * Optional ID of the scoring preset this configuration was derived from. - * Used by application-layer read models to surface preset metadata such as - * name and drop policy summaries. - */ scoringPresetId?: string; championships: ChampionshipConfig[]; +} + +export class LeagueScoringConfig implements IEntity { + readonly id: LeagueScoringConfigId; + readonly seasonId: SeasonId; + readonly scoringPresetId: ScoringPresetId | undefined; + readonly championships: ChampionshipConfig[]; + + private constructor(props: { + id: LeagueScoringConfigId; + seasonId: SeasonId; + scoringPresetId?: ScoringPresetId; + championships: ChampionshipConfig[]; + }) { + this.id = props.id; + this.seasonId = props.seasonId; + this.scoringPresetId = props.scoringPresetId; + this.championships = props.championships; + } + + static create(props: LeagueScoringConfigProps): LeagueScoringConfig { + this.validate(props); + + const idString = props.id && props.id.trim().length > 0 ? props.id : this.generateId(props.seasonId); + const id = LeagueScoringConfigId.create(idString); + + const seasonId = SeasonId.create(props.seasonId); + const scoringPresetId = props.scoringPresetId ? ScoringPresetId.create(props.scoringPresetId) : undefined; + + return new LeagueScoringConfig({ + id, + seasonId, + ...(scoringPresetId ? { scoringPresetId } : {}), + championships: props.championships, + }); + } + + private static validate(props: LeagueScoringConfigProps): void { + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new RacingDomainValidationError('Season ID is required'); + } + + if (!props.championships || props.championships.length === 0) { + throw new RacingDomainValidationError('At least one championship is required'); + } + } + + private static generateId(seasonId: string): string { + return `scoring-config-${seasonId}`; + } + + equals(other: LeagueScoringConfig): boolean { + return !!other && this.id.equals(other.id); + } } \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueScoringConfigId.test.ts b/core/racing/domain/entities/LeagueScoringConfigId.test.ts new file mode 100644 index 000000000..fdd54c3c0 --- /dev/null +++ b/core/racing/domain/entities/LeagueScoringConfigId.test.ts @@ -0,0 +1,38 @@ +import { LeagueScoringConfigId } from './LeagueScoringConfigId'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('LeagueScoringConfigId', () => { + describe('create', () => { + it('should create a LeagueScoringConfigId with valid value', () => { + const id = LeagueScoringConfigId.create('scoring-config-123'); + expect(id.toString()).toBe('scoring-config-123'); + }); + + it('should trim whitespace', () => { + const id = LeagueScoringConfigId.create(' scoring-config-123 '); + expect(id.toString()).toBe('scoring-config-123'); + }); + + it('should throw error for empty string', () => { + expect(() => LeagueScoringConfigId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => LeagueScoringConfigId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = LeagueScoringConfigId.create('scoring-config-123'); + const id2 = LeagueScoringConfigId.create('scoring-config-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = LeagueScoringConfigId.create('scoring-config-123'); + const id2 = LeagueScoringConfigId.create('scoring-config-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueScoringConfigId.ts b/core/racing/domain/entities/LeagueScoringConfigId.ts new file mode 100644 index 000000000..c51e7ef91 --- /dev/null +++ b/core/racing/domain/entities/LeagueScoringConfigId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueScoringConfigId { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueScoringConfigId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('League Scoring Config ID cannot be empty'); + } + return new LeagueScoringConfigId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueScoringConfigId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueSocialLinks.ts b/core/racing/domain/entities/LeagueSocialLinks.ts new file mode 100644 index 000000000..c873384a4 --- /dev/null +++ b/core/racing/domain/entities/LeagueSocialLinks.ts @@ -0,0 +1,52 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LeagueSocialLinks { + readonly discordUrl: string | undefined; + readonly youtubeUrl: string | undefined; + readonly websiteUrl: string | undefined; + + private constructor(props: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }) { + this.discordUrl = props.discordUrl; + this.youtubeUrl = props.youtubeUrl; + this.websiteUrl = props.websiteUrl; + } + + static create(props: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }): LeagueSocialLinks { + // Basic validation, e.g., if provided, must be valid URL + if (props.discordUrl && !this.isValidUrl(props.discordUrl)) { + throw new RacingDomainValidationError('Invalid Discord URL'); + } + if (props.youtubeUrl && !this.isValidUrl(props.youtubeUrl)) { + throw new RacingDomainValidationError('Invalid YouTube URL'); + } + if (props.websiteUrl && !this.isValidUrl(props.websiteUrl)) { + throw new RacingDomainValidationError('Invalid website URL'); + } + return new LeagueSocialLinks(props); + } + + private static isValidUrl(url: string): boolean { + try { + new URL(url); + return true; + } catch { + return false; + } + } + + equals(other: LeagueSocialLinks): boolean { + return ( + this.discordUrl === other.discordUrl && + this.youtubeUrl === other.youtubeUrl && + this.websiteUrl === other.websiteUrl + ); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LiveryTemplate.test.ts b/core/racing/domain/entities/LiveryTemplate.test.ts new file mode 100644 index 000000000..81db9519a --- /dev/null +++ b/core/racing/domain/entities/LiveryTemplate.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { LiveryTemplate } from './LiveryTemplate'; +import { LiveryDecal } from '../value-objects/LiveryDecal'; + +describe('LiveryTemplate', () => { + const validProps = { + id: 'template1', + leagueId: 'league1', + seasonId: 'season1', + carId: 'car1', + baseImageUrl: 'https://example.com/image.png', + }; + + it('should create a livery template', () => { + const template = LiveryTemplate.create(validProps); + expect(template.id.toString()).toBe('template1'); + expect(template.leagueId.toString()).toBe('league1'); + expect(template.seasonId.toString()).toBe('season1'); + expect(template.carId.toString()).toBe('car1'); + expect(template.baseImageUrl.toString()).toBe('https://example.com/image.png'); + expect(template.adminDecals).toEqual([]); + }); + + it('should throw on empty id', () => { + expect(() => LiveryTemplate.create({ ...validProps, id: '' })).toThrow('LiveryTemplate ID is required'); + }); + + it('should throw on empty leagueId', () => { + expect(() => LiveryTemplate.create({ ...validProps, leagueId: '' })).toThrow('LiveryTemplate leagueId is required'); + }); + + it('should throw on empty seasonId', () => { + expect(() => LiveryTemplate.create({ ...validProps, seasonId: '' })).toThrow('LiveryTemplate seasonId is required'); + }); + + it('should throw on empty carId', () => { + expect(() => LiveryTemplate.create({ ...validProps, carId: '' })).toThrow('LiveryTemplate carId is required'); + }); + + it('should throw on empty baseImageUrl', () => { + expect(() => LiveryTemplate.create({ ...validProps, baseImageUrl: '' })).toThrow('LiveryTemplate baseImageUrl is required'); + }); + + it('should add a sponsor decal', () => { + const template = LiveryTemplate.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const updated = template.addDecal(decal); + expect(updated.adminDecals).toHaveLength(1); + expect(updated.adminDecals[0]).toBe(decal); + expect(updated.updatedAt).toBeDefined(); + }); + + it('should throw when adding non-sponsor decal', () => { + const template = LiveryTemplate.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + expect(() => template.addDecal(decal)).toThrow('Only sponsor decals can be added to admin template'); + }); + + it('should remove a decal', () => { + const decal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const template = LiveryTemplate.create({ ...validProps, adminDecals: [decal] }); + const updated = template.removeDecal('decal1'); + expect(updated.adminDecals).toHaveLength(0); + expect(updated.updatedAt).toBeDefined(); + }); + + it('should throw when removing non-existent decal', () => { + const template = LiveryTemplate.create(validProps); + expect(() => template.removeDecal('nonexistent')).toThrow('Decal not found in template'); + }); + + it('should update a decal', () => { + const decal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const template = LiveryTemplate.create({ ...validProps, adminDecals: [decal] }); + const updatedDecal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.6, + y: 0.6, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const updated = template.updateDecal('decal1', updatedDecal); + expect(updated.adminDecals[0]).toBe(updatedDecal); + expect(updated.updatedAt).toBeDefined(); + }); + + it('should throw when updating non-existent decal', () => { + const template = LiveryTemplate.create(validProps); + const decal = LiveryDecal.create({ + id: 'decal1', + imageUrl: 'https://example.com/decal.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + expect(() => template.updateDecal('nonexistent', decal)).toThrow('Decal not found in template'); + }); + + it('should get sponsor decals', () => { + const sponsorDecal = LiveryDecal.create({ + id: 'sponsor1', + imageUrl: 'https://example.com/sponsor.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const userDecal = LiveryDecal.create({ + id: 'user1', + imageUrl: 'https://example.com/user.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'user', + }); + const template = LiveryTemplate.create({ ...validProps, adminDecals: [sponsorDecal, userDecal] }); + const sponsors = template.getSponsorDecals(); + expect(sponsors).toHaveLength(1); + expect(sponsors[0]).toBe(sponsorDecal); + }); + + it('should check if has sponsor decals', () => { + const sponsorDecal = LiveryDecal.create({ + id: 'sponsor1', + imageUrl: 'https://example.com/sponsor.png', + x: 0.5, + y: 0.5, + width: 0.1, + height: 0.1, + zIndex: 1, + type: 'sponsor', + }); + const templateWithSponsor = LiveryTemplate.create({ ...validProps, adminDecals: [sponsorDecal] }); + const templateWithout = LiveryTemplate.create(validProps); + expect(templateWithSponsor.hasSponsorDecals()).toBe(true); + expect(templateWithout.hasSponsorDecals()).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/LiveryTemplate.ts b/core/racing/domain/entities/LiveryTemplate.ts index fdb79199e..f12ee7e2a 100644 --- a/core/racing/domain/entities/LiveryTemplate.ts +++ b/core/racing/domain/entities/LiveryTemplate.ts @@ -6,55 +6,82 @@ */ import type { IEntity } from '@core/shared/domain'; - +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { LiveryDecal } from '../value-objects/LiveryDecal'; +import { LiveryTemplateId } from './LiveryTemplateId'; +import { LeagueId } from './LeagueId'; +import { SeasonId } from './SeasonId'; +import { CarId } from './CarId'; +import { ImageUrl } from './ImageUrl'; +import { LiveryTemplateCreatedAt } from './LiveryTemplateCreatedAt'; +import { LiveryTemplateUpdatedAt } from './LiveryTemplateUpdatedAt'; -export interface LiveryTemplateProps { - id: string; - leagueId: string; - seasonId: string; - carId: string; - baseImageUrl: string; - adminDecals: LiveryDecal[]; - createdAt: Date; - updatedAt: Date | undefined; -} - -export class LiveryTemplate implements IEntity { - readonly id: string; - readonly leagueId: string; - readonly seasonId: string; - readonly carId: string; - readonly baseImageUrl: string; +export class LiveryTemplate implements IEntity { + readonly id: LiveryTemplateId; + readonly leagueId: LeagueId; + readonly seasonId: SeasonId; + readonly carId: CarId; + readonly baseImageUrl: ImageUrl; readonly adminDecals: LiveryDecal[]; - readonly createdAt: Date; - readonly updatedAt: Date | undefined; + readonly createdAt: LiveryTemplateCreatedAt; + readonly updatedAt: LiveryTemplateUpdatedAt | undefined; - private constructor(props: LiveryTemplateProps) { + private constructor(props: { + id: LiveryTemplateId; + leagueId: LeagueId; + seasonId: SeasonId; + carId: CarId; + baseImageUrl: ImageUrl; + adminDecals: LiveryDecal[]; + createdAt: LiveryTemplateCreatedAt; + updatedAt?: LiveryTemplateUpdatedAt; + }) { this.id = props.id; this.leagueId = props.leagueId; this.seasonId = props.seasonId; this.carId = props.carId; this.baseImageUrl = props.baseImageUrl; this.adminDecals = props.adminDecals; - this.createdAt = props.createdAt ?? new Date(); + this.createdAt = props.createdAt; this.updatedAt = props.updatedAt; } - static create(props: Omit & { + static create(props: { + id: string; + leagueId: string; + seasonId: string; + carId: string; + baseImageUrl: string; createdAt?: Date; adminDecals?: LiveryDecal[]; }): LiveryTemplate { this.validate(props); + const id = LiveryTemplateId.create(props.id); + const leagueId = LeagueId.create(props.leagueId); + const seasonId = SeasonId.create(props.seasonId); + const carId = CarId.create(props.carId); + const baseImageUrl = ImageUrl.create(props.baseImageUrl); + const createdAt = LiveryTemplateCreatedAt.create(props.createdAt ?? new Date()); + return new LiveryTemplate({ - ...props, - createdAt: props.createdAt ?? new Date(), + id, + leagueId, + seasonId, + carId, + baseImageUrl, adminDecals: props.adminDecals ?? [], + createdAt, }); } - private static validate(props: Omit): void { + private static validate(props: { + id: string; + leagueId: string; + seasonId: string; + carId: string; + baseImageUrl: string; + }): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('LiveryTemplate ID is required'); } @@ -87,7 +114,7 @@ export class LiveryTemplate implements IEntity { return new LiveryTemplate({ ...this, adminDecals: [...this.adminDecals, decal], - updatedAt: new Date(), + updatedAt: LiveryTemplateUpdatedAt.create(new Date()), }); } @@ -104,7 +131,7 @@ export class LiveryTemplate implements IEntity { return new LiveryTemplate({ ...this, adminDecals: updatedDecals, - updatedAt: new Date(), + updatedAt: LiveryTemplateUpdatedAt.create(new Date()), }); } @@ -113,7 +140,7 @@ export class LiveryTemplate implements IEntity { */ updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate { const index = this.adminDecals.findIndex(d => d.id === decalId); - + if (index === -1) { throw new RacingDomainValidationError('Decal not found in template'); } @@ -124,7 +151,7 @@ export class LiveryTemplate implements IEntity { return new LiveryTemplate({ ...this, adminDecals: updatedDecals, - updatedAt: new Date(), + updatedAt: LiveryTemplateUpdatedAt.create(new Date()), }); } diff --git a/core/racing/domain/entities/LiveryTemplateCreatedAt.ts b/core/racing/domain/entities/LiveryTemplateCreatedAt.ts new file mode 100644 index 000000000..fc6d89122 --- /dev/null +++ b/core/racing/domain/entities/LiveryTemplateCreatedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LiveryTemplateCreatedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): LiveryTemplateCreatedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Created date cannot be in the future'); + } + return new LiveryTemplateCreatedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: LiveryTemplateCreatedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LiveryTemplateId.ts b/core/racing/domain/entities/LiveryTemplateId.ts new file mode 100644 index 000000000..9bfd487cf --- /dev/null +++ b/core/racing/domain/entities/LiveryTemplateId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LiveryTemplateId { + private constructor(private readonly value: string) {} + + static create(value: string): LiveryTemplateId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('LiveryTemplate ID cannot be empty'); + } + return new LiveryTemplateId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LiveryTemplateId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/LiveryTemplateUpdatedAt.ts b/core/racing/domain/entities/LiveryTemplateUpdatedAt.ts new file mode 100644 index 000000000..4aba24f6a --- /dev/null +++ b/core/racing/domain/entities/LiveryTemplateUpdatedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class LiveryTemplateUpdatedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): LiveryTemplateUpdatedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Updated date cannot be in the future'); + } + return new LiveryTemplateUpdatedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: LiveryTemplateUpdatedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Manufacturer.ts b/core/racing/domain/entities/Manufacturer.ts new file mode 100644 index 000000000..87cd6e0e3 --- /dev/null +++ b/core/racing/domain/entities/Manufacturer.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class Manufacturer { + private constructor(private readonly value: string) {} + + static create(value: string): Manufacturer { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Manufacturer cannot be empty'); + } + if (value.length > 50) { + throw new RacingDomainValidationError('Manufacturer cannot exceed 50 characters'); + } + return new Manufacturer(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: Manufacturer): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/MembershipRole.ts b/core/racing/domain/entities/MembershipRole.ts new file mode 100644 index 000000000..5ab843904 --- /dev/null +++ b/core/racing/domain/entities/MembershipRole.ts @@ -0,0 +1,22 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export type MembershipRoleValue = 'owner' | 'admin' | 'steward' | 'member'; + +export class MembershipRole { + private constructor(private readonly value: MembershipRoleValue) {} + + static create(value: MembershipRoleValue): MembershipRole { + if (!value) { + throw new RacingDomainValidationError('Membership role is required'); + } + return new MembershipRole(value); + } + + toString(): MembershipRoleValue { + return this.value; + } + + equals(other: MembershipRole): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/MembershipStatus.ts b/core/racing/domain/entities/MembershipStatus.ts new file mode 100644 index 000000000..6a250d85a --- /dev/null +++ b/core/racing/domain/entities/MembershipStatus.ts @@ -0,0 +1,22 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export type MembershipStatusValue = 'active' | 'inactive' | 'pending'; + +export class MembershipStatus { + private constructor(private readonly value: MembershipStatusValue) {} + + static create(value: MembershipStatusValue): MembershipStatus { + if (!value) { + throw new RacingDomainValidationError('Membership status is required'); + } + return new MembershipStatus(value); + } + + toString(): MembershipStatusValue { + return this.value; + } + + equals(other: MembershipStatus): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Penalty.ts b/core/racing/domain/entities/Penalty.ts deleted file mode 100644 index 2805cac92..000000000 --- a/core/racing/domain/entities/Penalty.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Domain Entity: Penalty - * - * Represents a penalty applied to a driver for an incident during a race. - * Penalties can be applied as a result of an upheld protest or directly by stewards. - */ - -import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; -import type { IEntity } from '@core/shared/domain'; - -export type PenaltyType = - | 'time_penalty' // Add time to race result (e.g., +5 seconds) - | 'grid_penalty' // Grid position penalty for next race - | 'points_deduction' // Deduct championship points - | 'disqualification' // DSQ from the race - | 'warning' // Official warning (no immediate consequence) - | 'license_points' // Add penalty points to license (safety rating) - | 'probation' // Conditional penalty - | 'fine' // Monetary/points fine - | 'race_ban'; // Multi-race suspension - -export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned'; - -export interface PenaltyProps { - id: string; - leagueId: string; - raceId: string; - /** The driver receiving the penalty */ - driverId: string; - /** Type of penalty */ - type: PenaltyType; - /** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */ - value?: number; - /** Reason for the penalty */ - reason: string; - /** ID of the protest that led to this penalty (if applicable) */ - protestId?: string; - /** ID of the steward who issued the penalty */ - issuedBy: string; - /** Current status of the penalty */ - status: PenaltyStatus; - /** Timestamp when the penalty was issued */ - issuedAt: Date; - /** Timestamp when the penalty was applied to results */ - appliedAt?: Date; - /** Notes about the penalty application */ - notes?: string; -} - -export class Penalty implements IEntity { - private constructor(private readonly props: PenaltyProps) {} - - static create(props: PenaltyProps): Penalty { - if (!props.id) throw new RacingDomainValidationError('Penalty ID is required'); - if (!props.leagueId) throw new RacingDomainValidationError('League ID is required'); - if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); - if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required'); - if (!props.type) throw new RacingDomainValidationError('Penalty type is required'); - if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); - if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); - - // Validate value based on type - if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) { - if (props.value === undefined || props.value <= 0) { - throw new RacingDomainValidationError(`${props.type} requires a positive value`); - } - } - - return new Penalty({ - ...props, - status: props.status || 'pending', - issuedAt: props.issuedAt || new Date(), - }); - } - - get id(): string { return this.props.id; } - get leagueId(): string { return this.props.leagueId; } - get raceId(): string { return this.props.raceId; } - get driverId(): string { return this.props.driverId; } - get type(): PenaltyType { return this.props.type; } - get value(): number | undefined { return this.props.value; } - get reason(): string { return this.props.reason; } - get protestId(): string | undefined { return this.props.protestId; } - get issuedBy(): string { return this.props.issuedBy; } - get status(): PenaltyStatus { return this.props.status; } - get issuedAt(): Date { return this.props.issuedAt; } - get appliedAt(): Date | undefined { return this.props.appliedAt; } - get notes(): string | undefined { return this.props.notes; } - - isPending(): boolean { - return this.props.status === 'pending'; - } - - isApplied(): boolean { - return this.props.status === 'applied'; - } - - /** - * Mark penalty as applied (after recalculating results) - */ - markAsApplied(notes?: string): Penalty { - if (this.isApplied()) { - throw new RacingDomainInvariantError('Penalty is already applied'); - } - if (this.props.status === 'overturned') { - throw new RacingDomainInvariantError('Cannot apply an overturned penalty'); - } - const base: PenaltyProps = { - ...this.props, - status: 'applied', - appliedAt: new Date(), - }; - - const next: PenaltyProps = - notes !== undefined ? { ...base, notes } : base; - - return Penalty.create(next); - } - - /** - * Overturn the penalty (e.g., after successful appeal) - */ - overturn(reason: string): Penalty { - if (this.props.status === 'overturned') { - throw new RacingDomainInvariantError('Penalty is already overturned'); - } - return new Penalty({ - ...this.props, - status: 'overturned', - notes: reason, - }); - } - - /** - * Get a human-readable description of the penalty - */ - getDescription(): string { - switch (this.props.type) { - case 'time_penalty': - return `+${this.props.value}s time penalty`; - case 'grid_penalty': - return `${this.props.value} place grid penalty (next race)`; - case 'points_deduction': - return `${this.props.value} championship points deducted`; - case 'disqualification': - return 'Disqualified from race'; - case 'warning': - return 'Official warning'; - case 'license_points': - return `${this.props.value} license penalty points`; - case 'probation': - return 'Probationary period'; - case 'fine': - return `${this.props.value} points fine`; - case 'race_ban': - return `${this.props.value} race suspension`; - default: - return 'Penalty'; - } - } -} \ No newline at end of file diff --git a/core/racing/domain/entities/Protest.test.ts b/core/racing/domain/entities/Protest.test.ts new file mode 100644 index 000000000..d37ebafe7 --- /dev/null +++ b/core/racing/domain/entities/Protest.test.ts @@ -0,0 +1,226 @@ +import { Protest } from './Protest'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('Protest', () => { + describe('create', () => { + it('should create a protest with required fields', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + + expect(protest.id).toBe('protest-1'); + expect(protest.raceId).toBe('race-1'); + expect(protest.protestingDriverId).toBe('driver-1'); + expect(protest.accusedDriverId).toBe('driver-2'); + expect(protest.incident.lap.toNumber()).toBe(5); + expect(protest.incident.description.toString()).toBe('Unsafe overtake'); + expect(protest.status.toString()).toBe('pending'); + expect(protest.filedAt).toBeInstanceOf(Date); + }); + + it('should create a protest with all fields', () => { + const filedAt = new Date('2023-01-01'); + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake', timeInRace: 120.5 }, + comment: 'He cut me off', + proofVideoUrl: 'https://example.com/video.mp4', + status: 'under_review', + reviewedBy: 'steward-1', + decisionNotes: 'Reviewed and upheld', + filedAt, + reviewedAt: new Date('2023-01-02'), + defense: { statement: 'It was a racing incident', videoUrl: 'https://example.com/defense.mp4', submittedAt: new Date('2023-01-01T12:00:00Z') }, + defenseRequestedAt: new Date('2023-01-01T10:00:00Z'), + defenseRequestedBy: 'steward-1', + }); + + expect(protest.id).toBe('protest-1'); + expect(protest.comment).toBe('He cut me off'); + expect(protest.proofVideoUrl).toBe('https://example.com/video.mp4'); + expect(protest.status.toString()).toBe('under_review'); + expect(protest.reviewedBy).toBe('steward-1'); + expect(protest.decisionNotes).toBe('Reviewed and upheld'); + expect(protest.filedAt).toEqual(filedAt); + expect(protest.defense).toBeDefined(); + expect(protest.defenseRequestedAt).toBeInstanceOf(Date); + expect(protest.defenseRequestedBy).toBe('steward-1'); + }); + + it('should throw error for invalid id', () => { + expect(() => Protest.create({ + id: '', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid lap', () => { + expect(() => Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: -1, description: 'Unsafe overtake' }, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty description', () => { + expect(() => Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: '' }, + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('requestDefense', () => { + it('should request defense for pending protest', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + const updated = protest.requestDefense('steward-1'); + expect(updated.status.toString()).toBe('awaiting_defense'); + expect(updated.defenseRequestedAt).toBeInstanceOf(Date); + expect(updated.defenseRequestedBy).toBe('steward-1'); + }); + + it('should throw error if not pending', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + status: 'upheld', + }); + expect(() => protest.requestDefense('steward-1')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('submitDefense', () => { + it('should submit defense for awaiting defense protest', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + status: 'awaiting_defense', + }); + const updated = protest.submitDefense('It was a racing incident'); + expect(updated.status.toString()).toBe('under_review'); + expect(updated.defense).toBeDefined(); + if (updated.defense) { + expect(updated.defense.statement.toString()).toBe('It was a racing incident'); + } + }); + + it('should throw error if not awaiting defense', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + expect(() => protest.submitDefense('Statement')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('uphold', () => { + it('should uphold pending protest', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + const updated = protest.uphold('steward-1', 'Penalty applied'); + expect(updated.status.toString()).toBe('upheld'); + expect(updated.reviewedBy).toBe('steward-1'); + expect(updated.decisionNotes).toBe('Penalty applied'); + expect(updated.reviewedAt).toBeInstanceOf(Date); + }); + + it('should throw error if resolved', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + status: 'upheld', + }); + expect(() => protest.uphold('steward-1', 'Notes')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('withdraw', () => { + it('should withdraw pending protest', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + const updated = protest.withdraw(); + expect(updated.status.toString()).toBe('withdrawn'); + expect(updated.reviewedAt).toBeInstanceOf(Date); + }); + + it('should throw error if resolved', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + status: 'upheld', + }); + expect(() => protest.withdraw()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('isPending', () => { + it('should return true for pending status', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + }); + expect(protest.isPending()).toBe(true); + }); + + it('should return false for upheld status', () => { + const protest = Protest.create({ + id: 'protest-1', + raceId: 'race-1', + protestingDriverId: 'driver-1', + accusedDriverId: 'driver-2', + incident: { lap: 5, description: 'Unsafe overtake' }, + status: 'upheld', + }); + expect(protest.isPending()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Protest.ts b/core/racing/domain/entities/Protest.ts index d54d3c545..aa806864e 100644 --- a/core/racing/domain/entities/Protest.ts +++ b/core/racing/domain/entities/Protest.ts @@ -13,107 +13,136 @@ */ import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; - -export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn'; - -export interface ProtestIncident { - /** Lap number where the incident occurred */ - lap: number; - /** Time in the race (seconds from start, or timestamp) */ - timeInRace?: number; - /** Brief description of the incident */ - description: string; -} - -export interface ProtestDefense { - /** The accused driver's statement/defense */ - statement: string; - /** URL to defense video clip (optional) */ - videoUrl?: string; - /** Timestamp when defense was submitted */ - submittedAt: Date; -} +import { ProtestId } from './ProtestId'; +import { RaceId } from './RaceId'; +import { DriverId } from './DriverId'; +import { StewardId } from './StewardId'; +import { ProtestStatus } from './ProtestStatus'; +import { ProtestIncident } from './ProtestIncident'; +import { ProtestComment } from './ProtestComment'; +import { VideoUrl } from './VideoUrl'; +import { FiledAt } from './FiledAt'; +import { ReviewedAt } from './ReviewedAt'; +import { ProtestDefense } from './ProtestDefense'; +import { DefenseRequestedAt } from './DefenseRequestedAt'; +import { DecisionNotes } from './DecisionNotes'; export interface ProtestProps { - id: string; - raceId: string; + id: ProtestId; + raceId: RaceId; /** The driver filing the protest */ - protestingDriverId: string; + protestingDriverId: DriverId; /** The driver being protested against */ - accusedDriverId: string; + accusedDriverId: DriverId; /** Details of the incident */ incident: ProtestIncident; /** Optional comment/statement from the protesting driver */ - comment?: string; + comment?: ProtestComment; /** URL to proof video clip */ - proofVideoUrl?: string; + proofVideoUrl?: VideoUrl; /** Current status of the protest */ status: ProtestStatus; /** ID of the steward/admin who reviewed (if any) */ - reviewedBy?: string; + reviewedBy?: StewardId; /** Decision notes from the steward */ - decisionNotes?: string; + decisionNotes?: DecisionNotes; /** Timestamp when the protest was filed */ - filedAt: Date; + filedAt: FiledAt; /** Timestamp when the protest was reviewed */ - reviewedAt?: Date; + reviewedAt?: ReviewedAt; /** Defense from the accused driver (if requested and submitted) */ defense?: ProtestDefense; /** Timestamp when defense was requested */ - defenseRequestedAt?: Date; + defenseRequestedAt?: DefenseRequestedAt; /** ID of the steward who requested defense */ - defenseRequestedBy?: string; + defenseRequestedBy?: StewardId; } export class Protest implements IEntity { private constructor(private readonly props: ProtestProps) {} - static create(props: ProtestProps): Protest { - if (!props.id) throw new RacingDomainValidationError('Protest ID is required'); - if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); - if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required'); - if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required'); - if (!props.incident) throw new RacingDomainValidationError('Incident details are required'); - if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative'); - if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required'); - + static create(props: { + id: string; + raceId: string; + protestingDriverId: string; + accusedDriverId: string; + incident: { lap: number; description: string; timeInRace?: number }; + comment?: string; + proofVideoUrl?: string; + status?: string; + reviewedBy?: string; + decisionNotes?: string; + filedAt?: Date; + reviewedAt?: Date; + defense?: { statement: string; videoUrl?: string; submittedAt: Date }; + defenseRequestedAt?: Date; + defenseRequestedBy?: string; + }): Protest { + const id = ProtestId.create(props.id); + const raceId = RaceId.create(props.raceId); + const protestingDriverId = DriverId.create(props.protestingDriverId); + const accusedDriverId = DriverId.create(props.accusedDriverId); + const incident = ProtestIncident.create(props.incident.lap, props.incident.description, props.incident.timeInRace); + const comment = props.comment ? ProtestComment.create(props.comment) : undefined; + const proofVideoUrl = props.proofVideoUrl ? VideoUrl.create(props.proofVideoUrl) : undefined; + const status = ProtestStatus.create(props.status || 'pending'); + const reviewedBy = props.reviewedBy ? StewardId.create(props.reviewedBy) : undefined; + const decisionNotes = props.decisionNotes ? DecisionNotes.create(props.decisionNotes) : undefined; + const filedAt = FiledAt.create(props.filedAt || new Date()); + const reviewedAt = props.reviewedAt ? ReviewedAt.create(props.reviewedAt) : undefined; + const defense = props.defense ? ProtestDefense.create(props.defense.statement, props.defense.submittedAt, props.defense.videoUrl) : undefined; + const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined; + const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined; + return new Protest({ - ...props, - status: props.status || 'pending', - filedAt: props.filedAt || new Date(), + id, + raceId, + protestingDriverId, + accusedDriverId, + incident, + comment, + proofVideoUrl, + status, + reviewedBy, + decisionNotes, + filedAt, + reviewedAt, + defense, + defenseRequestedAt, + defenseRequestedBy, }); } - get id(): string { return this.props.id; } - get raceId(): string { return this.props.raceId; } - get protestingDriverId(): string { return this.props.protestingDriverId; } - get accusedDriverId(): string { return this.props.accusedDriverId; } - get incident(): ProtestIncident { return { ...this.props.incident }; } - get comment(): string | undefined { return this.props.comment; } - get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; } + get id(): string { return this.props.id.toString(); } + get raceId(): string { return this.props.raceId.toString(); } + get protestingDriverId(): string { return this.props.protestingDriverId.toString(); } + get accusedDriverId(): string { return this.props.accusedDriverId.toString(); } + get incident(): ProtestIncident { return this.props.incident; } + get comment(): string | undefined { return this.props.comment?.toString(); } + get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl?.toString(); } get status(): ProtestStatus { return this.props.status; } - get reviewedBy(): string | undefined { return this.props.reviewedBy; } - get decisionNotes(): string | undefined { return this.props.decisionNotes; } - get filedAt(): Date { return this.props.filedAt; } - get reviewedAt(): Date | undefined { return this.props.reviewedAt; } - get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; } - get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; } - get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; } + get reviewedBy(): string | undefined { return this.props.reviewedBy?.toString(); } + get decisionNotes(): string | undefined { return this.props.decisionNotes?.toString(); } + get filedAt(): Date { return this.props.filedAt.toDate(); } + get reviewedAt(): Date | undefined { return this.props.reviewedAt?.toDate(); } + get defense(): ProtestDefense | undefined { return this.props.defense; } + get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt?.toDate(); } + get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy?.toString(); } isPending(): boolean { - return this.props.status === 'pending'; + return this.props.status.toString() === 'pending'; } isAwaitingDefense(): boolean { - return this.props.status === 'awaiting_defense'; + return this.props.status.toString() === 'awaiting_defense'; } isUnderReview(): boolean { - return this.props.status === 'under_review'; + return this.props.status.toString() === 'under_review'; } isResolved(): boolean { - return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status); + return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status.toString()); } hasDefense(): boolean { @@ -137,9 +166,9 @@ export class Protest implements IEntity { } return new Protest({ ...this.props, - status: 'awaiting_defense', - defenseRequestedAt: new Date(), - defenseRequestedBy: stewardId, + status: ProtestStatus.create('awaiting_defense'), + defenseRequestedAt: DefenseRequestedAt.create(new Date()), + defenseRequestedBy: StewardId.create(stewardId), }); } @@ -153,18 +182,12 @@ export class Protest implements IEntity { if (!statement?.trim()) { throw new RacingDomainValidationError('Defense statement is required'); } - const defenseBase: ProtestDefense = { - statement: statement.trim(), - submittedAt: new Date(), - }; - - const nextDefense: ProtestDefense = - videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase; + const defense = ProtestDefense.create(statement.trim(), new Date(), videoUrl); return new Protest({ ...this.props, - status: 'under_review', - defense: nextDefense, + status: ProtestStatus.create('under_review'), + defense, }); } @@ -177,8 +200,8 @@ export class Protest implements IEntity { } return new Protest({ ...this.props, - status: 'under_review', - reviewedBy: stewardId, + status: ProtestStatus.create('under_review'), + reviewedBy: StewardId.create(stewardId), }); } @@ -191,10 +214,10 @@ export class Protest implements IEntity { } return new Protest({ ...this.props, - status: 'upheld', - reviewedBy: stewardId, - decisionNotes, - reviewedAt: new Date(), + status: ProtestStatus.create('upheld'), + reviewedBy: StewardId.create(stewardId), + decisionNotes: DecisionNotes.create(decisionNotes), + reviewedAt: ReviewedAt.create(new Date()), }); } @@ -207,10 +230,10 @@ export class Protest implements IEntity { } return new Protest({ ...this.props, - status: 'dismissed', - reviewedBy: stewardId, - decisionNotes, - reviewedAt: new Date(), + status: ProtestStatus.create('dismissed'), + reviewedBy: StewardId.create(stewardId), + decisionNotes: DecisionNotes.create(decisionNotes), + reviewedAt: ReviewedAt.create(new Date()), }); } @@ -223,8 +246,8 @@ export class Protest implements IEntity { } return new Protest({ ...this.props, - status: 'withdrawn', - reviewedAt: new Date(), + status: ProtestStatus.create('withdrawn'), + reviewedAt: ReviewedAt.create(new Date()), }); } } \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestComment.ts b/core/racing/domain/entities/ProtestComment.ts new file mode 100644 index 000000000..6ca0f4070 --- /dev/null +++ b/core/racing/domain/entities/ProtestComment.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class ProtestComment { + private constructor(private readonly value: string) {} + + static create(value: string): ProtestComment { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Protest comment cannot be empty'); + } + return new ProtestComment(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: ProtestComment): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestDefense.ts b/core/racing/domain/entities/ProtestDefense.ts new file mode 100644 index 000000000..59044550e --- /dev/null +++ b/core/racing/domain/entities/ProtestDefense.ts @@ -0,0 +1,40 @@ +import { DefenseStatement } from './DefenseStatement'; +import { VideoUrl } from './VideoUrl'; +import { SubmittedAt } from './SubmittedAt'; + +export class ProtestDefense { + private constructor( + private readonly _statement: DefenseStatement, + private readonly _videoUrl: VideoUrl | undefined, + private readonly _submittedAt: SubmittedAt + ) {} + + static create(statement: string, submittedAt: Date, videoUrl?: string): ProtestDefense { + const stmt = DefenseStatement.create(statement); + const video = videoUrl !== undefined ? VideoUrl.create(videoUrl) : undefined; + const submitted = SubmittedAt.create(submittedAt); + return new ProtestDefense(stmt, video, submitted); + } + + get statement(): DefenseStatement { + return this._statement; + } + + get videoUrl(): VideoUrl | undefined { + return this._videoUrl; + } + + get submittedAt(): SubmittedAt { + return this._submittedAt; + } + + equals(other: ProtestDefense): boolean { + const videoEqual = this._videoUrl === undefined && other._videoUrl === undefined || + (this._videoUrl !== undefined && other._videoUrl !== undefined && this._videoUrl.equals(other._videoUrl)); + return ( + this._statement.equals(other._statement) && + videoEqual && + this._submittedAt.equals(other._submittedAt) + ); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestId.test.ts b/core/racing/domain/entities/ProtestId.test.ts new file mode 100644 index 000000000..52261f7bb --- /dev/null +++ b/core/racing/domain/entities/ProtestId.test.ts @@ -0,0 +1,38 @@ +import { ProtestId } from './ProtestId'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('ProtestId', () => { + describe('create', () => { + it('should create a ProtestId with valid value', () => { + const id = ProtestId.create('protest-123'); + expect(id.toString()).toBe('protest-123'); + }); + + it('should trim whitespace', () => { + const id = ProtestId.create(' protest-123 '); + expect(id.toString()).toBe('protest-123'); + }); + + it('should throw error for empty string', () => { + expect(() => ProtestId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => ProtestId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = ProtestId.create('protest-123'); + const id2 = ProtestId.create('protest-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = ProtestId.create('protest-123'); + const id2 = ProtestId.create('protest-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestId.ts b/core/racing/domain/entities/ProtestId.ts new file mode 100644 index 000000000..e366f857f --- /dev/null +++ b/core/racing/domain/entities/ProtestId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class ProtestId { + private constructor(private readonly value: string) {} + + static create(value: string): ProtestId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Protest ID cannot be empty'); + } + return new ProtestId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: ProtestId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestIncident.ts b/core/racing/domain/entities/ProtestIncident.ts new file mode 100644 index 000000000..c56752f0f --- /dev/null +++ b/core/racing/domain/entities/ProtestIncident.ts @@ -0,0 +1,40 @@ +import { LapNumber } from './LapNumber'; +import { TimeInRace } from './TimeInRace'; +import { IncidentDescription } from './IncidentDescription'; + +export class ProtestIncident { + private constructor( + private readonly _lap: LapNumber, + private readonly _timeInRace: TimeInRace | undefined, + private readonly _description: IncidentDescription + ) {} + + static create(lap: number, description: string, timeInRace?: number): ProtestIncident { + const lapNumber = LapNumber.create(lap); + const time = timeInRace !== undefined ? TimeInRace.create(timeInRace) : undefined; + const desc = IncidentDescription.create(description); + return new ProtestIncident(lapNumber, time, desc); + } + + get lap(): LapNumber { + return this._lap; + } + + get timeInRace(): TimeInRace | undefined { + return this._timeInRace; + } + + get description(): IncidentDescription { + return this._description; + } + + equals(other: ProtestIncident): boolean { + const timeEqual = this._timeInRace === undefined && other._timeInRace === undefined || + (this._timeInRace !== undefined && other._timeInRace !== undefined && this._timeInRace.equals(other._timeInRace)); + return ( + this._lap.equals(other._lap) && + timeEqual && + this._description.equals(other._description) + ); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ProtestStatus.ts b/core/racing/domain/entities/ProtestStatus.ts new file mode 100644 index 000000000..1dd522610 --- /dev/null +++ b/core/racing/domain/entities/ProtestStatus.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export type ProtestStatusValue = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn'; + +export class ProtestStatus { + private constructor(private readonly value: ProtestStatusValue) {} + + static create(value: string): ProtestStatus { + const validStatuses: ProtestStatusValue[] = ['pending', 'awaiting_defense', 'under_review', 'upheld', 'dismissed', 'withdrawn']; + + if (!validStatuses.includes(value as ProtestStatusValue)) { + throw new RacingDomainValidationError(`Invalid protest status: ${value}`); + } + + return new ProtestStatus(value as ProtestStatusValue); + } + + toString(): ProtestStatusValue { + return this.value; + } + + equals(other: ProtestStatus): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Race.test.ts b/core/racing/domain/entities/Race.test.ts new file mode 100644 index 000000000..5e0eaf020 --- /dev/null +++ b/core/racing/domain/entities/Race.test.ts @@ -0,0 +1,340 @@ +import { Race } from './Race'; +import { SessionType } from '../value-objects/SessionType'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('Race', () => { + describe('create', () => { + it('should create a race with required fields', () => { + const scheduledAt = new Date('2023-01-01T10:00:00Z'); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + }); + + expect(race.id).toBe('race-1'); + expect(race.leagueId).toBe('league-1'); + expect(race.scheduledAt).toEqual(scheduledAt); + expect(race.track).toBe('Monza'); + expect(race.car).toBe('Ferrari SF21'); + expect(race.sessionType).toEqual(SessionType.main()); + expect(race.status).toBe('scheduled'); + expect(race.trackId).toBeUndefined(); + expect(race.carId).toBeUndefined(); + expect(race.strengthOfField).toBeUndefined(); + expect(race.registeredCount).toBeUndefined(); + expect(race.maxParticipants).toBeUndefined(); + }); + + it('should create a race with all fields', () => { + const scheduledAt = new Date('2023-01-01T10:00:00Z'); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + trackId: 'track-1', + car: 'Ferrari SF21', + carId: 'car-1', + sessionType: SessionType.qualifying(), + status: 'running', + strengthOfField: 1500, + registeredCount: 20, + maxParticipants: 24, + }); + + expect(race.id).toBe('race-1'); + expect(race.leagueId).toBe('league-1'); + expect(race.scheduledAt).toEqual(scheduledAt); + expect(race.track).toBe('Monza'); + expect(race.trackId).toBe('track-1'); + expect(race.car).toBe('Ferrari SF21'); + expect(race.carId).toBe('car-1'); + expect(race.sessionType).toEqual(SessionType.qualifying()); + expect(race.status).toBe('running'); + expect(race.strengthOfField).toBe(1500); + expect(race.registeredCount).toBe(20); + expect(race.maxParticipants).toBe(24); + }); + + it('should throw error for invalid id', () => { + const scheduledAt = new Date(); + expect(() => Race.create({ + id: '', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid leagueId', () => { + const scheduledAt = new Date(); + expect(() => Race.create({ + id: 'race-1', + leagueId: '', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid scheduledAt', () => { + expect(() => Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt: 'invalid' as unknown as Date, + track: 'Monza', + car: 'Ferrari SF21', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid track', () => { + const scheduledAt = new Date(); + expect(() => Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: '', + car: 'Ferrari SF21', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid car', () => { + const scheduledAt = new Date(); + expect(() => Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: '', + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('start', () => { + it('should start a scheduled race', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'scheduled', + }); + const started = race.start(); + expect(started.status).toBe('running'); + }); + + it('should throw error if not scheduled', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'running', + }); + expect(() => race.start()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('complete', () => { + it('should complete a running race', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'running', + }); + const completed = race.complete(); + expect(completed.status).toBe('completed'); + }); + + it('should throw error if already completed', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'completed', + }); + expect(() => race.complete()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if cancelled', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'cancelled', + }); + expect(() => race.complete()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('cancel', () => { + it('should cancel a scheduled race', () => { + const scheduledAt = new Date(Date.now() + 3600000); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'scheduled', + }); + const cancelled = race.cancel(); + expect(cancelled.status).toBe('cancelled'); + }); + + it('should throw error if completed', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'completed', + }); + expect(() => race.cancel()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if already cancelled', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'cancelled', + }); + expect(() => race.cancel()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('updateField', () => { + it('should update strengthOfField and registeredCount', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + }); + const updated = race.updateField(1600, 22); + expect(updated.strengthOfField).toBe(1600); + expect(updated.registeredCount).toBe(22); + }); + }); + + describe('isPast', () => { + it('should return true for past race', () => { + const scheduledAt = new Date(Date.now() - 3600000); // past + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + }); + expect(race.isPast()).toBe(true); + }); + + it('should return false for future race', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + }); + expect(race.isPast()).toBe(false); + }); + }); + + describe('isUpcoming', () => { + it('should return true for scheduled future race', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'scheduled', + }); + expect(race.isUpcoming()).toBe(true); + }); + + it('should return false for past scheduled race', () => { + const scheduledAt = new Date(Date.now() - 3600000); // past + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'scheduled', + }); + expect(race.isUpcoming()).toBe(false); + }); + + it('should return false for running race', () => { + const scheduledAt = new Date(Date.now() + 3600000); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'running', + }); + expect(race.isUpcoming()).toBe(false); + }); + }); + + describe('isLive', () => { + it('should return true for running race', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'running', + }); + expect(race.isLive()).toBe(true); + }); + + it('should return false for scheduled race', () => { + const scheduledAt = new Date(); + const race = Race.create({ + id: 'race-1', + leagueId: 'league-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + status: 'scheduled', + }); + expect(race.isLive()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/RaceEvent.test.ts b/core/racing/domain/entities/RaceEvent.test.ts new file mode 100644 index 000000000..068203817 --- /dev/null +++ b/core/racing/domain/entities/RaceEvent.test.ts @@ -0,0 +1,578 @@ +import { RaceEvent } from './RaceEvent'; +import { Session } from './Session'; +import { SessionType } from '../value-objects/SessionType'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('RaceEvent', () => { + const createMockSession = (overrides: Partial<{ + id: string; + raceEventId: string; + sessionType: SessionType; + status: 'scheduled' | 'running' | 'completed' | 'cancelled'; + scheduledAt: Date; + }> = {}): Session => { + return Session.create({ + id: overrides.id ?? 'session-1', + raceEventId: overrides.raceEventId ?? 'race-event-1', + scheduledAt: overrides.scheduledAt ?? new Date('2023-01-01T10:00:00Z'), + track: 'Monza', + car: 'Ferrari SF21', + sessionType: overrides.sessionType ?? SessionType.main(), + status: overrides.status ?? 'scheduled', + }); + }; + + describe('create', () => { + it('should create a race event with required fields', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + + expect(raceEvent.id).toBe('race-event-1'); + expect(raceEvent.seasonId).toBe('season-1'); + expect(raceEvent.leagueId).toBe('league-1'); + expect(raceEvent.name).toBe('Monza Grand Prix'); + expect(raceEvent.sessions).toHaveLength(1); + expect(raceEvent.status).toBe('scheduled'); + expect(raceEvent.stewardingClosesAt).toBeUndefined(); + }); + + it('should create a race event with all fields', () => { + const sessions = [ + createMockSession({ id: 'session-1', sessionType: SessionType.practice() }), + createMockSession({ id: 'session-2', sessionType: SessionType.qualifying() }), + createMockSession({ id: 'session-3', sessionType: SessionType.main() }), + ]; + const stewardingClosesAt = new Date('2023-01-02T10:00:00Z'); + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + stewardingClosesAt, + }); + + expect(raceEvent.id).toBe('race-event-1'); + expect(raceEvent.seasonId).toBe('season-1'); + expect(raceEvent.leagueId).toBe('league-1'); + expect(raceEvent.name).toBe('Monza Grand Prix'); + expect(raceEvent.sessions).toHaveLength(3); + expect(raceEvent.status).toBe('in_progress'); + expect(raceEvent.stewardingClosesAt).toEqual(stewardingClosesAt); + }); + + it('should throw error for invalid id', () => { + const sessions = [createMockSession()]; + expect(() => RaceEvent.create({ + id: '', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid seasonId', () => { + const sessions = [createMockSession()]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: '', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid leagueId', () => { + const sessions = [createMockSession()]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: '', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid name', () => { + const sessions = [createMockSession()]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: '', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for no sessions', () => { + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions: [], + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for sessions not belonging to race event', () => { + const sessions = [createMockSession({ raceEventId: 'other-race-event' })]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for duplicate session types', () => { + const sessions = [ + createMockSession({ id: 'session-1', sessionType: SessionType.main() }), + createMockSession({ id: 'session-2', sessionType: SessionType.main() }), + ]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for no main race session', () => { + const sessions = [createMockSession({ sessionType: SessionType.practice() })]; + expect(() => RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('start', () => { + it('should start a scheduled race event', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + const started = raceEvent.start(); + expect(started.status).toBe('in_progress'); + }); + + it('should throw error if not scheduled', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + }); + expect(() => raceEvent.start()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('completeMainRace', () => { + it('should complete main race and move to awaiting_stewarding', () => { + const mainSession = createMockSession({ sessionType: SessionType.main(), status: 'completed' }); + const sessions = [mainSession]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + }); + const completed = raceEvent.completeMainRace(); + expect(completed.status).toBe('awaiting_stewarding'); + }); + + it('should throw error if not in progress', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + expect(() => raceEvent.completeMainRace()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if main race not completed', () => { + const sessions = [createMockSession({ sessionType: SessionType.main(), status: 'running' })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + }); + expect(() => raceEvent.completeMainRace()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('closeStewarding', () => { + it('should close stewarding and finalize race event', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'awaiting_stewarding', + }); + const closed = raceEvent.closeStewarding(); + expect(closed.status).toBe('closed'); + }); + + it('should throw error if not awaiting stewarding', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + }); + expect(() => raceEvent.closeStewarding()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('cancel', () => { + it('should cancel a scheduled race event', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + const cancelled = raceEvent.cancel(); + expect(cancelled.status).toBe('cancelled'); + }); + + it('should return same instance if already cancelled', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'cancelled', + }); + const cancelled = raceEvent.cancel(); + expect(cancelled).toBe(raceEvent); + }); + + it('should throw error if closed', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'closed', + }); + expect(() => raceEvent.cancel()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('getMainRaceSession', () => { + it('should return the main race session', () => { + const mainSession = createMockSession({ sessionType: SessionType.main() }); + const practiceSession = createMockSession({ id: 'session-2', sessionType: SessionType.practice() }); + const sessions = [practiceSession, mainSession]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.getMainRaceSession()).toBe(mainSession); + }); + }); + + describe('getSessionsByType', () => { + it('should return sessions of specific type', () => { + const practice = createMockSession({ id: 'p1', sessionType: SessionType.practice() }); + const qualifying = createMockSession({ id: 'q1', sessionType: SessionType.qualifying() }); + const main = createMockSession({ id: 'm1', sessionType: SessionType.main() }); + const sessions = [practice, qualifying, main]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + const practices = raceEvent.getSessionsByType(SessionType.practice()); + expect(practices).toHaveLength(1); + expect(practices).toContain(practice); + }); + }); + + describe('getCompletedSessions', () => { + it('should return only completed sessions', () => { + const completed1 = createMockSession({ id: 'c1', sessionType: SessionType.practice(), status: 'completed' }); + const completed2 = createMockSession({ id: 'c2', sessionType: SessionType.qualifying(), status: 'completed' }); + const running = createMockSession({ id: 'r1', sessionType: SessionType.main(), status: 'running' }); + const sessions = [completed1, running, completed2]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + const completed = raceEvent.getCompletedSessions(); + expect(completed).toHaveLength(2); + expect(completed).toContain(completed1); + expect(completed).toContain(completed2); + }); + }); + + describe('areAllSessionsCompleted', () => { + it('should return true if all sessions completed', () => { + const sessions = [ + createMockSession({ id: 's1', sessionType: SessionType.practice(), status: 'completed' }), + createMockSession({ id: 's2', sessionType: SessionType.main(), status: 'completed' }), + ]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.areAllSessionsCompleted()).toBe(true); + }); + + it('should return false if not all sessions completed', () => { + const sessions = [ + createMockSession({ id: 's1', sessionType: SessionType.practice(), status: 'completed' }), + createMockSession({ id: 's2', sessionType: SessionType.main(), status: 'running' }), + ]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.areAllSessionsCompleted()).toBe(false); + }); + }); + + describe('isMainRaceCompleted', () => { + it('should return true if main race is completed', () => { + const mainSession = createMockSession({ sessionType: SessionType.main(), status: 'completed' }); + const sessions = [mainSession]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.isMainRaceCompleted()).toBe(true); + }); + + it('should return false if main race not completed', () => { + const mainSession = createMockSession({ sessionType: SessionType.main(), status: 'running' }); + const sessions = [mainSession]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.isMainRaceCompleted()).toBe(false); + }); + }); + + describe('hasStewardingExpired', () => { + it('should return true if stewarding has expired', () => { + const pastDate = new Date(Date.now() - 3600000); + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + stewardingClosesAt: pastDate, + }); + expect(raceEvent.hasStewardingExpired()).toBe(true); + }); + + it('should return false if stewarding not set', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.hasStewardingExpired()).toBe(false); + }); + + it('should return false if stewarding not expired', () => { + const futureDate = new Date(Date.now() + 3600000); + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + stewardingClosesAt: futureDate, + }); + expect(raceEvent.hasStewardingExpired()).toBe(false); + }); + }); + + describe('isPast', () => { + it('should return true if latest session is in past', () => { + const pastDate = new Date(Date.now() - 3600000); + const sessions = [createMockSession({ scheduledAt: pastDate, sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.isPast()).toBe(true); + }); + + it('should return false if latest session is future', () => { + const futureDate = new Date(Date.now() + 3600000); + const sessions = [createMockSession({ scheduledAt: futureDate, sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + }); + expect(raceEvent.isPast()).toBe(false); + }); + }); + + describe('isUpcoming', () => { + it('should return true for scheduled future event', () => { + const futureDate = new Date(Date.now() + 3600000); + const sessions = [createMockSession({ scheduledAt: futureDate, sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + expect(raceEvent.isUpcoming()).toBe(true); + }); + + it('should return false for past scheduled event', () => { + const pastDate = new Date(Date.now() - 3600000); + const sessions = [createMockSession({ scheduledAt: pastDate, sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + expect(raceEvent.isUpcoming()).toBe(false); + }); + }); + + describe('isLive', () => { + it('should return true for in_progress status', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'in_progress', + }); + expect(raceEvent.isLive()).toBe(true); + }); + + it('should return false for other statuses', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'scheduled', + }); + expect(raceEvent.isLive()).toBe(false); + }); + }); + + describe('isAwaitingStewarding', () => { + it('should return true for awaiting_stewarding status', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'awaiting_stewarding', + }); + expect(raceEvent.isAwaitingStewarding()).toBe(true); + }); + }); + + describe('isClosed', () => { + it('should return true for closed status', () => { + const sessions = [createMockSession({ sessionType: SessionType.main() })]; + const raceEvent = RaceEvent.create({ + id: 'race-event-1', + seasonId: 'season-1', + leagueId: 'league-1', + name: 'Monza Grand Prix', + sessions, + status: 'closed', + }); + expect(raceEvent.isClosed()).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/RaceEvent.ts b/core/racing/domain/entities/RaceEvent.ts index 0888918ef..472fbc202 100644 --- a/core/racing/domain/entities/RaceEvent.ts +++ b/core/racing/domain/entities/RaceEvent.ts @@ -8,7 +8,7 @@ import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; import type { Session } from './Session'; -import type { SessionType } from '../value-objects/SessionType'; +import { SessionType } from '../value-objects/SessionType'; export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled'; @@ -127,9 +127,9 @@ export class RaceEvent implements IEntity { seasonId: this.seasonId, leagueId: this.leagueId, name: this.name, - sessions: this.sessions, + sessions: [...this.sessions], status: 'in_progress', - stewardingClosesAt: this.stewardingClosesAt, + ...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}), }); } @@ -151,9 +151,9 @@ export class RaceEvent implements IEntity { seasonId: this.seasonId, leagueId: this.leagueId, name: this.name, - sessions: this.sessions, + sessions: [...this.sessions], status: 'awaiting_stewarding', - stewardingClosesAt: this.stewardingClosesAt, + ...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}), }); } @@ -170,9 +170,9 @@ export class RaceEvent implements IEntity { seasonId: this.seasonId, leagueId: this.leagueId, name: this.name, - sessions: this.sessions, + sessions: [...this.sessions], status: 'closed', - stewardingClosesAt: this.stewardingClosesAt, + ...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}), }); } @@ -193,9 +193,9 @@ export class RaceEvent implements IEntity { seasonId: this.seasonId, leagueId: this.leagueId, name: this.name, - sessions: this.sessions, + sessions: [...this.sessions], status: 'cancelled', - stewardingClosesAt: this.stewardingClosesAt, + ...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}), }); } @@ -232,7 +232,7 @@ export class RaceEvent implements IEntity { */ isMainRaceCompleted(): boolean { const mainRace = this.getMainRaceSession(); - return mainRace?.status === 'completed' ?? false; + return mainRace ? mainRace.status === 'completed' : false; } /** diff --git a/core/racing/domain/entities/RaceId.test.ts b/core/racing/domain/entities/RaceId.test.ts new file mode 100644 index 000000000..db69ac168 --- /dev/null +++ b/core/racing/domain/entities/RaceId.test.ts @@ -0,0 +1,38 @@ +import { RaceId } from './RaceId'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('RaceId', () => { + describe('create', () => { + it('should create a RaceId with valid value', () => { + const id = RaceId.create('race-123'); + expect(id.toString()).toBe('race-123'); + }); + + it('should trim whitespace', () => { + const id = RaceId.create(' race-123 '); + expect(id.toString()).toBe('race-123'); + }); + + it('should throw error for empty string', () => { + expect(() => RaceId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => RaceId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = RaceId.create('race-123'); + const id2 = RaceId.create('race-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = RaceId.create('race-123'); + const id2 = RaceId.create('race-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/RaceId.ts b/core/racing/domain/entities/RaceId.ts new file mode 100644 index 000000000..fce7c8da9 --- /dev/null +++ b/core/racing/domain/entities/RaceId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class RaceId { + private constructor(private readonly value: string) {} + + static create(value: string): RaceId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Race ID cannot be empty'); + } + return new RaceId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: RaceId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/RaceRegistration.test.ts b/core/racing/domain/entities/RaceRegistration.test.ts new file mode 100644 index 000000000..0ef5554e9 --- /dev/null +++ b/core/racing/domain/entities/RaceRegistration.test.ts @@ -0,0 +1,120 @@ +import { RaceRegistration } from './RaceRegistration'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { RaceId } from './RaceId'; +import { DriverId } from './DriverId'; +import { RegisteredAt } from './RegisteredAt'; + +describe('RaceRegistration', () => { + const validRaceId = 'race-123'; + const validDriverId = 'driver-456'; + const validDate = new Date('2023-01-01T00:00:00Z'); + + describe('create', () => { + it('should create a RaceRegistration with valid props', () => { + const props = { + raceId: validRaceId, + driverId: validDriverId, + registeredAt: validDate, + }; + + const registration = RaceRegistration.create(props); + + expect(registration.id).toBe(`${validRaceId}:${validDriverId}`); + expect(registration.raceId.toString()).toBe(validRaceId); + expect(registration.driverId.toString()).toBe(validDriverId); + expect(registration.registeredAt.toDate()).toEqual(validDate); + }); + + it('should create with default registeredAt if not provided', () => { + const props = { + raceId: validRaceId, + driverId: validDriverId, + }; + + const registration = RaceRegistration.create(props); + + expect(registration.registeredAt).toBeDefined(); + expect(registration.registeredAt.toDate()).toBeInstanceOf(Date); + }); + + it('should use provided id if given', () => { + const customId = 'custom-id'; + const props = { + id: customId, + raceId: validRaceId, + driverId: validDriverId, + }; + + const registration = RaceRegistration.create(props); + + expect(registration.id).toBe(customId); + }); + + it('should throw error for empty raceId', () => { + const props = { + raceId: '', + driverId: validDriverId, + }; + + expect(() => RaceRegistration.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty driverId', () => { + const props = { + raceId: validRaceId, + driverId: '', + }; + + expect(() => RaceRegistration.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid raceId', () => { + const props = { + raceId: ' ', + driverId: validDriverId, + }; + + expect(() => RaceRegistration.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid driverId', () => { + const props = { + raceId: validRaceId, + driverId: ' ', + }; + + expect(() => RaceRegistration.create(props)).toThrow(RacingDomainValidationError); + }); + }); + + describe('entity properties', () => { + let registration: RaceRegistration; + + beforeEach(() => { + registration = RaceRegistration.create({ + raceId: validRaceId, + driverId: validDriverId, + registeredAt: validDate, + }); + }); + + it('should have readonly id', () => { + expect(registration.id).toBe(`${validRaceId}:${validDriverId}`); + }); + + it('should have readonly raceId as RaceId', () => { + expect(registration.raceId).toBeInstanceOf(RaceId); + expect(registration.raceId.toString()).toBe(validRaceId); + }); + + it('should have readonly driverId as DriverId', () => { + expect(registration.driverId).toBeInstanceOf(DriverId); + expect(registration.driverId.toString()).toBe(validDriverId); + }); + + it('should have readonly registeredAt as RegisteredAt', () => { + expect(registration.registeredAt).toBeInstanceOf(RegisteredAt); + expect(registration.registeredAt.toDate()).toEqual(validDate); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/RaceRegistration.ts b/core/racing/domain/entities/RaceRegistration.ts index 46f54daa9..962603abc 100644 --- a/core/racing/domain/entities/RaceRegistration.ts +++ b/core/racing/domain/entities/RaceRegistration.ts @@ -6,52 +6,62 @@ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { RaceId } from './RaceId'; +import { DriverId } from './DriverId'; +import { RegisteredAt } from './RegisteredAt'; export interface RaceRegistrationProps { - id?: string; - raceId: string; - driverId: string; - registeredAt?: Date; + id?: string; + raceId: string; + driverId: string; + registeredAt?: Date; } export class RaceRegistration implements IEntity { - readonly id: string; - readonly raceId: string; - readonly driverId: string; - readonly registeredAt: Date; + readonly id: string; + readonly raceId: RaceId; + readonly driverId: DriverId; + readonly registeredAt: RegisteredAt; - private constructor(props: Required) { - this.id = props.id; - this.raceId = props.raceId; - this.driverId = props.driverId; - this.registeredAt = props.registeredAt; - } + private constructor(props: { + id: string; + raceId: RaceId; + driverId: DriverId; + registeredAt: RegisteredAt; + }) { + this.id = props.id; + this.raceId = props.raceId; + this.driverId = props.driverId; + this.registeredAt = props.registeredAt; + } - static create(props: RaceRegistrationProps): RaceRegistration { - this.validate(props); + static create(props: RaceRegistrationProps): RaceRegistration { + RaceRegistration.validate(props); - const id = - props.id && props.id.trim().length > 0 - ? props.id - : `${props.raceId}:${props.driverId}`; + const raceId = RaceId.create(props.raceId); + const driverId = DriverId.create(props.driverId); + const registeredAt = RegisteredAt.create(props.registeredAt ?? new Date()); - const registeredAt = props.registeredAt ?? new Date(); + const id = + props.id && props.id.trim().length > 0 + ? props.id + : `${raceId.toString()}:${driverId.toString()}`; - return new RaceRegistration({ - id, - raceId: props.raceId, - driverId: props.driverId, - registeredAt, - }); - } + return new RaceRegistration({ + id, + raceId, + driverId, + registeredAt, + }); + } - private static validate(props: RaceRegistrationProps): void { - if (!props.raceId || props.raceId.trim().length === 0) { - throw new RacingDomainValidationError('Race ID is required'); - } + private static validate(props: RaceRegistrationProps): void { + if (!props.raceId || props.raceId.trim().length === 0) { + throw new RacingDomainValidationError('Race ID is required'); + } - if (!props.driverId || props.driverId.trim().length === 0) { - throw new RacingDomainValidationError('Driver ID is required'); - } - } + if (!props.driverId || props.driverId.trim().length === 0) { + throw new RacingDomainValidationError('Driver ID is required'); + } + } } \ No newline at end of file diff --git a/core/racing/domain/entities/RegisteredAt.test.ts b/core/racing/domain/entities/RegisteredAt.test.ts new file mode 100644 index 000000000..b9ff72291 --- /dev/null +++ b/core/racing/domain/entities/RegisteredAt.test.ts @@ -0,0 +1,44 @@ +import { RegisteredAt } from './RegisteredAt'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('RegisteredAt', () => { + describe('create', () => { + it('should create a RegisteredAt with a valid date', () => { + const date = new Date('2023-01-01T00:00:00Z'); + const registeredAt = RegisteredAt.create(date); + expect(registeredAt.toDate()).toEqual(date); + }); + + it('should throw an error for invalid date', () => { + expect(() => RegisteredAt.create(new Date('invalid'))).toThrow(RacingDomainValidationError); + }); + }); + + describe('toDate', () => { + it('should return a copy of the date', () => { + const date = new Date(); + const registeredAt = RegisteredAt.create(date); + const result = registeredAt.toDate(); + expect(result).toEqual(date); + expect(result).not.toBe(date); // should be a copy + }); + }); + + describe('equals', () => { + it('should return true for equal dates', () => { + const date1 = new Date('2023-01-01T00:00:00Z'); + const date2 = new Date('2023-01-01T00:00:00Z'); + const registeredAt1 = RegisteredAt.create(date1); + const registeredAt2 = RegisteredAt.create(date2); + expect(registeredAt1.equals(registeredAt2)).toBe(true); + }); + + it('should return false for different dates', () => { + const date1 = new Date('2023-01-01T00:00:00Z'); + const date2 = new Date('2023-01-02T00:00:00Z'); + const registeredAt1 = RegisteredAt.create(date1); + const registeredAt2 = RegisteredAt.create(date2); + expect(registeredAt1.equals(registeredAt2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/RegisteredAt.ts b/core/racing/domain/entities/RegisteredAt.ts new file mode 100644 index 000000000..36f69ba22 --- /dev/null +++ b/core/racing/domain/entities/RegisteredAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class RegisteredAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): RegisteredAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('RegisteredAt must be a valid Date'); + } + return new RegisteredAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: RegisteredAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ResultWithIncidents.ts b/core/racing/domain/entities/ResultWithIncidents.ts index adbdfa4a0..bc9c6fb76 100644 --- a/core/racing/domain/entities/ResultWithIncidents.ts +++ b/core/racing/domain/entities/ResultWithIncidents.ts @@ -5,24 +5,28 @@ import { RacingDomainValidationError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents'; +import { RaceId } from './RaceId'; +import { DriverId } from './DriverId'; +import { Position } from './result/Position'; +import { LapTime } from './result/LapTime'; export class ResultWithIncidents implements IEntity { readonly id: string; - readonly raceId: string; - readonly driverId: string; - readonly position: number; - readonly fastestLap: number; + readonly raceId: RaceId; + readonly driverId: DriverId; + readonly position: Position; + readonly fastestLap: LapTime; readonly incidents: RaceIncidents; - readonly startPosition: number; + readonly startPosition: Position; private constructor(props: { id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; + raceId: RaceId; + driverId: DriverId; + position: Position; + fastestLap: LapTime; incidents: RaceIncidents; - startPosition: number; + startPosition: Position; }) { this.id = props.id; this.raceId = props.raceId; @@ -45,8 +49,21 @@ export class ResultWithIncidents implements IEntity { incidents: RaceIncidents; startPosition: number; }): ResultWithIncidents { - ResultWithIncidents.validate(props); - return new ResultWithIncidents(props); + this.validate(props); + const raceId = RaceId.create(props.raceId); + const driverId = DriverId.create(props.driverId); + const position = Position.create(props.position); + const fastestLap = LapTime.create(props.fastestLap); + const startPosition = Position.create(props.startPosition); + return new ResultWithIncidents({ + id: props.id, + raceId, + driverId, + position, + fastestLap, + incidents: props.incidents, + startPosition, + }); } /** @@ -109,14 +126,14 @@ export class ResultWithIncidents implements IEntity { * Calculate positions gained/lost */ getPositionChange(): number { - return this.startPosition - this.position; + return this.startPosition.toNumber() - this.position.toNumber(); } /** * Check if driver finished on podium */ isPodium(): boolean { - return this.position <= 3; + return this.position.toNumber() <= 3; } /** diff --git a/core/racing/domain/entities/ReviewedAt.ts b/core/racing/domain/entities/ReviewedAt.ts new file mode 100644 index 000000000..b9e41072a --- /dev/null +++ b/core/racing/domain/entities/ReviewedAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class ReviewedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): ReviewedAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('ReviewedAt must be a valid Date'); + } + return new ReviewedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: ReviewedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/ScoringPresetId.ts b/core/racing/domain/entities/ScoringPresetId.ts new file mode 100644 index 000000000..54fb0addb --- /dev/null +++ b/core/racing/domain/entities/ScoringPresetId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class ScoringPresetId { + private constructor(private readonly value: string) {} + + static create(value: string): ScoringPresetId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Scoring Preset ID cannot be empty'); + } + return new ScoringPresetId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: ScoringPresetId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Session.test.ts b/core/racing/domain/entities/Session.test.ts new file mode 100644 index 000000000..ec7758534 --- /dev/null +++ b/core/racing/domain/entities/Session.test.ts @@ -0,0 +1,426 @@ +import { Session } from './Session'; +import { SessionType } from '../value-objects/SessionType'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('Session', () => { + describe('create', () => { + it('should create a session with required fields', () => { + const scheduledAt = new Date('2023-01-01T10:00:00Z'); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + }); + + expect(session.id).toBe('session-1'); + expect(session.raceEventId).toBe('race-event-1'); + expect(session.scheduledAt).toEqual(scheduledAt); + expect(session.track).toBe('Monza'); + expect(session.car).toBe('Ferrari SF21'); + expect(session.sessionType).toEqual(SessionType.main()); + expect(session.status).toBe('scheduled'); + expect(session.trackId).toBeUndefined(); + expect(session.carId).toBeUndefined(); + expect(session.strengthOfField).toBeUndefined(); + expect(session.registeredCount).toBeUndefined(); + expect(session.maxParticipants).toBeUndefined(); + }); + + it('should create a session with all fields', () => { + const scheduledAt = new Date('2023-01-01T10:00:00Z'); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + trackId: 'track-1', + car: 'Ferrari SF21', + carId: 'car-1', + sessionType: SessionType.qualifying(), + status: 'running', + strengthOfField: 1500, + registeredCount: 20, + maxParticipants: 24, + }); + + expect(session.id).toBe('session-1'); + expect(session.raceEventId).toBe('race-event-1'); + expect(session.scheduledAt).toEqual(scheduledAt); + expect(session.track).toBe('Monza'); + expect(session.trackId).toBe('track-1'); + expect(session.car).toBe('Ferrari SF21'); + expect(session.carId).toBe('car-1'); + expect(session.sessionType).toEqual(SessionType.qualifying()); + expect(session.status).toBe('running'); + expect(session.strengthOfField).toBe(1500); + expect(session.registeredCount).toBe(20); + expect(session.maxParticipants).toBe(24); + }); + + it('should throw error for invalid id', () => { + const scheduledAt = new Date(); + expect(() => Session.create({ + id: '', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid raceEventId', () => { + const scheduledAt = new Date(); + expect(() => Session.create({ + id: 'session-1', + raceEventId: '', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid scheduledAt', () => { + expect(() => Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt: 'invalid' as unknown as Date, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid track', () => { + const scheduledAt = new Date(); + expect(() => Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: '', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid car', () => { + const scheduledAt = new Date(); + expect(() => Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: '', + sessionType: SessionType.main(), + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid sessionType', () => { + const scheduledAt = new Date(); + expect(() => Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: undefined as unknown as SessionType, + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('start', () => { + it('should start a scheduled session', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'scheduled', + }); + const started = session.start(); + expect(started.status).toBe('running'); + }); + + it('should throw error if not scheduled', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'running', + }); + expect(() => session.start()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('complete', () => { + it('should complete a running session', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'running', + }); + const completed = session.complete(); + expect(completed.status).toBe('completed'); + }); + + it('should throw error if already completed', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'completed', + }); + expect(() => session.complete()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if cancelled', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'cancelled', + }); + expect(() => session.complete()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('cancel', () => { + it('should cancel a scheduled session', () => { + const scheduledAt = new Date(Date.now() + 3600000); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'scheduled', + }); + const cancelled = session.cancel(); + expect(cancelled.status).toBe('cancelled'); + }); + + it('should throw error if completed', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'completed', + }); + expect(() => session.cancel()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if already cancelled', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'cancelled', + }); + expect(() => session.cancel()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('updateField', () => { + it('should update strengthOfField and registeredCount', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + }); + const updated = session.updateField(1600, 22); + expect(updated.strengthOfField).toBe(1600); + expect(updated.registeredCount).toBe(22); + }); + }); + + describe('isPast', () => { + it('should return true for past session', () => { + const scheduledAt = new Date(Date.now() - 3600000); // past + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + }); + expect(session.isPast()).toBe(true); + }); + + it('should return false for future session', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + }); + expect(session.isPast()).toBe(false); + }); + }); + + describe('isUpcoming', () => { + it('should return true for scheduled future session', () => { + const scheduledAt = new Date(Date.now() + 3600000); // future + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'scheduled', + }); + expect(session.isUpcoming()).toBe(true); + }); + + it('should return false for past scheduled session', () => { + const scheduledAt = new Date(Date.now() - 3600000); // past + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'scheduled', + }); + expect(session.isUpcoming()).toBe(false); + }); + + it('should return false for running session', () => { + const scheduledAt = new Date(Date.now() + 3600000); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'running', + }); + expect(session.isUpcoming()).toBe(false); + }); + }); + + describe('isLive', () => { + it('should return true for running session', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'running', + }); + expect(session.isLive()).toBe(true); + }); + + it('should return false for scheduled session', () => { + const scheduledAt = new Date(); + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt, + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + status: 'scheduled', + }); + expect(session.isLive()).toBe(false); + }); + }); + + describe('countsForPoints', () => { + it('should return true for main session', () => { + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt: new Date(), + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.main(), + }); + expect(session.countsForPoints()).toBe(true); + }); + + it('should return false for practice session', () => { + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt: new Date(), + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.practice(), + }); + expect(session.countsForPoints()).toBe(false); + }); + }); + + describe('determinesGrid', () => { + it('should return true for qualifying session', () => { + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt: new Date(), + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.qualifying(), + }); + expect(session.determinesGrid()).toBe(true); + }); + + it('should return false for practice session', () => { + const session = Session.create({ + id: 'session-1', + raceEventId: 'race-event-1', + scheduledAt: new Date(), + track: 'Monza', + car: 'Ferrari SF21', + sessionType: SessionType.practice(), + }); + expect(session.determinesGrid()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Sponsor.ts b/core/racing/domain/entities/Sponsor.ts deleted file mode 100644 index 85e68e8c5..000000000 --- a/core/racing/domain/entities/Sponsor.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Domain Entity: Sponsor - * - * Represents a sponsor that can sponsor leagues/seasons. - * Aggregate root for sponsor information. - */ -import { RacingDomainValidationError } from '../errors/RacingDomainError'; -import type { IEntity } from '@core/shared/domain'; - -export interface SponsorProps { - id: string; - name: string; - contactEmail: string; - logoUrl?: string; - websiteUrl?: string; - createdAt: Date; -} - -export class Sponsor implements IEntity { - readonly id: string; - readonly name: string; - readonly contactEmail: string; - readonly logoUrl: string | undefined; - readonly websiteUrl: string | undefined; - readonly createdAt: Date; - - private constructor(props: SponsorProps) { - this.id = props.id; - this.name = props.name; - this.contactEmail = props.contactEmail; - this.logoUrl = props.logoUrl; - this.websiteUrl = props.websiteUrl; - this.createdAt = props.createdAt; - } - - static create(props: Omit & { createdAt?: Date }): Sponsor { - this.validate(props); - - const { createdAt, ...rest } = props; - const base = { - id: rest.id, - name: rest.name, - contactEmail: rest.contactEmail, - createdAt: createdAt ?? new Date(), - }; - - const withLogo = - rest.logoUrl !== undefined ? { ...base, logoUrl: rest.logoUrl } : base; - const withWebsite = - rest.websiteUrl !== undefined - ? { ...withLogo, websiteUrl: rest.websiteUrl } - : withLogo; - - return new Sponsor(withWebsite); - } - - private static validate(props: Omit): void { - if (!props.id || props.id.trim().length === 0) { - throw new RacingDomainValidationError('Sponsor ID is required'); - } - - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('Sponsor name is required'); - } - - if (props.name.length > 100) { - throw new RacingDomainValidationError('Sponsor name must be 100 characters or less'); - } - - if (!props.contactEmail || props.contactEmail.trim().length === 0) { - throw new RacingDomainValidationError('Sponsor contact email is required'); - } - - // Basic email validation - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(props.contactEmail)) { - throw new RacingDomainValidationError('Invalid sponsor contact email format'); - } - - if (props.websiteUrl && props.websiteUrl.trim().length > 0) { - try { - new URL(props.websiteUrl); - } catch { - throw new RacingDomainValidationError('Invalid sponsor website URL'); - } - } - } - - /** - * Update sponsor information - */ - update(props: Partial<{ - name: string; - contactEmail: string; - logoUrl?: string; - websiteUrl?: string; - }>): Sponsor { - const updatedBase = { - id: this.id, - name: props.name ?? this.name, - contactEmail: props.contactEmail ?? this.contactEmail, - createdAt: this.createdAt, - }; - - const withLogo = - props.logoUrl !== undefined - ? { ...updatedBase, logoUrl: props.logoUrl } - : this.logoUrl !== undefined - ? { ...updatedBase, logoUrl: this.logoUrl } - : updatedBase; - - const updated = - props.websiteUrl !== undefined - ? { ...withLogo, websiteUrl: props.websiteUrl } - : this.websiteUrl !== undefined - ? { ...withLogo, websiteUrl: this.websiteUrl } - : withLogo; - - Sponsor.validate(updated); - return new Sponsor(updated); - } -} \ No newline at end of file diff --git a/core/racing/domain/entities/SponsorshipRequest.test.ts b/core/racing/domain/entities/SponsorshipRequest.test.ts new file mode 100644 index 000000000..63167391b --- /dev/null +++ b/core/racing/domain/entities/SponsorshipRequest.test.ts @@ -0,0 +1,161 @@ +import { SponsorshipRequest, SponsorableEntityType, SponsorshipRequestProps } from './SponsorshipRequest'; +import { SponsorshipTier } from './season/SeasonSponsorship'; +import { Money } from '../value-objects/Money'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('SponsorshipRequest', () => { + const validMoney = Money.create(1000, 'USD'); + const validProps = { + id: 'request-123', + sponsorId: 'sponsor-456', + entityType: 'driver', + entityId: 'driver-789', + tier: 'main', + offeredAmount: validMoney, + }; + + describe('create', () => { + it('should create a SponsorshipRequest with valid props', () => { + const request = SponsorshipRequest.create(validProps); + expect(request.id).toBe('request-123'); + expect(request.sponsorId).toBe('sponsor-456'); + expect(request.entityType).toBe('driver'); + expect(request.entityId).toBe('driver-789'); + expect(request.tier).toBe('main'); + expect(request.offeredAmount).toEqual(validMoney); + expect(request.status).toBe('pending'); + expect(request.createdAt).toBeInstanceOf(Date); + }); + + it('should use provided createdAt and status', () => { + const customDate = new Date('2023-01-01'); + const request = SponsorshipRequest.create({ + ...validProps, + createdAt: customDate, + status: 'accepted', + }); + expect(request.createdAt).toBe(customDate); + expect(request.status).toBe('accepted'); + }); + + it('should throw for invalid id', () => { + expect(() => SponsorshipRequest.create({ ...validProps, id: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw for invalid sponsorId', () => { + expect(() => SponsorshipRequest.create({ ...validProps, sponsorId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw for invalid entityId', () => { + expect(() => SponsorshipRequest.create({ ...validProps, entityId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw for zero offeredAmount', () => { + const zeroMoney = Money.create(0, 'USD'); + expect(() => SponsorshipRequest.create({ ...validProps, offeredAmount: zeroMoney })).toThrow(RacingDomainValidationError); + }); + + it('should throw for negative offeredAmount', () => { + expect(() => Money.create(-100, 'USD')).toThrow(RacingDomainValidationError); + }); + }); + + describe('accept', () => { + it('should accept a pending request', () => { + const request = SponsorshipRequest.create(validProps); + const accepted = request.accept('responder-123'); + expect(accepted.status).toBe('accepted'); + expect(accepted.respondedAt).toBeInstanceOf(Date); + expect(accepted.respondedBy).toBe('responder-123'); + }); + + it('should throw for non-pending request', () => { + const acceptedRequest = SponsorshipRequest.create({ ...validProps, status: 'accepted' }); + expect(() => acceptedRequest.accept('responder-123')).toThrow(RacingDomainInvariantError); + }); + + it('should throw for empty respondedBy', () => { + const request = SponsorshipRequest.create(validProps); + expect(() => request.accept('')).toThrow(RacingDomainValidationError); + }); + }); + + describe('reject', () => { + it('should reject a pending request with reason', () => { + const request = SponsorshipRequest.create(validProps); + const rejected = request.reject('responder-123', 'Not interested'); + expect(rejected.status).toBe('rejected'); + expect(rejected.respondedAt).toBeInstanceOf(Date); + expect(rejected.respondedBy).toBe('responder-123'); + expect(rejected.rejectionReason).toBe('Not interested'); + }); + + it('should reject without reason', () => { + const request = SponsorshipRequest.create(validProps); + const rejected = request.reject('responder-123'); + expect(rejected.status).toBe('rejected'); + expect(rejected.rejectionReason).toBeUndefined(); + }); + + it('should throw for non-pending request', () => { + const rejectedRequest = SponsorshipRequest.create({ ...validProps, status: 'rejected' }); + expect(() => rejectedRequest.reject('responder-123')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('withdraw', () => { + it('should withdraw a pending request', () => { + const request = SponsorshipRequest.create(validProps); + const withdrawn = request.withdraw(); + expect(withdrawn.status).toBe('withdrawn'); + expect(withdrawn.respondedAt).toBeInstanceOf(Date); + }); + + it('should throw for non-pending request', () => { + const acceptedRequest = SponsorshipRequest.create({ ...validProps, status: 'accepted' }); + expect(() => acceptedRequest.withdraw()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('isPending', () => { + it('should return true for pending status', () => { + const request = SponsorshipRequest.create(validProps); + expect(request.isPending()).toBe(true); + }); + + it('should return false for non-pending status', () => { + const acceptedRequest = SponsorshipRequest.create({ ...validProps, status: 'accepted' }); + expect(acceptedRequest.isPending()).toBe(false); + }); + }); + + describe('isAccepted', () => { + it('should return true for accepted status', () => { + const acceptedRequest = SponsorshipRequest.create({ ...validProps, status: 'accepted' }); + expect(acceptedRequest.isAccepted()).toBe(true); + }); + + it('should return false for non-accepted status', () => { + const request = SponsorshipRequest.create(validProps); + expect(request.isAccepted()).toBe(false); + }); + }); + + describe('getPlatformFee', () => { + it('should calculate platform fee', () => { + const request = SponsorshipRequest.create(validProps); + const fee = request.getPlatformFee(); + expect(fee.amount).toBe(100); // 10% of 1000 + expect(fee.currency).toBe('USD'); + }); + }); + + describe('getNetAmount', () => { + it('should calculate net amount', () => { + const request = SponsorshipRequest.create(validProps); + const net = request.getNetAmount(); + expect(net.amount).toBe(900); // 1000 - 100 + expect(net.currency).toBe('USD'); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Standing.test.ts b/core/racing/domain/entities/Standing.test.ts new file mode 100644 index 000000000..679684f53 --- /dev/null +++ b/core/racing/domain/entities/Standing.test.ts @@ -0,0 +1,135 @@ +import { Standing } from './Standing'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('Standing', () => { + const validProps = { + id: 'standing-123', + leagueId: 'league-456', + driverId: 'driver-789', + points: 100, + wins: 5, + position: 2, + racesCompleted: 10, + }; + + describe('create', () => { + it('should create a Standing with valid props', () => { + const standing = Standing.create(validProps); + expect(standing.id).toBe('standing-123'); + expect(standing.leagueId.toString()).toBe('league-456'); + expect(standing.driverId.toString()).toBe('driver-789'); + expect(standing.points.toNumber()).toBe(100); + expect(standing.wins).toBe(5); + expect(standing.position.toNumber()).toBe(2); + expect(standing.racesCompleted).toBe(10); + }); + + it('should generate id if not provided', () => { + const propsWithoutId = { + leagueId: 'league-456', + driverId: 'driver-789', + points: 100, + wins: 5, + position: 2, + racesCompleted: 10, + }; + const standing = Standing.create(propsWithoutId); + expect(standing.id).toBe('league-456:driver-789'); + }); + + it('should use defaults for optional props', () => { + const minimalProps = { + leagueId: 'league-456', + driverId: 'driver-789', + }; + const standing = Standing.create(minimalProps); + expect(standing.points.toNumber()).toBe(0); + expect(standing.wins).toBe(0); + expect(standing.position.toNumber()).toBe(1); + expect(standing.racesCompleted).toBe(0); + }); + + it('should throw for invalid leagueId', () => { + expect(() => Standing.create({ ...validProps, leagueId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw for invalid driverId', () => { + expect(() => Standing.create({ ...validProps, driverId: '' })).toThrow(RacingDomainValidationError); + }); + + it('should throw for negative points', () => { + expect(() => Standing.create({ ...validProps, points: -1 })).toThrow(RacingDomainValidationError); + }); + + it('should throw for invalid position', () => { + expect(() => Standing.create({ ...validProps, position: 0 })).toThrow(RacingDomainValidationError); + }); + }); + + describe('addRaceResult', () => { + it('should add points and increment races completed', () => { + const standing = Standing.create(validProps); + const pointsSystem = { 1: 25, 2: 18, 3: 15 }; + const updated = standing.addRaceResult(1, pointsSystem); + expect(updated.points.toNumber()).toBe(125); // 100 + 25 + expect(updated.wins).toBe(6); // 5 + 1 + expect(updated.racesCompleted).toBe(11); // 10 + 1 + }); + + it('should not add win for non-first position', () => { + const standing = Standing.create(validProps); + const pointsSystem = { 1: 25, 2: 18, 3: 15 }; + const updated = standing.addRaceResult(2, pointsSystem); + expect(updated.points.toNumber()).toBe(118); // 100 + 18 + expect(updated.wins).toBe(5); // no change + expect(updated.racesCompleted).toBe(11); + }); + + it('should handle position not in points system', () => { + const standing = Standing.create(validProps); + const pointsSystem = { 1: 25, 2: 18 }; + const updated = standing.addRaceResult(5, pointsSystem); + expect(updated.points.toNumber()).toBe(100); // no points + expect(updated.wins).toBe(5); + expect(updated.racesCompleted).toBe(11); + }); + }); + + describe('updatePosition', () => { + it('should update position', () => { + const standing = Standing.create(validProps); + const updated = standing.updatePosition(1); + expect(updated.position.toNumber()).toBe(1); + expect(updated.points.toNumber()).toBe(100); // unchanged + }); + + it('should throw for invalid position', () => { + const standing = Standing.create(validProps); + expect(() => standing.updatePosition(0)).toThrow(RacingDomainValidationError); + }); + }); + + describe('getAveragePoints', () => { + it('should calculate average points', () => { + const standing = Standing.create(validProps); + expect(standing.getAveragePoints()).toBe(10); // 100 / 10 + }); + + it('should return 0 for no races completed', () => { + const standing = Standing.create({ ...validProps, racesCompleted: 0 }); + expect(standing.getAveragePoints()).toBe(0); + }); + }); + + describe('getWinPercentage', () => { + it('should calculate win percentage', () => { + const standing = Standing.create(validProps); + expect(standing.getWinPercentage()).toBe(50); // 5 / 10 * 100 + }); + + it('should return 0 for no races completed', () => { + const standing = Standing.create({ ...validProps, racesCompleted: 0 }); + expect(standing.getWinPercentage()).toBe(0); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Standing.ts b/core/racing/domain/entities/Standing.ts index 71db40787..22a61ba03 100644 --- a/core/racing/domain/entities/Standing.ts +++ b/core/racing/domain/entities/Standing.ts @@ -4,26 +4,31 @@ * Represents a championship standing in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - + import type { IEntity } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { LeagueId } from './LeagueId'; +import { DriverId } from './DriverId'; +import { Points } from '../value-objects/Points'; +import { Position } from './championship/Position'; export class Standing implements IEntity { readonly id: string; - readonly leagueId: string; - readonly driverId: string; - readonly points: number; + readonly leagueId: LeagueId; + readonly driverId: DriverId; + readonly points: Points; readonly wins: number; - readonly position: number; + readonly position: Position; readonly racesCompleted: number; - + private constructor(props: { id: string; - leagueId: string; - driverId: string; - points: number; + leagueId: LeagueId; + driverId: DriverId; + points: Points; wins: number; - position: number; + position: Position; racesCompleted: number; }) { this.id = props.id; @@ -47,19 +52,24 @@ export class Standing implements IEntity { position?: number; racesCompleted?: number; }): Standing { - this.validate(props); + Standing.validate(props); const id = props.id && props.id.trim().length > 0 ? props.id : `${props.leagueId}:${props.driverId}`; + const leagueId = LeagueId.create(props.leagueId); + const driverId = DriverId.create(props.driverId); + const points = Points.create(props.points ?? 0); + const position = Position.create(props.position ?? 1); // Default to 1 for position + return new Standing({ id, - leagueId: props.leagueId, - driverId: props.driverId, - points: props.points ?? 0, + leagueId, + driverId, + points, wins: props.wins ?? 0, - position: props.position ?? 0, + position, racesCompleted: props.racesCompleted ?? 0, }); } @@ -75,7 +85,7 @@ export class Standing implements IEntity { if (!props.leagueId || props.leagueId.trim().length === 0) { throw new RacingDomainValidationError('League ID is required'); } - + if (!props.driverId || props.driverId.trim().length === 0) { throw new RacingDomainValidationError('Driver ID is required'); } @@ -88,13 +98,16 @@ export class Standing implements IEntity { const racePoints = pointsSystem[position] ?? 0; const isWin = position === 1; + const newPoints = Points.create(this.points.toNumber() + racePoints); + const newPosition = this.position; // Position might be updated separately + return new Standing({ id: this.id, leagueId: this.leagueId, driverId: this.driverId, - points: this.points + racePoints, + points: newPoints, wins: this.wins + (isWin ? 1 : 0), - position: this.position, + position: newPosition, racesCompleted: this.racesCompleted + 1, }); } @@ -103,17 +116,15 @@ export class Standing implements IEntity { * Update championship position */ updatePosition(position: number): Standing { - if (!Number.isInteger(position) || position < 1) { - throw new RacingDomainValidationError('Position must be a positive integer'); - } + const newPosition = Position.create(position); - return Standing.create({ + return new Standing({ id: this.id, leagueId: this.leagueId, driverId: this.driverId, points: this.points, wins: this.wins, - position, + position: newPosition, racesCompleted: this.racesCompleted, }); } @@ -123,7 +134,7 @@ export class Standing implements IEntity { */ getAveragePoints(): number { if (this.racesCompleted === 0) return 0; - return this.points / this.racesCompleted; + return this.points.toNumber() / this.racesCompleted; } /** diff --git a/core/racing/domain/entities/StewardId.test.ts b/core/racing/domain/entities/StewardId.test.ts new file mode 100644 index 000000000..d2283b6b2 --- /dev/null +++ b/core/racing/domain/entities/StewardId.test.ts @@ -0,0 +1,38 @@ +import { StewardId } from './StewardId'; +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +describe('StewardId', () => { + describe('create', () => { + it('should create a StewardId with valid value', () => { + const id = StewardId.create('steward-123'); + expect(id.toString()).toBe('steward-123'); + }); + + it('should trim whitespace', () => { + const id = StewardId.create(' steward-123 '); + expect(id.toString()).toBe('steward-123'); + }); + + it('should throw error for empty string', () => { + expect(() => StewardId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => StewardId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = StewardId.create('steward-123'); + const id2 = StewardId.create('steward-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = StewardId.create('steward-123'); + const id2 = StewardId.create('steward-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/StewardId.ts b/core/racing/domain/entities/StewardId.ts new file mode 100644 index 000000000..395b2cbbc --- /dev/null +++ b/core/racing/domain/entities/StewardId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class StewardId { + private constructor(private readonly value: string) {} + + static create(value: string): StewardId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Steward ID cannot be empty'); + } + return new StewardId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: StewardId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/SubmittedAt.ts b/core/racing/domain/entities/SubmittedAt.ts new file mode 100644 index 000000000..bc75ba9d8 --- /dev/null +++ b/core/racing/domain/entities/SubmittedAt.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class SubmittedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): SubmittedAt { + if (!(value instanceof Date) || isNaN(value.getTime())) { + throw new RacingDomainValidationError('SubmittedAt must be a valid Date'); + } + return new SubmittedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: SubmittedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Team.test.ts b/core/racing/domain/entities/Team.test.ts new file mode 100644 index 000000000..611a53d96 --- /dev/null +++ b/core/racing/domain/entities/Team.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest'; +import { Team } from './Team'; + +describe('Team', () => { + it('should create a team', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: ['league1', 'league2'], + createdAt: new Date('2020-01-01'), + }); + + expect(team.id).toBe('team1'); + expect(team.name.toString()).toBe('Team Alpha'); + expect(team.tag.toString()).toBe('TA'); + expect(team.description.toString()).toBe('A great team'); + expect(team.ownerId.toString()).toBe('driver1'); + expect(team.leagues).toHaveLength(2); + expect(team.leagues[0]!.toString()).toBe('league1'); + expect(team.leagues[1]!.toString()).toBe('league2'); + expect(team.createdAt.toDate()).toEqual(new Date('2020-01-01')); + }); + + it('should create team with default createdAt', () => { + const before = new Date(); + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + }); + const after = new Date(); + + expect(team.createdAt.toDate().getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(team.createdAt.toDate().getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should update name', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + }); + + const updated = team.update({ name: 'Team Beta' }); + expect(updated.name.toString()).toBe('Team Beta'); + expect(updated.id).toBe('team1'); + }); + + it('should update tag', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + }); + + const updated = team.update({ tag: 'TB' }); + expect(updated.tag.toString()).toBe('TB'); + }); + + it('should update description', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + }); + + const updated = team.update({ description: 'Updated description' }); + expect(updated.description.toString()).toBe('Updated description'); + }); + + it('should update ownerId', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + }); + + const updated = team.update({ ownerId: 'driver2' }); + expect(updated.ownerId.toString()).toBe('driver2'); + }); + + it('should update leagues', () => { + const team = Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: ['league1'], + }); + + const updated = team.update({ leagues: ['league2', 'league3'] }); + expect(updated.leagues).toHaveLength(2); + expect(updated.leagues[0]!.toString()).toBe('league2'); + expect(updated.leagues[1]!.toString()).toBe('league3'); + }); + + it('should throw on invalid id', () => { + expect(() => Team.create({ + id: '', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + })).toThrow('Team ID is required'); + }); + + it('should throw on invalid name', () => { + expect(() => Team.create({ + id: 'team1', + name: '', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + })).toThrow('Team name is required'); + }); + + it('should throw on invalid tag', () => { + expect(() => Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: '', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + })).toThrow('Team tag is required'); + }); + + it('should throw on invalid description', () => { + expect(() => Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: '', + ownerId: 'driver1', + leagues: [], + })).toThrow('Team description is required'); + }); + + it('should throw on invalid ownerId', () => { + expect(() => Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: '', + leagues: [], + })).toThrow('Driver ID cannot be empty'); + }); + + it('should throw on invalid leagues', () => { + expect(() => Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: 'not an array' as unknown as string[], + })).toThrow('Team leagues must be an array'); + }); + + it('should throw on future createdAt', () => { + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + expect(() => Team.create({ + id: 'team1', + name: 'Team Alpha', + tag: 'TA', + description: 'A great team', + ownerId: 'driver1', + leagues: [], + createdAt: future, + })).toThrow('Created date cannot be in the future'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Team.ts b/core/racing/domain/entities/Team.ts index 96d48f6bc..bbca4154a 100644 --- a/core/racing/domain/entities/Team.ts +++ b/core/racing/domain/entities/Team.ts @@ -8,24 +8,30 @@ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import { TeamName } from '../value-objects/TeamName'; +import { TeamTag } from '../value-objects/TeamTag'; +import { TeamDescription } from '../value-objects/TeamDescription'; +import { DriverId } from './DriverId'; +import { LeagueId } from './LeagueId'; +import { TeamCreatedAt } from '../value-objects/TeamCreatedAt'; export class Team implements IEntity { readonly id: string; - readonly name: string; - readonly tag: string; - readonly description: string; - readonly ownerId: string; - readonly leagues: string[]; - readonly createdAt: Date; + readonly name: TeamName; + readonly tag: TeamTag; + readonly description: TeamDescription; + readonly ownerId: DriverId; + readonly leagues: LeagueId[]; + readonly createdAt: TeamCreatedAt; private constructor(props: { id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - createdAt: Date; + name: TeamName; + tag: TeamTag; + description: TeamDescription; + ownerId: DriverId; + leagues: LeagueId[]; + createdAt: TeamCreatedAt; }) { this.id = props.id; this.name = props.name; @@ -48,16 +54,22 @@ export class Team implements IEntity { leagues: string[]; createdAt?: Date; }): Team { - this.validate(props); + if (!props.id || props.id.trim().length === 0) { + throw new RacingDomainValidationError('Team ID is required'); + } + + if (!Array.isArray(props.leagues)) { + throw new RacingDomainValidationError('Team leagues must be an array'); + } return new Team({ id: props.id, - name: props.name, - tag: props.tag, - description: props.description, - ownerId: props.ownerId, - leagues: [...props.leagues], - createdAt: props.createdAt ?? new Date(), + name: TeamName.create(props.name), + tag: TeamTag.create(props.tag), + description: TeamDescription.create(props.description), + ownerId: DriverId.create(props.ownerId), + leagues: props.leagues.map(leagueId => LeagueId.create(leagueId)), + createdAt: TeamCreatedAt.create(props.createdAt ?? new Date()), }); } @@ -71,58 +83,21 @@ export class Team implements IEntity { ownerId: string; leagues: string[]; }>): Team { - const next: Team = new Team({ + const nextName = 'name' in props ? TeamName.create(props.name!) : this.name; + const nextTag = 'tag' in props ? TeamTag.create(props.tag!) : this.tag; + const nextDescription = 'description' in props ? TeamDescription.create(props.description!) : this.description; + const nextOwnerId = 'ownerId' in props ? DriverId.create(props.ownerId!) : this.ownerId; + const nextLeagues = 'leagues' in props ? props.leagues!.map(leagueId => LeagueId.create(leagueId)) : this.leagues; + + return new Team({ id: this.id, - name: props.name ?? this.name, - tag: props.tag ?? this.tag, - description: props.description ?? this.description, - ownerId: props.ownerId ?? this.ownerId, - leagues: props.leagues ? [...props.leagues] : [...this.leagues], + name: nextName, + tag: nextTag, + description: nextDescription, + ownerId: nextOwnerId, + leagues: nextLeagues, createdAt: this.createdAt, }); - - // Re-validate updated aggregate - Team.validate({ - id: next.id, - name: next.name, - tag: next.tag, - description: next.description, - ownerId: next.ownerId, - leagues: next.leagues, - }); - - return next; } - /** - * Domain validation logic for core invariants. - */ - private static validate(props: { - id: string; - name: string; - tag: string; - description: string; - ownerId: string; - leagues: string[]; - }): void { - if (!props.id || props.id.trim().length === 0) { - throw new RacingDomainValidationError('Team ID is required'); - } - - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('Team name is required'); - } - - if (!props.tag || props.tag.trim().length === 0) { - throw new RacingDomainValidationError('Team tag is required'); - } - - if (!props.ownerId || props.ownerId.trim().length === 0) { - throw new RacingDomainValidationError('Team owner ID is required'); - } - - if (!Array.isArray(props.leagues)) { - throw new RacingDomainValidationError('Team leagues must be an array'); - } - } } \ No newline at end of file diff --git a/core/racing/domain/entities/TimeInRace.ts b/core/racing/domain/entities/TimeInRace.ts new file mode 100644 index 000000000..643c1dd3b --- /dev/null +++ b/core/racing/domain/entities/TimeInRace.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TimeInRace { + private constructor(private readonly value: number) {} + + static create(value: number): TimeInRace { + if (typeof value !== 'number' || value < 0 || isNaN(value)) { + throw new RacingDomainValidationError('Time in race must be a non-negative number'); + } + return new TimeInRace(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: TimeInRace): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Track.test.ts b/core/racing/domain/entities/Track.test.ts new file mode 100644 index 000000000..b870cabc3 --- /dev/null +++ b/core/racing/domain/entities/Track.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { Track } from './Track'; + +describe('Track', () => { + it('should create a track', () => { + const track = Track.create({ + id: 'track1', + name: 'Monza Circuit', + shortName: 'MON', + country: 'Italy', + category: 'road', + difficulty: 'advanced', + lengthKm: 5.793, + turns: 11, + imageUrl: 'https://example.com/monza.jpg', + gameId: 'game1', + }); + + expect(track.id).toBe('track1'); + expect(track.name.toString()).toBe('Monza Circuit'); + expect(track.shortName.toString()).toBe('MON'); + expect(track.country.toString()).toBe('Italy'); + expect(track.category).toBe('road'); + expect(track.difficulty).toBe('advanced'); + expect(track.lengthKm.toNumber()).toBe(5.793); + expect(track.turns.toNumber()).toBe(11); + expect(track.imageUrl.toString()).toBe('https://example.com/monza.jpg'); + expect(track.gameId.toString()).toBe('game1'); + }); + + it('should create track with defaults', () => { + const track = Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 5.793, + turns: 11, + gameId: 'game1', + }); + + expect(track.shortName.toString()).toBe('MON'); + expect(track.category).toBe('road'); + expect(track.difficulty).toBe('intermediate'); + expect(track.imageUrl.toString()).toBeUndefined(); + }); + + it('should create track with generated shortName', () => { + const track = Track.create({ + id: 'track1', + name: 'Silverstone', + country: 'UK', + lengthKm: 5.891, + turns: 18, + gameId: 'game1', + }); + + expect(track.shortName.toString()).toBe('SIL'); + }); + + it('should throw on invalid id', () => { + expect(() => Track.create({ + id: '', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 5.793, + turns: 11, + gameId: 'game1', + })).toThrow('Track ID is required'); + }); + + it('should throw on invalid name', () => { + expect(() => Track.create({ + id: 'track1', + name: '', + country: 'Italy', + lengthKm: 5.793, + turns: 11, + gameId: 'game1', + })).toThrow('Track name is required'); + }); + + it('should throw on invalid country', () => { + expect(() => Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: '', + lengthKm: 5.793, + turns: 11, + gameId: 'game1', + })).toThrow('Track country is required'); + }); + + it('should throw on invalid lengthKm', () => { + expect(() => Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 0, + turns: 11, + gameId: 'game1', + })).toThrow('Track length must be positive'); + }); + + it('should throw on negative turns', () => { + expect(() => Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 5.793, + turns: -1, + gameId: 'game1', + })).toThrow('Track turns cannot be negative'); + }); + + it('should throw on invalid gameId', () => { + expect(() => Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 5.793, + turns: 11, + gameId: '', + })).toThrow('Track game ID cannot be empty'); + }); + + it('should throw on empty imageUrl string', () => { + expect(() => Track.create({ + id: 'track1', + name: 'Monza Circuit', + country: 'Italy', + lengthKm: 5.793, + turns: 11, + imageUrl: '', + gameId: 'game1', + })).toThrow('Track image URL cannot be empty string'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Track.ts b/core/racing/domain/entities/Track.ts index df1c2dd5b..6018800e2 100644 --- a/core/racing/domain/entities/Track.ts +++ b/core/racing/domain/entities/Track.ts @@ -4,36 +4,43 @@ * Represents a racing track/circuit in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - + import { RacingDomainValidationError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; +import { TrackName } from '../value-objects/TrackName'; +import { TrackShortName } from '../value-objects/TrackShortName'; +import { TrackCountry } from '../value-objects/TrackCountry'; +import { TrackLength } from '../value-objects/TrackLength'; +import { TrackTurns } from '../value-objects/TrackTurns'; +import { TrackGameId } from '../value-objects/TrackGameId'; +import { TrackImageUrl } from '../value-objects/TrackImageUrl'; export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt'; export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert'; export class Track implements IEntity { readonly id: string; - readonly name: string; - readonly shortName: string; - readonly country: string; + readonly name: TrackName; + readonly shortName: TrackShortName; + readonly country: TrackCountry; readonly category: TrackCategory; readonly difficulty: TrackDifficulty; - readonly lengthKm: number; - readonly turns: number; - readonly imageUrl: string | undefined; - readonly gameId: string; + readonly lengthKm: TrackLength; + readonly turns: TrackTurns; + readonly imageUrl: TrackImageUrl; + readonly gameId: TrackGameId; private constructor(props: { id: string; - name: string; - shortName: string; - country: string; + name: TrackName; + shortName: TrackShortName; + country: TrackCountry; category: TrackCategory; difficulty: TrackDifficulty; - lengthKm: number; - turns: number; - imageUrl?: string | undefined; - gameId: string; + lengthKm: TrackLength; + turns: TrackTurns; + imageUrl: TrackImageUrl; + gameId: TrackGameId; }) { this.id = props.id; this.name = props.name; @@ -62,66 +69,23 @@ export class Track implements IEntity { imageUrl?: string; gameId: string; }): Track { - this.validate(props); - - const base = { - id: props.id, - name: props.name, - shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(), - country: props.country, - category: props.category ?? 'road', - difficulty: props.difficulty ?? 'intermediate', - lengthKm: props.lengthKm, - turns: props.turns, - gameId: props.gameId, - }; - - const withImage = - props.imageUrl !== undefined ? { ...base, imageUrl: props.imageUrl } : base; - - return new Track(withImage); - } - - /** - * Domain validation logic - */ - private static validate(props: { - id: string; - name: string; - country: string; - lengthKm: number; - turns: number; - gameId: string; - }): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('Track ID is required'); } - if (!props.name || props.name.trim().length === 0) { - throw new RacingDomainValidationError('Track name is required'); - } + const shortNameValue = props.shortName ?? props.name.slice(0, 3).toUpperCase(); - if (!props.country || props.country.trim().length === 0) { - throw new RacingDomainValidationError('Track country is required'); - } - - if (props.lengthKm <= 0) { - throw new RacingDomainValidationError('Track length must be positive'); - } - - if (props.turns < 0) { - throw new RacingDomainValidationError('Track turns cannot be negative'); - } - - if (!props.gameId || props.gameId.trim().length === 0) { - throw new RacingDomainValidationError('Game ID is required'); - } - } - - /** - * Get formatted length string - */ - getFormattedLength(): string { - return `${this.lengthKm.toFixed(2)} km`; + return new Track({ + id: props.id, + name: TrackName.create(props.name), + shortName: TrackShortName.create(shortNameValue), + country: TrackCountry.create(props.country), + category: props.category ?? 'road', + difficulty: props.difficulty ?? 'intermediate', + lengthKm: TrackLength.create(props.lengthKm), + turns: TrackTurns.create(props.turns), + imageUrl: TrackImageUrl.create(props.imageUrl), + gameId: TrackGameId.create(props.gameId), + }); } } \ No newline at end of file diff --git a/core/racing/domain/entities/VideoUrl.ts b/core/racing/domain/entities/VideoUrl.ts new file mode 100644 index 000000000..c38ee81c8 --- /dev/null +++ b/core/racing/domain/entities/VideoUrl.ts @@ -0,0 +1,26 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class VideoUrl { + private constructor(private readonly value: string) {} + + static create(value: string): VideoUrl { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Video URL cannot be empty'); + } + // Basic URL validation + try { + new URL(value); + } catch { + throw new RacingDomainValidationError('Invalid video URL format'); + } + return new VideoUrl(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: VideoUrl): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Weight.ts b/core/racing/domain/entities/Weight.ts new file mode 100644 index 000000000..21d89e733 --- /dev/null +++ b/core/racing/domain/entities/Weight.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class Weight { + private constructor(private readonly value: number) {} + + static create(value: number): Weight { + if (value <= 0) { + throw new RacingDomainValidationError('Weight must be positive'); + } + return new Weight(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Weight): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/Year.ts b/core/racing/domain/entities/Year.ts new file mode 100644 index 000000000..63feeb3b8 --- /dev/null +++ b/core/racing/domain/entities/Year.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class Year { + private constructor(private readonly value: number) {} + + static create(value: number): Year { + if (value < 1900 || value > new Date().getFullYear() + 1) { + throw new RacingDomainValidationError('Year must be between 1900 and next year'); + } + return new Year(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Year): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/championship/ChampionshipStanding.test.ts b/core/racing/domain/entities/championship/ChampionshipStanding.test.ts new file mode 100644 index 000000000..1e382dbde --- /dev/null +++ b/core/racing/domain/entities/championship/ChampionshipStanding.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { ChampionshipStanding } from './ChampionshipStanding'; +import type { ParticipantRef } from '../../types/ParticipantRef'; + +describe('ChampionshipStanding', () => { + const participant: ParticipantRef = { type: 'driver', id: 'driver1' }; + + it('should create a championship standing', () => { + const standing = ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: 'champ1', + participant, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + }); + + expect(standing.id).toBe('season1-champ1-driver1'); + expect(standing.seasonId).toBe('season1'); + expect(standing.championshipId).toBe('champ1'); + expect(standing.participant).toBe(participant); + expect(standing.totalPoints.toNumber()).toBe(100); + expect(standing.resultsCounted.toNumber()).toBe(5); + expect(standing.resultsDropped.toNumber()).toBe(1); + expect(standing.position.toNumber()).toBe(1); + }); + + it('should update position', () => { + const standing = ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: 'champ1', + participant, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + }); + + const updated = standing.withPosition(2); + expect(updated.position.toNumber()).toBe(2); + expect(updated.id).toBe('season1-champ1-driver1'); + expect(updated.totalPoints.toNumber()).toBe(100); + }); + + it('should throw on invalid seasonId', () => { + expect(() => ChampionshipStanding.create({ + seasonId: '', + championshipId: 'champ1', + participant, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + })).toThrow('Season ID is required'); + }); + + it('should throw on invalid championshipId', () => { + expect(() => ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: '', + participant, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + })).toThrow('Championship ID is required'); + }); + + it('should throw on invalid participant', () => { + expect(() => ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: 'champ1', + participant: { type: 'driver', id: '' }, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + })).toThrow('Participant is required'); + }); + + it('should throw on negative points', () => { + expect(() => ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: 'champ1', + participant, + totalPoints: -1, + resultsCounted: 5, + resultsDropped: 1, + position: 1, + })).toThrow('Points cannot be negative'); + }); + + it('should throw on invalid position', () => { + expect(() => ChampionshipStanding.create({ + seasonId: 'season1', + championshipId: 'champ1', + participant, + totalPoints: 100, + resultsCounted: 5, + resultsDropped: 1, + position: 0, + })).toThrow('Position must be a positive integer'); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/championship/ChampionshipStanding.ts b/core/racing/domain/entities/championship/ChampionshipStanding.ts new file mode 100644 index 000000000..c2c358715 --- /dev/null +++ b/core/racing/domain/entities/championship/ChampionshipStanding.ts @@ -0,0 +1,69 @@ +import type { IEntity } from '@core/shared/domain'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; +import type { ParticipantRef } from '../../types/ParticipantRef'; +import { Points } from '../../value-objects/Points'; +import { Position } from './Position'; +import { ResultsCount } from './ResultsCount'; + +export class ChampionshipStanding implements IEntity { + readonly id: string; + readonly seasonId: string; + readonly championshipId: string; + readonly participant: ParticipantRef; + readonly totalPoints: Points; + readonly resultsCounted: ResultsCount; + readonly resultsDropped: ResultsCount; + readonly position: Position; + + private constructor(props: { + seasonId: string; + championshipId: string; + participant: ParticipantRef; + totalPoints: number; + resultsCounted: number; + resultsDropped: number; + position: number; + }) { + this.seasonId = props.seasonId; + this.championshipId = props.championshipId; + this.participant = props.participant; + this.totalPoints = Points.create(props.totalPoints); + this.resultsCounted = ResultsCount.create(props.resultsCounted); + this.resultsDropped = ResultsCount.create(props.resultsDropped); + this.position = Position.create(props.position); + this.id = `${this.seasonId}-${this.championshipId}-${this.participant.id}`; + } + + static create(props: { + seasonId: string; + championshipId: string; + participant: ParticipantRef; + totalPoints: number; + resultsCounted: number; + resultsDropped: number; + position: number; + }): ChampionshipStanding { + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new RacingDomainValidationError('Season ID is required'); + } + if (!props.championshipId || props.championshipId.trim().length === 0) { + throw new RacingDomainValidationError('Championship ID is required'); + } + if (!props.participant || !props.participant.id || props.participant.id.trim().length === 0) { + throw new RacingDomainValidationError('Participant is required'); + } + return new ChampionshipStanding(props); + } + + withPosition(position: number): ChampionshipStanding { + return ChampionshipStanding.create({ + seasonId: this.seasonId, + championshipId: this.championshipId, + participant: this.participant, + totalPoints: this.totalPoints.toNumber(), + resultsCounted: this.resultsCounted.toNumber(), + resultsDropped: this.resultsDropped.toNumber(), + position, + }); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/championship/Position.test.ts b/core/racing/domain/entities/championship/Position.test.ts new file mode 100644 index 000000000..431b2fabf --- /dev/null +++ b/core/racing/domain/entities/championship/Position.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { Position } from './Position'; + +describe('Position', () => { + it('should create position', () => { + const position = Position.create(1); + expect(position.toNumber()).toBe(1); + }); + + it('should not create zero position', () => { + expect(() => Position.create(0)).toThrow('Position must be a positive integer'); + }); + + it('should not create negative position', () => { + expect(() => Position.create(-1)).toThrow('Position must be a positive integer'); + }); + + it('should not create non-integer position', () => { + expect(() => Position.create(1.5)).toThrow('Position must be a positive integer'); + }); + + it('should equal same position', () => { + const p1 = Position.create(2); + const p2 = Position.create(2); + expect(p1.equals(p2)).toBe(true); + }); + + it('should not equal different position', () => { + const p1 = Position.create(2); + const p2 = Position.create(3); + expect(p1.equals(p2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/championship/Position.ts b/core/racing/domain/entities/championship/Position.ts new file mode 100644 index 000000000..58d592ecb --- /dev/null +++ b/core/racing/domain/entities/championship/Position.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class Position { + private constructor(private readonly value: number) {} + + static create(value: number): Position { + if (!Number.isInteger(value) || value <= 0) { + throw new RacingDomainValidationError('Position must be a positive integer'); + } + return new Position(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Position): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/championship/ResultsCount.test.ts b/core/racing/domain/entities/championship/ResultsCount.test.ts new file mode 100644 index 000000000..ea2d5285f --- /dev/null +++ b/core/racing/domain/entities/championship/ResultsCount.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { ResultsCount } from './ResultsCount'; + +describe('ResultsCount', () => { + it('should create results count', () => { + const count = ResultsCount.create(5); + expect(count.toNumber()).toBe(5); + }); + + it('should create zero results count', () => { + const count = ResultsCount.create(0); + expect(count.toNumber()).toBe(0); + }); + + it('should not create negative results count', () => { + expect(() => ResultsCount.create(-1)).toThrow('Results count must be a non-negative integer'); + }); + + it('should not create non-integer results count', () => { + expect(() => ResultsCount.create(1.5)).toThrow('Results count must be a non-negative integer'); + }); + + it('should equal same count', () => { + const c1 = ResultsCount.create(3); + const c2 = ResultsCount.create(3); + expect(c1.equals(c2)).toBe(true); + }); + + it('should not equal different count', () => { + const c1 = ResultsCount.create(3); + const c2 = ResultsCount.create(4); + expect(c1.equals(c2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/championship/ResultsCount.ts b/core/racing/domain/entities/championship/ResultsCount.ts new file mode 100644 index 000000000..de4bbccdd --- /dev/null +++ b/core/racing/domain/entities/championship/ResultsCount.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class ResultsCount { + private constructor(private readonly value: number) {} + + static create(value: number): ResultsCount { + if (!Number.isInteger(value) || value < 0) { + throw new RacingDomainValidationError('Results count must be a non-negative integer'); + } + return new ResultsCount(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: ResultsCount): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/league-wallet/LeagueWallet.test.ts b/core/racing/domain/entities/league-wallet/LeagueWallet.test.ts new file mode 100644 index 000000000..416caf3c0 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/LeagueWallet.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueWallet } from './LeagueWallet'; +import { Money } from '../value-objects/Money'; + +describe('LeagueWallet', () => { + it('should create a league wallet', () => { + const balance = Money.create(10000, 'USD'); // $100 + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + expect(wallet.id.toString()).toBe('wallet1'); + expect(wallet.leagueId.toString()).toBe('league1'); + expect(wallet.getBalance().equals(balance)).toBe(true); + expect(wallet.getTransactionIds()).toEqual([]); + }); + + it('should throw on invalid id', () => { + const balance = Money.create(10000, 'USD'); + expect(() => LeagueWallet.create({ + id: '', + leagueId: 'league1', + balance, + })).toThrow('LeagueWallet ID is required'); + }); + + it('should throw on invalid leagueId', () => { + const balance = Money.create(10000, 'USD'); + expect(() => LeagueWallet.create({ + id: 'wallet1', + leagueId: '', + balance, + })).toThrow('LeagueWallet leagueId is required'); + }); + + + it('should add funds', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const netAmount = Money.create(5000, 'USD'); // $50 + const updated = wallet.addFunds(netAmount, 'tx1'); + + expect(updated.getBalance().amount).toBe(15000); + expect(updated.getTransactionIds()).toEqual(['tx1']); + }); + + it('should throw on add funds with different currency', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const netAmount = Money.create(5000, 'EUR'); + expect(() => wallet.addFunds(netAmount, 'tx1')).toThrow('Cannot add funds with different currency'); + }); + + it('should withdraw funds', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const amount = Money.create(5000, 'USD'); + const updated = wallet.withdrawFunds(amount, 'tx1'); + + expect(updated.getBalance().amount).toBe(5000); + expect(updated.getTransactionIds()).toEqual(['tx1']); + }); + + it('should throw on withdraw with insufficient balance', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const amount = Money.create(15000, 'USD'); + expect(() => wallet.withdrawFunds(amount, 'tx1')).toThrow('Insufficient balance for withdrawal'); + }); + + it('should throw on withdraw with different currency', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const amount = Money.create(5000, 'EUR'); + expect(() => wallet.withdrawFunds(amount, 'tx1')).toThrow('Cannot withdraw funds with different currency'); + }); + + it('should check if can withdraw', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + const amount = Money.create(5000, 'USD'); + expect(wallet.canWithdraw(amount)).toBe(true); + + const largeAmount = Money.create(15000, 'USD'); + expect(wallet.canWithdraw(largeAmount)).toBe(false); + + const exactAmount = Money.create(10000, 'USD'); + expect(wallet.canWithdraw(exactAmount)).toBe(true); + + const differentCurrency = Money.create(5000, 'EUR'); + expect(wallet.canWithdraw(differentCurrency)).toBe(false); + }); + + it('should get balance', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + }); + + expect(wallet.getBalance().equals(balance)).toBe(true); + }); + + it('should get transaction ids', () => { + const balance = Money.create(10000, 'USD'); + const wallet = LeagueWallet.create({ + id: 'wallet1', + leagueId: 'league1', + balance, + transactionIds: ['tx1', 'tx2'], + }); + + expect(wallet.getTransactionIds()).toEqual(['tx1', 'tx2']); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/LeagueWallet.ts b/core/racing/domain/entities/league-wallet/LeagueWallet.ts similarity index 70% rename from core/racing/domain/entities/LeagueWallet.ts rename to core/racing/domain/entities/league-wallet/LeagueWallet.ts index f87b4314a..25929bff6 100644 --- a/core/racing/domain/entities/LeagueWallet.ts +++ b/core/racing/domain/entities/league-wallet/LeagueWallet.ts @@ -4,26 +4,28 @@ * Represents a league's financial wallet. * Aggregate root for managing league finances and transactions. */ - + import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; import type { Money } from '../value-objects/Money'; -import type { Transaction } from './Transaction'; +import { LeagueWalletId } from './LeagueWalletId'; +import { LeagueId } from './LeagueId'; +import { TransactionId } from './TransactionId'; export interface LeagueWalletProps { - id: string; - leagueId: string; + id: LeagueWalletId; + leagueId: LeagueId; balance: Money; - transactionIds: string[]; + transactionIds: TransactionId[]; createdAt: Date; } -export class LeagueWallet implements IEntity { - readonly id: string; - readonly leagueId: string; +export class LeagueWallet implements IEntity { + readonly id: LeagueWalletId; + readonly leagueId: LeagueId; readonly balance: Money; - readonly transactionIds: string[]; + readonly transactionIds: TransactionId[]; readonly createdAt: Date; private constructor(props: LeagueWalletProps) { @@ -34,20 +36,33 @@ export class LeagueWallet implements IEntity { this.createdAt = props.createdAt; } - static create(props: Omit & { + static create(props: { + id: string; + leagueId: string; + balance: Money; createdAt?: Date; transactionIds?: string[]; }): LeagueWallet { this.validate(props); + const id = LeagueWalletId.create(props.id); + const leagueId = LeagueId.create(props.leagueId); + const transactionIds = props.transactionIds?.map(tid => TransactionId.create(tid)) ?? []; + return new LeagueWallet({ - ...props, + id, + leagueId, + balance: props.balance, + transactionIds, createdAt: props.createdAt ?? new Date(), - transactionIds: props.transactionIds ?? [], }); } - private static validate(props: Omit): void { + private static validate(props: { + id: string; + leagueId: string; + balance: Money; + }): void { if (!props.id || props.id.trim().length === 0) { throw new RacingDomainValidationError('LeagueWallet ID is required'); } @@ -70,11 +85,12 @@ export class LeagueWallet implements IEntity { } const newBalance = this.balance.add(netAmount); + const newTransactionId = TransactionId.create(transactionId); return new LeagueWallet({ ...this, balance: newBalance, - transactionIds: [...this.transactionIds, transactionId], + transactionIds: [...this.transactionIds, newTransactionId], }); } @@ -92,11 +108,12 @@ export class LeagueWallet implements IEntity { } const newBalance = this.balance.subtract(amount); + const newTransactionId = TransactionId.create(transactionId); return new LeagueWallet({ ...this, balance: newBalance, - transactionIds: [...this.transactionIds, transactionId], + transactionIds: [...this.transactionIds, newTransactionId], }); } @@ -121,6 +138,6 @@ export class LeagueWallet implements IEntity { * Get all transaction IDs */ getTransactionIds(): string[] { - return [...this.transactionIds]; + return this.transactionIds.map(tid => tid.toString()); } } \ No newline at end of file diff --git a/core/racing/domain/entities/league-wallet/LeagueWalletId.test.ts b/core/racing/domain/entities/league-wallet/LeagueWalletId.test.ts new file mode 100644 index 000000000..202e7f975 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/LeagueWalletId.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { LeagueWalletId } from './LeagueWalletId'; + +describe('LeagueWalletId', () => { + it('should create a league wallet id', () => { + const id = LeagueWalletId.create('wallet1'); + expect(id.toString()).toBe('wallet1'); + }); + + it('should throw on empty id', () => { + expect(() => LeagueWalletId.create('')).toThrow('LeagueWallet ID cannot be empty'); + }); + + it('should trim whitespace', () => { + const id = LeagueWalletId.create(' wallet1 '); + expect(id.toString()).toBe('wallet1'); + }); + + it('should check equality', () => { + const id1 = LeagueWalletId.create('wallet1'); + const id2 = LeagueWalletId.create('wallet1'); + const id3 = LeagueWalletId.create('wallet2'); + + expect(id1.equals(id2)).toBe(true); + expect(id1.equals(id3)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/league-wallet/LeagueWalletId.ts b/core/racing/domain/entities/league-wallet/LeagueWalletId.ts new file mode 100644 index 000000000..d72e79bb6 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/LeagueWalletId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class LeagueWalletId { + private constructor(private readonly value: string) {} + + static create(value: string): LeagueWalletId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('LeagueWallet ID cannot be empty'); + } + return new LeagueWalletId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: LeagueWalletId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/league-wallet/Transaction.test.ts b/core/racing/domain/entities/league-wallet/Transaction.test.ts new file mode 100644 index 000000000..37aa26026 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/Transaction.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from 'vitest'; +import { Transaction } from './Transaction'; +import { TransactionId } from './TransactionId'; +import { LeagueWalletId } from './LeagueWalletId'; +import { Money } from '../../value-objects/Money'; + +describe('Transaction', () => { + const validId = TransactionId.create('tx1'); + const validWalletId = LeagueWalletId.create('wallet1'); + const validAmount = Money.create(10000, 'USD'); // $100.00 + + const validProps = { + id: validId, + walletId: validWalletId, + type: 'sponsorship_payment' as const, + amount: validAmount, + completedAt: undefined, + description: 'Test transaction', + metadata: undefined, + }; + + describe('create', () => { + it('should create a transaction with default values', () => { + const transaction = Transaction.create(validProps); + + expect(transaction.id).toBe(validId); + expect(transaction.walletId).toBe(validWalletId); + expect(transaction.type).toBe('sponsorship_payment'); + expect(transaction.amount).toBe(validAmount); + expect(transaction.status).toBe('pending'); + expect(transaction.createdAt).toBeInstanceOf(Date); + expect(transaction.completedAt).toBeUndefined(); + expect(transaction.description).toBe('Test transaction'); + expect(transaction.platformFee.amount).toBe(1000); // 10% of 10000 + expect(transaction.netAmount.amount).toBe(9000); // 10000 - 1000 + }); + + it('should create a transaction with custom createdAt and status', () => { + const customDate = new Date('2023-01-01'); + const transaction = Transaction.create({ + ...validProps, + createdAt: customDate, + status: 'completed', + }); + + expect(transaction.createdAt).toBe(customDate); + expect(transaction.status).toBe('completed'); + }); + + + it('should throw on zero amount', () => { + const zeroAmount = Money.create(0, 'USD'); + expect(() => Transaction.create({ ...validProps, amount: zeroAmount })).toThrow('Transaction amount must be greater than zero'); + }); + + it('should throw on negative amount', () => { + expect(() => Money.create(-100, 'USD')).toThrow(); + }); + }); + + describe('complete', () => { + it('should complete a pending transaction', () => { + const transaction = Transaction.create(validProps); + const completed = transaction.complete(); + + expect(completed.status).toBe('completed'); + expect(completed.completedAt).toBeInstanceOf(Date); + }); + + it('should throw on completing already completed transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'completed' }); + expect(() => transaction.complete()).toThrow('Transaction is already completed'); + }); + + it('should throw on completing failed transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'failed' }); + expect(() => transaction.complete()).toThrow('Cannot complete a failed or cancelled transaction'); + }); + + it('should throw on completing cancelled transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'cancelled' }); + expect(() => transaction.complete()).toThrow('Cannot complete a failed or cancelled transaction'); + }); + }); + + describe('fail', () => { + it('should fail a pending transaction', () => { + const transaction = Transaction.create(validProps); + const failed = transaction.fail(); + + expect(failed.status).toBe('failed'); + }); + + it('should throw on failing completed transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'completed' }); + expect(() => transaction.fail()).toThrow('Cannot fail a completed transaction'); + }); + }); + + describe('cancel', () => { + it('should cancel a pending transaction', () => { + const transaction = Transaction.create(validProps); + const cancelled = transaction.cancel(); + + expect(cancelled.status).toBe('cancelled'); + }); + + it('should throw on cancelling completed transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'completed' }); + expect(() => transaction.cancel()).toThrow('Cannot cancel a completed transaction'); + }); + }); + + describe('isCompleted', () => { + it('should return true for completed transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'completed' }); + expect(transaction.isCompleted()).toBe(true); + }); + + it('should return false for non-completed transaction', () => { + const transaction = Transaction.create(validProps); + expect(transaction.isCompleted()).toBe(false); + }); + }); + + describe('isPending', () => { + it('should return true for pending transaction', () => { + const transaction = Transaction.create(validProps); + expect(transaction.isPending()).toBe(true); + }); + + it('should return false for non-pending transaction', () => { + const transaction = Transaction.create({ ...validProps, status: 'completed' }); + expect(transaction.isPending()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Transaction.ts b/core/racing/domain/entities/league-wallet/Transaction.ts similarity index 89% rename from core/racing/domain/entities/Transaction.ts rename to core/racing/domain/entities/league-wallet/Transaction.ts index 31f43d1ca..53a19af2e 100644 --- a/core/racing/domain/entities/Transaction.ts +++ b/core/racing/domain/entities/league-wallet/Transaction.ts @@ -3,13 +3,15 @@ * * Represents a financial transaction in the league wallet system. */ - -import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; - -import type { Money } from '../value-objects/Money'; -import type { IEntity } from '@core/shared/domain'; -export type TransactionType = +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; + +import type { Money } from '../../value-objects/Money'; +import type { IEntity } from '@core/shared/domain'; +import type { TransactionId } from './TransactionId'; +import type { LeagueWalletId } from './LeagueWalletId'; + +export type TransactionType = | 'sponsorship_payment' | 'membership_payment' | 'prize_payout' @@ -19,8 +21,8 @@ export type TransactionType = export type TransactionStatus = 'pending' | 'completed' | 'failed' | 'cancelled'; export interface TransactionProps { - id: string; - walletId: string; + id: TransactionId; + walletId: LeagueWalletId; type: TransactionType; amount: Money; platformFee: Money; @@ -31,10 +33,10 @@ export interface TransactionProps { description: string | undefined; metadata: Record | undefined; } - -export class Transaction implements IEntity { - readonly id: string; - readonly walletId: string; + +export class Transaction implements IEntity { + readonly id: TransactionId; + readonly walletId: LeagueWalletId; readonly type: TransactionType; readonly amount: Money; readonly platformFee: Money; @@ -78,11 +80,11 @@ export class Transaction implements IEntity { } private static validate(props: Omit): void { - if (!props.id || props.id.trim().length === 0) { + if (!props.id) { throw new RacingDomainValidationError('Transaction ID is required'); } - if (!props.walletId || props.walletId.trim().length === 0) { + if (!props.walletId) { throw new RacingDomainValidationError('Transaction walletId is required'); } diff --git a/core/racing/domain/entities/league-wallet/TransactionId.test.ts b/core/racing/domain/entities/league-wallet/TransactionId.test.ts new file mode 100644 index 000000000..66ceeae30 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/TransactionId.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { TransactionId } from './TransactionId'; + +describe('TransactionId', () => { + it('should create a transaction id', () => { + const id = TransactionId.create('tx1'); + expect(id.toString()).toBe('tx1'); + }); + + it('should throw on empty id', () => { + expect(() => TransactionId.create('')).toThrow('Transaction ID cannot be empty'); + }); + + it('should trim whitespace', () => { + const id = TransactionId.create(' tx1 '); + expect(id.toString()).toBe('tx1'); + }); + + it('should check equality', () => { + const id1 = TransactionId.create('tx1'); + const id2 = TransactionId.create('tx1'); + const id3 = TransactionId.create('tx2'); + + expect(id1.equals(id2)).toBe(true); + expect(id1.equals(id3)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/league-wallet/TransactionId.ts b/core/racing/domain/entities/league-wallet/TransactionId.ts new file mode 100644 index 000000000..f7939a147 --- /dev/null +++ b/core/racing/domain/entities/league-wallet/TransactionId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class TransactionId { + private constructor(private readonly value: string) {} + + static create(value: string): TransactionId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Transaction ID cannot be empty'); + } + return new TransactionId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TransactionId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/Penalty.test.ts b/core/racing/domain/entities/penalty/Penalty.test.ts new file mode 100644 index 000000000..436ac5651 --- /dev/null +++ b/core/racing/domain/entities/penalty/Penalty.test.ts @@ -0,0 +1,231 @@ +import { Penalty } from './Penalty'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; + +describe('Penalty', () => { + describe('create', () => { + it('should create a penalty with required fields', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + }); + + expect(penalty.id).toBe('penalty-1'); + expect(penalty.leagueId).toBe('league-1'); + expect(penalty.raceId).toBe('race-1'); + expect(penalty.driverId).toBe('driver-1'); + expect(penalty.type).toBe('time_penalty'); + expect(penalty.value).toBe(5); + expect(penalty.reason).toBe('Speeding'); + expect(penalty.issuedBy).toBe('steward-1'); + expect(penalty.status).toBe('pending'); + expect(penalty.appliedAt).toBeUndefined(); + expect(penalty.notes).toBeUndefined(); + }); + + it('should create a penalty with all fields', () => { + const issuedAt = new Date('2023-01-01'); + const appliedAt = new Date('2023-01-02'); + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'disqualification', + reason: 'Unsafe driving', + protestId: 'protest-1', + issuedBy: 'steward-1', + status: 'applied', + issuedAt, + appliedAt, + notes: 'Applied after review', + }); + + expect(penalty.id).toBe('penalty-1'); + expect(penalty.type).toBe('disqualification'); + expect(penalty.value).toBeUndefined(); + expect(penalty.protestId).toBe('protest-1'); + expect(penalty.status).toBe('applied'); + expect(penalty.issuedAt).toEqual(issuedAt); + expect(penalty.appliedAt).toEqual(appliedAt); + expect(penalty.notes).toBe('Applied after review'); + }); + + it('should throw error for invalid id', () => { + expect(() => Penalty.create({ + id: '', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid type', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'invalid_type', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for missing value on time_penalty', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for zero value', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 0, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('isPending', () => { + it('should return true for pending status', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + expect(penalty.isPending()).toBe(true); + }); + + it('should return false for applied status', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'applied', + }); + expect(penalty.isPending()).toBe(false); + }); + }); + + describe('markAsApplied', () => { + it('should mark penalty as applied', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + const applied = penalty.markAsApplied('Applied successfully'); + expect(applied.status).toBe('applied'); + expect(applied.appliedAt).toBeInstanceOf(Date); + expect(applied.notes).toBe('Applied successfully'); + }); + + it('should throw error if already applied', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'applied', + }); + expect(() => penalty.markAsApplied()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('overturn', () => { + it('should overturn the penalty', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + const overturned = penalty.overturn('Appealed successfully'); + expect(overturned.status).toBe('overturned'); + expect(overturned.notes).toBe('Appealed successfully'); + }); + + it('should throw error if already overturned', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'overturned', + }); + expect(() => penalty.overturn('Reason')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('getDescription', () => { + it('should return description for time_penalty', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + }); + expect(penalty.getDescription()).toBe('+5s time penalty'); + }); + + it('should return description for disqualification', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'disqualification', + reason: 'Unsafe', + issuedBy: 'steward-1', + }); + expect(penalty.getDescription()).toBe('Disqualified from race'); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/Penalty.ts b/core/racing/domain/entities/penalty/Penalty.ts new file mode 100644 index 000000000..d9622cf60 --- /dev/null +++ b/core/racing/domain/entities/penalty/Penalty.ts @@ -0,0 +1,187 @@ +/** + * Domain Entity: Penalty + * + * Represents a penalty applied to a driver for an incident during a race. + * Penalties can be applied as a result of an upheld protest or directly by stewards. + */ + +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; +import type { IEntity } from '@core/shared/domain'; +import { PenaltyId } from './PenaltyId'; +import { LeagueId } from '../LeagueId'; +import { RaceId } from '../RaceId'; +import { DriverId } from '../DriverId'; +import { PenaltyType } from './PenaltyType'; +import { PenaltyValue } from './PenaltyValue'; +import { PenaltyReason } from './PenaltyReason'; +import { ProtestId } from '../ProtestId'; +import { StewardId } from '../StewardId'; +import { PenaltyStatus } from './PenaltyStatus'; +import { IssuedAt } from '../IssuedAt'; +import { AppliedAt } from '../AppliedAt'; +import { PenaltyNotes } from './PenaltyNotes'; + +export interface PenaltyProps { + id: PenaltyId; + leagueId: LeagueId; + raceId: RaceId; + /** The driver receiving the penalty */ + driverId: DriverId; + /** Type of penalty */ + type: PenaltyType; + /** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */ + value?: PenaltyValue; + /** Reason for the penalty */ + reason: PenaltyReason; + /** ID of the protest that led to this penalty (if applicable) */ + protestId?: ProtestId; + /** ID of the steward who issued the penalty */ + issuedBy: StewardId; + /** Current status of the penalty */ + status: PenaltyStatus; + /** Timestamp when the penalty was issued */ + issuedAt: IssuedAt; + /** Timestamp when the penalty was applied to results */ + appliedAt?: AppliedAt; + /** Notes about the penalty application */ + notes?: PenaltyNotes; +} + +export class Penalty implements IEntity { + private constructor(private readonly props: PenaltyProps) {} + + static create(props: { + id: string; + leagueId: string; + raceId: string; + driverId: string; + type: string; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + status?: string; + issuedAt?: Date; + appliedAt?: Date; + notes?: string; + }): Penalty { + if (!props.id) throw new RacingDomainValidationError('Penalty ID is required'); + if (!props.leagueId) throw new RacingDomainValidationError('League ID is required'); + if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); + if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required'); + if (!props.type) throw new RacingDomainValidationError('Penalty type is required'); + if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); + if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); + + // Validate value based on type + if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) { + if (props.value === undefined || props.value <= 0) { + throw new RacingDomainValidationError(`${props.type} requires a positive value`); + } + } + + const penaltyProps: PenaltyProps = { + id: PenaltyId.create(props.id), + leagueId: LeagueId.create(props.leagueId), + raceId: RaceId.create(props.raceId), + driverId: DriverId.create(props.driverId), + type: PenaltyType.create(props.type), + reason: PenaltyReason.create(props.reason), + issuedBy: StewardId.create(props.issuedBy), + status: PenaltyStatus.create(props.status || 'pending'), + issuedAt: IssuedAt.create(props.issuedAt || new Date()), + ...(props.value !== undefined && { value: PenaltyValue.create(props.value) }), + ...(props.protestId !== undefined && { protestId: ProtestId.create(props.protestId) }), + ...(props.appliedAt !== undefined && { appliedAt: AppliedAt.create(props.appliedAt) }), + ...(props.notes !== undefined && { notes: PenaltyNotes.create(props.notes) }), + }; + + return new Penalty(penaltyProps); + } + + get id(): string { return this.props.id.toString(); } + get leagueId(): string { return this.props.leagueId.toString(); } + get raceId(): string { return this.props.raceId.toString(); } + get driverId(): string { return this.props.driverId.toString(); } + get type(): string { return this.props.type.toString(); } + get value(): number | undefined { return this.props.value?.toNumber(); } + get reason(): string { return this.props.reason.toString(); } + get protestId(): string | undefined { return this.props.protestId?.toString(); } + get issuedBy(): string { return this.props.issuedBy.toString(); } + get status(): string { return this.props.status.toString(); } + get issuedAt(): Date { return this.props.issuedAt.toDate(); } + get appliedAt(): Date | undefined { return this.props.appliedAt?.toDate(); } + get notes(): string | undefined { return this.props.notes?.toString(); } + + isPending(): boolean { + return this.props.status.toString() === 'pending'; + } + + isApplied(): boolean { + return this.props.status.toString() === 'applied'; + } + + /** + * Mark penalty as applied (after recalculating results) + */ + markAsApplied(notes?: string): Penalty { + if (this.isApplied()) { + throw new RacingDomainInvariantError('Penalty is already applied'); + } + if (this.props.status.toString() === 'overturned') { + throw new RacingDomainInvariantError('Cannot apply an overturned penalty'); + } + const base: PenaltyProps = { + ...this.props, + status: PenaltyStatus.create('applied'), + appliedAt: AppliedAt.create(new Date()), + }; + + const next: PenaltyProps = + notes !== undefined ? { ...base, notes: PenaltyNotes.create(notes) } : base; + + return new Penalty(next); + } + + /** + * Overturn the penalty (e.g., after successful appeal) + */ + overturn(reason: string): Penalty { + if (this.props.status.toString() === 'overturned') { + throw new RacingDomainInvariantError('Penalty is already overturned'); + } + return new Penalty({ + ...this.props, + status: PenaltyStatus.create('overturned'), + notes: PenaltyNotes.create(reason), + }); + } + + /** + * Get a human-readable description of the penalty + */ + getDescription(): string { + switch (this.props.type.toString()) { + case 'time_penalty': + return `+${this.props.value?.toNumber()}s time penalty`; + case 'grid_penalty': + return `${this.props.value?.toNumber()} place grid penalty (next race)`; + case 'points_deduction': + return `${this.props.value?.toNumber()} championship points deducted`; + case 'disqualification': + return 'Disqualified from race'; + case 'warning': + return 'Official warning'; + case 'license_points': + return `${this.props.value?.toNumber()} license penalty points`; + case 'probation': + return 'Probationary period'; + case 'fine': + return `${this.props.value?.toNumber()} points fine`; + case 'race_ban': + return `${this.props.value?.toNumber()} race suspension`; + default: + return 'Penalty'; + } + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyId.test.ts b/core/racing/domain/entities/penalty/PenaltyId.test.ts new file mode 100644 index 000000000..83c96607f --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyId.test.ts @@ -0,0 +1,38 @@ +import { PenaltyId } from './PenaltyId'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyId', () => { + describe('create', () => { + it('should create a PenaltyId with valid value', () => { + const id = PenaltyId.create('penalty-123'); + expect(id.toString()).toBe('penalty-123'); + }); + + it('should trim whitespace', () => { + const id = PenaltyId.create(' penalty-123 '); + expect(id.toString()).toBe('penalty-123'); + }); + + it('should throw error for empty string', () => { + expect(() => PenaltyId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => PenaltyId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = PenaltyId.create('penalty-123'); + const id2 = PenaltyId.create('penalty-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = PenaltyId.create('penalty-123'); + const id2 = PenaltyId.create('penalty-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyId.ts b/core/racing/domain/entities/penalty/PenaltyId.ts new file mode 100644 index 000000000..6c770c437 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class PenaltyId { + private constructor(private readonly value: string) {} + + static create(value: string): PenaltyId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Penalty ID cannot be empty'); + } + return new PenaltyId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: PenaltyId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyNotes.test.ts b/core/racing/domain/entities/penalty/PenaltyNotes.test.ts new file mode 100644 index 000000000..5e570c398 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyNotes.test.ts @@ -0,0 +1,38 @@ +import { PenaltyNotes } from './PenaltyNotes'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyNotes', () => { + describe('create', () => { + it('should create a PenaltyNotes with valid value', () => { + const notes = PenaltyNotes.create('Additional notes'); + expect(notes.toString()).toBe('Additional notes'); + }); + + it('should trim whitespace', () => { + const notes = PenaltyNotes.create(' Additional notes '); + expect(notes.toString()).toBe('Additional notes'); + }); + + it('should throw error for empty string', () => { + expect(() => PenaltyNotes.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => PenaltyNotes.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal notes', () => { + const notes1 = PenaltyNotes.create('Note'); + const notes2 = PenaltyNotes.create('Note'); + expect(notes1.equals(notes2)).toBe(true); + }); + + it('should return false for different notes', () => { + const notes1 = PenaltyNotes.create('Note1'); + const notes2 = PenaltyNotes.create('Note2'); + expect(notes1.equals(notes2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyNotes.ts b/core/racing/domain/entities/penalty/PenaltyNotes.ts new file mode 100644 index 000000000..9fc539003 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyNotes.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class PenaltyNotes { + private constructor(private readonly value: string) {} + + static create(value: string): PenaltyNotes { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Penalty notes cannot be empty'); + } + return new PenaltyNotes(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: PenaltyNotes): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyReason.test.ts b/core/racing/domain/entities/penalty/PenaltyReason.test.ts new file mode 100644 index 000000000..fee2681b8 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyReason.test.ts @@ -0,0 +1,38 @@ +import { PenaltyReason } from './PenaltyReason'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyReason', () => { + describe('create', () => { + it('should create a PenaltyReason with valid value', () => { + const reason = PenaltyReason.create('Speeding in pit lane'); + expect(reason.toString()).toBe('Speeding in pit lane'); + }); + + it('should trim whitespace', () => { + const reason = PenaltyReason.create(' Speeding in pit lane '); + expect(reason.toString()).toBe('Speeding in pit lane'); + }); + + it('should throw error for empty string', () => { + expect(() => PenaltyReason.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => PenaltyReason.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal reasons', () => { + const reason1 = PenaltyReason.create('Speeding'); + const reason2 = PenaltyReason.create('Speeding'); + expect(reason1.equals(reason2)).toBe(true); + }); + + it('should return false for different reasons', () => { + const reason1 = PenaltyReason.create('Speeding'); + const reason2 = PenaltyReason.create('Cutting'); + expect(reason1.equals(reason2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyReason.ts b/core/racing/domain/entities/penalty/PenaltyReason.ts new file mode 100644 index 000000000..3d93504ec --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyReason.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class PenaltyReason { + private constructor(private readonly value: string) {} + + static create(value: string): PenaltyReason { + const trimmed = value.trim(); + if (!trimmed) { + throw new RacingDomainValidationError('Penalty reason cannot be empty'); + } + return new PenaltyReason(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: PenaltyReason): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyStatus.test.ts b/core/racing/domain/entities/penalty/PenaltyStatus.test.ts new file mode 100644 index 000000000..b4a0b124c --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyStatus.test.ts @@ -0,0 +1,42 @@ +import { PenaltyStatus } from './PenaltyStatus'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyStatus', () => { + describe('create', () => { + it('should create a PenaltyStatus with valid value', () => { + const status = PenaltyStatus.create('pending'); + expect(status.toString()).toBe('pending'); + }); + + it('should create all valid statuses', () => { + const validStatuses = ['pending', 'applied', 'appealed', 'overturned']; + + validStatuses.forEach(statusValue => { + const status = PenaltyStatus.create(statusValue); + expect(status.toString()).toBe(statusValue); + }); + }); + + it('should throw error for invalid status', () => { + expect(() => PenaltyStatus.create('invalid_status')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty string', () => { + expect(() => PenaltyStatus.create('')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal statuses', () => { + const status1 = PenaltyStatus.create('pending'); + const status2 = PenaltyStatus.create('pending'); + expect(status1.equals(status2)).toBe(true); + }); + + it('should return false for different statuses', () => { + const status1 = PenaltyStatus.create('pending'); + const status2 = PenaltyStatus.create('applied'); + expect(status1.equals(status2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyStatus.ts b/core/racing/domain/entities/penalty/PenaltyStatus.ts new file mode 100644 index 000000000..1bf030ea6 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyStatus.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export type PenaltyStatusValue = 'pending' | 'applied' | 'appealed' | 'overturned'; + +export class PenaltyStatus { + private constructor(private readonly value: PenaltyStatusValue) {} + + static create(value: string): PenaltyStatus { + const validStatuses: PenaltyStatusValue[] = ['pending', 'applied', 'appealed', 'overturned']; + + if (!validStatuses.includes(value as PenaltyStatusValue)) { + throw new RacingDomainValidationError(`Invalid penalty status: ${value}`); + } + + return new PenaltyStatus(value as PenaltyStatusValue); + } + + toString(): PenaltyStatusValue { + return this.value; + } + + equals(other: PenaltyStatus): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyType.test.ts b/core/racing/domain/entities/penalty/PenaltyType.test.ts new file mode 100644 index 000000000..e1a885246 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyType.test.ts @@ -0,0 +1,52 @@ +import { PenaltyType } from './PenaltyType'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyType', () => { + describe('create', () => { + it('should create a PenaltyType with valid value', () => { + const type = PenaltyType.create('time_penalty'); + expect(type.toString()).toBe('time_penalty'); + }); + + it('should create all valid types', () => { + const validTypes = [ + 'time_penalty', + 'grid_penalty', + 'points_deduction', + 'disqualification', + 'warning', + 'license_points', + 'probation', + 'fine', + 'race_ban', + ]; + + validTypes.forEach(typeValue => { + const type = PenaltyType.create(typeValue); + expect(type.toString()).toBe(typeValue); + }); + }); + + it('should throw error for invalid type', () => { + expect(() => PenaltyType.create('invalid_type')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty string', () => { + expect(() => PenaltyType.create('')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal types', () => { + const type1 = PenaltyType.create('time_penalty'); + const type2 = PenaltyType.create('time_penalty'); + expect(type1.equals(type2)).toBe(true); + }); + + it('should return false for different types', () => { + const type1 = PenaltyType.create('time_penalty'); + const type2 = PenaltyType.create('grid_penalty'); + expect(type1.equals(type2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyType.ts b/core/racing/domain/entities/penalty/PenaltyType.ts new file mode 100644 index 000000000..09b044e09 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyType.ts @@ -0,0 +1,44 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export type PenaltyTypeValue = + | 'time_penalty' + | 'grid_penalty' + | 'points_deduction' + | 'disqualification' + | 'warning' + | 'license_points' + | 'probation' + | 'fine' + | 'race_ban'; + +export class PenaltyType { + private constructor(private readonly value: PenaltyTypeValue) {} + + static create(value: string): PenaltyType { + const validTypes: PenaltyTypeValue[] = [ + 'time_penalty', + 'grid_penalty', + 'points_deduction', + 'disqualification', + 'warning', + 'license_points', + 'probation', + 'fine', + 'race_ban', + ]; + + if (!validTypes.includes(value as PenaltyTypeValue)) { + throw new RacingDomainValidationError(`Invalid penalty type: ${value}`); + } + + return new PenaltyType(value as PenaltyTypeValue); + } + + toString(): PenaltyTypeValue { + return this.value; + } + + equals(other: PenaltyType): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyValue.test.ts b/core/racing/domain/entities/penalty/PenaltyValue.test.ts new file mode 100644 index 000000000..90c11613f --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyValue.test.ts @@ -0,0 +1,37 @@ +import { PenaltyValue } from './PenaltyValue'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PenaltyValue', () => { + describe('create', () => { + it('should create a PenaltyValue with positive integer', () => { + const value = PenaltyValue.create(5); + expect(value.toNumber()).toBe(5); + }); + + it('should throw error for zero', () => { + expect(() => PenaltyValue.create(0)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for negative number', () => { + expect(() => PenaltyValue.create(-1)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-integer', () => { + expect(() => PenaltyValue.create(5.5)).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal values', () => { + const value1 = PenaltyValue.create(5); + const value2 = PenaltyValue.create(5); + expect(value1.equals(value2)).toBe(true); + }); + + it('should return false for different values', () => { + const value1 = PenaltyValue.create(5); + const value2 = PenaltyValue.create(10); + expect(value1.equals(value2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/penalty/PenaltyValue.ts b/core/racing/domain/entities/penalty/PenaltyValue.ts new file mode 100644 index 000000000..04bfa49c2 --- /dev/null +++ b/core/racing/domain/entities/penalty/PenaltyValue.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class PenaltyValue { + private constructor(private readonly value: number) {} + + static create(value: number): PenaltyValue { + if (value <= 0 || !Number.isInteger(value)) { + throw new RacingDomainValidationError('Penalty value must be a positive integer'); + } + return new PenaltyValue(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: PenaltyValue): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/prize/Prize.test.ts b/core/racing/domain/entities/prize/Prize.test.ts new file mode 100644 index 000000000..73797c36a --- /dev/null +++ b/core/racing/domain/entities/prize/Prize.test.ts @@ -0,0 +1,303 @@ +import { Prize } from './Prize'; +import { Money } from '../../value-objects/Money'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; + +describe('Prize', () => { + describe('create', () => { + it('should create a prize with required fields', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + + expect(prize.id.toString()).toBe('prize-1'); + expect(prize.seasonId.toString()).toBe('season-1'); + expect(prize.position.toNumber()).toBe(1); + expect(prize.amount).toEqual(amount); + expect(prize.driverId).toBeUndefined(); + expect(prize.status.toString()).toBe('pending'); + expect(prize.awardedAt).toBeUndefined(); + expect(prize.paidAt).toBeUndefined(); + expect(prize.description).toBeUndefined(); + }); + + it('should create a prize with all fields', () => { + const amount = Money.create(2000, 'USD'); + const createdAt = new Date('2023-01-01'); + const awardedAt = new Date('2023-01-02'); + const paidAt = new Date('2023-01-03'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 2, + amount, + driverId: 'driver-1', + status: 'paid', + createdAt, + awardedAt, + paidAt, + description: 'First place prize', + }); + + expect(prize.id.toString()).toBe('prize-1'); + expect(prize.seasonId.toString()).toBe('season-1'); + expect(prize.position.toNumber()).toBe(2); + expect(prize.amount).toEqual(amount); + expect(prize.driverId!.toString()).toBe('driver-1'); + expect(prize.status.toString()).toBe('paid'); + expect(prize.createdAt).toEqual(createdAt); + expect(prize.awardedAt).toEqual(awardedAt); + expect(prize.paidAt).toEqual(paidAt); + expect(prize.description).toBe('First place prize'); + }); + + it('should throw error for invalid id', () => { + const amount = Money.create(1000, 'USD'); + expect(() => Prize.create({ + id: '', + seasonId: 'season-1', + position: 1, + amount, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid seasonId', () => { + const amount = Money.create(1000, 'USD'); + expect(() => Prize.create({ + id: 'prize-1', + seasonId: '', + position: 1, + amount, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid position', () => { + const amount = Money.create(1000, 'USD'); + expect(() => Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 0, + amount, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-integer position', () => { + const amount = Money.create(1000, 'USD'); + expect(() => Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1.5, + amount, + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for zero amount', () => { + const amount = Money.create(0, 'USD'); + expect(() => Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('awardTo', () => { + it('should award prize to a driver', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + const awarded = prize.awardTo('driver-1'); + expect(awarded.driverId!.toString()).toBe('driver-1'); + expect(awarded.status.toString()).toBe('awarded'); + expect(awarded.awardedAt).toBeInstanceOf(Date); + }); + + it('should throw error for empty driverId', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + expect(() => prize.awardTo('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error if not pending', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'awarded', + }); + expect(() => prize.awardTo('driver-1')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('markAsPaid', () => { + it('should mark prize as paid', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + driverId: 'driver-1', + status: 'awarded', + }); + const paid = prize.markAsPaid(); + expect(paid.status.toString()).toBe('paid'); + expect(paid.paidAt).toBeInstanceOf(Date); + }); + + it('should throw error if not awarded', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + expect(() => prize.markAsPaid()).toThrow(RacingDomainInvariantError); + }); + + it('should throw error if no driverId', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'awarded', + }); + expect(() => prize.markAsPaid()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('cancel', () => { + it('should cancel pending prize', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + const cancelled = prize.cancel(); + expect(cancelled.status.toString()).toBe('cancelled'); + }); + + it('should cancel awarded prize', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + driverId: 'driver-1', + status: 'awarded', + }); + const cancelled = prize.cancel(); + expect(cancelled.status.toString()).toBe('cancelled'); + }); + + it('should throw error if paid', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + driverId: 'driver-1', + status: 'paid', + }); + expect(() => prize.cancel()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('isPending', () => { + it('should return true for pending status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + expect(prize.isPending()).toBe(true); + }); + + it('should return false for awarded status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'awarded', + }); + expect(prize.isPending()).toBe(false); + }); + }); + + describe('isAwarded', () => { + it('should return true for awarded status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'awarded', + }); + expect(prize.isAwarded()).toBe(true); + }); + + it('should return false for pending status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + }); + expect(prize.isAwarded()).toBe(false); + }); + }); + + describe('isPaid', () => { + it('should return true for paid status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'paid', + }); + expect(prize.isPaid()).toBe(true); + }); + + it('should return false for awarded status', () => { + const amount = Money.create(1000, 'USD'); + const prize = Prize.create({ + id: 'prize-1', + seasonId: 'season-1', + position: 1, + amount, + status: 'awarded', + }); + expect(prize.isPaid()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Prize.ts b/core/racing/domain/entities/prize/Prize.ts similarity index 64% rename from core/racing/domain/entities/Prize.ts rename to core/racing/domain/entities/prize/Prize.ts index 224851700..4427d4654 100644 --- a/core/racing/domain/entities/Prize.ts +++ b/core/racing/domain/entities/prize/Prize.ts @@ -3,20 +3,23 @@ * * Represents a prize awarded to a driver for a specific position in a season. */ - -import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; -import type { Money } from '../value-objects/Money'; - -export type PrizeStatus = 'pending' | 'awarded' | 'paid' | 'cancelled'; +import type { Money } from '../../value-objects/Money'; +import { Position } from '../championship/Position'; +import { PrizeId } from './PrizeId'; +import { PrizeStatus } from './PrizeStatus'; +import { SeasonId } from '../SeasonId'; +import { DriverId } from '../DriverId'; export interface PrizeProps { - id: string; - seasonId: string; - position: number; + id: PrizeId; + seasonId: SeasonId; + position: Position; amount: Money; - driverId: string; + driverId: DriverId | undefined; status: PrizeStatus; createdAt: Date; awardedAt: Date | undefined; @@ -24,12 +27,12 @@ export interface PrizeProps { description: string | undefined; } -export class Prize implements IEntity { - readonly id: string; - readonly seasonId: string; - readonly position: number; +export class Prize implements IEntity { + readonly id: PrizeId; + readonly seasonId: SeasonId; + readonly position: Position; readonly amount: Money; - readonly driverId: string; + readonly driverId: DriverId | undefined; readonly status: PrizeStatus; readonly createdAt: Date; readonly awardedAt: Date | undefined; @@ -43,23 +46,29 @@ export class Prize implements IEntity { this.amount = props.amount; this.driverId = props.driverId; this.status = props.status; - this.createdAt = props.createdAt ?? new Date(); + this.createdAt = props.createdAt; this.awardedAt = props.awardedAt; this.paidAt = props.paidAt; this.description = props.description; } - static create(props: Omit & { + static create(props: Omit & { + id: string; + seasonId: string; + position: number; createdAt?: Date; - status?: PrizeStatus; + status?: string; driverId?: string; awardedAt?: Date; paidAt?: Date; description?: string; }): Prize { const fullProps: Omit = { - ...props, - driverId: props.driverId ?? '', + id: PrizeId.create(props.id), + seasonId: SeasonId.create(props.seasonId), + position: Position.create(props.position), + amount: props.amount, + driverId: props.driverId ? DriverId.create(props.driverId) : undefined, awardedAt: props.awardedAt, paidAt: props.paidAt, description: props.description, @@ -70,23 +79,11 @@ export class Prize implements IEntity { return new Prize({ ...fullProps, createdAt: props.createdAt ?? new Date(), - status: props.status ?? 'pending', + status: PrizeStatus.create(props.status ?? 'pending'), }); } private static validate(props: Omit): void { - if (!props.id || props.id.trim().length === 0) { - throw new RacingDomainValidationError('Prize ID is required'); - } - - if (!props.seasonId || props.seasonId.trim().length === 0) { - throw new RacingDomainValidationError('Prize seasonId is required'); - } - - if (!Number.isInteger(props.position) || props.position < 1) { - throw new RacingDomainValidationError('Prize position must be a positive integer'); - } - if (!props.amount) { throw new RacingDomainValidationError('Prize amount is required'); } @@ -104,14 +101,14 @@ export class Prize implements IEntity { throw new RacingDomainValidationError('Driver ID is required to award prize'); } - if (this.status !== 'pending') { + if (this.status.toString() !== 'pending') { throw new RacingDomainInvariantError('Only pending prizes can be awarded'); } return new Prize({ ...this, - driverId, - status: 'awarded', + driverId: DriverId.create(driverId), + status: PrizeStatus.create('awarded'), awardedAt: new Date(), }); } @@ -120,17 +117,17 @@ export class Prize implements IEntity { * Mark prize as paid */ markAsPaid(): Prize { - if (this.status !== 'awarded') { + if (this.status.toString() !== 'awarded') { throw new RacingDomainInvariantError('Only awarded prizes can be marked as paid'); } - if (!this.driverId || this.driverId.trim() === '') { + if (!this.driverId) { throw new RacingDomainInvariantError('Prize must have a driver to be paid'); } return new Prize({ ...this, - status: 'paid', + status: PrizeStatus.create('paid'), paidAt: new Date(), }); } @@ -139,13 +136,13 @@ export class Prize implements IEntity { * Cancel prize */ cancel(): Prize { - if (this.status === 'paid') { + if (this.status.toString() === 'paid') { throw new RacingDomainInvariantError('Cannot cancel a paid prize'); } return new Prize({ ...this, - status: 'cancelled', + status: PrizeStatus.create('cancelled'), }); } @@ -153,20 +150,20 @@ export class Prize implements IEntity { * Check if prize is pending */ isPending(): boolean { - return this.status === 'pending'; + return this.status.toString() === 'pending'; } /** * Check if prize is awarded */ isAwarded(): boolean { - return this.status === 'awarded'; + return this.status.toString() === 'awarded'; } /** * Check if prize is paid */ isPaid(): boolean { - return this.status === 'paid'; + return this.status.toString() === 'paid'; } } \ No newline at end of file diff --git a/core/racing/domain/entities/prize/PrizeId.test.ts b/core/racing/domain/entities/prize/PrizeId.test.ts new file mode 100644 index 000000000..cb1b2f5dc --- /dev/null +++ b/core/racing/domain/entities/prize/PrizeId.test.ts @@ -0,0 +1,38 @@ +import { PrizeId } from './PrizeId'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PrizeId', () => { + describe('create', () => { + it('should create a PrizeId with valid value', () => { + const id = PrizeId.create('prize-123'); + expect(id.toString()).toBe('prize-123'); + }); + + it('should trim whitespace', () => { + const id = PrizeId.create(' prize-123 '); + expect(id.toString()).toBe('prize-123'); + }); + + it('should throw error for empty string', () => { + expect(() => PrizeId.create('')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for whitespace only', () => { + expect(() => PrizeId.create(' ')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal ids', () => { + const id1 = PrizeId.create('prize-123'); + const id2 = PrizeId.create('prize-123'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should return false for different ids', () => { + const id1 = PrizeId.create('prize-123'); + const id2 = PrizeId.create('prize-456'); + expect(id1.equals(id2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/prize/PrizeId.ts b/core/racing/domain/entities/prize/PrizeId.ts new file mode 100644 index 000000000..35d0438ed --- /dev/null +++ b/core/racing/domain/entities/prize/PrizeId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class PrizeId { + private constructor(private readonly value: string) {} + + static create(value: string): PrizeId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Prize ID cannot be empty'); + } + return new PrizeId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: PrizeId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/prize/PrizeStatus.test.ts b/core/racing/domain/entities/prize/PrizeStatus.test.ts new file mode 100644 index 000000000..570bc5ed2 --- /dev/null +++ b/core/racing/domain/entities/prize/PrizeStatus.test.ts @@ -0,0 +1,42 @@ +import { PrizeStatus } from './PrizeStatus'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('PrizeStatus', () => { + describe('create', () => { + it('should create a PrizeStatus with valid value', () => { + const status = PrizeStatus.create('pending'); + expect(status.toString()).toBe('pending'); + }); + + it('should create all valid statuses', () => { + const validStatuses = ['pending', 'awarded', 'paid', 'cancelled']; + + validStatuses.forEach(statusValue => { + const status = PrizeStatus.create(statusValue); + expect(status.toString()).toBe(statusValue); + }); + }); + + it('should throw error for invalid status', () => { + expect(() => PrizeStatus.create('invalid_status')).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty string', () => { + expect(() => PrizeStatus.create('')).toThrow(RacingDomainValidationError); + }); + }); + + describe('equals', () => { + it('should return true for equal statuses', () => { + const status1 = PrizeStatus.create('pending'); + const status2 = PrizeStatus.create('pending'); + expect(status1.equals(status2)).toBe(true); + }); + + it('should return false for different statuses', () => { + const status1 = PrizeStatus.create('pending'); + const status2 = PrizeStatus.create('awarded'); + expect(status1.equals(status2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/prize/PrizeStatus.ts b/core/racing/domain/entities/prize/PrizeStatus.ts new file mode 100644 index 000000000..68191672d --- /dev/null +++ b/core/racing/domain/entities/prize/PrizeStatus.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export type PrizeStatusValue = 'pending' | 'awarded' | 'paid' | 'cancelled'; + +export class PrizeStatus { + private constructor(private readonly value: PrizeStatusValue) {} + + static create(value: string): PrizeStatus { + const validStatuses: PrizeStatusValue[] = ['pending', 'awarded', 'paid', 'cancelled']; + + if (!validStatuses.includes(value as PrizeStatusValue)) { + throw new RacingDomainValidationError(`Invalid prize status: ${value}`); + } + + return new PrizeStatus(value as PrizeStatusValue); + } + + toString(): PrizeStatusValue { + return this.value; + } + + equals(other: PrizeStatus): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/result/IncidentCount.test.ts b/core/racing/domain/entities/result/IncidentCount.test.ts new file mode 100644 index 000000000..2c5d8d22e --- /dev/null +++ b/core/racing/domain/entities/result/IncidentCount.test.ts @@ -0,0 +1,45 @@ +import { IncidentCount } from './IncidentCount'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('IncidentCount', () => { + describe('create', () => { + it('should create an IncidentCount with valid non-negative integer', () => { + const incidentCount = IncidentCount.create(5); + expect(incidentCount.toNumber()).toBe(5); + }); + + it('should create with zero', () => { + const incidentCount = IncidentCount.create(0); + expect(incidentCount.toNumber()).toBe(0); + }); + + it('should throw error for negative number', () => { + expect(() => IncidentCount.create(-1)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-integer', () => { + expect(() => IncidentCount.create(1.5)).toThrow(RacingDomainValidationError); + }); + }); + + describe('toNumber', () => { + it('should return the number value', () => { + const incidentCount = IncidentCount.create(3); + expect(incidentCount.toNumber()).toBe(3); + }); + }); + + describe('equals', () => { + it('should return true for equal incident counts', () => { + const ic1 = IncidentCount.create(2); + const ic2 = IncidentCount.create(2); + expect(ic1.equals(ic2)).toBe(true); + }); + + it('should return false for different incident counts', () => { + const ic1 = IncidentCount.create(2); + const ic2 = IncidentCount.create(3); + expect(ic1.equals(ic2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/result/IncidentCount.ts b/core/racing/domain/entities/result/IncidentCount.ts new file mode 100644 index 000000000..f9669368b --- /dev/null +++ b/core/racing/domain/entities/result/IncidentCount.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class IncidentCount { + private constructor(private readonly value: number) {} + + static create(value: number): IncidentCount { + if (!Number.isInteger(value) || value < 0) { + throw new RacingDomainValidationError('Incident count must be a non-negative integer'); + } + return new IncidentCount(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: IncidentCount): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/result/LapTime.test.ts b/core/racing/domain/entities/result/LapTime.test.ts new file mode 100644 index 000000000..deeb7833b --- /dev/null +++ b/core/racing/domain/entities/result/LapTime.test.ts @@ -0,0 +1,45 @@ +import { LapTime } from './LapTime'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('LapTime', () => { + describe('create', () => { + it('should create a LapTime with valid non-negative number', () => { + const lapTime = LapTime.create(120.5); + expect(lapTime.toNumber()).toBe(120.5); + }); + + it('should create with zero', () => { + const lapTime = LapTime.create(0); + expect(lapTime.toNumber()).toBe(0); + }); + + it('should throw error for negative number', () => { + expect(() => LapTime.create(-1)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for NaN', () => { + expect(() => LapTime.create(NaN)).toThrow(RacingDomainValidationError); + }); + }); + + describe('toNumber', () => { + it('should return the number value', () => { + const lapTime = LapTime.create(95.2); + expect(lapTime.toNumber()).toBe(95.2); + }); + }); + + describe('equals', () => { + it('should return true for equal lap times', () => { + const lt1 = LapTime.create(100.0); + const lt2 = LapTime.create(100.0); + expect(lt1.equals(lt2)).toBe(true); + }); + + it('should return false for different lap times', () => { + const lt1 = LapTime.create(100.0); + const lt2 = LapTime.create(101.0); + expect(lt1.equals(lt2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/result/LapTime.ts b/core/racing/domain/entities/result/LapTime.ts new file mode 100644 index 000000000..029e8d451 --- /dev/null +++ b/core/racing/domain/entities/result/LapTime.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class LapTime { + private constructor(private readonly value: number) {} + + static create(value: number): LapTime { + if (typeof value !== 'number' || value < 0 || isNaN(value)) { + throw new RacingDomainValidationError('Lap time must be a non-negative number'); + } + return new LapTime(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: LapTime): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/result/Position.test.ts b/core/racing/domain/entities/result/Position.test.ts new file mode 100644 index 000000000..a614bb19d --- /dev/null +++ b/core/racing/domain/entities/result/Position.test.ts @@ -0,0 +1,44 @@ +import { Position } from './Position'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +describe('Position', () => { + describe('create', () => { + it('should create a Position with valid positive integer', () => { + const position = Position.create(1); + expect(position.toNumber()).toBe(1); + }); + + it('should throw error for zero', () => { + expect(() => Position.create(0)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for negative number', () => { + expect(() => Position.create(-1)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for non-integer', () => { + expect(() => Position.create(1.5)).toThrow(RacingDomainValidationError); + }); + }); + + describe('toNumber', () => { + it('should return the number value', () => { + const position = Position.create(5); + expect(position.toNumber()).toBe(5); + }); + }); + + describe('equals', () => { + it('should return true for equal positions', () => { + const pos1 = Position.create(2); + const pos2 = Position.create(2); + expect(pos1.equals(pos2)).toBe(true); + }); + + it('should return false for different positions', () => { + const pos1 = Position.create(2); + const pos2 = Position.create(3); + expect(pos1.equals(pos2)).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/result/Position.ts b/core/racing/domain/entities/result/Position.ts new file mode 100644 index 000000000..58d592ecb --- /dev/null +++ b/core/racing/domain/entities/result/Position.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class Position { + private constructor(private readonly value: number) {} + + static create(value: number): Position { + if (!Number.isInteger(value) || value <= 0) { + throw new RacingDomainValidationError('Position must be a positive integer'); + } + return new Position(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Position): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/result/Result.test.ts b/core/racing/domain/entities/result/Result.test.ts new file mode 100644 index 000000000..3182c2b74 --- /dev/null +++ b/core/racing/domain/entities/result/Result.test.ts @@ -0,0 +1,261 @@ +import { Result } from './Result'; +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; +import { RaceId } from '../RaceId'; +import { DriverId } from '../DriverId'; +import { Position } from './Position'; +import { LapTime } from './LapTime'; +import { IncidentCount } from './IncidentCount'; + +describe('Result', () => { + const validId = 'result-123'; + const validRaceId = 'race-456'; + const validDriverId = 'driver-789'; + const validPosition = 1; + const validFastestLap = 95.5; + const validIncidents = 0; + const validStartPosition = 2; + + describe('create', () => { + it('should create a Result with valid props', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + const result = Result.create(props); + + expect(result.id).toBe(validId); + expect(result.raceId.toString()).toBe(validRaceId); + expect(result.driverId.toString()).toBe(validDriverId); + expect(result.position.toNumber()).toBe(validPosition); + expect(result.fastestLap.toNumber()).toBe(validFastestLap); + expect(result.incidents.toNumber()).toBe(validIncidents); + expect(result.startPosition.toNumber()).toBe(validStartPosition); + }); + + it('should throw error for empty id', () => { + const props = { + id: '', + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty raceId', () => { + const props = { + id: validId, + raceId: '', + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for empty driverId', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: '', + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid position', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: 0, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid fastestLap', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: -1, + incidents: validIncidents, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid incidents', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: -1, + startPosition: validStartPosition, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid startPosition', () => { + const props = { + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: 0, + }; + + expect(() => Result.create(props)).toThrow(RacingDomainValidationError); + }); + }); + + describe('entity properties', () => { + let result: Result; + + beforeEach(() => { + result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }); + }); + + it('should have readonly id', () => { + expect(result.id).toBe(validId); + }); + + it('should have readonly raceId as RaceId', () => { + expect(result.raceId).toBeInstanceOf(RaceId); + expect(result.raceId.toString()).toBe(validRaceId); + }); + + it('should have readonly driverId as DriverId', () => { + expect(result.driverId).toBeInstanceOf(DriverId); + expect(result.driverId.toString()).toBe(validDriverId); + }); + + it('should have readonly position as Position', () => { + expect(result.position).toBeInstanceOf(Position); + expect(result.position.toNumber()).toBe(validPosition); + }); + + it('should have readonly fastestLap as LapTime', () => { + expect(result.fastestLap).toBeInstanceOf(LapTime); + expect(result.fastestLap.toNumber()).toBe(validFastestLap); + }); + + it('should have readonly incidents as IncidentCount', () => { + expect(result.incidents).toBeInstanceOf(IncidentCount); + expect(result.incidents.toNumber()).toBe(validIncidents); + }); + + it('should have readonly startPosition as Position', () => { + expect(result.startPosition).toBeInstanceOf(Position); + expect(result.startPosition.toNumber()).toBe(validStartPosition); + }); + }); + + describe('domain methods', () => { + it('should calculate position change correctly', () => { + const result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: 1, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: 3, + }); + + expect(result.getPositionChange()).toBe(2); // 3 - 1 + }); + + it('should return true for podium finish', () => { + const result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: 3, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }); + + expect(result.isPodium()).toBe(true); + }); + + it('should return false for non-podium finish', () => { + const result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: 4, + fastestLap: validFastestLap, + incidents: validIncidents, + startPosition: validStartPosition, + }); + + expect(result.isPodium()).toBe(false); + }); + + it('should return true for clean race', () => { + const result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: 0, + startPosition: validStartPosition, + }); + + expect(result.isClean()).toBe(true); + }); + + it('should return false for race with incidents', () => { + const result = Result.create({ + id: validId, + raceId: validRaceId, + driverId: validDriverId, + position: validPosition, + fastestLap: validFastestLap, + incidents: 1, + startPosition: validStartPosition, + }); + + expect(result.isClean()).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Result.ts b/core/racing/domain/entities/result/Result.ts similarity index 58% rename from core/racing/domain/entities/Result.ts rename to core/racing/domain/entities/result/Result.ts index 0f1997bb0..8f6ffdb94 100644 --- a/core/racing/domain/entities/Result.ts +++ b/core/racing/domain/entities/result/Result.ts @@ -4,27 +4,32 @@ * Represents a race result in the GridPilot platform. * Immutable entity with factory methods and domain validation. */ - -import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; +import { RaceId } from '../RaceId'; +import { DriverId } from '../DriverId'; +import { Position } from './Position'; +import { LapTime } from './LapTime'; +import { IncidentCount } from './IncidentCount'; export class Result implements IEntity { readonly id: string; - readonly raceId: string; - readonly driverId: string; - readonly position: number; - readonly fastestLap: number; - readonly incidents: number; - readonly startPosition: number; + readonly raceId: RaceId; + readonly driverId: DriverId; + readonly position: Position; + readonly fastestLap: LapTime; + readonly incidents: IncidentCount; + readonly startPosition: Position; private constructor(props: { id: string; - raceId: string; - driverId: string; - position: number; - fastestLap: number; - incidents: number; - startPosition: number; + raceId: RaceId; + driverId: DriverId; + position: Position; + fastestLap: LapTime; + incidents: IncidentCount; + startPosition: Position; }) { this.id = props.id; this.raceId = props.raceId; @@ -49,7 +54,22 @@ export class Result implements IEntity { }): Result { this.validate(props); - return new Result(props); + const raceId = RaceId.create(props.raceId); + const driverId = DriverId.create(props.driverId); + const position = Position.create(props.position); + const fastestLap = LapTime.create(props.fastestLap); + const incidents = IncidentCount.create(props.incidents); + const startPosition = Position.create(props.startPosition); + + return new Result({ + id: props.id, + raceId, + driverId, + position, + fastestLap, + incidents, + startPosition, + }); } /** @@ -75,42 +95,26 @@ export class Result implements IEntity { if (!props.driverId || props.driverId.trim().length === 0) { throw new RacingDomainValidationError('Driver ID is required'); } - - if (!Number.isInteger(props.position) || props.position < 1) { - throw new RacingDomainValidationError('Position must be a positive integer'); - } - - if (props.fastestLap < 0) { - throw new RacingDomainValidationError('Fastest lap cannot be negative'); - } - - if (!Number.isInteger(props.incidents) || props.incidents < 0) { - throw new RacingDomainValidationError('Incidents must be a non-negative integer'); - } - - if (!Number.isInteger(props.startPosition) || props.startPosition < 1) { - throw new RacingDomainValidationError('Start position must be a positive integer'); - } } /** * Calculate positions gained/lost */ getPositionChange(): number { - return this.startPosition - this.position; + return this.startPosition.toNumber() - this.position.toNumber(); } /** * Check if driver finished on podium */ isPodium(): boolean { - return this.position <= 3; + return this.position.toNumber() <= 3; } /** * Check if driver had a clean race (0 incidents) */ isClean(): boolean { - return this.incidents === 0; + return this.incidents.toNumber() === 0; } } \ No newline at end of file diff --git a/core/racing/domain/entities/Season.test.ts b/core/racing/domain/entities/season/Season.test.ts similarity index 50% rename from core/racing/domain/entities/Season.test.ts rename to core/racing/domain/entities/season/Season.test.ts index 2d9f617e7..29251c4ac 100644 --- a/core/racing/domain/entities/Season.test.ts +++ b/core/racing/domain/entities/season/Season.test.ts @@ -8,10 +8,9 @@ import { import { SeasonScoringConfig } from '@core/racing/domain/value-objects/SeasonScoringConfig'; import { SeasonDropPolicy, - type SeasonDropStrategy, } from '@core/racing/domain/value-objects/SeasonDropPolicy'; import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig'; -import { createMinimalSeason, createBaseSeason } from '../../testing/factories/racing/SeasonFactory'; +import { createMinimalSeason, createBaseSeason } from '../../../../../testing/factories/racing/SeasonFactory'; describe('Season aggregate lifecycle', () => { @@ -204,280 +203,4 @@ describe('Season configuration updates', () => { }); }); -describe('SeasonScoringConfig', () => { - it('constructs from preset id and customScoringEnabled', () => { - const config = new SeasonScoringConfig({ - scoringPresetId: 'club-default', - customScoringEnabled: true, - }); - expect(config.scoringPresetId).toBe('club-default'); - expect(config.customScoringEnabled).toBe(true); - expect(config.props.scoringPresetId).toBe('club-default'); - expect(config.props.customScoringEnabled).toBe(true); - }); - - it('normalizes customScoringEnabled to false when omitted', () => { - const config = new SeasonScoringConfig({ - scoringPresetId: 'sprint-main-driver', - }); - - expect(config.customScoringEnabled).toBe(false); - expect(config.props.customScoringEnabled).toBeUndefined(); - }); - - it('throws when scoringPresetId is empty', () => { - expect( - () => - new SeasonScoringConfig({ - scoringPresetId: ' ', - }), - ).toThrow(RacingDomainValidationError); - }); - - it('equals compares by preset id and customScoringEnabled', () => { - const a = new SeasonScoringConfig({ - scoringPresetId: 'club-default', - customScoringEnabled: false, - }); - const b = new SeasonScoringConfig({ - scoringPresetId: 'club-default', - customScoringEnabled: false, - }); - const c = new SeasonScoringConfig({ - scoringPresetId: 'club-default', - customScoringEnabled: true, - }); - - expect(a.equals(b)).toBe(true); - expect(a.equals(c)).toBe(false); - }); -}); - -describe('SeasonDropPolicy', () => { - it('allows strategy "none" with undefined n', () => { - const policy = new SeasonDropPolicy({ strategy: 'none' }); - - expect(policy.strategy).toBe('none'); - expect(policy.n).toBeUndefined(); - }); - - it('throws when strategy "none" has n defined', () => { - expect( - () => - new SeasonDropPolicy({ - strategy: 'none', - n: 1, - }), - ).toThrow(RacingDomainValidationError); - }); - - it('requires positive integer n for "bestNResults" and "dropWorstN"', () => { - const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN']; - - for (const strategy of strategies) { - expect( - () => - new SeasonDropPolicy({ - strategy, - n: 0, - }), - ).toThrow(RacingDomainValidationError); - - expect( - () => - new SeasonDropPolicy({ - strategy, - n: -1, - }), - ).toThrow(RacingDomainValidationError); - } - - const okBest = new SeasonDropPolicy({ - strategy: 'bestNResults', - n: 3, - }); - const okDrop = new SeasonDropPolicy({ - strategy: 'dropWorstN', - n: 2, - }); - - expect(okBest.n).toBe(3); - expect(okDrop.n).toBe(2); - }); - - it('equals compares strategy and n', () => { - const a = new SeasonDropPolicy({ strategy: 'none' }); - const b = new SeasonDropPolicy({ strategy: 'none' }); - const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 }); - - expect(a.equals(b)).toBe(true); - expect(a.equals(c)).toBe(false); - }); -}); - -describe('SeasonStewardingConfig', () => { - it('creates a valid config with voting mode and requiredVotes', () => { - const config = new SeasonStewardingConfig({ - decisionMode: 'steward_vote', - requiredVotes: 3, - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }); - - expect(config.decisionMode).toBe('steward_vote'); - expect(config.requiredVotes).toBe(3); - expect(config.requireDefense).toBe(true); - expect(config.defenseTimeLimit).toBe(24); - expect(config.voteTimeLimit).toBe(24); - expect(config.protestDeadlineHours).toBe(48); - expect(config.stewardingClosesHours).toBe(72); - expect(config.notifyAccusedOnProtest).toBe(true); - expect(config.notifyOnVoteRequired).toBe(true); - }); - - it('throws when decisionMode is missing', () => { - expect( - () => - new SeasonStewardingConfig({ - // @ts-expect-error intentional invalid - decisionMode: undefined, - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - }); - - it('requires requiredVotes for voting/veto modes', () => { - const votingModes = [ - 'steward_vote', - 'member_vote', - 'steward_veto', - 'member_veto', - ] as const; - - for (const mode of votingModes) { - expect( - () => - new SeasonStewardingConfig({ - decisionMode: mode, - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - } - }); - - it('validates numeric limits as non-negative / positive integers', () => { - expect( - () => - new SeasonStewardingConfig({ - decisionMode: 'steward_decides', - requireDefense: true, - defenseTimeLimit: -1, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - - expect( - () => - new SeasonStewardingConfig({ - decisionMode: 'steward_decides', - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 0, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - - expect( - () => - new SeasonStewardingConfig({ - decisionMode: 'steward_decides', - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 0, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - - expect( - () => - new SeasonStewardingConfig({ - decisionMode: 'steward_decides', - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 0, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }), - ).toThrow(RacingDomainValidationError); - }); - - it('equals compares all props', () => { - const a = new SeasonStewardingConfig({ - decisionMode: 'steward_vote', - requiredVotes: 3, - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }); - - const b = new SeasonStewardingConfig({ - decisionMode: 'steward_vote', - requiredVotes: 3, - requireDefense: true, - defenseTimeLimit: 24, - voteTimeLimit: 24, - protestDeadlineHours: 48, - stewardingClosesHours: 72, - notifyAccusedOnProtest: true, - notifyOnVoteRequired: true, - }); - - const c = new SeasonStewardingConfig({ - decisionMode: 'steward_decides', - requireDefense: false, - defenseTimeLimit: 0, - voteTimeLimit: 24, - protestDeadlineHours: 24, - stewardingClosesHours: 48, - notifyAccusedOnProtest: false, - notifyOnVoteRequired: false, - }); - - expect(a.equals(b)).toBe(true); - expect(a.equals(c)).toBe(false); - }); -}); \ No newline at end of file diff --git a/core/racing/domain/entities/Season.ts b/core/racing/domain/entities/season/Season.ts similarity index 97% rename from core/racing/domain/entities/Season.ts rename to core/racing/domain/entities/season/Season.ts index a7354eead..ab6d02987 100644 --- a/core/racing/domain/entities/Season.ts +++ b/core/racing/domain/entities/season/Season.ts @@ -8,12 +8,12 @@ export type SeasonStatus = import { RacingDomainInvariantError, RacingDomainValidationError, -} from '../errors/RacingDomainError'; +} from '../../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; -import type { SeasonSchedule } from '../value-objects/SeasonSchedule'; -import type { SeasonScoringConfig } from '../value-objects/SeasonScoringConfig'; -import type { SeasonDropPolicy } from '../value-objects/SeasonDropPolicy'; -import type { SeasonStewardingConfig } from '../value-objects/SeasonStewardingConfig'; +import type { SeasonSchedule } from '../../value-objects/SeasonSchedule'; +import type { SeasonScoringConfig } from '../../value-objects/SeasonScoringConfig'; +import type { SeasonDropPolicy } from '../../value-objects/SeasonDropPolicy'; +import type { SeasonStewardingConfig } from '../../value-objects/SeasonStewardingConfig'; export class Season implements IEntity { readonly id: string; diff --git a/core/racing/domain/entities/season/SeasonId.ts b/core/racing/domain/entities/season/SeasonId.ts new file mode 100644 index 000000000..69b19f6f8 --- /dev/null +++ b/core/racing/domain/entities/season/SeasonId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class SeasonId { + private constructor(private readonly value: string) {} + + static create(value: string): SeasonId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Season ID cannot be empty'); + } + return new SeasonId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: SeasonId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/season/SeasonSponsorship.test.ts b/core/racing/domain/entities/season/SeasonSponsorship.test.ts new file mode 100644 index 000000000..ff85d11ec --- /dev/null +++ b/core/racing/domain/entities/season/SeasonSponsorship.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; + +import { + RacingDomainInvariantError, + RacingDomainValidationError, +} from '../../errors/RacingDomainError'; + +import { SeasonSponsorship } from './SeasonSponsorship'; +import { Money } from '../../value-objects/Money'; + +describe('SeasonSponsorship', () => { + const validProps = { + id: 'sponsorship-1', + seasonId: 'season-1', + sponsorId: 'sponsor-1', + tier: 'main' as const, + pricing: Money.create(1000), + status: 'pending' as const, + createdAt: new Date(), + }; + + it('creates a valid SeasonSponsorship', () => { + const sponsorship = SeasonSponsorship.create(validProps); + expect(sponsorship.id).toBe('sponsorship-1'); + expect(sponsorship.seasonId).toBe('season-1'); + expect(sponsorship.sponsorId).toBe('sponsor-1'); + expect(sponsorship.tier).toBe('main'); + expect(sponsorship.status).toBe('pending'); + }); + + it('throws on invalid id', () => { + expect(() => SeasonSponsorship.create({ ...validProps, id: '' })).toThrow(RacingDomainValidationError); + }); + + it('throws on invalid seasonId', () => { + expect(() => SeasonSponsorship.create({ ...validProps, seasonId: '' })).toThrow(RacingDomainValidationError); + }); + + it('throws on invalid sponsorId', () => { + expect(() => SeasonSponsorship.create({ ...validProps, sponsorId: '' })).toThrow(RacingDomainValidationError); + }); + + it('throws on invalid pricing', () => { + expect(() => SeasonSponsorship.create({ ...validProps, pricing: Money.create(0) })).toThrow(RacingDomainValidationError); + }); + + it('activates from pending', () => { + const sponsorship = SeasonSponsorship.create(validProps); + const activated = sponsorship.activate(); + expect(activated.status).toBe('active'); + expect(activated.activatedAt).toBeInstanceOf(Date); + }); + + it('throws when activating active sponsorship', () => { + const active = SeasonSponsorship.create({ ...validProps, status: 'active' }); + expect(() => active.activate()).toThrow(RacingDomainInvariantError); + }); + + it('ends active sponsorship', () => { + const active = SeasonSponsorship.create({ ...validProps, status: 'active' }); + const ended = active.end(); + expect(ended.status).toBe('ended'); + expect(ended.endedAt).toBeInstanceOf(Date); + }); + + it('cancels pending sponsorship', () => { + const sponsorship = SeasonSponsorship.create(validProps); + const cancelled = sponsorship.cancel(); + expect(cancelled.status).toBe('cancelled'); + expect(cancelled.cancelledAt).toBeInstanceOf(Date); + }); + + it('isActive returns true for active status', () => { + const active = SeasonSponsorship.create({ ...validProps, status: 'active' }); + expect(active.isActive()).toBe(true); + }); + + it('calculates platform fee', () => { + const sponsorship = SeasonSponsorship.create(validProps); + const fee = sponsorship.getPlatformFee(); + expect(fee.amount).toBe(100); + }); + + it('calculates net amount', () => { + const sponsorship = SeasonSponsorship.create(validProps); + const net = sponsorship.getNetAmount(); + expect(net.amount).toBe(900); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/SeasonSponsorship.ts b/core/racing/domain/entities/season/SeasonSponsorship.ts similarity index 98% rename from core/racing/domain/entities/SeasonSponsorship.ts rename to core/racing/domain/entities/season/SeasonSponsorship.ts index 0d6e234fe..a9537b353 100644 --- a/core/racing/domain/entities/SeasonSponsorship.ts +++ b/core/racing/domain/entities/season/SeasonSponsorship.ts @@ -5,10 +5,10 @@ * Aggregate root for managing sponsorship slots and pricing. */ -import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../../errors/RacingDomainError'; import type { IEntity } from '@core/shared/domain'; -import type { Money } from '../value-objects/Money'; +import type { Money } from '../../value-objects/Money'; export type SponsorshipTier = 'main' | 'secondary'; export type SponsorshipStatus = 'pending' | 'active' | 'ended' | 'cancelled'; diff --git a/core/racing/domain/entities/sponsor/Sponsor.ts b/core/racing/domain/entities/sponsor/Sponsor.ts new file mode 100644 index 000000000..904dcae04 --- /dev/null +++ b/core/racing/domain/entities/sponsor/Sponsor.ts @@ -0,0 +1,86 @@ +/** + * Domain Entity: Sponsor + * + * Represents a sponsor that can sponsor leagues/seasons. + * Aggregate root for sponsor information. + */ +import type { IEntity } from '@core/shared/domain'; +import { SponsorId } from './SponsorId'; +import { SponsorName } from './SponsorName'; +import { SponsorEmail } from './SponsorEmail'; +import { Url } from './Url'; +import { SponsorCreatedAt } from './SponsorCreatedAt'; + +export class Sponsor implements IEntity { + readonly id: SponsorId; + readonly name: SponsorName; + readonly contactEmail: SponsorEmail; + readonly logoUrl: Url | undefined; + readonly websiteUrl: Url | undefined; + readonly createdAt: SponsorCreatedAt; + + private constructor(props: { + id: SponsorId; + name: SponsorName; + contactEmail: SponsorEmail; + logoUrl?: Url; + websiteUrl?: Url; + createdAt: SponsorCreatedAt; + }) { + this.id = props.id; + this.name = props.name; + this.contactEmail = props.contactEmail; + this.logoUrl = props.logoUrl; + this.websiteUrl = props.websiteUrl; + this.createdAt = props.createdAt; + } + + static create(props: { + id: string; + name: string; + contactEmail: string; + logoUrl?: string; + websiteUrl?: string; + createdAt?: Date; + }): Sponsor { + const id = SponsorId.create(props.id); + const name = SponsorName.create(props.name); + const contactEmail = SponsorEmail.create(props.contactEmail); + const logoUrl = props.logoUrl ? Url.create(props.logoUrl) : undefined; + const websiteUrl = props.websiteUrl ? Url.create(props.websiteUrl) : undefined; + const createdAt = SponsorCreatedAt.create(props.createdAt ?? new Date()); + + return new Sponsor({ + id, + name, + contactEmail, + createdAt, + ...(logoUrl !== undefined ? { logoUrl } : {}), + ...(websiteUrl !== undefined ? { websiteUrl } : {}), + }); + } + + /** + * Update sponsor information + */ + update(props: Partial<{ + name: string; + contactEmail: string; + logoUrl?: string; + websiteUrl?: string; + }>): Sponsor { + const name = props.name ? SponsorName.create(props.name) : this.name; + const contactEmail = props.contactEmail ? SponsorEmail.create(props.contactEmail) : this.contactEmail; + const logoUrl = props.logoUrl !== undefined ? (props.logoUrl ? Url.create(props.logoUrl) : undefined) : this.logoUrl; + const websiteUrl = props.websiteUrl !== undefined ? (props.websiteUrl ? Url.create(props.websiteUrl) : undefined) : this.websiteUrl; + + return new Sponsor({ + id: this.id, + name, + contactEmail, + logoUrl, + websiteUrl, + createdAt: this.createdAt, + }); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/sponsor/SponsorCreatedAt.ts b/core/racing/domain/entities/sponsor/SponsorCreatedAt.ts new file mode 100644 index 000000000..50f30885e --- /dev/null +++ b/core/racing/domain/entities/sponsor/SponsorCreatedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class SponsorCreatedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): SponsorCreatedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Created date cannot be in the future'); + } + return new SponsorCreatedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: SponsorCreatedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/sponsor/SponsorEmail.ts b/core/racing/domain/entities/sponsor/SponsorEmail.ts new file mode 100644 index 000000000..9fba1d5bb --- /dev/null +++ b/core/racing/domain/entities/sponsor/SponsorEmail.ts @@ -0,0 +1,24 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class SponsorEmail { + private constructor(private readonly value: string) {} + + static create(value: string): SponsorEmail { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Sponsor contact email cannot be empty'); + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(value)) { + throw new RacingDomainValidationError('Invalid sponsor contact email format'); + } + return new SponsorEmail(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: SponsorEmail): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/sponsor/SponsorId.ts b/core/racing/domain/entities/sponsor/SponsorId.ts new file mode 100644 index 000000000..969007d0c --- /dev/null +++ b/core/racing/domain/entities/sponsor/SponsorId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class SponsorId { + private constructor(private readonly value: string) {} + + static create(value: string): SponsorId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Sponsor ID cannot be empty'); + } + return new SponsorId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: SponsorId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/sponsor/SponsorName.ts b/core/racing/domain/entities/sponsor/SponsorName.ts new file mode 100644 index 000000000..380d89aef --- /dev/null +++ b/core/racing/domain/entities/sponsor/SponsorName.ts @@ -0,0 +1,23 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class SponsorName { + private constructor(private readonly value: string) {} + + static create(value: string): SponsorName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Sponsor name cannot be empty'); + } + if (value.length > 100) { + throw new RacingDomainValidationError('Sponsor name cannot exceed 100 characters'); + } + return new SponsorName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: SponsorName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/entities/sponsor/Url.ts b/core/racing/domain/entities/sponsor/Url.ts new file mode 100644 index 000000000..feef2f0fa --- /dev/null +++ b/core/racing/domain/entities/sponsor/Url.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../../errors/RacingDomainError'; + +export class Url { + private constructor(private readonly value: string) {} + + static create(value: string): Url { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('URL cannot be empty'); + } + try { + new URL(value); + } catch { + throw new RacingDomainValidationError('Invalid URL format'); + } + return new Url(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: Url): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/repositories/IChampionshipStandingRepository.ts b/core/racing/domain/repositories/IChampionshipStandingRepository.ts index 79c33e7c9..8e8867b9b 100644 --- a/core/racing/domain/repositories/IChampionshipStandingRepository.ts +++ b/core/racing/domain/repositories/IChampionshipStandingRepository.ts @@ -1,4 +1,4 @@ -import type { ChampionshipStanding } from '../entities/ChampionshipStanding'; +import type { ChampionshipStanding } from '../entities/championship/ChampionshipStanding'; export interface IChampionshipStandingRepository { findBySeasonAndChampionship( diff --git a/core/racing/domain/repositories/ILeagueWalletRepository.ts b/core/racing/domain/repositories/ILeagueWalletRepository.ts index 34cac6f1d..fbd9703bd 100644 --- a/core/racing/domain/repositories/ILeagueWalletRepository.ts +++ b/core/racing/domain/repositories/ILeagueWalletRepository.ts @@ -1,10 +1,10 @@ /** * Repository Interface: ILeagueWalletRepository - * + * * Defines operations for LeagueWallet aggregate persistence */ -import type { LeagueWallet } from '../entities/LeagueWallet'; +import type { LeagueWallet } from '../entities/league-wallet/LeagueWallet'; export interface ILeagueWalletRepository { findById(id: string): Promise; diff --git a/core/racing/domain/services/ChampionshipAggregator.ts b/core/racing/domain/services/ChampionshipAggregator.ts index aaf2090c2..0238d4cda 100644 --- a/core/racing/domain/services/ChampionshipAggregator.ts +++ b/core/racing/domain/services/ChampionshipAggregator.ts @@ -1,6 +1,6 @@ import type { ChampionshipConfig } from '../types/ChampionshipConfig'; import type { ParticipantRef } from '../types/ParticipantRef'; -import { ChampionshipStanding } from '../entities/ChampionshipStanding'; +import { ChampionshipStanding } from '../entities/championship/ChampionshipStanding'; import type { ParticipantEventPoints } from './EventScoringService'; import { DropScoreApplier, type EventPointsEntry } from './DropScoreApplier'; @@ -52,7 +52,7 @@ export class ChampionshipAggregator { const resultsDropped = dropResult.dropped.length; standings.push( - new ChampionshipStanding({ + ChampionshipStanding.create({ seasonId, championshipId: championship.id, participant, @@ -64,7 +64,7 @@ export class ChampionshipAggregator { ); } - standings.sort((a, b) => b.totalPoints - a.totalPoints); + standings.sort((a, b) => b.totalPoints.toNumber() - a.totalPoints.toNumber()); return standings.map((s, index) => s.withPosition(index + 1)); } diff --git a/core/racing/domain/value-objects/CarId.ts b/core/racing/domain/value-objects/CarId.ts new file mode 100644 index 000000000..c64ca81ee --- /dev/null +++ b/core/racing/domain/value-objects/CarId.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { IValueObject } from '@core/shared/domain'; + +export class CarId implements IValueObject { + private constructor(private readonly value: string) {} + + static create(value: string): CarId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Car ID cannot be empty'); + } + return new CarId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: IValueObject): boolean { + return this.value === other.props; + } + + get props(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/CountryCode.test.ts b/core/racing/domain/value-objects/CountryCode.test.ts new file mode 100644 index 000000000..05bfaf019 --- /dev/null +++ b/core/racing/domain/value-objects/CountryCode.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { CountryCode } from './CountryCode'; + +describe('CountryCode', () => { + it('should create a country code', () => { + const code = CountryCode.create('US'); + expect(code.toString()).toBe('US'); + }); + + it('should uppercase the code', () => { + const code = CountryCode.create('us'); + expect(code.toString()).toBe('US'); + }); + + it('should accept 3 letter code', () => { + const code = CountryCode.create('USA'); + expect(code.toString()).toBe('USA'); + }); + + it('should throw on empty code', () => { + expect(() => CountryCode.create('')).toThrow('Country code is required'); + }); + + it('should throw on invalid length', () => { + expect(() => CountryCode.create('U')).toThrow('Country must be a valid ISO code (2-3 letters)'); + }); + + it('should throw on 4 letters', () => { + expect(() => CountryCode.create('USAA')).toThrow('Country must be a valid ISO code (2-3 letters)'); + }); + + it('should throw on non-letters', () => { + expect(() => CountryCode.create('U1')).toThrow('Country must be a valid ISO code (2-3 letters)'); + }); + + it('should equal same code', () => { + const c1 = CountryCode.create('US'); + const c2 = CountryCode.create('US'); + expect(c1.equals(c2)).toBe(true); + }); + + it('should not equal different code', () => { + const c1 = CountryCode.create('US'); + const c2 = CountryCode.create('CA'); + expect(c1.equals(c2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/CountryCode.ts b/core/racing/domain/value-objects/CountryCode.ts new file mode 100644 index 000000000..1a6f69e7c --- /dev/null +++ b/core/racing/domain/value-objects/CountryCode.ts @@ -0,0 +1,24 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class CountryCode { + private constructor(private readonly value: string) {} + + static create(value: string): CountryCode { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Country code is required'); + } + const trimmed = value.trim().toUpperCase(); + if (!/^[A-Z]{2,3}$/.test(trimmed)) { + throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)'); + } + return new CountryCode(trimmed); + } + + toString(): string { + return this.value; + } + + equals(other: CountryCode): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/DecalOverride.ts b/core/racing/domain/value-objects/DecalOverride.ts new file mode 100644 index 000000000..d03e47278 --- /dev/null +++ b/core/racing/domain/value-objects/DecalOverride.ts @@ -0,0 +1,71 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { IValueObject } from '@core/shared/domain'; + +export interface DecalOverrideProps { + leagueId: string; + seasonId: string; + decalId: string; + newX: number; + newY: number; +} + +export class DecalOverride implements IValueObject { + readonly leagueId: string; + readonly seasonId: string; + readonly decalId: string; + readonly newX: number; + readonly newY: number; + + private constructor(props: DecalOverrideProps) { + this.leagueId = props.leagueId; + this.seasonId = props.seasonId; + this.decalId = props.decalId; + this.newX = props.newX; + this.newY = props.newY; + } + + static create(props: DecalOverrideProps): DecalOverride { + this.validate(props); + return new DecalOverride(props); + } + + private static validate(props: DecalOverrideProps): void { + if (!props.leagueId || props.leagueId.trim().length === 0) { + throw new RacingDomainValidationError('DecalOverride leagueId is required'); + } + if (!props.seasonId || props.seasonId.trim().length === 0) { + throw new RacingDomainValidationError('DecalOverride seasonId is required'); + } + if (!props.decalId || props.decalId.trim().length === 0) { + throw new RacingDomainValidationError('DecalOverride decalId is required'); + } + if (props.newX < 0 || props.newX > 1) { + throw new RacingDomainValidationError('DecalOverride newX must be between 0 and 1'); + } + if (props.newY < 0 || props.newY > 1) { + throw new RacingDomainValidationError('DecalOverride newY must be between 0 and 1'); + } + } + + equals(other: IValueObject): boolean { + const a = this.props; + const b = other.props; + return ( + a.leagueId === b.leagueId && + a.seasonId === b.seasonId && + a.decalId === b.decalId && + a.newX === b.newX && + a.newY === b.newY + ); + } + + get props(): DecalOverrideProps { + return { + leagueId: this.leagueId, + seasonId: this.seasonId, + decalId: this.decalId, + newX: this.newX, + newY: this.newY, + }; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverBio.test.ts b/core/racing/domain/value-objects/DriverBio.test.ts new file mode 100644 index 000000000..6cc3709b2 --- /dev/null +++ b/core/racing/domain/value-objects/DriverBio.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { DriverBio } from './DriverBio'; + +describe('DriverBio', () => { + it('should create a driver bio', () => { + const bio = DriverBio.create('A passionate racer.'); + expect(bio.toString()).toBe('A passionate racer.'); + }); + + it('should allow empty string', () => { + const bio = DriverBio.create(''); + expect(bio.toString()).toBe(''); + }); + + it('should throw on bio too long', () => { + const longBio = 'a'.repeat(501); + expect(() => DriverBio.create(longBio)).toThrow('Driver bio cannot exceed 500 characters'); + }); + + it('should equal same bio', () => { + const b1 = DriverBio.create('Racer'); + const b2 = DriverBio.create('Racer'); + expect(b1.equals(b2)).toBe(true); + }); + + it('should not equal different bio', () => { + const b1 = DriverBio.create('Racer'); + const b2 = DriverBio.create('Driver'); + expect(b1.equals(b2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverBio.ts b/core/racing/domain/value-objects/DriverBio.ts new file mode 100644 index 000000000..1683bca1d --- /dev/null +++ b/core/racing/domain/value-objects/DriverBio.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DriverBio { + private constructor(private readonly value: string) {} + + static create(value: string): DriverBio { + if (value.length > 500) { + throw new RacingDomainValidationError('Driver bio cannot exceed 500 characters'); + } + return new DriverBio(value); + } + + toString(): string { + return this.value; + } + + equals(other: DriverBio): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverId.test.ts b/core/racing/domain/value-objects/DriverId.test.ts new file mode 100644 index 000000000..2b37bbb9b --- /dev/null +++ b/core/racing/domain/value-objects/DriverId.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { DriverId } from './DriverId'; + +describe('DriverId', () => { + it('should create a driver id', () => { + const id = DriverId.create('driver1'); + expect(id.toString()).toBe('driver1'); + }); + + it('should throw on empty id', () => { + expect(() => DriverId.create('')).toThrow('Driver ID is required'); + }); + + it('should throw on whitespace id', () => { + expect(() => DriverId.create(' ')).toThrow('Driver ID is required'); + }); + + it('should equal same id', () => { + const id1 = DriverId.create('driver1'); + const id2 = DriverId.create('driver1'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should not equal different id', () => { + const id1 = DriverId.create('driver1'); + const id2 = DriverId.create('driver2'); + expect(id1.equals(id2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverId.ts b/core/racing/domain/value-objects/DriverId.ts new file mode 100644 index 000000000..455ce18a2 --- /dev/null +++ b/core/racing/domain/value-objects/DriverId.ts @@ -0,0 +1,25 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { IValueObject } from '@core/shared/domain'; + +export class DriverId implements IValueObject { + private constructor(private readonly value: string) {} + + static create(value: string): DriverId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Driver ID cannot be empty'); + } + return new DriverId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: IValueObject): boolean { + return this.value === other.props; + } + + get props(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverName.test.ts b/core/racing/domain/value-objects/DriverName.test.ts new file mode 100644 index 000000000..a5c63ccec --- /dev/null +++ b/core/racing/domain/value-objects/DriverName.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { DriverName } from './DriverName'; + +describe('DriverName', () => { + it('should create a driver name', () => { + const name = DriverName.create('John Doe'); + expect(name.toString()).toBe('John Doe'); + }); + + it('should trim whitespace', () => { + const name = DriverName.create(' John Doe '); + expect(name.toString()).toBe('John Doe'); + }); + + it('should throw on empty name', () => { + expect(() => DriverName.create('')).toThrow('Driver name is required'); + }); + + it('should throw on whitespace only', () => { + expect(() => DriverName.create(' ')).toThrow('Driver name is required'); + }); + + it('should equal same name', () => { + const n1 = DriverName.create('John Doe'); + const n2 = DriverName.create('John Doe'); + expect(n1.equals(n2)).toBe(true); + }); + + it('should not equal different name', () => { + const n1 = DriverName.create('John Doe'); + const n2 = DriverName.create('Jane Doe'); + expect(n1.equals(n2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/DriverName.ts b/core/racing/domain/value-objects/DriverName.ts new file mode 100644 index 000000000..85cbebfbf --- /dev/null +++ b/core/racing/domain/value-objects/DriverName.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class DriverName { + private constructor(private readonly value: string) {} + + static create(value: string): DriverName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Driver name is required'); + } + return new DriverName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: DriverName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/IRacingId.test.ts b/core/racing/domain/value-objects/IRacingId.test.ts new file mode 100644 index 000000000..4b5429ba4 --- /dev/null +++ b/core/racing/domain/value-objects/IRacingId.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { IRacingId } from './IRacingId'; + +describe('IRacingId', () => { + it('should create an iRacing id', () => { + const id = IRacingId.create('12345'); + expect(id.toString()).toBe('12345'); + }); + + it('should throw on empty id', () => { + expect(() => IRacingId.create('')).toThrow('iRacing ID is required'); + }); + + it('should throw on whitespace id', () => { + expect(() => IRacingId.create(' ')).toThrow('iRacing ID is required'); + }); + + it('should equal same id', () => { + const id1 = IRacingId.create('12345'); + const id2 = IRacingId.create('12345'); + expect(id1.equals(id2)).toBe(true); + }); + + it('should not equal different id', () => { + const id1 = IRacingId.create('12345'); + const id2 = IRacingId.create('67890'); + expect(id1.equals(id2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/IRacingId.ts b/core/racing/domain/value-objects/IRacingId.ts new file mode 100644 index 000000000..c59eb36dc --- /dev/null +++ b/core/racing/domain/value-objects/IRacingId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class IRacingId { + private constructor(private readonly value: string) {} + + static create(value: string): IRacingId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('iRacing ID is required'); + } + return new IRacingId(value); + } + + toString(): string { + return this.value; + } + + equals(other: IRacingId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/ImageUrl.ts b/core/racing/domain/value-objects/ImageUrl.ts new file mode 100644 index 000000000..df530b03b --- /dev/null +++ b/core/racing/domain/value-objects/ImageUrl.ts @@ -0,0 +1,31 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; +import type { IValueObject } from '@core/shared/domain'; + +export class ImageUrl implements IValueObject { + private constructor(private readonly value: string) {} + + static create(value: string): ImageUrl { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Image URL cannot be empty'); + } + // Basic URL validation + try { + new URL(value); + } catch { + throw new RacingDomainValidationError('Invalid image URL format'); + } + return new ImageUrl(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: IValueObject): boolean { + return this.value === other.props; + } + + get props(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/JoinedAt.test.ts b/core/racing/domain/value-objects/JoinedAt.test.ts new file mode 100644 index 000000000..559d3269a --- /dev/null +++ b/core/racing/domain/value-objects/JoinedAt.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { JoinedAt } from './JoinedAt'; + +describe('JoinedAt', () => { + it('should create a joined date', () => { + const past = new Date('2020-01-01'); + const joined = JoinedAt.create(past); + expect(joined.toDate()).toEqual(past); + }); + + it('should throw on future date', () => { + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + expect(() => JoinedAt.create(future)).toThrow('Joined date cannot be in the future'); + }); + + it('should allow current date', () => { + const now = new Date(); + const joined = JoinedAt.create(now); + expect(joined.toDate().getTime()).toBeCloseTo(now.getTime(), -3); // close enough + }); + + it('should equal same date', () => { + const d1 = new Date('2020-01-01'); + const d2 = new Date('2020-01-01'); + const j1 = JoinedAt.create(d1); + const j2 = JoinedAt.create(d2); + expect(j1.equals(j2)).toBe(true); + }); + + it('should not equal different date', () => { + const d1 = new Date('2020-01-01'); + const d2 = new Date('2020-01-02'); + const j1 = JoinedAt.create(d1); + const j2 = JoinedAt.create(d2); + expect(j1.equals(j2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/JoinedAt.ts b/core/racing/domain/value-objects/JoinedAt.ts new file mode 100644 index 000000000..31a970ca5 --- /dev/null +++ b/core/racing/domain/value-objects/JoinedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class JoinedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): JoinedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Joined date cannot be in the future'); + } + return new JoinedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: JoinedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/Points.test.ts b/core/racing/domain/value-objects/Points.test.ts new file mode 100644 index 000000000..6eb52ff3f --- /dev/null +++ b/core/racing/domain/value-objects/Points.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { Points } from './Points'; + +describe('Points', () => { + it('should create points', () => { + const points = Points.create(100); + expect(points.toNumber()).toBe(100); + }); + + it('should create zero points', () => { + const points = Points.create(0); + expect(points.toNumber()).toBe(0); + }); + + it('should not create negative points', () => { + expect(() => Points.create(-1)).toThrow('Points cannot be negative'); + }); + + it('should equal same points', () => { + const p1 = Points.create(50); + const p2 = Points.create(50); + expect(p1.equals(p2)).toBe(true); + }); + + it('should not equal different points', () => { + const p1 = Points.create(50); + const p2 = Points.create(51); + expect(p1.equals(p2)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/Points.ts b/core/racing/domain/value-objects/Points.ts new file mode 100644 index 000000000..ea893953d --- /dev/null +++ b/core/racing/domain/value-objects/Points.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class Points { + private constructor(private readonly value: number) {} + + static create(value: number): Points { + if (value < 0) { + throw new RacingDomainValidationError('Points cannot be negative'); + } + return new Points(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: Points): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/SeasonDropPolicy.test.ts b/core/racing/domain/value-objects/SeasonDropPolicy.test.ts new file mode 100644 index 000000000..320d55029 --- /dev/null +++ b/core/racing/domain/value-objects/SeasonDropPolicy.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; + +import { + RacingDomainValidationError, +} from '../errors/RacingDomainError'; + +import { + SeasonDropPolicy, + type SeasonDropStrategy, +} from './SeasonDropPolicy'; + +describe('SeasonDropPolicy', () => { + it('allows strategy "none" with undefined n', () => { + const policy = new SeasonDropPolicy({ strategy: 'none' }); + + expect(policy.strategy).toBe('none'); + expect(policy.n).toBeUndefined(); + }); + + it('throws when strategy "none" has n defined', () => { + expect( + () => + new SeasonDropPolicy({ + strategy: 'none', + n: 1, + }), + ).toThrow(RacingDomainValidationError); + }); + + it('requires positive integer n for "bestNResults" and "dropWorstN"', () => { + const strategies: SeasonDropStrategy[] = ['bestNResults', 'dropWorstN']; + + for (const strategy of strategies) { + expect( + () => + new SeasonDropPolicy({ + strategy, + n: 0, + }), + ).toThrow(RacingDomainValidationError); + + expect( + () => + new SeasonDropPolicy({ + strategy, + n: -1, + }), + ).toThrow(RacingDomainValidationError); + } + + const okBest = new SeasonDropPolicy({ + strategy: 'bestNResults', + n: 3, + }); + const okDrop = new SeasonDropPolicy({ + strategy: 'dropWorstN', + n: 2, + }); + + expect(okBest.n).toBe(3); + expect(okDrop.n).toBe(2); + }); + + it('equals compares strategy and n', () => { + const a = new SeasonDropPolicy({ strategy: 'none' }); + const b = new SeasonDropPolicy({ strategy: 'none' }); + const c = new SeasonDropPolicy({ strategy: 'bestNResults', n: 3 }); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/SeasonScoringConfig.test.ts b/core/racing/domain/value-objects/SeasonScoringConfig.test.ts new file mode 100644 index 000000000..ae313a6d0 --- /dev/null +++ b/core/racing/domain/value-objects/SeasonScoringConfig.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; + +import { + RacingDomainValidationError, +} from '../errors/RacingDomainError'; + +import { SeasonScoringConfig } from './SeasonScoringConfig'; + +describe('SeasonScoringConfig', () => { + it('constructs from preset id and customScoringEnabled', () => { + const config = new SeasonScoringConfig({ + scoringPresetId: 'club-default', + customScoringEnabled: true, + }); + + expect(config.scoringPresetId).toBe('club-default'); + expect(config.customScoringEnabled).toBe(true); + expect(config.props.scoringPresetId).toBe('club-default'); + expect(config.props.customScoringEnabled).toBe(true); + }); + + it('normalizes customScoringEnabled to false when omitted', () => { + const config = new SeasonScoringConfig({ + scoringPresetId: 'sprint-main-driver', + }); + + expect(config.customScoringEnabled).toBe(false); + expect(config.props.customScoringEnabled).toBeUndefined(); + }); + + it('throws when scoringPresetId is empty', () => { + expect( + () => + new SeasonScoringConfig({ + scoringPresetId: ' ', + }), + ).toThrow(RacingDomainValidationError); + }); + + it('equals compares by preset id and customScoringEnabled', () => { + const a = new SeasonScoringConfig({ + scoringPresetId: 'club-default', + customScoringEnabled: false, + }); + const b = new SeasonScoringConfig({ + scoringPresetId: 'club-default', + customScoringEnabled: false, + }); + const c = new SeasonScoringConfig({ + scoringPresetId: 'club-default', + customScoringEnabled: true, + }); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/SeasonStewardingConfig.test.ts b/core/racing/domain/value-objects/SeasonStewardingConfig.test.ts new file mode 100644 index 000000000..68699552c --- /dev/null +++ b/core/racing/domain/value-objects/SeasonStewardingConfig.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; + +import { + RacingDomainValidationError, +} from '../errors/RacingDomainError'; + +import { SeasonStewardingConfig } from './SeasonStewardingConfig'; + +describe('SeasonStewardingConfig', () => { + it('creates a valid config with voting mode and requiredVotes', () => { + const config = new SeasonStewardingConfig({ + decisionMode: 'steward_vote', + requiredVotes: 3, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }); + + expect(config.decisionMode).toBe('steward_vote'); + expect(config.requiredVotes).toBe(3); + expect(config.requireDefense).toBe(true); + expect(config.defenseTimeLimit).toBe(24); + expect(config.voteTimeLimit).toBe(24); + expect(config.protestDeadlineHours).toBe(48); + expect(config.stewardingClosesHours).toBe(72); + expect(config.notifyAccusedOnProtest).toBe(true); + expect(config.notifyOnVoteRequired).toBe(true); + }); + + it('throws when decisionMode is missing', () => { + expect( + () => + new SeasonStewardingConfig({ + // @ts-expect-error intentional invalid + decisionMode: undefined, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + }); + + it('requires requiredVotes for voting/veto modes', () => { + const votingModes = [ + 'steward_vote', + 'member_vote', + 'steward_veto', + 'member_veto', + ] as const; + + for (const mode of votingModes) { + expect( + () => + new SeasonStewardingConfig({ + decisionMode: mode, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + } + }); + + it('validates numeric limits as non-negative / positive integers', () => { + expect( + () => + new SeasonStewardingConfig({ + decisionMode: 'steward_decides', + requireDefense: true, + defenseTimeLimit: -1, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + + expect( + () => + new SeasonStewardingConfig({ + decisionMode: 'steward_decides', + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 0, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + + expect( + () => + new SeasonStewardingConfig({ + decisionMode: 'steward_decides', + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 0, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + + expect( + () => + new SeasonStewardingConfig({ + decisionMode: 'steward_decides', + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 0, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }), + ).toThrow(RacingDomainValidationError); + }); + + it('equals compares all props', () => { + const a = new SeasonStewardingConfig({ + decisionMode: 'steward_vote', + requiredVotes: 3, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }); + + const b = new SeasonStewardingConfig({ + decisionMode: 'steward_vote', + requiredVotes: 3, + requireDefense: true, + defenseTimeLimit: 24, + voteTimeLimit: 24, + protestDeadlineHours: 48, + stewardingClosesHours: 72, + notifyAccusedOnProtest: true, + notifyOnVoteRequired: true, + }); + + const c = new SeasonStewardingConfig({ + decisionMode: 'steward_decides', + requireDefense: false, + defenseTimeLimit: 0, + voteTimeLimit: 24, + protestDeadlineHours: 24, + stewardingClosesHours: 48, + notifyAccusedOnProtest: false, + notifyOnVoteRequired: false, + }); + + expect(a.equals(b)).toBe(true); + expect(a.equals(c)).toBe(false); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/value-objects/TeamCreatedAt.ts b/core/racing/domain/value-objects/TeamCreatedAt.ts new file mode 100644 index 000000000..73e56dca1 --- /dev/null +++ b/core/racing/domain/value-objects/TeamCreatedAt.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TeamCreatedAt { + private constructor(private readonly value: Date) {} + + static create(value: Date): TeamCreatedAt { + const now = new Date(); + if (value > now) { + throw new RacingDomainValidationError('Created date cannot be in the future'); + } + return new TeamCreatedAt(new Date(value)); + } + + toDate(): Date { + return new Date(this.value); + } + + equals(other: TeamCreatedAt): boolean { + return this.value.getTime() === other.value.getTime(); + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TeamDescription.ts b/core/racing/domain/value-objects/TeamDescription.ts new file mode 100644 index 000000000..44be11612 --- /dev/null +++ b/core/racing/domain/value-objects/TeamDescription.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TeamDescription { + private constructor(private readonly value: string) {} + + static create(value: string): TeamDescription { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Team description is required'); + } + return new TeamDescription(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TeamDescription): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TeamName.ts b/core/racing/domain/value-objects/TeamName.ts new file mode 100644 index 000000000..805f6a5d6 --- /dev/null +++ b/core/racing/domain/value-objects/TeamName.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TeamName { + private constructor(private readonly value: string) {} + + static create(value: string): TeamName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Team name is required'); + } + return new TeamName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TeamName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TeamTag.ts b/core/racing/domain/value-objects/TeamTag.ts new file mode 100644 index 000000000..096df7f9e --- /dev/null +++ b/core/racing/domain/value-objects/TeamTag.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TeamTag { + private constructor(private readonly value: string) {} + + static create(value: string): TeamTag { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Team tag is required'); + } + return new TeamTag(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TeamTag): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackCountry.ts b/core/racing/domain/value-objects/TrackCountry.ts new file mode 100644 index 000000000..bb5d590a1 --- /dev/null +++ b/core/racing/domain/value-objects/TrackCountry.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackCountry { + private constructor(private readonly value: string) {} + + static create(value: string): TrackCountry { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Track country is required'); + } + return new TrackCountry(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TrackCountry): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackGameId.ts b/core/racing/domain/value-objects/TrackGameId.ts new file mode 100644 index 000000000..fdf922736 --- /dev/null +++ b/core/racing/domain/value-objects/TrackGameId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackGameId { + private constructor(private readonly value: string) {} + + static create(value: string): TrackGameId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Track game ID cannot be empty'); + } + return new TrackGameId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TrackGameId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackId.ts b/core/racing/domain/value-objects/TrackId.ts new file mode 100644 index 000000000..08824ff44 --- /dev/null +++ b/core/racing/domain/value-objects/TrackId.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackId { + private constructor(private readonly value: string) {} + + static create(value: string): TrackId { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Track ID cannot be empty'); + } + return new TrackId(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TrackId): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackImageUrl.ts b/core/racing/domain/value-objects/TrackImageUrl.ts new file mode 100644 index 000000000..8ca9fd657 --- /dev/null +++ b/core/racing/domain/value-objects/TrackImageUrl.ts @@ -0,0 +1,21 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackImageUrl { + private constructor(private readonly value: string | undefined) {} + + static create(value: string | undefined): TrackImageUrl { + // Allow undefined or valid URL, but for simplicity, just check if string is not empty if provided + if (value !== undefined && value.trim().length === 0) { + throw new RacingDomainValidationError('Track image URL cannot be empty string'); + } + return new TrackImageUrl(value); + } + + toString(): string | undefined { + return this.value; + } + + equals(other: TrackImageUrl): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackLength.ts b/core/racing/domain/value-objects/TrackLength.ts new file mode 100644 index 000000000..618df4d79 --- /dev/null +++ b/core/racing/domain/value-objects/TrackLength.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackLength { + private constructor(private readonly value: number) {} + + static create(value: number): TrackLength { + if (value <= 0) { + throw new RacingDomainValidationError('Track length must be positive'); + } + return new TrackLength(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: TrackLength): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackName.ts b/core/racing/domain/value-objects/TrackName.ts new file mode 100644 index 000000000..2cdcc2bda --- /dev/null +++ b/core/racing/domain/value-objects/TrackName.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackName { + private constructor(private readonly value: string) {} + + static create(value: string): TrackName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Track name is required'); + } + return new TrackName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TrackName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackShortName.ts b/core/racing/domain/value-objects/TrackShortName.ts new file mode 100644 index 000000000..f0ac8cc00 --- /dev/null +++ b/core/racing/domain/value-objects/TrackShortName.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackShortName { + private constructor(private readonly value: string) {} + + static create(value: string): TrackShortName { + if (!value || value.trim().length === 0) { + throw new RacingDomainValidationError('Track short name is required'); + } + return new TrackShortName(value.trim()); + } + + toString(): string { + return this.value; + } + + equals(other: TrackShortName): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/racing/domain/value-objects/TrackTurns.ts b/core/racing/domain/value-objects/TrackTurns.ts new file mode 100644 index 000000000..427b549ef --- /dev/null +++ b/core/racing/domain/value-objects/TrackTurns.ts @@ -0,0 +1,20 @@ +import { RacingDomainValidationError } from '../errors/RacingDomainError'; + +export class TrackTurns { + private constructor(private readonly value: number) {} + + static create(value: number): TrackTurns { + if (value < 0) { + throw new RacingDomainValidationError('Track turns cannot be negative'); + } + return new TrackTurns(value); + } + + toNumber(): number { + return this.value; + } + + equals(other: TrackTurns): boolean { + return this.value === other.value; + } +} \ No newline at end of file diff --git a/core/shared/domain/Entity.ts b/core/shared/domain/Entity.ts index c507fbd5e..7ee8271a3 100644 --- a/core/shared/domain/Entity.ts +++ b/core/shared/domain/Entity.ts @@ -1,3 +1,11 @@ export interface IEntity { readonly id: Id; +} + +export abstract class Entity implements IEntity { + protected constructor(readonly id: Id) {} + + equals(other?: Entity): boolean { + return !!other && this.id === other.id; + } } \ No newline at end of file diff --git a/testing/factories/racing/SeasonFactory.ts b/testing/factories/racing/SeasonFactory.ts index d59cc295e..0437ccbcd 100644 --- a/testing/factories/racing/SeasonFactory.ts +++ b/testing/factories/racing/SeasonFactory.ts @@ -1,5 +1,5 @@ -import { Season } from '@core/racing/domain/entities/Season'; -import type { SeasonStatus } from '@core/racing/domain/entities/Season'; +import { Season } from '@core/racing/domain/entities/season/Season'; +import type { SeasonStatus } from '@core/racing/domain/entities/season/Season'; export const createMinimalSeason = (overrides?: { status?: SeasonStatus }) => Season.create({