diff --git a/core/racing/domain/entities/LeagueMembership.ts b/core/racing/domain/entities/LeagueMembership.ts index ffdaff56d..6344bd1a8 100644 --- a/core/racing/domain/entities/LeagueMembership.ts +++ b/core/racing/domain/entities/LeagueMembership.ts @@ -7,10 +7,10 @@ import type { IEntity } from '@core/shared/domain'; import { RacingDomainValidationError } from '../errors/RacingDomainError'; 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'; +import { DriverId } from './DriverId'; export interface LeagueMembershipProps { id?: string; diff --git a/core/racing/domain/entities/Penalty.test.ts b/core/racing/domain/entities/Penalty.test.ts new file mode 100644 index 000000000..1fb1dff45 --- /dev/null +++ b/core/racing/domain/entities/Penalty.test.ts @@ -0,0 +1,231 @@ +import { Penalty } from './Penalty'; +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; + +describe('Penalty', () => { + describe('create', () => { + it('should create a penalty with required fields', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + }); + + expect(penalty.id).toBe('penalty-1'); + expect(penalty.leagueId).toBe('league-1'); + expect(penalty.raceId).toBe('race-1'); + expect(penalty.driverId).toBe('driver-1'); + expect(penalty.type).toBe('time_penalty'); + expect(penalty.value).toBe(5); + expect(penalty.reason).toBe('Speeding'); + expect(penalty.issuedBy).toBe('steward-1'); + expect(penalty.status).toBe('pending'); + expect(penalty.appliedAt).toBeUndefined(); + expect(penalty.notes).toBeUndefined(); + }); + + it('should create a penalty with all fields', () => { + const issuedAt = new Date('2023-01-01'); + const appliedAt = new Date('2023-01-02'); + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'disqualification', + reason: 'Unsafe driving', + protestId: 'protest-1', + issuedBy: 'steward-1', + status: 'applied', + issuedAt, + appliedAt, + notes: 'Applied after review', + }); + + expect(penalty.id).toBe('penalty-1'); + expect(penalty.type).toBe('disqualification'); + expect(penalty.value).toBeUndefined(); + expect(penalty.protestId).toBe('protest-1'); + expect(penalty.status).toBe('applied'); + expect(penalty.issuedAt).toEqual(issuedAt); + expect(penalty.appliedAt).toEqual(appliedAt); + expect(penalty.notes).toBe('Applied after review'); + }); + + it('should throw error for invalid id', () => { + expect(() => Penalty.create({ + id: '', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for invalid type', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'invalid_type', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for missing value on time_penalty', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + + it('should throw error for zero value', () => { + expect(() => Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 0, + reason: 'Speeding', + issuedBy: 'steward-1', + })).toThrow(RacingDomainValidationError); + }); + }); + + describe('isPending', () => { + it('should return true for pending status', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + expect(penalty.isPending()).toBe(true); + }); + + it('should return false for applied status', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'applied', + }); + expect(penalty.isPending()).toBe(false); + }); + }); + + describe('markAsApplied', () => { + it('should mark penalty as applied', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + const applied = penalty.markAsApplied('Applied successfully'); + expect(applied.status).toBe('applied'); + expect(applied.appliedAt).toBeInstanceOf(Date); + expect(applied.notes).toBe('Applied successfully'); + }); + + it('should throw error if already applied', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'applied', + }); + expect(() => penalty.markAsApplied()).toThrow(RacingDomainInvariantError); + }); + }); + + describe('overturn', () => { + it('should overturn the penalty', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + }); + const overturned = penalty.overturn('Appealed successfully'); + expect(overturned.status).toBe('overturned'); + expect(overturned.notes).toBe('Appealed successfully'); + }); + + it('should throw error if already overturned', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'warning', + reason: 'Warning', + issuedBy: 'steward-1', + status: 'overturned', + }); + expect(() => penalty.overturn('Reason')).toThrow(RacingDomainInvariantError); + }); + }); + + describe('getDescription', () => { + it('should return description for time_penalty', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'time_penalty', + value: 5, + reason: 'Speeding', + issuedBy: 'steward-1', + }); + expect(penalty.getDescription()).toBe('+5s time penalty'); + }); + + it('should return description for disqualification', () => { + const penalty = Penalty.create({ + id: 'penalty-1', + leagueId: 'league-1', + raceId: 'race-1', + driverId: 'driver-1', + type: 'disqualification', + reason: 'Unsafe', + issuedBy: 'steward-1', + }); + expect(penalty.getDescription()).toBe('Disqualified from race'); + }); + }); +}); \ No newline at end of file diff --git a/core/racing/domain/entities/Penalty.ts b/core/racing/domain/entities/Penalty.ts index e69de29bb..b3a24ca06 100644 --- a/core/racing/domain/entities/Penalty.ts +++ b/core/racing/domain/entities/Penalty.ts @@ -0,0 +1,195 @@ +/** + * Domain Entity: Penalty + * + * Represents a penalty applied to a driver for an incident during a race. + * Penalties can be applied as a result of an upheld protest or directly by stewards. + */ + +import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError'; +import type { IEntity } from '@core/shared/domain'; +import { PenaltyId } from './penalty/PenaltyId'; +import { LeagueId } from './LeagueId'; +import { RaceId } from './RaceId'; +import { DriverId } from './DriverId'; +import { PenaltyType } from './penalty/PenaltyType'; +import { PenaltyValue } from './penalty/PenaltyValue'; +import { PenaltyReason } from './penalty/PenaltyReason'; +import { ProtestId } from './ProtestId'; +import { StewardId } from './StewardId'; +import { PenaltyStatus } from './penalty/PenaltyStatus'; +import { IssuedAt } from './IssuedAt'; +import { AppliedAt } from './AppliedAt'; +import { PenaltyNotes } from './penalty/PenaltyNotes'; + +export interface PenaltyProps { + id: PenaltyId; + leagueId: LeagueId; + raceId: RaceId; + /** The driver receiving the penalty */ + driverId: DriverId; + /** Type of penalty */ + type: PenaltyType; + /** Value depends on type: seconds for time_penalty, positions for grid_penalty, points for points_deduction */ + value?: PenaltyValue; + /** Reason for the penalty */ + reason: PenaltyReason; + /** ID of the protest that led to this penalty (if applicable) */ + protestId?: ProtestId; + /** ID of the steward who issued the penalty */ + issuedBy: StewardId; + /** Current status of the penalty */ + status: PenaltyStatus; + /** Timestamp when the penalty was issued */ + issuedAt: IssuedAt; + /** Timestamp when the penalty was applied to results */ + appliedAt?: AppliedAt; + /** Notes about the penalty application */ + notes?: PenaltyNotes; +} + +export class Penalty implements IEntity { + private constructor(private readonly props: PenaltyProps) {} + + static create(props: { + id: string; + leagueId: string; + raceId: string; + driverId: string; + type: string; + value?: number; + reason: string; + protestId?: string; + issuedBy: string; + status?: string; + issuedAt?: Date; + appliedAt?: Date; + notes?: string; + }): Penalty { + if (!props.id) throw new RacingDomainValidationError('Penalty ID is required'); + if (!props.leagueId) throw new RacingDomainValidationError('League ID is required'); + if (!props.raceId) throw new RacingDomainValidationError('Race ID is required'); + if (!props.driverId) throw new RacingDomainValidationError('Driver ID is required'); + if (!props.type) throw new RacingDomainValidationError('Penalty type is required'); + if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required'); + if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward'); + + // Validate value based on type + if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) { + if (props.value === undefined || props.value <= 0) { + throw new RacingDomainValidationError(`${props.type} requires a positive value`); + } + } + + const penaltyProps: PenaltyProps = { + id: PenaltyId.create(props.id), + leagueId: LeagueId.create(props.leagueId), + raceId: RaceId.create(props.raceId), + driverId: DriverId.create(props.driverId), + type: PenaltyType.create(props.type), + reason: PenaltyReason.create(props.reason), + issuedBy: StewardId.create(props.issuedBy), + status: PenaltyStatus.create(props.status || 'pending'), + issuedAt: IssuedAt.create(props.issuedAt || new Date()), + ...(props.value !== undefined && { value: PenaltyValue.create(props.value) }), + ...(props.protestId !== undefined && { protestId: ProtestId.create(props.protestId) }), + ...(props.appliedAt !== undefined && { appliedAt: AppliedAt.create(props.appliedAt) }), + ...(props.notes !== undefined && { notes: PenaltyNotes.create(props.notes) }), + }; + + return new Penalty(penaltyProps); + } + + get id(): string { return this.props.id.toString(); } + get leagueId(): string { return this.props.leagueId.toString(); } + get raceId(): string { return this.props.raceId.toString(); } + get driverId(): string { return this.props.driverId.toString(); } + get type(): string { return this.props.type.toString(); } + get value(): number | undefined { return this.props.value?.toNumber(); } + get reason(): string { return this.props.reason.toString(); } + get protestId(): string | undefined { return this.props.protestId?.toString(); } + get issuedBy(): string { return this.props.issuedBy.toString(); } + get status(): string { return this.props.status.toString(); } + get issuedAt(): Date { return this.props.issuedAt.toDate(); } + get appliedAt(): Date | undefined { return this.props.appliedAt?.toDate(); } + get notes(): string | undefined { return this.props.notes?.toString(); } + + isPending(): boolean { + return this.props.status.toString() === 'pending'; + } + + isApplied(): boolean { + return this.props.status.toString() === 'applied'; + } + + /** + * Mark penalty as applied (after recalculating results) + */ + markAsApplied(notes?: string): Penalty { + if (this.isApplied()) { + throw new RacingDomainInvariantError('Penalty is already applied'); + } + if (this.props.status.toString() === 'overturned') { + throw new RacingDomainInvariantError('Cannot apply an overturned penalty'); + } + const base: PenaltyProps = { + ...this.props, + status: PenaltyStatus.create('applied'), + appliedAt: AppliedAt.create(new Date()), + }; + + const next: PenaltyProps = + notes !== undefined ? { ...base, notes: PenaltyNotes.create(notes) } : base; + + return new Penalty(next); + } + + /** + * Overturn the penalty (e.g., after successful appeal) + */ + overturn(reason: string): Penalty { + if (this.props.status.toString() === 'overturned') { + throw new RacingDomainInvariantError('Penalty is already overturned'); + } + return new Penalty({ + ...this.props, + status: PenaltyStatus.create('overturned'), + notes: PenaltyNotes.create(reason), + }); + } + + /** + * Get a human-readable description of the penalty + */ + getDescription(): string { + switch (this.props.type.toString()) { + case 'time_penalty': + return `+${this.props.value?.toNumber()}s time penalty`; + case 'grid_penalty': + return `${this.props.value?.toNumber()} place grid penalty (next race)`; + case 'points_deduction': + return `${this.props.value?.toNumber()} championship points deducted`; + case 'disqualification': + return 'Disqualified from race'; + case 'warning': + return 'Official warning'; + case 'license_points': + return `${this.props.value?.toNumber()} license penalty points`; + case 'probation': + return 'Probationary period'; + case 'fine': + return `${this.props.value?.toNumber()} points fine`; + case 'race_ban': + return `${this.props.value?.toNumber()} race suspension`; + default: + return 'Penalty'; + } + } +} + +// Export types for external use +export { PenaltyType } from './penalty/PenaltyType'; +export { PenaltyStatus } from './penalty/PenaltyStatus'; +export { PenaltyValue } from './penalty/PenaltyValue'; +export { PenaltyReason } from './penalty/PenaltyReason'; +export { PenaltyNotes } from './penalty/PenaltyNotes'; +export { PenaltyId } from './penalty/PenaltyId'; \ No newline at end of file