team rating
This commit is contained in:
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
198
core/racing/domain/entities/TeamRatingEvent.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { TeamRatingEvent } from './TeamRatingEvent';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
describe('TeamRatingEvent', () => {
|
||||
const validProps = {
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||
teamId: 'team-123',
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
delta: TeamRatingDelta.create(10),
|
||||
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
reason: { code: 'RACE_FINISH', description: 'Finished 1st in race' },
|
||||
visibility: { public: true },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a valid rating event', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
expect(event.occurredAt).toEqual(validProps.occurredAt);
|
||||
expect(event.createdAt).toEqual(validProps.createdAt);
|
||||
expect(event.source).toEqual(validProps.source);
|
||||
expect(event.reason).toEqual(validProps.reason);
|
||||
expect(event.visibility).toEqual(validProps.visibility);
|
||||
expect(event.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should create event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should throw for empty teamId', () => {
|
||||
const props = { ...validProps, teamId: '' };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing dimension', () => {
|
||||
const { dimension: _dimension, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing delta', () => {
|
||||
const { delta: _delta, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing source', () => {
|
||||
const { source: _source, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing reason', () => {
|
||||
const { reason: _reason, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for missing visibility', () => {
|
||||
const { visibility: _visibility, ...rest } = validProps;
|
||||
expect(() => TeamRatingEvent.create(rest as typeof validProps)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for invalid weight', () => {
|
||||
const props = { ...validProps, weight: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future occurredAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, occurredAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for future createdAt', () => {
|
||||
const futureDate = new Date(Date.now() + 86400000); // Tomorrow
|
||||
const props = { ...validProps, createdAt: futureDate };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for version < 1', () => {
|
||||
const props = { ...validProps, version: 0 };
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainValidationError);
|
||||
});
|
||||
|
||||
it('should throw for adminTrust dimension with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should throw for driving dimension with vote source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'vote' as const, id: 'vote-456' },
|
||||
};
|
||||
expect(() => TeamRatingEvent.create(props)).toThrow(RacingDomainInvariantError);
|
||||
});
|
||||
|
||||
it('should allow adminTrust with adminAction source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('adminTrust'),
|
||||
source: { type: 'adminAction' as const, id: 'action-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
});
|
||||
|
||||
it('should allow driving with race source', () => {
|
||||
const props = {
|
||||
...validProps,
|
||||
dimension: TeamRatingDimensionKey.create('driving'),
|
||||
source: { type: 'race' as const, id: 'race-456' },
|
||||
};
|
||||
const event = TeamRatingEvent.create(props);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rehydrate', () => {
|
||||
it('should rehydrate event from stored data', () => {
|
||||
const event = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event.id.value).toBe(validProps.id.value);
|
||||
expect(event.teamId).toBe(validProps.teamId);
|
||||
expect(event.dimension.value).toBe('driving');
|
||||
expect(event.delta.value).toBe(10);
|
||||
});
|
||||
|
||||
it('should rehydrate event with optional weight', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.rehydrate(props);
|
||||
|
||||
expect(event.weight).toBe(2);
|
||||
});
|
||||
|
||||
it('should return true for same ID', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.rehydrate(validProps);
|
||||
|
||||
expect(event1.equals(event2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for different IDs', () => {
|
||||
const event1 = TeamRatingEvent.create(validProps);
|
||||
const event2 = TeamRatingEvent.create({
|
||||
...validProps,
|
||||
id: TeamRatingEventId.create('123e4567-e89b-12d3-a456-426614174001'),
|
||||
});
|
||||
|
||||
expect(event1.equals(event2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toJSON', () => {
|
||||
it('should return plain object representation', () => {
|
||||
const event = TeamRatingEvent.create(validProps);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
id: validProps.id.value,
|
||||
teamId: validProps.teamId,
|
||||
dimension: 'driving',
|
||||
delta: 10,
|
||||
weight: undefined,
|
||||
occurredAt: validProps.occurredAt.toISOString(),
|
||||
createdAt: validProps.createdAt.toISOString(),
|
||||
source: validProps.source,
|
||||
reason: validProps.reason,
|
||||
visibility: validProps.visibility,
|
||||
version: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include weight when present', () => {
|
||||
const props = { ...validProps, weight: 2 };
|
||||
const event = TeamRatingEvent.create(props);
|
||||
const json = event.toJSON();
|
||||
|
||||
expect(json).toHaveProperty('weight', 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
181
core/racing/domain/entities/TeamRatingEvent.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { IEntity } from '@core/shared/domain';
|
||||
import { TeamRatingEventId } from '../value-objects/TeamRatingEventId';
|
||||
import { TeamRatingDimensionKey } from '../value-objects/TeamRatingDimensionKey';
|
||||
import { TeamRatingDelta } from '../value-objects/TeamRatingDelta';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
|
||||
export interface TeamRatingEventSource {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id?: string; // e.g., raceId, penaltyId, voteId
|
||||
}
|
||||
|
||||
export interface TeamRatingEventReason {
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventVisibility {
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface TeamRatingEventProps {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export class TeamRatingEvent implements IEntity<TeamRatingEventId> {
|
||||
readonly id: TeamRatingEventId;
|
||||
readonly teamId: string;
|
||||
readonly dimension: TeamRatingDimensionKey;
|
||||
readonly delta: TeamRatingDelta;
|
||||
readonly weight: number | undefined;
|
||||
readonly occurredAt: Date;
|
||||
readonly createdAt: Date;
|
||||
readonly source: TeamRatingEventSource;
|
||||
readonly reason: TeamRatingEventReason;
|
||||
readonly visibility: TeamRatingEventVisibility;
|
||||
readonly version: number;
|
||||
|
||||
private constructor(props: TeamRatingEventProps) {
|
||||
this.id = props.id;
|
||||
this.teamId = props.teamId;
|
||||
this.dimension = props.dimension;
|
||||
this.delta = props.delta;
|
||||
this.weight = props.weight;
|
||||
this.occurredAt = props.occurredAt;
|
||||
this.createdAt = props.createdAt;
|
||||
this.source = props.source;
|
||||
this.reason = props.reason;
|
||||
this.visibility = props.visibility;
|
||||
this.version = props.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a new TeamRatingEvent.
|
||||
*/
|
||||
static create(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Validate required fields
|
||||
if (!props.teamId || props.teamId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
}
|
||||
|
||||
if (!props.dimension) {
|
||||
throw new RacingDomainValidationError('Dimension is required');
|
||||
}
|
||||
|
||||
if (!props.delta) {
|
||||
throw new RacingDomainValidationError('Delta is required');
|
||||
}
|
||||
|
||||
if (!props.source) {
|
||||
throw new RacingDomainValidationError('Source is required');
|
||||
}
|
||||
|
||||
if (!props.reason) {
|
||||
throw new RacingDomainValidationError('Reason is required');
|
||||
}
|
||||
|
||||
if (!props.visibility) {
|
||||
throw new RacingDomainValidationError('Visibility is required');
|
||||
}
|
||||
|
||||
if (props.weight !== undefined && (typeof props.weight !== 'number' || props.weight <= 0)) {
|
||||
throw new RacingDomainValidationError('Weight must be a positive number if provided');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
if (props.occurredAt > now) {
|
||||
throw new RacingDomainValidationError('Occurrence date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.createdAt > now) {
|
||||
throw new RacingDomainValidationError('Creation date cannot be in the future');
|
||||
}
|
||||
|
||||
if (props.version < 1) {
|
||||
throw new RacingDomainValidationError('Version must be at least 1');
|
||||
}
|
||||
|
||||
// Validate invariants
|
||||
if (props.dimension.value === 'adminTrust' && props.source.type === 'race') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'adminTrust dimension cannot be updated from race events'
|
||||
);
|
||||
}
|
||||
|
||||
if (props.dimension.value === 'driving' && props.source.type === 'vote') {
|
||||
throw new RacingDomainInvariantError(
|
||||
'driving dimension cannot be updated from vote events'
|
||||
);
|
||||
}
|
||||
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rehydrate event from stored data (assumes data is already validated).
|
||||
*/
|
||||
static rehydrate(props: {
|
||||
id: TeamRatingEventId;
|
||||
teamId: string;
|
||||
dimension: TeamRatingDimensionKey;
|
||||
delta: TeamRatingDelta;
|
||||
weight?: number;
|
||||
occurredAt: Date;
|
||||
createdAt: Date;
|
||||
source: TeamRatingEventSource;
|
||||
reason: TeamRatingEventReason;
|
||||
visibility: TeamRatingEventVisibility;
|
||||
version: number;
|
||||
}): TeamRatingEvent {
|
||||
// Rehydration assumes data is already validated (from persistence)
|
||||
return new TeamRatingEvent(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare with another event.
|
||||
*/
|
||||
equals(other: IEntity<TeamRatingEventId>): boolean {
|
||||
return this.id.equals(other.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return plain object representation for serialization.
|
||||
*/
|
||||
toJSON(): object {
|
||||
return {
|
||||
id: this.id.value,
|
||||
teamId: this.teamId,
|
||||
dimension: this.dimension.value,
|
||||
delta: this.delta.value,
|
||||
weight: this.weight,
|
||||
occurredAt: this.occurredAt.toISOString(),
|
||||
createdAt: this.createdAt.toISOString(),
|
||||
source: this.source,
|
||||
reason: this.reason,
|
||||
visibility: this.visibility,
|
||||
version: this.version,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user