refactor
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Game } from '@core/racing/domain/entities/Game';
|
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 type { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
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 { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||||
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
|
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||||
import type { IChampionshipStandingRepository } from '@core/racing/domain/repositories/IChampionshipStandingRepository';
|
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 { ChampionshipType } from '@core/racing/domain/types/ChampionshipType';
|
||||||
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
|
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
|
||||||
import type { Logger } from '@core/shared/application';
|
import type { Logger } from '@core/shared/application';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
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';
|
import { Logger } from '@core/shared/application';
|
||||||
|
|
||||||
export class InMemorySeasonRepository implements ISeasonRepository {
|
export class InMemorySeasonRepository implements ISeasonRepository {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
|
|||||||
this.logger.debug(`Generated seasonId: ${seasonId}`);
|
this.logger.debug(`Generated seasonId: ${seasonId}`);
|
||||||
const season = Season.create({
|
const season = Season.create({
|
||||||
id: seasonId,
|
id: seasonId,
|
||||||
leagueId: league.id,
|
leagueId: league.id.toString(),
|
||||||
gameId: command.gameId,
|
gameId: command.gameId,
|
||||||
name: `${command.name} Season 1`,
|
name: `${command.name} Season 1`,
|
||||||
year: new Date().getFullYear(),
|
year: new Date().getFullYear(),
|
||||||
@@ -113,7 +113,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
|
|||||||
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
|
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
|
||||||
|
|
||||||
const result: CreateLeagueWithSeasonAndScoringResultDTO = {
|
const result: CreateLeagueWithSeasonAndScoringResultDTO = {
|
||||||
leagueId: league.id,
|
leagueId: league.id.toString(),
|
||||||
seasonId,
|
seasonId,
|
||||||
scoringPresetId: preset.id,
|
scoringPresetId: preset.id,
|
||||||
scoringPresetName: preset.name,
|
scoringPresetName: preset.name,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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 { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class GetAllRacesPageDataUseCase
|
|||||||
]);
|
]);
|
||||||
this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`);
|
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
|
const races: AllRacesListItemViewModel[] = allRaces
|
||||||
.slice()
|
.slice()
|
||||||
@@ -46,7 +46,7 @@ export class GetAllRacesPageDataUseCase
|
|||||||
|
|
||||||
const uniqueLeagues = new Map<string, { id: string; name: string }>();
|
const uniqueLeagues = new Map<string, { id: string; name: string }>();
|
||||||
for (const league of allLeagues) {
|
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 = {
|
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 { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
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 { EventScoringService } from '@core/racing/domain/services/EventScoringService';
|
||||||
import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator';
|
import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator';
|
||||||
|
|
||||||
@@ -101,10 +101,10 @@ export class RecalculateChampionshipStandingsUseCase
|
|||||||
|
|
||||||
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
|
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
|
||||||
participant: s.participant,
|
participant: s.participant,
|
||||||
position: s.position,
|
position: s.position.toNumber(),
|
||||||
totalPoints: s.totalPoints,
|
totalPoints: s.totalPoints.toNumber(),
|
||||||
resultsCounted: s.resultsCounted,
|
resultsCounted: s.resultsCounted.toNumber(),
|
||||||
resultsDropped: s.resultsDropped,
|
resultsDropped: s.resultsDropped.toNumber(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const dto: ChampionshipStandingsDTO = {
|
const dto: ChampionshipStandingsDTO = {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
InMemorySeasonRepository,
|
InMemorySeasonRepository,
|
||||||
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
|
} 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 { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||||
import {
|
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 type { IEntity } from '@core/shared/domain';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
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 class Car implements IEntity<CarId> {
|
||||||
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
|
readonly id: CarId;
|
||||||
|
readonly name: CarName;
|
||||||
export class Car implements IEntity<string> {
|
|
||||||
readonly id: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly shortName: string;
|
readonly shortName: string;
|
||||||
readonly manufacturer: string;
|
readonly manufacturer: Manufacturer;
|
||||||
readonly carClass: CarClass;
|
readonly carClass: CarClass;
|
||||||
readonly license: CarLicense;
|
readonly license: CarLicense;
|
||||||
readonly year: number;
|
readonly year: Year;
|
||||||
readonly horsepower: number | undefined;
|
readonly horsepower: Horsepower | undefined;
|
||||||
readonly weight: number | undefined;
|
readonly weight: Weight | undefined;
|
||||||
readonly imageUrl: string | undefined;
|
readonly imageUrl: ImageUrl | undefined;
|
||||||
readonly gameId: string;
|
readonly gameId: GameId;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: CarId;
|
||||||
name: string;
|
name: CarName;
|
||||||
shortName: string;
|
shortName: string;
|
||||||
manufacturer: string;
|
manufacturer: Manufacturer;
|
||||||
carClass: CarClass;
|
carClass: CarClass;
|
||||||
license: CarLicense;
|
license: CarLicense;
|
||||||
year: number;
|
year: Year;
|
||||||
horsepower?: number;
|
horsepower?: Horsepower;
|
||||||
weight?: number;
|
weight?: Weight;
|
||||||
imageUrl?: string;
|
imageUrl?: ImageUrl;
|
||||||
gameId: string;
|
gameId: GameId;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.name = props.name;
|
this.name = props.name;
|
||||||
@@ -58,8 +65,8 @@ export class Car implements IEntity<string> {
|
|||||||
name: string;
|
name: string;
|
||||||
shortName?: string;
|
shortName?: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
carClass?: CarClass;
|
carClass?: CarClassType;
|
||||||
license?: CarLicense;
|
license?: CarLicenseType;
|
||||||
year?: number;
|
year?: number;
|
||||||
horsepower?: number;
|
horsepower?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -69,17 +76,17 @@ export class Car implements IEntity<string> {
|
|||||||
this.validate(props);
|
this.validate(props);
|
||||||
|
|
||||||
return new Car({
|
return new Car({
|
||||||
id: props.id,
|
id: CarId.create(props.id),
|
||||||
name: props.name,
|
name: CarName.create(props.name),
|
||||||
shortName: props.shortName ?? props.name.slice(0, 10),
|
shortName: props.shortName ?? props.name.slice(0, 10),
|
||||||
manufacturer: props.manufacturer,
|
manufacturer: Manufacturer.create(props.manufacturer),
|
||||||
carClass: props.carClass ?? 'gt',
|
carClass: CarClass.create(props.carClass ?? 'gt'),
|
||||||
license: props.license ?? 'D',
|
license: CarLicense.create(props.license ?? 'D'),
|
||||||
year: props.year ?? new Date().getFullYear(),
|
year: Year.create(props.year ?? new Date().getFullYear()),
|
||||||
...(props.horsepower !== undefined ? { horsepower: props.horsepower } : {}),
|
...(props.horsepower !== undefined ? { horsepower: Horsepower.create(props.horsepower) } : {}),
|
||||||
...(props.weight !== undefined ? { weight: props.weight } : {}),
|
...(props.weight !== undefined ? { weight: Weight.create(props.weight) } : {}),
|
||||||
...(props.imageUrl !== undefined ? { imageUrl: props.imageUrl } : {}),
|
...(props.imageUrl !== undefined ? { imageUrl: ImageUrl.create(props.imageUrl) } : {}),
|
||||||
gameId: props.gameId,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,22 +7,27 @@
|
|||||||
|
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
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> {
|
export class Driver implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly iracingId: string;
|
readonly iracingId: IRacingId;
|
||||||
readonly name: string;
|
readonly name: DriverName;
|
||||||
readonly country: string;
|
readonly country: CountryCode;
|
||||||
readonly bio: string | undefined;
|
readonly bio: DriverBio | undefined;
|
||||||
readonly joinedAt: Date;
|
readonly joinedAt: JoinedAt;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
iracingId: string;
|
iracingId: IRacingId;
|
||||||
name: string;
|
name: DriverName;
|
||||||
country: string;
|
country: CountryCode;
|
||||||
bio?: string;
|
bio?: DriverBio;
|
||||||
joinedAt: Date;
|
joinedAt: JoinedAt;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.iracingId = props.iracingId;
|
this.iracingId = props.iracingId;
|
||||||
@@ -43,47 +48,18 @@ export class Driver implements IEntity<string> {
|
|||||||
bio?: string;
|
bio?: string;
|
||||||
joinedAt?: Date;
|
joinedAt?: Date;
|
||||||
}): Driver {
|
}): 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) {
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Driver ID is required');
|
throw new RacingDomainValidationError('Driver ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.iracingId || props.iracingId.trim().length === 0) {
|
return new Driver({
|
||||||
throw new RacingDomainValidationError('iRacing ID is required');
|
id: props.id,
|
||||||
}
|
iracingId: IRacingId.create(props.iracingId),
|
||||||
|
name: DriverName.create(props.name),
|
||||||
if (!props.name || props.name.trim().length === 0) {
|
country: CountryCode.create(props.country),
|
||||||
throw new RacingDomainValidationError('Driver name is required');
|
...(props.bio !== undefined ? { bio: DriverBio.create(props.bio) } : {}),
|
||||||
}
|
joinedAt: JoinedAt.create(props.joinedAt ?? new Date()),
|
||||||
|
});
|
||||||
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)');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,11 +68,11 @@ export class Driver implements IEntity<string> {
|
|||||||
update(props: Partial<{
|
update(props: Partial<{
|
||||||
name: string;
|
name: string;
|
||||||
country: string;
|
country: string;
|
||||||
bio?: string;
|
bio: string | undefined;
|
||||||
}>): Driver {
|
}>): Driver {
|
||||||
const nextName = props.name ?? this.name;
|
const nextName = 'name' in props ? DriverName.create(props.name!) : this.name;
|
||||||
const nextCountry = props.country ?? this.country;
|
const nextCountry = 'country' in props ? CountryCode.create(props.country!) : this.country;
|
||||||
const nextBio = props.bio ?? this.bio;
|
const nextBio = 'bio' in props ? (props.bio ? DriverBio.create(props.bio) : undefined) : this.bio;
|
||||||
|
|
||||||
return new Driver({
|
return new Driver({
|
||||||
id: this.id,
|
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 { IEntity } from '@core/shared/domain';
|
||||||
|
|
||||||
import type { LiveryDecal } from '../value-objects/LiveryDecal';
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
|
import { LiveryDecal } from '../value-objects/LiveryDecal';
|
||||||
export interface DecalOverride {
|
import { DecalOverride } from '../value-objects/DecalOverride';
|
||||||
leagueId: string;
|
import { DriverId } from '../value-objects/DriverId';
|
||||||
seasonId: string;
|
import { GameId } from './GameId';
|
||||||
decalId: string;
|
import { CarId } from '../value-objects/CarId';
|
||||||
newX: number;
|
import { ImageUrl } from '../value-objects/ImageUrl';
|
||||||
newY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DriverLiveryProps {
|
export interface DriverLiveryProps {
|
||||||
id: string;
|
id: string;
|
||||||
driverId: string;
|
driverId: DriverId;
|
||||||
gameId: string;
|
gameId: GameId;
|
||||||
carId: string;
|
carId: CarId;
|
||||||
uploadedImageUrl: string;
|
uploadedImageUrl: ImageUrl;
|
||||||
userDecals: LiveryDecal[];
|
userDecals: LiveryDecal[];
|
||||||
leagueOverrides: DecalOverride[];
|
leagueOverrides: DecalOverride[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -32,10 +30,10 @@ export interface DriverLiveryProps {
|
|||||||
|
|
||||||
export class DriverLivery implements IEntity<string> {
|
export class DriverLivery implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly driverId: string;
|
readonly driverId: DriverId;
|
||||||
readonly gameId: string;
|
readonly gameId: GameId;
|
||||||
readonly carId: string;
|
readonly carId: CarId;
|
||||||
readonly uploadedImageUrl: string;
|
readonly uploadedImageUrl: ImageUrl;
|
||||||
readonly userDecals: LiveryDecal[];
|
readonly userDecals: LiveryDecal[];
|
||||||
readonly leagueOverrides: DecalOverride[];
|
readonly leagueOverrides: DecalOverride[];
|
||||||
readonly createdAt: Date;
|
readonly createdAt: Date;
|
||||||
@@ -55,7 +53,12 @@ export class DriverLivery implements IEntity<string> {
|
|||||||
this.validatedAt = props.validatedAt;
|
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;
|
createdAt?: Date;
|
||||||
userDecals?: LiveryDecal[];
|
userDecals?: LiveryDecal[];
|
||||||
leagueOverrides?: DecalOverride[];
|
leagueOverrides?: DecalOverride[];
|
||||||
@@ -63,14 +66,26 @@ export class DriverLivery implements IEntity<string> {
|
|||||||
this.validate(props);
|
this.validate(props);
|
||||||
|
|
||||||
return new DriverLivery({
|
return new DriverLivery({
|
||||||
...props,
|
id: props.id,
|
||||||
createdAt: props.createdAt ?? new Date(),
|
driverId: DriverId.create(props.driverId),
|
||||||
|
gameId: GameId.create(props.gameId),
|
||||||
|
carId: CarId.create(props.carId),
|
||||||
|
uploadedImageUrl: ImageUrl.create(props.uploadedImageUrl),
|
||||||
userDecals: props.userDecals ?? [],
|
userDecals: props.userDecals ?? [],
|
||||||
leagueOverrides: props.leagueOverrides ?? [],
|
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) {
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('DriverLivery ID is required');
|
throw new RacingDomainValidationError('DriverLivery ID is required');
|
||||||
}
|
}
|
||||||
@@ -173,7 +188,7 @@ export class DriverLivery implements IEntity<string> {
|
|||||||
o => o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId
|
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[];
|
let updatedOverrides: DecalOverride[];
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
|
|||||||
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';
|
import type { IEntity } from '@core/shared/domain';
|
||||||
|
import { GameId } from './GameId';
|
||||||
|
import { GameName } from './GameName';
|
||||||
|
|
||||||
export class Game implements IEntity<string> {
|
export class Game implements IEntity<GameId> {
|
||||||
readonly id: string;
|
readonly id: GameId;
|
||||||
readonly name: string;
|
readonly name: GameName;
|
||||||
|
|
||||||
private constructor(props: { id: string; name: string }) {
|
private constructor(props: { id: GameId; name: GameName }) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.name = props.name;
|
this.name = props.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
static create(props: { id: string; name: string }): Game {
|
static create(props: { id: string; name: string }): Game {
|
||||||
if (!props.id || props.id.trim().length === 0) {
|
const id = GameId.create(props.id);
|
||||||
throw new RacingDomainValidationError('Game ID is required');
|
const name = GameName.create(props.name);
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.name || props.name.trim().length === 0) {
|
|
||||||
throw new RacingDomainValidationError('Game name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Game({
|
return new Game({
|
||||||
id: props.id,
|
id,
|
||||||
name: props.name,
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,8 +5,13 @@
|
|||||||
* Immutable entity with factory methods and domain validation.
|
* Immutable entity with factory methods and domain validation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
|
||||||
import type { IEntity } from '@core/shared/domain';
|
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
|
* Stewarding decision mode for protests
|
||||||
@@ -21,41 +26,41 @@ export type StewardingDecisionMode =
|
|||||||
|
|
||||||
export interface StewardingSettings {
|
export interface StewardingSettings {
|
||||||
/**
|
/**
|
||||||
* How protest decisions are made
|
* How protest decisions are made
|
||||||
*/
|
*/
|
||||||
decisionMode: StewardingDecisionMode;
|
decisionMode: StewardingDecisionMode;
|
||||||
/**
|
/**
|
||||||
* Number of votes required to uphold/reject a protest
|
* Number of votes required to uphold/reject a protest
|
||||||
* Used with steward_vote, member_vote, steward_veto, member_veto modes
|
* Used with steward_vote, member_vote, steward_veto, member_veto modes
|
||||||
*/
|
*/
|
||||||
requiredVotes?: number;
|
requiredVotes?: number;
|
||||||
/**
|
/**
|
||||||
* Whether to require a defense from the accused before deciding
|
* Whether to require a defense from the accused before deciding
|
||||||
*/
|
*/
|
||||||
requireDefense?: boolean;
|
requireDefense?: boolean;
|
||||||
/**
|
/**
|
||||||
* Time limit (hours) for accused to submit defense
|
* Time limit (hours) for accused to submit defense
|
||||||
*/
|
*/
|
||||||
defenseTimeLimit?: number;
|
defenseTimeLimit?: number;
|
||||||
/**
|
/**
|
||||||
* Time limit (hours) for voting to complete
|
* Time limit (hours) for voting to complete
|
||||||
*/
|
*/
|
||||||
voteTimeLimit?: number;
|
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;
|
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;
|
stewardingClosesHours?: number;
|
||||||
/**
|
/**
|
||||||
* Whether to notify the accused when a protest is filed
|
* Whether to notify the accused when a protest is filed
|
||||||
*/
|
*/
|
||||||
notifyAccusedOnProtest?: boolean;
|
notifyAccusedOnProtest?: boolean;
|
||||||
/**
|
/**
|
||||||
* Whether to notify eligible voters when a vote is required
|
* Whether to notify eligible voters when a vote is required
|
||||||
*/
|
*/
|
||||||
notifyOnVoteRequired?: boolean;
|
notifyOnVoteRequired?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,38 +70,32 @@ export interface LeagueSettings {
|
|||||||
qualifyingFormat?: 'single-lap' | 'open';
|
qualifyingFormat?: 'single-lap' | 'open';
|
||||||
customPoints?: Record<number, number>;
|
customPoints?: Record<number, number>;
|
||||||
/**
|
/**
|
||||||
* Maximum number of drivers allowed in the league.
|
* Maximum number of drivers allowed in the league.
|
||||||
* Used for simple capacity display on the website.
|
* Used for simple capacity display on the website.
|
||||||
*/
|
*/
|
||||||
maxDrivers?: number;
|
maxDrivers?: number;
|
||||||
/**
|
/**
|
||||||
* Stewarding settings for protest handling
|
* Stewarding settings for protest handling
|
||||||
*/
|
*/
|
||||||
stewarding?: StewardingSettings;
|
stewarding?: StewardingSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeagueSocialLinks {
|
export class League implements IEntity<LeagueId> {
|
||||||
discordUrl?: string;
|
readonly id: LeagueId;
|
||||||
youtubeUrl?: string;
|
readonly name: LeagueName;
|
||||||
websiteUrl?: string;
|
readonly description: LeagueDescription;
|
||||||
}
|
readonly ownerId: LeagueOwnerId;
|
||||||
|
|
||||||
export class League implements IEntity<string> {
|
|
||||||
readonly id: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
readonly ownerId: string;
|
|
||||||
readonly settings: LeagueSettings;
|
readonly settings: LeagueSettings;
|
||||||
readonly createdAt: Date;
|
readonly createdAt: LeagueCreatedAt;
|
||||||
readonly socialLinks: LeagueSocialLinks | undefined;
|
readonly socialLinks: LeagueSocialLinks | undefined;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: LeagueId;
|
||||||
name: string;
|
name: LeagueName;
|
||||||
description: string;
|
description: LeagueDescription;
|
||||||
ownerId: string;
|
ownerId: LeagueOwnerId;
|
||||||
settings: LeagueSettings;
|
settings: LeagueSettings;
|
||||||
createdAt: Date;
|
createdAt: LeagueCreatedAt;
|
||||||
socialLinks?: LeagueSocialLinks;
|
socialLinks?: LeagueSocialLinks;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
@@ -118,9 +117,17 @@ export class League implements IEntity<string> {
|
|||||||
ownerId: string;
|
ownerId: string;
|
||||||
settings?: Partial<LeagueSettings>;
|
settings?: Partial<LeagueSettings>;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
socialLinks?: LeagueSocialLinks;
|
socialLinks?: {
|
||||||
|
discordUrl?: string;
|
||||||
|
youtubeUrl?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
};
|
||||||
}): League {
|
}): 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 = {
|
const defaultStewardingSettings: StewardingSettings = {
|
||||||
decisionMode: 'admin_only',
|
decisionMode: 'admin_only',
|
||||||
@@ -141,48 +148,19 @@ export class League implements IEntity<string> {
|
|||||||
stewarding: defaultStewardingSettings,
|
stewarding: defaultStewardingSettings,
|
||||||
};
|
};
|
||||||
|
|
||||||
const socialLinks = props.socialLinks;
|
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined;
|
||||||
|
|
||||||
return new League({
|
return new League({
|
||||||
id: props.id,
|
id,
|
||||||
name: props.name,
|
name,
|
||||||
description: props.description,
|
description,
|
||||||
ownerId: props.ownerId,
|
ownerId,
|
||||||
settings: { ...defaultSettings, ...props.settings },
|
settings: { ...defaultSettings, ...props.settings },
|
||||||
createdAt: props.createdAt ?? new Date(),
|
createdAt,
|
||||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
...(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
|
* Create a copy with updated properties
|
||||||
@@ -192,20 +170,25 @@ export class League implements IEntity<string> {
|
|||||||
description: string;
|
description: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
settings: LeagueSettings;
|
settings: LeagueSettings;
|
||||||
socialLinks?: LeagueSocialLinks;
|
socialLinks?: {
|
||||||
|
discordUrl?: string;
|
||||||
|
youtubeUrl?: string;
|
||||||
|
websiteUrl?: string;
|
||||||
|
};
|
||||||
}>): League {
|
}>): 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({
|
return new League({
|
||||||
id: this.id,
|
id: this.id,
|
||||||
name: props.name ?? this.name,
|
name,
|
||||||
description: props.description ?? this.description,
|
description,
|
||||||
ownerId: props.ownerId ?? this.ownerId,
|
ownerId,
|
||||||
settings: props.settings ?? this.settings,
|
settings: props.settings ?? this.settings,
|
||||||
createdAt: this.createdAt,
|
createdAt: this.createdAt,
|
||||||
...(props.socialLinks !== undefined
|
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||||
? { socialLinks: props.socialLinks }
|
|
||||||
: this.socialLinks !== undefined
|
|
||||||
? { socialLinks: this.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 type { IEntity } from '@core/shared/domain';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import { LeagueId } from './LeagueId';
|
||||||
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
|
import { DriverId } from '../value-objects/DriverId';
|
||||||
export type MembershipStatus = 'active' | 'inactive' | 'pending';
|
import { MembershipRole, MembershipRoleValue } from './MembershipRole';
|
||||||
|
import { MembershipStatus, MembershipStatusValue } from './MembershipStatus';
|
||||||
|
import { JoinedAt } from '../value-objects/JoinedAt';
|
||||||
|
|
||||||
export interface LeagueMembershipProps {
|
export interface LeagueMembershipProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
role: MembershipRole;
|
role: string;
|
||||||
status?: MembershipStatus;
|
status?: string;
|
||||||
joinedAt?: Date;
|
joinedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LeagueMembership implements IEntity<string> {
|
export class LeagueMembership implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly leagueId: string;
|
readonly leagueId: LeagueId;
|
||||||
readonly driverId: string;
|
readonly driverId: DriverId;
|
||||||
readonly role: MembershipRole;
|
readonly role: MembershipRole;
|
||||||
readonly status: MembershipStatus;
|
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.id = props.id;
|
||||||
this.leagueId = props.leagueId;
|
this.leagueId = props.leagueId;
|
||||||
this.driverId = props.driverId;
|
this.driverId = props.driverId;
|
||||||
@@ -44,14 +53,17 @@ export class LeagueMembership implements IEntity<string> {
|
|||||||
? props.id
|
? props.id
|
||||||
: `${props.leagueId}:${props.driverId}`;
|
: `${props.leagueId}:${props.driverId}`;
|
||||||
|
|
||||||
const status = props.status ?? 'pending';
|
const leagueId = LeagueId.create(props.leagueId);
|
||||||
const joinedAt = props.joinedAt ?? new Date();
|
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({
|
return new LeagueMembership({
|
||||||
id,
|
id,
|
||||||
leagueId: props.leagueId,
|
leagueId,
|
||||||
driverId: props.driverId,
|
driverId,
|
||||||
role: props.role,
|
role,
|
||||||
status,
|
status,
|
||||||
joinedAt,
|
joinedAt,
|
||||||
});
|
});
|
||||||
@@ -71,11 +83,3 @@ export class LeagueMembership implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
import type { IEntity } from '@core/shared/domain';
|
||||||
id: string;
|
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;
|
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;
|
scoringPresetId?: string;
|
||||||
championships: ChampionshipConfig[];
|
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 type { IEntity } from '@core/shared/domain';
|
||||||
|
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
import type { LiveryDecal } from '../value-objects/LiveryDecal';
|
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 {
|
export class LiveryTemplate implements IEntity<LiveryTemplateId> {
|
||||||
id: string;
|
readonly id: LiveryTemplateId;
|
||||||
leagueId: string;
|
readonly leagueId: LeagueId;
|
||||||
seasonId: string;
|
readonly seasonId: SeasonId;
|
||||||
carId: string;
|
readonly carId: CarId;
|
||||||
baseImageUrl: string;
|
readonly baseImageUrl: ImageUrl;
|
||||||
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;
|
|
||||||
readonly adminDecals: LiveryDecal[];
|
readonly adminDecals: LiveryDecal[];
|
||||||
readonly createdAt: Date;
|
readonly createdAt: LiveryTemplateCreatedAt;
|
||||||
readonly updatedAt: Date | undefined;
|
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.id = props.id;
|
||||||
this.leagueId = props.leagueId;
|
this.leagueId = props.leagueId;
|
||||||
this.seasonId = props.seasonId;
|
this.seasonId = props.seasonId;
|
||||||
this.carId = props.carId;
|
this.carId = props.carId;
|
||||||
this.baseImageUrl = props.baseImageUrl;
|
this.baseImageUrl = props.baseImageUrl;
|
||||||
this.adminDecals = props.adminDecals;
|
this.adminDecals = props.adminDecals;
|
||||||
this.createdAt = props.createdAt ?? new Date();
|
this.createdAt = props.createdAt;
|
||||||
this.updatedAt = props.updatedAt;
|
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;
|
createdAt?: Date;
|
||||||
adminDecals?: LiveryDecal[];
|
adminDecals?: LiveryDecal[];
|
||||||
}): LiveryTemplate {
|
}): LiveryTemplate {
|
||||||
this.validate(props);
|
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({
|
return new LiveryTemplate({
|
||||||
...props,
|
id,
|
||||||
createdAt: props.createdAt ?? new Date(),
|
leagueId,
|
||||||
|
seasonId,
|
||||||
|
carId,
|
||||||
|
baseImageUrl,
|
||||||
adminDecals: props.adminDecals ?? [],
|
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) {
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('LiveryTemplate ID is required');
|
throw new RacingDomainValidationError('LiveryTemplate ID is required');
|
||||||
}
|
}
|
||||||
@@ -87,7 +114,7 @@ export class LiveryTemplate implements IEntity<string> {
|
|||||||
return new LiveryTemplate({
|
return new LiveryTemplate({
|
||||||
...this,
|
...this,
|
||||||
adminDecals: [...this.adminDecals, decal],
|
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({
|
return new LiveryTemplate({
|
||||||
...this,
|
...this,
|
||||||
adminDecals: updatedDecals,
|
adminDecals: updatedDecals,
|
||||||
updatedAt: new Date(),
|
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +151,7 @@ export class LiveryTemplate implements IEntity<string> {
|
|||||||
return new LiveryTemplate({
|
return new LiveryTemplate({
|
||||||
...this,
|
...this,
|
||||||
adminDecals: updatedDecals,
|
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 { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
import type { IEntity } from '@core/shared/domain';
|
||||||
|
import { ProtestId } from './ProtestId';
|
||||||
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
|
import { RaceId } from './RaceId';
|
||||||
|
import { DriverId } from './DriverId';
|
||||||
export interface ProtestIncident {
|
import { StewardId } from './StewardId';
|
||||||
/** Lap number where the incident occurred */
|
import { ProtestStatus } from './ProtestStatus';
|
||||||
lap: number;
|
import { ProtestIncident } from './ProtestIncident';
|
||||||
/** Time in the race (seconds from start, or timestamp) */
|
import { ProtestComment } from './ProtestComment';
|
||||||
timeInRace?: number;
|
import { VideoUrl } from './VideoUrl';
|
||||||
/** Brief description of the incident */
|
import { FiledAt } from './FiledAt';
|
||||||
description: string;
|
import { ReviewedAt } from './ReviewedAt';
|
||||||
}
|
import { ProtestDefense } from './ProtestDefense';
|
||||||
|
import { DefenseRequestedAt } from './DefenseRequestedAt';
|
||||||
export interface ProtestDefense {
|
import { DecisionNotes } from './DecisionNotes';
|
||||||
/** The accused driver's statement/defense */
|
|
||||||
statement: string;
|
|
||||||
/** URL to defense video clip (optional) */
|
|
||||||
videoUrl?: string;
|
|
||||||
/** Timestamp when defense was submitted */
|
|
||||||
submittedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProtestProps {
|
export interface ProtestProps {
|
||||||
id: string;
|
id: ProtestId;
|
||||||
raceId: string;
|
raceId: RaceId;
|
||||||
/** The driver filing the protest */
|
/** The driver filing the protest */
|
||||||
protestingDriverId: string;
|
protestingDriverId: DriverId;
|
||||||
/** The driver being protested against */
|
/** The driver being protested against */
|
||||||
accusedDriverId: string;
|
accusedDriverId: DriverId;
|
||||||
/** Details of the incident */
|
/** Details of the incident */
|
||||||
incident: ProtestIncident;
|
incident: ProtestIncident;
|
||||||
/** Optional comment/statement from the protesting driver */
|
/** Optional comment/statement from the protesting driver */
|
||||||
comment?: string;
|
comment?: ProtestComment;
|
||||||
/** URL to proof video clip */
|
/** URL to proof video clip */
|
||||||
proofVideoUrl?: string;
|
proofVideoUrl?: VideoUrl;
|
||||||
/** Current status of the protest */
|
/** Current status of the protest */
|
||||||
status: ProtestStatus;
|
status: ProtestStatus;
|
||||||
/** ID of the steward/admin who reviewed (if any) */
|
/** ID of the steward/admin who reviewed (if any) */
|
||||||
reviewedBy?: string;
|
reviewedBy?: StewardId;
|
||||||
/** Decision notes from the steward */
|
/** Decision notes from the steward */
|
||||||
decisionNotes?: string;
|
decisionNotes?: DecisionNotes;
|
||||||
/** Timestamp when the protest was filed */
|
/** Timestamp when the protest was filed */
|
||||||
filedAt: Date;
|
filedAt: FiledAt;
|
||||||
/** Timestamp when the protest was reviewed */
|
/** Timestamp when the protest was reviewed */
|
||||||
reviewedAt?: Date;
|
reviewedAt?: ReviewedAt;
|
||||||
/** Defense from the accused driver (if requested and submitted) */
|
/** Defense from the accused driver (if requested and submitted) */
|
||||||
defense?: ProtestDefense;
|
defense?: ProtestDefense;
|
||||||
/** Timestamp when defense was requested */
|
/** Timestamp when defense was requested */
|
||||||
defenseRequestedAt?: Date;
|
defenseRequestedAt?: DefenseRequestedAt;
|
||||||
/** ID of the steward who requested defense */
|
/** ID of the steward who requested defense */
|
||||||
defenseRequestedBy?: string;
|
defenseRequestedBy?: StewardId;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Protest implements IEntity<string> {
|
export class Protest implements IEntity<string> {
|
||||||
private constructor(private readonly props: ProtestProps) {}
|
private constructor(private readonly props: ProtestProps) {}
|
||||||
|
|
||||||
static create(props: ProtestProps): Protest {
|
static create(props: {
|
||||||
if (!props.id) throw new RacingDomainValidationError('Protest ID is required');
|
id: string;
|
||||||
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
|
raceId: string;
|
||||||
if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required');
|
protestingDriverId: string;
|
||||||
if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required');
|
accusedDriverId: string;
|
||||||
if (!props.incident) throw new RacingDomainValidationError('Incident details are required');
|
incident: { lap: number; description: string; timeInRace?: number };
|
||||||
if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative');
|
comment?: string;
|
||||||
if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required');
|
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({
|
return new Protest({
|
||||||
...props,
|
id,
|
||||||
status: props.status || 'pending',
|
raceId,
|
||||||
filedAt: props.filedAt || new Date(),
|
protestingDriverId,
|
||||||
|
accusedDriverId,
|
||||||
|
incident,
|
||||||
|
comment,
|
||||||
|
proofVideoUrl,
|
||||||
|
status,
|
||||||
|
reviewedBy,
|
||||||
|
decisionNotes,
|
||||||
|
filedAt,
|
||||||
|
reviewedAt,
|
||||||
|
defense,
|
||||||
|
defenseRequestedAt,
|
||||||
|
defenseRequestedBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string { return this.props.id; }
|
get id(): string { return this.props.id.toString(); }
|
||||||
get raceId(): string { return this.props.raceId; }
|
get raceId(): string { return this.props.raceId.toString(); }
|
||||||
get protestingDriverId(): string { return this.props.protestingDriverId; }
|
get protestingDriverId(): string { return this.props.protestingDriverId.toString(); }
|
||||||
get accusedDriverId(): string { return this.props.accusedDriverId; }
|
get accusedDriverId(): string { return this.props.accusedDriverId.toString(); }
|
||||||
get incident(): ProtestIncident { return { ...this.props.incident }; }
|
get incident(): ProtestIncident { return this.props.incident; }
|
||||||
get comment(): string | undefined { return this.props.comment; }
|
get comment(): string | undefined { return this.props.comment?.toString(); }
|
||||||
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
|
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl?.toString(); }
|
||||||
get status(): ProtestStatus { return this.props.status; }
|
get status(): ProtestStatus { return this.props.status; }
|
||||||
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
|
get reviewedBy(): string | undefined { return this.props.reviewedBy?.toString(); }
|
||||||
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
|
get decisionNotes(): string | undefined { return this.props.decisionNotes?.toString(); }
|
||||||
get filedAt(): Date { return this.props.filedAt; }
|
get filedAt(): Date { return this.props.filedAt.toDate(); }
|
||||||
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
|
get reviewedAt(): Date | undefined { return this.props.reviewedAt?.toDate(); }
|
||||||
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
|
get defense(): ProtestDefense | undefined { return this.props.defense; }
|
||||||
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
|
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt?.toDate(); }
|
||||||
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
|
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy?.toString(); }
|
||||||
|
|
||||||
isPending(): boolean {
|
isPending(): boolean {
|
||||||
return this.props.status === 'pending';
|
return this.props.status.toString() === 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
isAwaitingDefense(): boolean {
|
isAwaitingDefense(): boolean {
|
||||||
return this.props.status === 'awaiting_defense';
|
return this.props.status.toString() === 'awaiting_defense';
|
||||||
}
|
}
|
||||||
|
|
||||||
isUnderReview(): boolean {
|
isUnderReview(): boolean {
|
||||||
return this.props.status === 'under_review';
|
return this.props.status.toString() === 'under_review';
|
||||||
}
|
}
|
||||||
|
|
||||||
isResolved(): boolean {
|
isResolved(): boolean {
|
||||||
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
|
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
hasDefense(): boolean {
|
hasDefense(): boolean {
|
||||||
@@ -137,9 +166,9 @@ export class Protest implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'awaiting_defense',
|
status: ProtestStatus.create('awaiting_defense'),
|
||||||
defenseRequestedAt: new Date(),
|
defenseRequestedAt: DefenseRequestedAt.create(new Date()),
|
||||||
defenseRequestedBy: stewardId,
|
defenseRequestedBy: StewardId.create(stewardId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,18 +182,12 @@ export class Protest implements IEntity<string> {
|
|||||||
if (!statement?.trim()) {
|
if (!statement?.trim()) {
|
||||||
throw new RacingDomainValidationError('Defense statement is required');
|
throw new RacingDomainValidationError('Defense statement is required');
|
||||||
}
|
}
|
||||||
const defenseBase: ProtestDefense = {
|
const defense = ProtestDefense.create(statement.trim(), new Date(), videoUrl);
|
||||||
statement: statement.trim(),
|
|
||||||
submittedAt: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const nextDefense: ProtestDefense =
|
|
||||||
videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase;
|
|
||||||
|
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'under_review',
|
status: ProtestStatus.create('under_review'),
|
||||||
defense: nextDefense,
|
defense,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,8 +200,8 @@ export class Protest implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'under_review',
|
status: ProtestStatus.create('under_review'),
|
||||||
reviewedBy: stewardId,
|
reviewedBy: StewardId.create(stewardId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,10 +214,10 @@ export class Protest implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'upheld',
|
status: ProtestStatus.create('upheld'),
|
||||||
reviewedBy: stewardId,
|
reviewedBy: StewardId.create(stewardId),
|
||||||
decisionNotes,
|
decisionNotes: DecisionNotes.create(decisionNotes),
|
||||||
reviewedAt: new Date(),
|
reviewedAt: ReviewedAt.create(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +230,10 @@ export class Protest implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'dismissed',
|
status: ProtestStatus.create('dismissed'),
|
||||||
reviewedBy: stewardId,
|
reviewedBy: StewardId.create(stewardId),
|
||||||
decisionNotes,
|
decisionNotes: DecisionNotes.create(decisionNotes),
|
||||||
reviewedAt: new Date(),
|
reviewedAt: ReviewedAt.create(new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,8 +246,8 @@ export class Protest implements IEntity<string> {
|
|||||||
}
|
}
|
||||||
return new Protest({
|
return new Protest({
|
||||||
...this.props,
|
...this.props,
|
||||||
status: 'withdrawn',
|
status: ProtestStatus.create('withdrawn'),
|
||||||
reviewedAt: new Date(),
|
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 { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
import type { IEntity } from '@core/shared/domain';
|
||||||
import type { Session } from './Session';
|
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';
|
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
|
||||||
|
|
||||||
@@ -127,9 +127,9 @@ export class RaceEvent implements IEntity<string> {
|
|||||||
seasonId: this.seasonId,
|
seasonId: this.seasonId,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
sessions: this.sessions,
|
sessions: [...this.sessions],
|
||||||
status: 'in_progress',
|
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,
|
seasonId: this.seasonId,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
sessions: this.sessions,
|
sessions: [...this.sessions],
|
||||||
status: 'awaiting_stewarding',
|
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,
|
seasonId: this.seasonId,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
sessions: this.sessions,
|
sessions: [...this.sessions],
|
||||||
status: 'closed',
|
status: 'closed',
|
||||||
stewardingClosesAt: this.stewardingClosesAt,
|
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +193,9 @@ export class RaceEvent implements IEntity<string> {
|
|||||||
seasonId: this.seasonId,
|
seasonId: this.seasonId,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
name: this.name,
|
name: this.name,
|
||||||
sessions: this.sessions,
|
sessions: [...this.sessions],
|
||||||
status: 'cancelled',
|
status: 'cancelled',
|
||||||
stewardingClosesAt: this.stewardingClosesAt,
|
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ export class RaceEvent implements IEntity<string> {
|
|||||||
*/
|
*/
|
||||||
isMainRaceCompleted(): boolean {
|
isMainRaceCompleted(): boolean {
|
||||||
const mainRace = this.getMainRaceSession();
|
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 type { IEntity } from '@core/shared/domain';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
|
import { RaceId } from './RaceId';
|
||||||
|
import { DriverId } from './DriverId';
|
||||||
|
import { RegisteredAt } from './RegisteredAt';
|
||||||
|
|
||||||
export interface RaceRegistrationProps {
|
export interface RaceRegistrationProps {
|
||||||
id?: string;
|
id?: string;
|
||||||
raceId: string;
|
raceId: string;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
registeredAt?: Date;
|
registeredAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RaceRegistration implements IEntity<string> {
|
export class RaceRegistration implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly raceId: string;
|
readonly raceId: RaceId;
|
||||||
readonly driverId: string;
|
readonly driverId: DriverId;
|
||||||
readonly registeredAt: Date;
|
readonly registeredAt: RegisteredAt;
|
||||||
|
|
||||||
private constructor(props: Required<RaceRegistrationProps>) {
|
private constructor(props: {
|
||||||
this.id = props.id;
|
id: string;
|
||||||
this.raceId = props.raceId;
|
raceId: RaceId;
|
||||||
this.driverId = props.driverId;
|
driverId: DriverId;
|
||||||
this.registeredAt = props.registeredAt;
|
registeredAt: RegisteredAt;
|
||||||
}
|
}) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.raceId = props.raceId;
|
||||||
|
this.driverId = props.driverId;
|
||||||
|
this.registeredAt = props.registeredAt;
|
||||||
|
}
|
||||||
|
|
||||||
static create(props: RaceRegistrationProps): RaceRegistration {
|
static create(props: RaceRegistrationProps): RaceRegistration {
|
||||||
this.validate(props);
|
RaceRegistration.validate(props);
|
||||||
|
|
||||||
const id =
|
const raceId = RaceId.create(props.raceId);
|
||||||
props.id && props.id.trim().length > 0
|
const driverId = DriverId.create(props.driverId);
|
||||||
? props.id
|
const registeredAt = RegisteredAt.create(props.registeredAt ?? new Date());
|
||||||
: `${props.raceId}:${props.driverId}`;
|
|
||||||
|
|
||||||
const registeredAt = props.registeredAt ?? new Date();
|
const id =
|
||||||
|
props.id && props.id.trim().length > 0
|
||||||
|
? props.id
|
||||||
|
: `${raceId.toString()}:${driverId.toString()}`;
|
||||||
|
|
||||||
return new RaceRegistration({
|
return new RaceRegistration({
|
||||||
id,
|
id,
|
||||||
raceId: props.raceId,
|
raceId,
|
||||||
driverId: props.driverId,
|
driverId,
|
||||||
registeredAt,
|
registeredAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static validate(props: RaceRegistrationProps): void {
|
private static validate(props: RaceRegistrationProps): void {
|
||||||
if (!props.raceId || props.raceId.trim().length === 0) {
|
if (!props.raceId || props.raceId.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Race ID is required');
|
throw new RacingDomainValidationError('Race ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.driverId || props.driverId.trim().length === 0) {
|
if (!props.driverId || props.driverId.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Driver ID is required');
|
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 { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
import type { IEntity } from '@core/shared/domain';
|
||||||
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
|
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> {
|
export class ResultWithIncidents implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly raceId: string;
|
readonly raceId: RaceId;
|
||||||
readonly driverId: string;
|
readonly driverId: DriverId;
|
||||||
readonly position: number;
|
readonly position: Position;
|
||||||
readonly fastestLap: number;
|
readonly fastestLap: LapTime;
|
||||||
readonly incidents: RaceIncidents;
|
readonly incidents: RaceIncidents;
|
||||||
readonly startPosition: number;
|
readonly startPosition: Position;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
raceId: string;
|
raceId: RaceId;
|
||||||
driverId: string;
|
driverId: DriverId;
|
||||||
position: number;
|
position: Position;
|
||||||
fastestLap: number;
|
fastestLap: LapTime;
|
||||||
incidents: RaceIncidents;
|
incidents: RaceIncidents;
|
||||||
startPosition: number;
|
startPosition: Position;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.raceId = props.raceId;
|
this.raceId = props.raceId;
|
||||||
@@ -45,8 +49,21 @@ export class ResultWithIncidents implements IEntity<string> {
|
|||||||
incidents: RaceIncidents;
|
incidents: RaceIncidents;
|
||||||
startPosition: number;
|
startPosition: number;
|
||||||
}): ResultWithIncidents {
|
}): ResultWithIncidents {
|
||||||
ResultWithIncidents.validate(props);
|
this.validate(props);
|
||||||
return new ResultWithIncidents(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
|
* Calculate positions gained/lost
|
||||||
*/
|
*/
|
||||||
getPositionChange(): number {
|
getPositionChange(): number {
|
||||||
return this.startPosition - this.position;
|
return this.startPosition.toNumber() - this.position.toNumber();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if driver finished on podium
|
* Check if driver finished on podium
|
||||||
*/
|
*/
|
||||||
isPodium(): boolean {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,23 +7,28 @@
|
|||||||
|
|
||||||
|
|
||||||
import type { IEntity } from '@core/shared/domain';
|
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> {
|
export class Standing implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly leagueId: string;
|
readonly leagueId: LeagueId;
|
||||||
readonly driverId: string;
|
readonly driverId: DriverId;
|
||||||
readonly points: number;
|
readonly points: Points;
|
||||||
readonly wins: number;
|
readonly wins: number;
|
||||||
readonly position: number;
|
readonly position: Position;
|
||||||
readonly racesCompleted: number;
|
readonly racesCompleted: number;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
leagueId: string;
|
leagueId: LeagueId;
|
||||||
driverId: string;
|
driverId: DriverId;
|
||||||
points: number;
|
points: Points;
|
||||||
wins: number;
|
wins: number;
|
||||||
position: number;
|
position: Position;
|
||||||
racesCompleted: number;
|
racesCompleted: number;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
@@ -47,19 +52,24 @@ export class Standing implements IEntity<string> {
|
|||||||
position?: number;
|
position?: number;
|
||||||
racesCompleted?: number;
|
racesCompleted?: number;
|
||||||
}): Standing {
|
}): Standing {
|
||||||
this.validate(props);
|
Standing.validate(props);
|
||||||
|
|
||||||
const id = props.id && props.id.trim().length > 0
|
const id = props.id && props.id.trim().length > 0
|
||||||
? props.id
|
? props.id
|
||||||
: `${props.leagueId}:${props.driverId}`;
|
: `${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({
|
return new Standing({
|
||||||
id,
|
id,
|
||||||
leagueId: props.leagueId,
|
leagueId,
|
||||||
driverId: props.driverId,
|
driverId,
|
||||||
points: props.points ?? 0,
|
points,
|
||||||
wins: props.wins ?? 0,
|
wins: props.wins ?? 0,
|
||||||
position: props.position ?? 0,
|
position,
|
||||||
racesCompleted: props.racesCompleted ?? 0,
|
racesCompleted: props.racesCompleted ?? 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -88,13 +98,16 @@ export class Standing implements IEntity<string> {
|
|||||||
const racePoints = pointsSystem[position] ?? 0;
|
const racePoints = pointsSystem[position] ?? 0;
|
||||||
const isWin = position === 1;
|
const isWin = position === 1;
|
||||||
|
|
||||||
|
const newPoints = Points.create(this.points.toNumber() + racePoints);
|
||||||
|
const newPosition = this.position; // Position might be updated separately
|
||||||
|
|
||||||
return new Standing({
|
return new Standing({
|
||||||
id: this.id,
|
id: this.id,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
driverId: this.driverId,
|
driverId: this.driverId,
|
||||||
points: this.points + racePoints,
|
points: newPoints,
|
||||||
wins: this.wins + (isWin ? 1 : 0),
|
wins: this.wins + (isWin ? 1 : 0),
|
||||||
position: this.position,
|
position: newPosition,
|
||||||
racesCompleted: this.racesCompleted + 1,
|
racesCompleted: this.racesCompleted + 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,17 +116,15 @@ export class Standing implements IEntity<string> {
|
|||||||
* Update championship position
|
* Update championship position
|
||||||
*/
|
*/
|
||||||
updatePosition(position: number): Standing {
|
updatePosition(position: number): Standing {
|
||||||
if (!Number.isInteger(position) || position < 1) {
|
const newPosition = Position.create(position);
|
||||||
throw new RacingDomainValidationError('Position must be a positive integer');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Standing.create({
|
return new Standing({
|
||||||
id: this.id,
|
id: this.id,
|
||||||
leagueId: this.leagueId,
|
leagueId: this.leagueId,
|
||||||
driverId: this.driverId,
|
driverId: this.driverId,
|
||||||
points: this.points,
|
points: this.points,
|
||||||
wins: this.wins,
|
wins: this.wins,
|
||||||
position,
|
position: newPosition,
|
||||||
racesCompleted: this.racesCompleted,
|
racesCompleted: this.racesCompleted,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -123,7 +134,7 @@ export class Standing implements IEntity<string> {
|
|||||||
*/
|
*/
|
||||||
getAveragePoints(): number {
|
getAveragePoints(): number {
|
||||||
if (this.racesCompleted === 0) return 0;
|
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 type { IEntity } from '@core/shared/domain';
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
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> {
|
export class Team implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: TeamName;
|
||||||
readonly tag: string;
|
readonly tag: TeamTag;
|
||||||
readonly description: string;
|
readonly description: TeamDescription;
|
||||||
readonly ownerId: string;
|
readonly ownerId: DriverId;
|
||||||
readonly leagues: string[];
|
readonly leagues: LeagueId[];
|
||||||
readonly createdAt: Date;
|
readonly createdAt: TeamCreatedAt;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: TeamName;
|
||||||
tag: string;
|
tag: TeamTag;
|
||||||
description: string;
|
description: TeamDescription;
|
||||||
ownerId: string;
|
ownerId: DriverId;
|
||||||
leagues: string[];
|
leagues: LeagueId[];
|
||||||
createdAt: Date;
|
createdAt: TeamCreatedAt;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.name = props.name;
|
this.name = props.name;
|
||||||
@@ -48,16 +54,22 @@ export class Team implements IEntity<string> {
|
|||||||
leagues: string[];
|
leagues: string[];
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
}): Team {
|
}): 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({
|
return new Team({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
name: props.name,
|
name: TeamName.create(props.name),
|
||||||
tag: props.tag,
|
tag: TeamTag.create(props.tag),
|
||||||
description: props.description,
|
description: TeamDescription.create(props.description),
|
||||||
ownerId: props.ownerId,
|
ownerId: DriverId.create(props.ownerId),
|
||||||
leagues: [...props.leagues],
|
leagues: props.leagues.map(leagueId => LeagueId.create(leagueId)),
|
||||||
createdAt: props.createdAt ?? new Date(),
|
createdAt: TeamCreatedAt.create(props.createdAt ?? new Date()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,58 +83,21 @@ export class Team implements IEntity<string> {
|
|||||||
ownerId: string;
|
ownerId: string;
|
||||||
leagues: string[];
|
leagues: string[];
|
||||||
}>): Team {
|
}>): 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,
|
id: this.id,
|
||||||
name: props.name ?? this.name,
|
name: nextName,
|
||||||
tag: props.tag ?? this.tag,
|
tag: nextTag,
|
||||||
description: props.description ?? this.description,
|
description: nextDescription,
|
||||||
ownerId: props.ownerId ?? this.ownerId,
|
ownerId: nextOwnerId,
|
||||||
leagues: props.leagues ? [...props.leagues] : [...this.leagues],
|
leagues: nextLeagues,
|
||||||
createdAt: this.createdAt,
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,33 +7,40 @@
|
|||||||
|
|
||||||
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
import { RacingDomainValidationError } from '../errors/RacingDomainError';
|
||||||
import type { IEntity } from '@core/shared/domain';
|
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 TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
|
||||||
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
|
||||||
|
|
||||||
export class Track implements IEntity<string> {
|
export class Track implements IEntity<string> {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly name: string;
|
readonly name: TrackName;
|
||||||
readonly shortName: string;
|
readonly shortName: TrackShortName;
|
||||||
readonly country: string;
|
readonly country: TrackCountry;
|
||||||
readonly category: TrackCategory;
|
readonly category: TrackCategory;
|
||||||
readonly difficulty: TrackDifficulty;
|
readonly difficulty: TrackDifficulty;
|
||||||
readonly lengthKm: number;
|
readonly lengthKm: TrackLength;
|
||||||
readonly turns: number;
|
readonly turns: TrackTurns;
|
||||||
readonly imageUrl: string | undefined;
|
readonly imageUrl: TrackImageUrl;
|
||||||
readonly gameId: string;
|
readonly gameId: TrackGameId;
|
||||||
|
|
||||||
private constructor(props: {
|
private constructor(props: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: TrackName;
|
||||||
shortName: string;
|
shortName: TrackShortName;
|
||||||
country: string;
|
country: TrackCountry;
|
||||||
category: TrackCategory;
|
category: TrackCategory;
|
||||||
difficulty: TrackDifficulty;
|
difficulty: TrackDifficulty;
|
||||||
lengthKm: number;
|
lengthKm: TrackLength;
|
||||||
turns: number;
|
turns: TrackTurns;
|
||||||
imageUrl?: string | undefined;
|
imageUrl: TrackImageUrl;
|
||||||
gameId: string;
|
gameId: TrackGameId;
|
||||||
}) {
|
}) {
|
||||||
this.id = props.id;
|
this.id = props.id;
|
||||||
this.name = props.name;
|
this.name = props.name;
|
||||||
@@ -62,66 +69,23 @@ export class Track implements IEntity<string> {
|
|||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
gameId: string;
|
gameId: string;
|
||||||
}): Track {
|
}): 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) {
|
if (!props.id || props.id.trim().length === 0) {
|
||||||
throw new RacingDomainValidationError('Track ID is required');
|
throw new RacingDomainValidationError('Track ID is required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!props.name || props.name.trim().length === 0) {
|
const shortNameValue = props.shortName ?? props.name.slice(0, 3).toUpperCase();
|
||||||
throw new RacingDomainValidationError('Track name is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!props.country || props.country.trim().length === 0) {
|
return new Track({
|
||||||
throw new RacingDomainValidationError('Track country is required');
|
id: props.id,
|
||||||
}
|
name: TrackName.create(props.name),
|
||||||
|
shortName: TrackShortName.create(shortNameValue),
|
||||||
if (props.lengthKm <= 0) {
|
country: TrackCountry.create(props.country),
|
||||||
throw new RacingDomainValidationError('Track length must be positive');
|
category: props.category ?? 'road',
|
||||||
}
|
difficulty: props.difficulty ?? 'intermediate',
|
||||||
|
lengthKm: TrackLength.create(props.lengthKm),
|
||||||
if (props.turns < 0) {
|
turns: TrackTurns.create(props.turns),
|
||||||
throw new RacingDomainValidationError('Track turns cannot be negative');
|
imageUrl: TrackImageUrl.create(props.imageUrl),
|
||||||
}
|
gameId: TrackGameId.create(props.gameId),
|
||||||
|
});
|
||||||
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`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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