refactor
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, { id: string; name: string }>();
|
||||
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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
43
core/racing/domain/entities/AppliedAt.test.ts
Normal file
43
core/racing/domain/entities/AppliedAt.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/AppliedAt.ts
Normal file
20
core/racing/domain/entities/AppliedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
118
core/racing/domain/entities/Car.test.ts
Normal file
118
core/racing/domain/entities/Car.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
export class Car implements IEntity<CarId> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formatted car display name
|
||||
*/
|
||||
getDisplayName(): string {
|
||||
return `${this.manufacturer} ${this.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get license badge color
|
||||
*/
|
||||
getLicenseColor(): string {
|
||||
const colors: Record<CarLicense, string> = {
|
||||
'R': '#FF6B6B',
|
||||
'D': '#FFB347',
|
||||
'C': '#FFD700',
|
||||
'B': '#7FFF00',
|
||||
'A': '#00BFFF',
|
||||
'Pro': '#9370DB',
|
||||
};
|
||||
return colors[this.license];
|
||||
}
|
||||
}
|
||||
17
core/racing/domain/entities/CarClass.ts
Normal file
17
core/racing/domain/entities/CarClass.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/CarId.ts
Normal file
20
core/racing/domain/entities/CarId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
17
core/racing/domain/entities/CarLicense.ts
Normal file
17
core/racing/domain/entities/CarLicense.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
23
core/racing/domain/entities/CarName.ts
Normal file
23
core/racing/domain/entities/CarName.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/DecisionNotes.ts
Normal file
21
core/racing/domain/entities/DecisionNotes.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/DefenseRequestedAt.ts
Normal file
20
core/racing/domain/entities/DefenseRequestedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/DefenseStatement.ts
Normal file
21
core/racing/domain/entities/DefenseStatement.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
155
core/racing/domain/entities/Driver.test.ts
Normal file
155
core/racing/domain/entities/Driver.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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,
|
||||
|
||||
38
core/racing/domain/entities/DriverId.test.ts
Normal file
38
core/racing/domain/entities/DriverId.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/DriverId.ts
Normal file
20
core/racing/domain/entities/DriverId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
245
core/racing/domain/entities/DriverLivery.test.ts
Normal file
245
core/racing/domain/entities/DriverLivery.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
this.validatedAt = props.validatedAt;
|
||||
}
|
||||
|
||||
static create(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'> & {
|
||||
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<string> {
|
||||
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<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'>): 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<string> {
|
||||
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];
|
||||
|
||||
20
core/racing/domain/entities/FiledAt.ts
Normal file
20
core/racing/domain/entities/FiledAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
36
core/racing/domain/entities/Game.test.ts
Normal file
36
core/racing/domain/entities/Game.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,23 @@
|
||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
|
||||
export class Game implements IEntity<string> {
|
||||
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<GameId> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/GameId.ts
Normal file
20
core/racing/domain/entities/GameId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
23
core/racing/domain/entities/GameName.ts
Normal file
23
core/racing/domain/entities/GameName.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/Horsepower.ts
Normal file
20
core/racing/domain/entities/Horsepower.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
core/racing/domain/entities/ImageUrl.ts
Normal file
26
core/racing/domain/entities/ImageUrl.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/IncidentDescription.ts
Normal file
21
core/racing/domain/entities/IncidentDescription.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
43
core/racing/domain/entities/IssuedAt.test.ts
Normal file
43
core/racing/domain/entities/IssuedAt.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/IssuedAt.ts
Normal file
20
core/racing/domain/entities/IssuedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
60
core/racing/domain/entities/JoinRequest.ts
Normal file
60
core/racing/domain/entities/JoinRequest.ts
Normal file
@@ -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<string> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/LapNumber.ts
Normal file
20
core/racing/domain/entities/LapNumber.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
116
core/racing/domain/entities/League.test.ts
Normal file
116
core/racing/domain/entities/League.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<number, number>;
|
||||
/**
|
||||
* 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<string> {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
readonly ownerId: string;
|
||||
export class League implements IEntity<LeagueId> {
|
||||
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<string> {
|
||||
ownerId: string;
|
||||
settings?: Partial<LeagueSettings>;
|
||||
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<string> {
|
||||
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<string> {
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/LeagueCreatedAt.ts
Normal file
21
core/racing/domain/entities/LeagueCreatedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
23
core/racing/domain/entities/LeagueDescription.ts
Normal file
23
core/racing/domain/entities/LeagueDescription.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/LeagueId.ts
Normal file
20
core/racing/domain/entities/LeagueId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
68
core/racing/domain/entities/LeagueMembership.test.ts
Normal file
68
core/racing/domain/entities/LeagueMembership.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<LeagueMembershipProps>) {
|
||||
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<string> {
|
||||
? 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<string> {
|
||||
throw new RacingDomainValidationError('Membership role is required');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
driverId: string;
|
||||
requestedAt: Date;
|
||||
message?: string;
|
||||
}
|
||||
23
core/racing/domain/entities/LeagueName.ts
Normal file
23
core/racing/domain/entities/LeagueName.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/LeagueOwnerId.ts
Normal file
20
core/racing/domain/entities/LeagueOwnerId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
73
core/racing/domain/entities/LeagueScoringConfig.test.ts
Normal file
73
core/racing/domain/entities/LeagueScoringConfig.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<LeagueScoringConfigId> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
38
core/racing/domain/entities/LeagueScoringConfigId.test.ts
Normal file
38
core/racing/domain/entities/LeagueScoringConfigId.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/LeagueScoringConfigId.ts
Normal file
20
core/racing/domain/entities/LeagueScoringConfigId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
52
core/racing/domain/entities/LeagueSocialLinks.ts
Normal file
52
core/racing/domain/entities/LeagueSocialLinks.ts
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
184
core/racing/domain/entities/LiveryTemplate.test.ts
Normal file
184
core/racing/domain/entities/LiveryTemplate.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
readonly id: string;
|
||||
readonly leagueId: string;
|
||||
readonly seasonId: string;
|
||||
readonly carId: string;
|
||||
readonly baseImageUrl: string;
|
||||
export class LiveryTemplate implements IEntity<LiveryTemplateId> {
|
||||
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<LiveryTemplateProps, 'createdAt' | 'adminDecals'> & {
|
||||
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<LiveryTemplateProps, 'createdAt' | 'adminDecals'>): 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<string> {
|
||||
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<string> {
|
||||
return new LiveryTemplate({
|
||||
...this,
|
||||
adminDecals: updatedDecals,
|
||||
updatedAt: new Date(),
|
||||
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,7 +140,7 @@ export class LiveryTemplate implements IEntity<string> {
|
||||
*/
|
||||
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<string> {
|
||||
return new LiveryTemplate({
|
||||
...this,
|
||||
adminDecals: updatedDecals,
|
||||
updatedAt: new Date(),
|
||||
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
21
core/racing/domain/entities/LiveryTemplateCreatedAt.ts
Normal file
21
core/racing/domain/entities/LiveryTemplateCreatedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/LiveryTemplateId.ts
Normal file
20
core/racing/domain/entities/LiveryTemplateId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/LiveryTemplateUpdatedAt.ts
Normal file
21
core/racing/domain/entities/LiveryTemplateUpdatedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
23
core/racing/domain/entities/Manufacturer.ts
Normal file
23
core/racing/domain/entities/Manufacturer.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
22
core/racing/domain/entities/MembershipRole.ts
Normal file
22
core/racing/domain/entities/MembershipRole.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
22
core/racing/domain/entities/MembershipStatus.ts
Normal file
22
core/racing/domain/entities/MembershipStatus.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
226
core/racing/domain/entities/Protest.test.ts
Normal file
226
core/racing/domain/entities/Protest.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
}
|
||||
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<string> {
|
||||
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<string> {
|
||||
}
|
||||
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<string> {
|
||||
}
|
||||
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<string> {
|
||||
}
|
||||
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<string> {
|
||||
}
|
||||
return new Protest({
|
||||
...this.props,
|
||||
status: 'withdrawn',
|
||||
reviewedAt: new Date(),
|
||||
status: ProtestStatus.create('withdrawn'),
|
||||
reviewedAt: ReviewedAt.create(new Date()),
|
||||
});
|
||||
}
|
||||
}
|
||||
21
core/racing/domain/entities/ProtestComment.ts
Normal file
21
core/racing/domain/entities/ProtestComment.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
40
core/racing/domain/entities/ProtestDefense.ts
Normal file
40
core/racing/domain/entities/ProtestDefense.ts
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
38
core/racing/domain/entities/ProtestId.test.ts
Normal file
38
core/racing/domain/entities/ProtestId.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/ProtestId.ts
Normal file
20
core/racing/domain/entities/ProtestId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
40
core/racing/domain/entities/ProtestIncident.ts
Normal file
40
core/racing/domain/entities/ProtestIncident.ts
Normal file
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
25
core/racing/domain/entities/ProtestStatus.ts
Normal file
25
core/racing/domain/entities/ProtestStatus.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
340
core/racing/domain/entities/Race.test.ts
Normal file
340
core/racing/domain/entities/Race.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
578
core/racing/domain/entities/RaceEvent.test.ts
Normal file
578
core/racing/domain/entities/RaceEvent.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
*/
|
||||
isMainRaceCompleted(): boolean {
|
||||
const mainRace = this.getMainRaceSession();
|
||||
return mainRace?.status === 'completed' ?? false;
|
||||
return mainRace ? mainRace.status === 'completed' : false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
38
core/racing/domain/entities/RaceId.test.ts
Normal file
38
core/racing/domain/entities/RaceId.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/RaceId.ts
Normal file
20
core/racing/domain/entities/RaceId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
120
core/racing/domain/entities/RaceRegistration.test.ts
Normal file
120
core/racing/domain/entities/RaceRegistration.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<RaceRegistrationProps>) {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
44
core/racing/domain/entities/RegisteredAt.test.ts
Normal file
44
core/racing/domain/entities/RegisteredAt.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/RegisteredAt.ts
Normal file
20
core/racing/domain/entities/RegisteredAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
20
core/racing/domain/entities/ReviewedAt.ts
Normal file
20
core/racing/domain/entities/ReviewedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/ScoringPresetId.ts
Normal file
20
core/racing/domain/entities/ScoringPresetId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
426
core/racing/domain/entities/Session.test.ts
Normal file
426
core/racing/domain/entities/Session.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<SponsorProps, 'createdAt'> & { 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<SponsorProps, 'createdAt'>): 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);
|
||||
}
|
||||
}
|
||||
161
core/racing/domain/entities/SponsorshipRequest.test.ts
Normal file
161
core/racing/domain/entities/SponsorshipRequest.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
135
core/racing/domain/entities/Standing.test.ts
Normal file
135
core/racing/domain/entities/Standing.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
* 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<string> {
|
||||
*/
|
||||
getAveragePoints(): number {
|
||||
if (this.racesCompleted === 0) return 0;
|
||||
return this.points / this.racesCompleted;
|
||||
return this.points.toNumber() / this.racesCompleted;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
38
core/racing/domain/entities/StewardId.test.ts
Normal file
38
core/racing/domain/entities/StewardId.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/StewardId.ts
Normal file
20
core/racing/domain/entities/StewardId.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/SubmittedAt.ts
Normal file
20
core/racing/domain/entities/SubmittedAt.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
195
core/racing/domain/entities/Team.test.ts
Normal file
195
core/racing/domain/entities/Team.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/TimeInRace.ts
Normal file
20
core/racing/domain/entities/TimeInRace.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
137
core/racing/domain/entities/Track.test.ts
Normal file
137
core/racing/domain/entities/Track.test.ts
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
}
|
||||
26
core/racing/domain/entities/VideoUrl.ts
Normal file
26
core/racing/domain/entities/VideoUrl.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/Weight.ts
Normal file
20
core/racing/domain/entities/Weight.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
20
core/racing/domain/entities/Year.ts
Normal file
20
core/racing/domain/entities/Year.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
33
core/racing/domain/entities/championship/Position.test.ts
Normal file
33
core/racing/domain/entities/championship/Position.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
20
core/racing/domain/entities/championship/Position.ts
Normal file
20
core/racing/domain/entities/championship/Position.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user