This commit is contained in:
2025-12-17 00:33:13 +01:00
parent 8c67081953
commit f01e01e50c
186 changed files with 9242 additions and 1342 deletions

View File

@@ -1,5 +1,5 @@
import { Game } from '@core/racing/domain/entities/Game';
import { Season } from '@core/racing/domain/entities/Season';
import { Season } from '@core/racing/domain/entities/season/Season';
import type { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
@@ -10,7 +10,7 @@ import type { IGameRepository } from '@core/racing/domain/repositories/IGameRepo
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
import type { IChampionshipStandingRepository } from '@core/racing/domain/repositories/IChampionshipStandingRepository';
import { ChampionshipStanding } from '@core/racing/domain/entities/ChampionshipStanding';
import { ChampionshipStanding } from '@core/racing/domain/entities/championship/ChampionshipStanding';
import type { ChampionshipType } from '@core/racing/domain/types/ChampionshipType';
import type { ParticipantRef } from '@core/racing/domain/types/ParticipantRef';
import type { Logger } from '@core/shared/application';

View File

@@ -1,5 +1,5 @@
import { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import { Season } from '@core/racing/domain/entities/Season';
import { Season } from '@core/racing/domain/entities/season/Season';
import { Logger } from '@core/shared/application';
export class InMemorySeasonRepository implements ISeasonRepository {

View File

@@ -81,7 +81,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
this.logger.debug(`Generated seasonId: ${seasonId}`);
const season = Season.create({
id: seasonId,
leagueId: league.id,
leagueId: league.id.toString(),
gameId: command.gameId,
name: `${command.name} Season 1`,
year: new Date().getFullYear(),
@@ -113,7 +113,7 @@ export class CreateLeagueWithSeasonAndScoringUseCase
this.logger.info(`Scoring configuration saved for season ${seasonId}.`);
const result: CreateLeagueWithSeasonAndScoringResultDTO = {
leagueId: league.id,
leagueId: league.id.toString(),
seasonId,
scoringPresetId: preset.id,
scoringPresetName: preset.name,

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { Season } from '@core/racing/domain/entities/Season';
import { Season } from '@core/racing/domain/entities/season/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {

View File

@@ -28,7 +28,7 @@ export class GetAllRacesPageDataUseCase
]);
this.logger.info(`Found ${allRaces.length} races and ${allLeagues.length} leagues.`);
const leagueMap = new Map(allLeagues.map((league) => [league.id, league.name]));
const leagueMap = new Map(allLeagues.map((league) => [league.id.toString(), league.name.toString()]));
const races: AllRacesListItemViewModel[] = allRaces
.slice()
@@ -46,7 +46,7 @@ export class GetAllRacesPageDataUseCase
const uniqueLeagues = new Map<string, { id: string; name: string }>();
for (const league of allLeagues) {
uniqueLeagues.set(league.id, { id: league.id, name: league.name });
uniqueLeagues.set(league.id.toString(), { id: league.id.toString(), name: league.name.toString() });
}
const filters: AllRacesFilterOptionsViewModel = {

View File

@@ -7,7 +7,7 @@ import type { IChampionshipStandingRepository } from '@core/racing/domain/reposi
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
import type { SessionType } from '@core/racing/domain/types/SessionType';
import type { ChampionshipStanding } from '@core/racing/domain/entities/ChampionshipStanding';
import type { ChampionshipStanding } from '@core/racing/domain/entities/championship/ChampionshipStanding';
import { EventScoringService } from '@core/racing/domain/services/EventScoringService';
import { ChampionshipAggregator } from '@core/racing/domain/services/ChampionshipAggregator';
@@ -101,10 +101,10 @@ export class RecalculateChampionshipStandingsUseCase
const rows: ChampionshipStandingsRowDTO[] = standings.map((s) => ({
participant: s.participant,
position: s.position,
totalPoints: s.totalPoints,
resultsCounted: s.resultsCounted,
resultsDropped: s.resultsDropped,
position: s.position.toNumber(),
totalPoints: s.totalPoints.toNumber(),
resultsCounted: s.resultsCounted.toNumber(),
resultsDropped: s.resultsDropped.toNumber(),
}));
const dto: ChampionshipStandingsDTO = {

View File

@@ -3,7 +3,7 @@ import { describe, it, expect } from 'vitest';
import {
InMemorySeasonRepository,
} from '@core/racing/infrastructure/repositories/InMemoryScoringRepositories';
import { Season } from '@core/racing/domain/entities/Season';
import { Season } from '@core/racing/domain/entities/season/Season';
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
import {

View 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);
});
});
});

View 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();
}
}

View 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);
});
});
});

View File

@@ -7,35 +7,42 @@
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { CarName } from './CarName';
import { Manufacturer } from './Manufacturer';
import { CarClass, CarClassType } from './CarClass';
import { CarLicense, CarLicenseType } from './CarLicense';
import { Year } from './Year';
import { Horsepower } from './Horsepower';
import { Weight } from './Weight';
import { GameId } from './GameId';
import { CarId } from './CarId';
import { ImageUrl } from './ImageUrl';
export type CarClass = 'formula' | 'gt' | 'prototype' | 'touring' | 'sports' | 'oval' | 'dirt';
export type CarLicense = 'R' | 'D' | 'C' | 'B' | 'A' | 'Pro';
export class Car implements IEntity<string> {
readonly id: string;
readonly name: string;
export class Car implements IEntity<CarId> {
readonly id: CarId;
readonly name: CarName;
readonly shortName: string;
readonly manufacturer: string;
readonly manufacturer: Manufacturer;
readonly carClass: CarClass;
readonly license: CarLicense;
readonly year: number;
readonly horsepower: number | undefined;
readonly weight: number | undefined;
readonly imageUrl: string | undefined;
readonly gameId: string;
readonly year: Year;
readonly horsepower: Horsepower | undefined;
readonly weight: Weight | undefined;
readonly imageUrl: ImageUrl | undefined;
readonly gameId: GameId;
private constructor(props: {
id: string;
name: string;
id: CarId;
name: CarName;
shortName: string;
manufacturer: string;
manufacturer: Manufacturer;
carClass: CarClass;
license: CarLicense;
year: number;
horsepower?: number;
weight?: number;
imageUrl?: string;
gameId: string;
year: Year;
horsepower?: Horsepower;
weight?: Weight;
imageUrl?: ImageUrl;
gameId: GameId;
}) {
this.id = props.id;
this.name = props.name;
@@ -58,8 +65,8 @@ export class Car implements IEntity<string> {
name: string;
shortName?: string;
manufacturer: string;
carClass?: CarClass;
license?: CarLicense;
carClass?: CarClassType;
license?: CarLicenseType;
year?: number;
horsepower?: number;
weight?: number;
@@ -69,17 +76,17 @@ export class Car implements IEntity<string> {
this.validate(props);
return new Car({
id: props.id,
name: props.name,
id: CarId.create(props.id),
name: CarName.create(props.name),
shortName: props.shortName ?? props.name.slice(0, 10),
manufacturer: props.manufacturer,
carClass: props.carClass ?? 'gt',
license: props.license ?? 'D',
year: props.year ?? new Date().getFullYear(),
...(props.horsepower !== undefined ? { horsepower: props.horsepower } : {}),
...(props.weight !== undefined ? { weight: props.weight } : {}),
...(props.imageUrl !== undefined ? { imageUrl: props.imageUrl } : {}),
gameId: props.gameId,
manufacturer: Manufacturer.create(props.manufacturer),
carClass: CarClass.create(props.carClass ?? 'gt'),
license: CarLicense.create(props.license ?? 'D'),
year: Year.create(props.year ?? new Date().getFullYear()),
...(props.horsepower !== undefined ? { horsepower: Horsepower.create(props.horsepower) } : {}),
...(props.weight !== undefined ? { weight: Weight.create(props.weight) } : {}),
...(props.imageUrl !== undefined ? { imageUrl: ImageUrl.create(props.imageUrl) } : {}),
gameId: GameId.create(props.gameId),
});
}
@@ -109,25 +116,4 @@ export class Car implements IEntity<string> {
}
}
/**
* Get formatted car display name
*/
getDisplayName(): string {
return `${this.manufacturer} ${this.name}`;
}
/**
* Get license badge color
*/
getLicenseColor(): string {
const colors: Record<CarLicense, string> = {
'R': '#FF6B6B',
'D': '#FFB347',
'C': '#FFD700',
'B': '#7FFF00',
'A': '#00BFFF',
'Pro': '#9370DB',
};
return colors[this.license];
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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,
});
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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');
});
});

View File

@@ -4,25 +4,30 @@
* Represents a driver profile in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { IRacingId } from '../value-objects/IRacingId';
import { DriverName } from '../value-objects/DriverName';
import { CountryCode } from '../value-objects/CountryCode';
import { DriverBio } from '../value-objects/DriverBio';
import { JoinedAt } from '../value-objects/JoinedAt';
export class Driver implements IEntity<string> {
readonly id: string;
readonly iracingId: string;
readonly name: string;
readonly country: string;
readonly bio: string | undefined;
readonly joinedAt: Date;
readonly iracingId: IRacingId;
readonly name: DriverName;
readonly country: CountryCode;
readonly bio: DriverBio | undefined;
readonly joinedAt: JoinedAt;
private constructor(props: {
id: string;
iracingId: string;
name: string;
country: string;
bio?: string;
joinedAt: Date;
iracingId: IRacingId;
name: DriverName;
country: CountryCode;
bio?: DriverBio;
joinedAt: JoinedAt;
}) {
this.id = props.id;
this.iracingId = props.iracingId;
@@ -43,47 +48,18 @@ export class Driver implements IEntity<string> {
bio?: string;
joinedAt?: Date;
}): Driver {
this.validate(props);
return new Driver({
id: props.id,
iracingId: props.iracingId,
name: props.name,
country: props.country,
...(props.bio !== undefined ? { bio: props.bio } : {}),
joinedAt: props.joinedAt ?? new Date(),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
iracingId: string;
name: string;
country: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
if (!props.iracingId || props.iracingId.trim().length === 0) {
throw new RacingDomainValidationError('iRacing ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Driver name is required');
}
if (!props.country || props.country.trim().length === 0) {
throw new RacingDomainValidationError('Country code is required');
}
// Validate ISO country code format (2-3 letters)
if (!/^[A-Z]{2,3}$/i.test(props.country)) {
throw new RacingDomainValidationError('Country must be a valid ISO code (2-3 letters)');
}
return new Driver({
id: props.id,
iracingId: IRacingId.create(props.iracingId),
name: DriverName.create(props.name),
country: CountryCode.create(props.country),
...(props.bio !== undefined ? { bio: DriverBio.create(props.bio) } : {}),
joinedAt: JoinedAt.create(props.joinedAt ?? new Date()),
});
}
/**
@@ -92,11 +68,11 @@ export class Driver implements IEntity<string> {
update(props: Partial<{
name: string;
country: string;
bio?: string;
bio: string | undefined;
}>): Driver {
const nextName = props.name ?? this.name;
const nextCountry = props.country ?? this.country;
const nextBio = props.bio ?? this.bio;
const nextName = 'name' in props ? DriverName.create(props.name!) : this.name;
const nextCountry = 'country' in props ? CountryCode.create(props.country!) : this.country;
const nextBio = 'bio' in props ? (props.bio ? DriverBio.create(props.bio) : undefined) : this.bio;
return new Driver({
id: this.id,

View 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);
});
});
});

View 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;
}
}

View 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);
});
});
});

View File

@@ -7,22 +7,20 @@
import type { IEntity } from '@core/shared/domain';
import type { LiveryDecal } from '../value-objects/LiveryDecal';
export interface DecalOverride {
leagueId: string;
seasonId: string;
decalId: string;
newX: number;
newY: number;
}
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import { LiveryDecal } from '../value-objects/LiveryDecal';
import { DecalOverride } from '../value-objects/DecalOverride';
import { DriverId } from '../value-objects/DriverId';
import { GameId } from './GameId';
import { CarId } from '../value-objects/CarId';
import { ImageUrl } from '../value-objects/ImageUrl';
export interface DriverLiveryProps {
id: string;
driverId: string;
gameId: string;
carId: string;
uploadedImageUrl: string;
driverId: DriverId;
gameId: GameId;
carId: CarId;
uploadedImageUrl: ImageUrl;
userDecals: LiveryDecal[];
leagueOverrides: DecalOverride[];
createdAt: Date;
@@ -32,10 +30,10 @@ export interface DriverLiveryProps {
export class DriverLivery implements IEntity<string> {
readonly id: string;
readonly driverId: string;
readonly gameId: string;
readonly carId: string;
readonly uploadedImageUrl: string;
readonly driverId: DriverId;
readonly gameId: GameId;
readonly carId: CarId;
readonly uploadedImageUrl: ImageUrl;
readonly userDecals: LiveryDecal[];
readonly leagueOverrides: DecalOverride[];
readonly createdAt: Date;
@@ -55,7 +53,12 @@ export class DriverLivery implements IEntity<string> {
this.validatedAt = props.validatedAt;
}
static create(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'> & {
static create(props: {
id: string;
driverId: string;
gameId: string;
carId: string;
uploadedImageUrl: string;
createdAt?: Date;
userDecals?: LiveryDecal[];
leagueOverrides?: DecalOverride[];
@@ -63,14 +66,26 @@ export class DriverLivery implements IEntity<string> {
this.validate(props);
return new DriverLivery({
...props,
createdAt: props.createdAt ?? new Date(),
id: props.id,
driverId: DriverId.create(props.driverId),
gameId: GameId.create(props.gameId),
carId: CarId.create(props.carId),
uploadedImageUrl: ImageUrl.create(props.uploadedImageUrl),
userDecals: props.userDecals ?? [],
leagueOverrides: props.leagueOverrides ?? [],
createdAt: props.createdAt ?? new Date(),
updatedAt: undefined,
validatedAt: undefined,
});
}
private static validate(props: Omit<DriverLiveryProps, 'createdAt' | 'userDecals' | 'leagueOverrides'>): void {
private static validate(props: {
id: string;
driverId: string;
gameId: string;
carId: string;
uploadedImageUrl: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('DriverLivery ID is required');
}
@@ -173,8 +188,8 @@ export class DriverLivery implements IEntity<string> {
o => o.leagueId === leagueId && o.seasonId === seasonId && o.decalId === decalId
);
const override: DecalOverride = { leagueId, seasonId, decalId, newX, newY };
const override = DecalOverride.create({ leagueId, seasonId, decalId, newX, newY });
let updatedOverrides: DecalOverride[];
if (existingIndex >= 0) {
updatedOverrides = [...this.leagueOverrides];

View 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();
}
}

View 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');
});
});

View File

@@ -1,27 +1,23 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
export class Game implements IEntity<string> {
readonly id: string;
readonly name: string;
import { GameId } from './GameId';
import { GameName } from './GameName';
private constructor(props: { id: string; name: string }) {
export class Game implements IEntity<GameId> {
readonly id: GameId;
readonly name: GameName;
private constructor(props: { id: GameId; name: GameName }) {
this.id = props.id;
this.name = props.name;
}
static create(props: { id: string; name: string }): Game {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Game ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Game name is required');
}
const id = GameId.create(props.id);
const name = GameName.create(props.name);
return new Game({
id: props.id,
name: props.name,
id,
name,
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
});
});
});

View 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();
}
}

View 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');
}
}
}

View 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;
}
}

View 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);
});
});

View File

@@ -4,9 +4,14 @@
* Represents a league in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { LeagueId } from './LeagueId';
import { LeagueName } from './LeagueName';
import { LeagueDescription } from './LeagueDescription';
import { LeagueOwnerId } from './LeagueOwnerId';
import { LeagueCreatedAt } from './LeagueCreatedAt';
import { LeagueSocialLinks } from './LeagueSocialLinks';
/**
* Stewarding decision mode for protests
@@ -21,41 +26,41 @@ export type StewardingDecisionMode =
export interface StewardingSettings {
/**
* How protest decisions are made
*/
* How protest decisions are made
*/
decisionMode: StewardingDecisionMode;
/**
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
requiredVotes?: number;
/**
* Whether to require a defense from the accused before deciding
*/
* Whether to require a defense from the accused before deciding
*/
requireDefense?: boolean;
/**
* Time limit (hours) for accused to submit defense
*/
* Time limit (hours) for accused to submit defense
*/
defenseTimeLimit?: number;
/**
* Time limit (hours) for voting to complete
*/
* Time limit (hours) for voting to complete
*/
voteTimeLimit?: number;
/**
* Time limit (hours) after race ends when protests can be filed
*/
* Time limit (hours) after race ends when protests can be filed
*/
protestDeadlineHours?: number;
/**
* Time limit (hours) after race ends when stewarding is closed (no more decisions)
*/
* Time limit (hours) after race ends when stewarding is closed (no more decisions)
*/
stewardingClosesHours?: number;
/**
* Whether to notify the accused when a protest is filed
*/
* Whether to notify the accused when a protest is filed
*/
notifyAccusedOnProtest?: boolean;
/**
* Whether to notify eligible voters when a vote is required
*/
* Whether to notify eligible voters when a vote is required
*/
notifyOnVoteRequired?: boolean;
}
@@ -65,38 +70,32 @@ export interface LeagueSettings {
qualifyingFormat?: 'single-lap' | 'open';
customPoints?: Record<number, number>;
/**
* Maximum number of drivers allowed in the league.
* Used for simple capacity display on the website.
*/
* Maximum number of drivers allowed in the league.
* Used for simple capacity display on the website.
*/
maxDrivers?: number;
/**
* Stewarding settings for protest handling
*/
* Stewarding settings for protest handling
*/
stewarding?: StewardingSettings;
}
export interface LeagueSocialLinks {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
}
export class League implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly description: string;
readonly ownerId: string;
export class League implements IEntity<LeagueId> {
readonly id: LeagueId;
readonly name: LeagueName;
readonly description: LeagueDescription;
readonly ownerId: LeagueOwnerId;
readonly settings: LeagueSettings;
readonly createdAt: Date;
readonly createdAt: LeagueCreatedAt;
readonly socialLinks: LeagueSocialLinks | undefined;
private constructor(props: {
id: string;
name: string;
description: string;
ownerId: string;
id: LeagueId;
name: LeagueName;
description: LeagueDescription;
ownerId: LeagueOwnerId;
settings: LeagueSettings;
createdAt: Date;
createdAt: LeagueCreatedAt;
socialLinks?: LeagueSocialLinks;
}) {
this.id = props.id;
@@ -118,9 +117,17 @@ export class League implements IEntity<string> {
ownerId: string;
settings?: Partial<LeagueSettings>;
createdAt?: Date;
socialLinks?: LeagueSocialLinks;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}): League {
this.validate(props);
const id = LeagueId.create(props.id);
const name = LeagueName.create(props.name);
const description = LeagueDescription.create(props.description);
const ownerId = LeagueOwnerId.create(props.ownerId);
const createdAt = LeagueCreatedAt.create(props.createdAt ?? new Date());
const defaultStewardingSettings: StewardingSettings = {
decisionMode: 'admin_only',
@@ -141,48 +148,19 @@ export class League implements IEntity<string> {
stewarding: defaultStewardingSettings,
};
const socialLinks = props.socialLinks;
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : undefined;
return new League({
id: props.id,
name: props.name,
description: props.description,
ownerId: props.ownerId,
id,
name,
description,
ownerId,
settings: { ...defaultSettings, ...props.settings },
createdAt: props.createdAt ?? new Date(),
createdAt,
...(socialLinks !== undefined ? { socialLinks } : {}),
});
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
description: string;
ownerId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('League name is required');
}
if (props.name.length > 100) {
throw new RacingDomainValidationError('League name must be 100 characters or less');
}
if (!props.description || props.description.trim().length === 0) {
throw new RacingDomainValidationError('League description is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new RacingDomainValidationError('League owner ID is required');
}
}
/**
* Create a copy with updated properties
@@ -192,20 +170,25 @@ export class League implements IEntity<string> {
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks?: LeagueSocialLinks;
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
}>): League {
const name = props.name ? LeagueName.create(props.name) : this.name;
const description = props.description ? LeagueDescription.create(props.description) : this.description;
const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId;
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks;
return new League({
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: props.ownerId ?? this.ownerId,
name,
description,
ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
...(props.socialLinks !== undefined
? { socialLinks: props.socialLinks }
: this.socialLinks !== undefined
? { socialLinks: this.socialLinks }
: {}),
...(socialLinks !== undefined ? { socialLinks } : {}),
});
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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');
});
});

View File

@@ -1,33 +1,42 @@
/**
* Domain Entity: LeagueMembership and JoinRequest
* Domain Entity: LeagueMembership
*
* Represents a driver's membership in a league and join requests.
* Represents a driver's membership in a league.
*/
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export type MembershipStatus = 'active' | 'inactive' | 'pending';
import { LeagueId } from './LeagueId';
import { DriverId } from '../value-objects/DriverId';
import { MembershipRole, MembershipRoleValue } from './MembershipRole';
import { MembershipStatus, MembershipStatusValue } from './MembershipStatus';
import { JoinedAt } from '../value-objects/JoinedAt';
export interface LeagueMembershipProps {
id?: string;
leagueId: string;
driverId: string;
role: MembershipRole;
status?: MembershipStatus;
role: string;
status?: string;
joinedAt?: Date;
}
export class LeagueMembership implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly leagueId: LeagueId;
readonly driverId: DriverId;
readonly role: MembershipRole;
readonly status: MembershipStatus;
readonly joinedAt: Date;
readonly joinedAt: JoinedAt;
private constructor(props: Required<LeagueMembershipProps>) {
private constructor(props: {
id: string;
leagueId: LeagueId;
driverId: DriverId;
role: MembershipRole;
status: MembershipStatus;
joinedAt: JoinedAt;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.driverId = props.driverId;
@@ -44,14 +53,17 @@ export class LeagueMembership implements IEntity<string> {
? props.id
: `${props.leagueId}:${props.driverId}`;
const status = props.status ?? 'pending';
const joinedAt = props.joinedAt ?? new Date();
const leagueId = LeagueId.create(props.leagueId);
const driverId = DriverId.create(props.driverId);
const role = MembershipRole.create(props.role as MembershipRoleValue);
const status = MembershipStatus.create((props.status ?? 'pending') as MembershipStatusValue);
const joinedAt = JoinedAt.create(props.joinedAt ?? new Date());
return new LeagueMembership({
id,
leagueId: props.leagueId,
driverId: props.driverId,
role: props.role,
leagueId,
driverId,
role,
status,
joinedAt,
});
@@ -70,12 +82,4 @@ export class LeagueMembership implements IEntity<string> {
throw new RacingDomainValidationError('Membership role is required');
}
}
}
export interface JoinRequest {
id: string;
leagueId: string;
driverId: string;
requestedAt: Date;
message?: string;
}

View 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;
}
}

View 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;
}
}

View 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);
});
});

View File

@@ -1,13 +1,73 @@
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
/**
* Domain Entity: LeagueScoringConfig
*
* Represents the scoring configuration for a league season.
*/
export interface LeagueScoringConfig {
id: string;
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { ChampionshipConfig } from '../types/ChampionshipConfig';
import { SeasonId } from './SeasonId';
import { ScoringPresetId } from './ScoringPresetId';
import { LeagueScoringConfigId } from './LeagueScoringConfigId';
export interface LeagueScoringConfigProps {
id?: string;
seasonId: string;
/**
* Optional ID of the scoring preset this configuration was derived from.
* Used by application-layer read models to surface preset metadata such as
* name and drop policy summaries.
*/
scoringPresetId?: string;
championships: ChampionshipConfig[];
}
export class LeagueScoringConfig implements IEntity<LeagueScoringConfigId> {
readonly id: LeagueScoringConfigId;
readonly seasonId: SeasonId;
readonly scoringPresetId: ScoringPresetId | undefined;
readonly championships: ChampionshipConfig[];
private constructor(props: {
id: LeagueScoringConfigId;
seasonId: SeasonId;
scoringPresetId?: ScoringPresetId;
championships: ChampionshipConfig[];
}) {
this.id = props.id;
this.seasonId = props.seasonId;
this.scoringPresetId = props.scoringPresetId;
this.championships = props.championships;
}
static create(props: LeagueScoringConfigProps): LeagueScoringConfig {
this.validate(props);
const idString = props.id && props.id.trim().length > 0 ? props.id : this.generateId(props.seasonId);
const id = LeagueScoringConfigId.create(idString);
const seasonId = SeasonId.create(props.seasonId);
const scoringPresetId = props.scoringPresetId ? ScoringPresetId.create(props.scoringPresetId) : undefined;
return new LeagueScoringConfig({
id,
seasonId,
...(scoringPresetId ? { scoringPresetId } : {}),
championships: props.championships,
});
}
private static validate(props: LeagueScoringConfigProps): void {
if (!props.seasonId || props.seasonId.trim().length === 0) {
throw new RacingDomainValidationError('Season ID is required');
}
if (!props.championships || props.championships.length === 0) {
throw new RacingDomainValidationError('At least one championship is required');
}
}
private static generateId(seasonId: string): string {
return `scoring-config-${seasonId}`;
}
equals(other: LeagueScoringConfig): boolean {
return !!other && this.id.equals(other.id);
}
}

View 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);
});
});
});

View 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;
}
}

View 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
);
}
}

View 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);
});
});

View File

@@ -6,55 +6,82 @@
*/
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { LiveryDecal } from '../value-objects/LiveryDecal';
import { LiveryTemplateId } from './LiveryTemplateId';
import { LeagueId } from './LeagueId';
import { SeasonId } from './SeasonId';
import { CarId } from './CarId';
import { ImageUrl } from './ImageUrl';
import { LiveryTemplateCreatedAt } from './LiveryTemplateCreatedAt';
import { LiveryTemplateUpdatedAt } from './LiveryTemplateUpdatedAt';
export interface LiveryTemplateProps {
id: string;
leagueId: string;
seasonId: string;
carId: string;
baseImageUrl: string;
adminDecals: LiveryDecal[];
createdAt: Date;
updatedAt: Date | undefined;
}
export class LiveryTemplate implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly seasonId: string;
readonly carId: string;
readonly baseImageUrl: string;
export class LiveryTemplate implements IEntity<LiveryTemplateId> {
readonly id: LiveryTemplateId;
readonly leagueId: LeagueId;
readonly seasonId: SeasonId;
readonly carId: CarId;
readonly baseImageUrl: ImageUrl;
readonly adminDecals: LiveryDecal[];
readonly createdAt: Date;
readonly updatedAt: Date | undefined;
readonly createdAt: LiveryTemplateCreatedAt;
readonly updatedAt: LiveryTemplateUpdatedAt | undefined;
private constructor(props: LiveryTemplateProps) {
private constructor(props: {
id: LiveryTemplateId;
leagueId: LeagueId;
seasonId: SeasonId;
carId: CarId;
baseImageUrl: ImageUrl;
adminDecals: LiveryDecal[];
createdAt: LiveryTemplateCreatedAt;
updatedAt?: LiveryTemplateUpdatedAt;
}) {
this.id = props.id;
this.leagueId = props.leagueId;
this.seasonId = props.seasonId;
this.carId = props.carId;
this.baseImageUrl = props.baseImageUrl;
this.adminDecals = props.adminDecals;
this.createdAt = props.createdAt ?? new Date();
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
static create(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'> & {
static create(props: {
id: string;
leagueId: string;
seasonId: string;
carId: string;
baseImageUrl: string;
createdAt?: Date;
adminDecals?: LiveryDecal[];
}): LiveryTemplate {
this.validate(props);
const id = LiveryTemplateId.create(props.id);
const leagueId = LeagueId.create(props.leagueId);
const seasonId = SeasonId.create(props.seasonId);
const carId = CarId.create(props.carId);
const baseImageUrl = ImageUrl.create(props.baseImageUrl);
const createdAt = LiveryTemplateCreatedAt.create(props.createdAt ?? new Date());
return new LiveryTemplate({
...props,
createdAt: props.createdAt ?? new Date(),
id,
leagueId,
seasonId,
carId,
baseImageUrl,
adminDecals: props.adminDecals ?? [],
createdAt,
});
}
private static validate(props: Omit<LiveryTemplateProps, 'createdAt' | 'adminDecals'>): void {
private static validate(props: {
id: string;
leagueId: string;
seasonId: string;
carId: string;
baseImageUrl: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('LiveryTemplate ID is required');
}
@@ -87,7 +114,7 @@ export class LiveryTemplate implements IEntity<string> {
return new LiveryTemplate({
...this,
adminDecals: [...this.adminDecals, decal],
updatedAt: new Date(),
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
});
}
@@ -104,7 +131,7 @@ export class LiveryTemplate implements IEntity<string> {
return new LiveryTemplate({
...this,
adminDecals: updatedDecals,
updatedAt: new Date(),
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
});
}
@@ -113,7 +140,7 @@ export class LiveryTemplate implements IEntity<string> {
*/
updateDecal(decalId: string, updatedDecal: LiveryDecal): LiveryTemplate {
const index = this.adminDecals.findIndex(d => d.id === decalId);
if (index === -1) {
throw new RacingDomainValidationError('Decal not found in template');
}
@@ -124,7 +151,7 @@ export class LiveryTemplate implements IEntity<string> {
return new LiveryTemplate({
...this,
adminDecals: updatedDecals,
updatedAt: new Date(),
updatedAt: LiveryTemplateUpdatedAt.create(new Date()),
});
}

View 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();
}
}

View 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;
}
}

View 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();
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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';
}
}
}

View 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);
});
});
});

View File

@@ -13,107 +13,136 @@
*/
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export interface ProtestIncident {
/** Lap number where the incident occurred */
lap: number;
/** Time in the race (seconds from start, or timestamp) */
timeInRace?: number;
/** Brief description of the incident */
description: string;
}
export interface ProtestDefense {
/** The accused driver's statement/defense */
statement: string;
/** URL to defense video clip (optional) */
videoUrl?: string;
/** Timestamp when defense was submitted */
submittedAt: Date;
}
import { ProtestId } from './ProtestId';
import { RaceId } from './RaceId';
import { DriverId } from './DriverId';
import { StewardId } from './StewardId';
import { ProtestStatus } from './ProtestStatus';
import { ProtestIncident } from './ProtestIncident';
import { ProtestComment } from './ProtestComment';
import { VideoUrl } from './VideoUrl';
import { FiledAt } from './FiledAt';
import { ReviewedAt } from './ReviewedAt';
import { ProtestDefense } from './ProtestDefense';
import { DefenseRequestedAt } from './DefenseRequestedAt';
import { DecisionNotes } from './DecisionNotes';
export interface ProtestProps {
id: string;
raceId: string;
id: ProtestId;
raceId: RaceId;
/** The driver filing the protest */
protestingDriverId: string;
protestingDriverId: DriverId;
/** The driver being protested against */
accusedDriverId: string;
accusedDriverId: DriverId;
/** Details of the incident */
incident: ProtestIncident;
/** Optional comment/statement from the protesting driver */
comment?: string;
comment?: ProtestComment;
/** URL to proof video clip */
proofVideoUrl?: string;
proofVideoUrl?: VideoUrl;
/** Current status of the protest */
status: ProtestStatus;
/** ID of the steward/admin who reviewed (if any) */
reviewedBy?: string;
reviewedBy?: StewardId;
/** Decision notes from the steward */
decisionNotes?: string;
decisionNotes?: DecisionNotes;
/** Timestamp when the protest was filed */
filedAt: Date;
filedAt: FiledAt;
/** Timestamp when the protest was reviewed */
reviewedAt?: Date;
reviewedAt?: ReviewedAt;
/** Defense from the accused driver (if requested and submitted) */
defense?: ProtestDefense;
/** Timestamp when defense was requested */
defenseRequestedAt?: Date;
defenseRequestedAt?: DefenseRequestedAt;
/** ID of the steward who requested defense */
defenseRequestedBy?: string;
defenseRequestedBy?: StewardId;
}
export class Protest implements IEntity<string> {
private constructor(private readonly props: ProtestProps) {}
static create(props: ProtestProps): Protest {
if (!props.id) throw new RacingDomainValidationError('Protest ID is required');
if (!props.raceId) throw new RacingDomainValidationError('Race ID is required');
if (!props.protestingDriverId) throw new RacingDomainValidationError('Protesting driver ID is required');
if (!props.accusedDriverId) throw new RacingDomainValidationError('Accused driver ID is required');
if (!props.incident) throw new RacingDomainValidationError('Incident details are required');
if (props.incident.lap < 0) throw new RacingDomainValidationError('Lap number must be non-negative');
if (!props.incident.description?.trim()) throw new RacingDomainValidationError('Incident description is required');
static create(props: {
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
incident: { lap: number; description: string; timeInRace?: number };
comment?: string;
proofVideoUrl?: string;
status?: string;
reviewedBy?: string;
decisionNotes?: string;
filedAt?: Date;
reviewedAt?: Date;
defense?: { statement: string; videoUrl?: string; submittedAt: Date };
defenseRequestedAt?: Date;
defenseRequestedBy?: string;
}): Protest {
const id = ProtestId.create(props.id);
const raceId = RaceId.create(props.raceId);
const protestingDriverId = DriverId.create(props.protestingDriverId);
const accusedDriverId = DriverId.create(props.accusedDriverId);
const incident = ProtestIncident.create(props.incident.lap, props.incident.description, props.incident.timeInRace);
const comment = props.comment ? ProtestComment.create(props.comment) : undefined;
const proofVideoUrl = props.proofVideoUrl ? VideoUrl.create(props.proofVideoUrl) : undefined;
const status = ProtestStatus.create(props.status || 'pending');
const reviewedBy = props.reviewedBy ? StewardId.create(props.reviewedBy) : undefined;
const decisionNotes = props.decisionNotes ? DecisionNotes.create(props.decisionNotes) : undefined;
const filedAt = FiledAt.create(props.filedAt || new Date());
const reviewedAt = props.reviewedAt ? ReviewedAt.create(props.reviewedAt) : undefined;
const defense = props.defense ? ProtestDefense.create(props.defense.statement, props.defense.submittedAt, props.defense.videoUrl) : undefined;
const defenseRequestedAt = props.defenseRequestedAt ? DefenseRequestedAt.create(props.defenseRequestedAt) : undefined;
const defenseRequestedBy = props.defenseRequestedBy ? StewardId.create(props.defenseRequestedBy) : undefined;
return new Protest({
...props,
status: props.status || 'pending',
filedAt: props.filedAt || new Date(),
id,
raceId,
protestingDriverId,
accusedDriverId,
incident,
comment,
proofVideoUrl,
status,
reviewedBy,
decisionNotes,
filedAt,
reviewedAt,
defense,
defenseRequestedAt,
defenseRequestedBy,
});
}
get id(): string { return this.props.id; }
get raceId(): string { return this.props.raceId; }
get protestingDriverId(): string { return this.props.protestingDriverId; }
get accusedDriverId(): string { return this.props.accusedDriverId; }
get incident(): ProtestIncident { return { ...this.props.incident }; }
get comment(): string | undefined { return this.props.comment; }
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl; }
get id(): string { return this.props.id.toString(); }
get raceId(): string { return this.props.raceId.toString(); }
get protestingDriverId(): string { return this.props.protestingDriverId.toString(); }
get accusedDriverId(): string { return this.props.accusedDriverId.toString(); }
get incident(): ProtestIncident { return this.props.incident; }
get comment(): string | undefined { return this.props.comment?.toString(); }
get proofVideoUrl(): string | undefined { return this.props.proofVideoUrl?.toString(); }
get status(): ProtestStatus { return this.props.status; }
get reviewedBy(): string | undefined { return this.props.reviewedBy; }
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
get filedAt(): Date { return this.props.filedAt; }
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
get reviewedBy(): string | undefined { return this.props.reviewedBy?.toString(); }
get decisionNotes(): string | undefined { return this.props.decisionNotes?.toString(); }
get filedAt(): Date { return this.props.filedAt.toDate(); }
get reviewedAt(): Date | undefined { return this.props.reviewedAt?.toDate(); }
get defense(): ProtestDefense | undefined { return this.props.defense; }
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt?.toDate(); }
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy?.toString(); }
isPending(): boolean {
return this.props.status === 'pending';
return this.props.status.toString() === 'pending';
}
isAwaitingDefense(): boolean {
return this.props.status === 'awaiting_defense';
return this.props.status.toString() === 'awaiting_defense';
}
isUnderReview(): boolean {
return this.props.status === 'under_review';
return this.props.status.toString() === 'under_review';
}
isResolved(): boolean {
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status.toString());
}
hasDefense(): boolean {
@@ -137,9 +166,9 @@ export class Protest implements IEntity<string> {
}
return new Protest({
...this.props,
status: 'awaiting_defense',
defenseRequestedAt: new Date(),
defenseRequestedBy: stewardId,
status: ProtestStatus.create('awaiting_defense'),
defenseRequestedAt: DefenseRequestedAt.create(new Date()),
defenseRequestedBy: StewardId.create(stewardId),
});
}
@@ -153,18 +182,12 @@ export class Protest implements IEntity<string> {
if (!statement?.trim()) {
throw new RacingDomainValidationError('Defense statement is required');
}
const defenseBase: ProtestDefense = {
statement: statement.trim(),
submittedAt: new Date(),
};
const nextDefense: ProtestDefense =
videoUrl !== undefined ? { ...defenseBase, videoUrl } : defenseBase;
const defense = ProtestDefense.create(statement.trim(), new Date(), videoUrl);
return new Protest({
...this.props,
status: 'under_review',
defense: nextDefense,
status: ProtestStatus.create('under_review'),
defense,
});
}
@@ -177,8 +200,8 @@ export class Protest implements IEntity<string> {
}
return new Protest({
...this.props,
status: 'under_review',
reviewedBy: stewardId,
status: ProtestStatus.create('under_review'),
reviewedBy: StewardId.create(stewardId),
});
}
@@ -191,10 +214,10 @@ export class Protest implements IEntity<string> {
}
return new Protest({
...this.props,
status: 'upheld',
reviewedBy: stewardId,
decisionNotes,
reviewedAt: new Date(),
status: ProtestStatus.create('upheld'),
reviewedBy: StewardId.create(stewardId),
decisionNotes: DecisionNotes.create(decisionNotes),
reviewedAt: ReviewedAt.create(new Date()),
});
}
@@ -207,10 +230,10 @@ export class Protest implements IEntity<string> {
}
return new Protest({
...this.props,
status: 'dismissed',
reviewedBy: stewardId,
decisionNotes,
reviewedAt: new Date(),
status: ProtestStatus.create('dismissed'),
reviewedBy: StewardId.create(stewardId),
decisionNotes: DecisionNotes.create(decisionNotes),
reviewedAt: ReviewedAt.create(new Date()),
});
}
@@ -223,8 +246,8 @@ export class Protest implements IEntity<string> {
}
return new Protest({
...this.props,
status: 'withdrawn',
reviewedAt: new Date(),
status: ProtestStatus.create('withdrawn'),
reviewedAt: ReviewedAt.create(new Date()),
});
}
}

View 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;
}
}

View 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)
);
}
}

View 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);
});
});
});

View 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;
}
}

View 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)
);
}
}

View 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;
}
}

View 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);
});
});
});

View 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);
});
});
});

View File

@@ -8,7 +8,7 @@
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import type { Session } from './Session';
import type { SessionType } from '../value-objects/SessionType';
import { SessionType } from '../value-objects/SessionType';
export type RaceEventStatus = 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed' | 'cancelled';
@@ -127,9 +127,9 @@ export class RaceEvent implements IEntity<string> {
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
sessions: [...this.sessions],
status: 'in_progress',
stewardingClosesAt: this.stewardingClosesAt,
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
@@ -151,9 +151,9 @@ export class RaceEvent implements IEntity<string> {
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
sessions: [...this.sessions],
status: 'awaiting_stewarding',
stewardingClosesAt: this.stewardingClosesAt,
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
@@ -170,9 +170,9 @@ export class RaceEvent implements IEntity<string> {
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
sessions: [...this.sessions],
status: 'closed',
stewardingClosesAt: this.stewardingClosesAt,
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
@@ -193,9 +193,9 @@ export class RaceEvent implements IEntity<string> {
seasonId: this.seasonId,
leagueId: this.leagueId,
name: this.name,
sessions: this.sessions,
sessions: [...this.sessions],
status: 'cancelled',
stewardingClosesAt: this.stewardingClosesAt,
...(this.stewardingClosesAt !== undefined ? { stewardingClosesAt: this.stewardingClosesAt } : {}),
});
}
@@ -232,7 +232,7 @@ export class RaceEvent implements IEntity<string> {
*/
isMainRaceCompleted(): boolean {
const mainRace = this.getMainRaceSession();
return mainRace?.status === 'completed' ?? false;
return mainRace ? mainRace.status === 'completed' : false;
}
/**

View 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);
});
});
});

View 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;
}
}

View 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);
});
});
});

View File

@@ -6,52 +6,62 @@
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { RaceId } from './RaceId';
import { DriverId } from './DriverId';
import { RegisteredAt } from './RegisteredAt';
export interface RaceRegistrationProps {
id?: string;
raceId: string;
driverId: string;
registeredAt?: Date;
id?: string;
raceId: string;
driverId: string;
registeredAt?: Date;
}
export class RaceRegistration implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly registeredAt: Date;
readonly id: string;
readonly raceId: RaceId;
readonly driverId: DriverId;
readonly registeredAt: RegisteredAt;
private constructor(props: Required<RaceRegistrationProps>) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.registeredAt = props.registeredAt;
}
private constructor(props: {
id: string;
raceId: RaceId;
driverId: DriverId;
registeredAt: RegisteredAt;
}) {
this.id = props.id;
this.raceId = props.raceId;
this.driverId = props.driverId;
this.registeredAt = props.registeredAt;
}
static create(props: RaceRegistrationProps): RaceRegistration {
this.validate(props);
static create(props: RaceRegistrationProps): RaceRegistration {
RaceRegistration.validate(props);
const id =
props.id && props.id.trim().length > 0
? props.id
: `${props.raceId}:${props.driverId}`;
const raceId = RaceId.create(props.raceId);
const driverId = DriverId.create(props.driverId);
const registeredAt = RegisteredAt.create(props.registeredAt ?? new Date());
const registeredAt = props.registeredAt ?? new Date();
const id =
props.id && props.id.trim().length > 0
? props.id
: `${raceId.toString()}:${driverId.toString()}`;
return new RaceRegistration({
id,
raceId: props.raceId,
driverId: props.driverId,
registeredAt,
});
}
return new RaceRegistration({
id,
raceId,
driverId,
registeredAt,
});
}
private static validate(props: RaceRegistrationProps): void {
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
private static validate(props: RaceRegistrationProps): void {
if (!props.raceId || props.raceId.trim().length === 0) {
throw new RacingDomainValidationError('Race ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
}
}

View 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);
});
});
});

View 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();
}
}

View File

@@ -5,24 +5,28 @@
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { RaceIncidents, type IncidentRecord } from '../value-objects/RaceIncidents';
import { RaceId } from './RaceId';
import { DriverId } from './DriverId';
import { Position } from './result/Position';
import { LapTime } from './result/LapTime';
export class ResultWithIncidents implements IEntity<string> {
readonly id: string;
readonly raceId: string;
readonly driverId: string;
readonly position: number;
readonly fastestLap: number;
readonly raceId: RaceId;
readonly driverId: DriverId;
readonly position: Position;
readonly fastestLap: LapTime;
readonly incidents: RaceIncidents;
readonly startPosition: number;
readonly startPosition: Position;
private constructor(props: {
id: string;
raceId: string;
driverId: string;
position: number;
fastestLap: number;
raceId: RaceId;
driverId: DriverId;
position: Position;
fastestLap: LapTime;
incidents: RaceIncidents;
startPosition: number;
startPosition: Position;
}) {
this.id = props.id;
this.raceId = props.raceId;
@@ -45,8 +49,21 @@ export class ResultWithIncidents implements IEntity<string> {
incidents: RaceIncidents;
startPosition: number;
}): ResultWithIncidents {
ResultWithIncidents.validate(props);
return new ResultWithIncidents(props);
this.validate(props);
const raceId = RaceId.create(props.raceId);
const driverId = DriverId.create(props.driverId);
const position = Position.create(props.position);
const fastestLap = LapTime.create(props.fastestLap);
const startPosition = Position.create(props.startPosition);
return new ResultWithIncidents({
id: props.id,
raceId,
driverId,
position,
fastestLap,
incidents: props.incidents,
startPosition,
});
}
/**
@@ -109,14 +126,14 @@ export class ResultWithIncidents implements IEntity<string> {
* Calculate positions gained/lost
*/
getPositionChange(): number {
return this.startPosition - this.position;
return this.startPosition.toNumber() - this.position.toNumber();
}
/**
* Check if driver finished on podium
*/
isPodium(): boolean {
return this.position <= 3;
return this.position.toNumber() <= 3;
}
/**

View 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();
}
}

View 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;
}
}

View 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);
});
});
});

View File

@@ -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);
}
}

View 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');
});
});
});

View 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);
});
});
});

View File

@@ -4,26 +4,31 @@
* Represents a championship standing in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { LeagueId } from './LeagueId';
import { DriverId } from './DriverId';
import { Points } from '../value-objects/Points';
import { Position } from './championship/Position';
export class Standing implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly driverId: string;
readonly points: number;
readonly leagueId: LeagueId;
readonly driverId: DriverId;
readonly points: Points;
readonly wins: number;
readonly position: number;
readonly position: Position;
readonly racesCompleted: number;
private constructor(props: {
id: string;
leagueId: string;
driverId: string;
points: number;
leagueId: LeagueId;
driverId: DriverId;
points: Points;
wins: number;
position: number;
position: Position;
racesCompleted: number;
}) {
this.id = props.id;
@@ -47,19 +52,24 @@ export class Standing implements IEntity<string> {
position?: number;
racesCompleted?: number;
}): Standing {
this.validate(props);
Standing.validate(props);
const id = props.id && props.id.trim().length > 0
? props.id
: `${props.leagueId}:${props.driverId}`;
const leagueId = LeagueId.create(props.leagueId);
const driverId = DriverId.create(props.driverId);
const points = Points.create(props.points ?? 0);
const position = Position.create(props.position ?? 1); // Default to 1 for position
return new Standing({
id,
leagueId: props.leagueId,
driverId: props.driverId,
points: props.points ?? 0,
leagueId,
driverId,
points,
wins: props.wins ?? 0,
position: props.position ?? 0,
position,
racesCompleted: props.racesCompleted ?? 0,
});
}
@@ -75,7 +85,7 @@ export class Standing implements IEntity<string> {
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new RacingDomainValidationError('League ID is required');
}
if (!props.driverId || props.driverId.trim().length === 0) {
throw new RacingDomainValidationError('Driver ID is required');
}
@@ -88,13 +98,16 @@ export class Standing implements IEntity<string> {
const racePoints = pointsSystem[position] ?? 0;
const isWin = position === 1;
const newPoints = Points.create(this.points.toNumber() + racePoints);
const newPosition = this.position; // Position might be updated separately
return new Standing({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points + racePoints,
points: newPoints,
wins: this.wins + (isWin ? 1 : 0),
position: this.position,
position: newPosition,
racesCompleted: this.racesCompleted + 1,
});
}
@@ -103,17 +116,15 @@ export class Standing implements IEntity<string> {
* Update championship position
*/
updatePosition(position: number): Standing {
if (!Number.isInteger(position) || position < 1) {
throw new RacingDomainValidationError('Position must be a positive integer');
}
const newPosition = Position.create(position);
return Standing.create({
return new Standing({
id: this.id,
leagueId: this.leagueId,
driverId: this.driverId,
points: this.points,
wins: this.wins,
position,
position: newPosition,
racesCompleted: this.racesCompleted,
});
}
@@ -123,7 +134,7 @@ export class Standing implements IEntity<string> {
*/
getAveragePoints(): number {
if (this.racesCompleted === 0) return 0;
return this.points / this.racesCompleted;
return this.points.toNumber() / this.racesCompleted;
}
/**

View 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);
});
});
});

View 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;
}
}

View 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();
}
}

View 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');
});
});

View File

@@ -8,24 +8,30 @@
import type { IEntity } from '@core/shared/domain';
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import { TeamName } from '../value-objects/TeamName';
import { TeamTag } from '../value-objects/TeamTag';
import { TeamDescription } from '../value-objects/TeamDescription';
import { DriverId } from './DriverId';
import { LeagueId } from './LeagueId';
import { TeamCreatedAt } from '../value-objects/TeamCreatedAt';
export class Team implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly tag: string;
readonly description: string;
readonly ownerId: string;
readonly leagues: string[];
readonly createdAt: Date;
readonly name: TeamName;
readonly tag: TeamTag;
readonly description: TeamDescription;
readonly ownerId: DriverId;
readonly leagues: LeagueId[];
readonly createdAt: TeamCreatedAt;
private constructor(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt: Date;
name: TeamName;
tag: TeamTag;
description: TeamDescription;
ownerId: DriverId;
leagues: LeagueId[];
createdAt: TeamCreatedAt;
}) {
this.id = props.id;
this.name = props.name;
@@ -48,16 +54,22 @@ export class Team implements IEntity<string> {
leagues: string[];
createdAt?: Date;
}): Team {
this.validate(props);
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Team ID is required');
}
if (!Array.isArray(props.leagues)) {
throw new RacingDomainValidationError('Team leagues must be an array');
}
return new Team({
id: props.id,
name: props.name,
tag: props.tag,
description: props.description,
ownerId: props.ownerId,
leagues: [...props.leagues],
createdAt: props.createdAt ?? new Date(),
name: TeamName.create(props.name),
tag: TeamTag.create(props.tag),
description: TeamDescription.create(props.description),
ownerId: DriverId.create(props.ownerId),
leagues: props.leagues.map(leagueId => LeagueId.create(leagueId)),
createdAt: TeamCreatedAt.create(props.createdAt ?? new Date()),
});
}
@@ -71,58 +83,21 @@ export class Team implements IEntity<string> {
ownerId: string;
leagues: string[];
}>): Team {
const next: Team = new Team({
const nextName = 'name' in props ? TeamName.create(props.name!) : this.name;
const nextTag = 'tag' in props ? TeamTag.create(props.tag!) : this.tag;
const nextDescription = 'description' in props ? TeamDescription.create(props.description!) : this.description;
const nextOwnerId = 'ownerId' in props ? DriverId.create(props.ownerId!) : this.ownerId;
const nextLeagues = 'leagues' in props ? props.leagues!.map(leagueId => LeagueId.create(leagueId)) : this.leagues;
return new Team({
id: this.id,
name: props.name ?? this.name,
tag: props.tag ?? this.tag,
description: props.description ?? this.description,
ownerId: props.ownerId ?? this.ownerId,
leagues: props.leagues ? [...props.leagues] : [...this.leagues],
name: nextName,
tag: nextTag,
description: nextDescription,
ownerId: nextOwnerId,
leagues: nextLeagues,
createdAt: this.createdAt,
});
// Re-validate updated aggregate
Team.validate({
id: next.id,
name: next.name,
tag: next.tag,
description: next.description,
ownerId: next.ownerId,
leagues: next.leagues,
});
return next;
}
/**
* Domain validation logic for core invariants.
*/
private static validate(props: {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Team ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Team name is required');
}
if (!props.tag || props.tag.trim().length === 0) {
throw new RacingDomainValidationError('Team tag is required');
}
if (!props.ownerId || props.ownerId.trim().length === 0) {
throw new RacingDomainValidationError('Team owner ID is required');
}
if (!Array.isArray(props.leagues)) {
throw new RacingDomainValidationError('Team leagues must be an array');
}
}
}

View 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;
}
}

View 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');
});
});

View File

@@ -4,36 +4,43 @@
* Represents a racing track/circuit in the GridPilot platform.
* Immutable entity with factory methods and domain validation.
*/
import { RacingDomainValidationError } from '../errors/RacingDomainError';
import type { IEntity } from '@core/shared/domain';
import { TrackName } from '../value-objects/TrackName';
import { TrackShortName } from '../value-objects/TrackShortName';
import { TrackCountry } from '../value-objects/TrackCountry';
import { TrackLength } from '../value-objects/TrackLength';
import { TrackTurns } from '../value-objects/TrackTurns';
import { TrackGameId } from '../value-objects/TrackGameId';
import { TrackImageUrl } from '../value-objects/TrackImageUrl';
export type TrackCategory = 'oval' | 'road' | 'street' | 'dirt';
export type TrackDifficulty = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export class Track implements IEntity<string> {
readonly id: string;
readonly name: string;
readonly shortName: string;
readonly country: string;
readonly name: TrackName;
readonly shortName: TrackShortName;
readonly country: TrackCountry;
readonly category: TrackCategory;
readonly difficulty: TrackDifficulty;
readonly lengthKm: number;
readonly turns: number;
readonly imageUrl: string | undefined;
readonly gameId: string;
readonly lengthKm: TrackLength;
readonly turns: TrackTurns;
readonly imageUrl: TrackImageUrl;
readonly gameId: TrackGameId;
private constructor(props: {
id: string;
name: string;
shortName: string;
country: string;
name: TrackName;
shortName: TrackShortName;
country: TrackCountry;
category: TrackCategory;
difficulty: TrackDifficulty;
lengthKm: number;
turns: number;
imageUrl?: string | undefined;
gameId: string;
lengthKm: TrackLength;
turns: TrackTurns;
imageUrl: TrackImageUrl;
gameId: TrackGameId;
}) {
this.id = props.id;
this.name = props.name;
@@ -62,66 +69,23 @@ export class Track implements IEntity<string> {
imageUrl?: string;
gameId: string;
}): Track {
this.validate(props);
const base = {
id: props.id,
name: props.name,
shortName: props.shortName ?? props.name.slice(0, 3).toUpperCase(),
country: props.country,
category: props.category ?? 'road',
difficulty: props.difficulty ?? 'intermediate',
lengthKm: props.lengthKm,
turns: props.turns,
gameId: props.gameId,
};
const withImage =
props.imageUrl !== undefined ? { ...base, imageUrl: props.imageUrl } : base;
return new Track(withImage);
}
/**
* Domain validation logic
*/
private static validate(props: {
id: string;
name: string;
country: string;
lengthKm: number;
turns: number;
gameId: string;
}): void {
if (!props.id || props.id.trim().length === 0) {
throw new RacingDomainValidationError('Track ID is required');
}
if (!props.name || props.name.trim().length === 0) {
throw new RacingDomainValidationError('Track name is required');
}
const shortNameValue = props.shortName ?? props.name.slice(0, 3).toUpperCase();
if (!props.country || props.country.trim().length === 0) {
throw new RacingDomainValidationError('Track country is required');
}
if (props.lengthKm <= 0) {
throw new RacingDomainValidationError('Track length must be positive');
}
if (props.turns < 0) {
throw new RacingDomainValidationError('Track turns cannot be negative');
}
if (!props.gameId || props.gameId.trim().length === 0) {
throw new RacingDomainValidationError('Game ID is required');
}
}
/**
* Get formatted length string
*/
getFormattedLength(): string {
return `${this.lengthKm.toFixed(2)} km`;
return new Track({
id: props.id,
name: TrackName.create(props.name),
shortName: TrackShortName.create(shortNameValue),
country: TrackCountry.create(props.country),
category: props.category ?? 'road',
difficulty: props.difficulty ?? 'intermediate',
lengthKm: TrackLength.create(props.lengthKm),
turns: TrackTurns.create(props.turns),
imageUrl: TrackImageUrl.create(props.imageUrl),
gameId: TrackGameId.create(props.gameId),
});
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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');
});
});

View File

@@ -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,
});
}
}

View 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);
});
});

View 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