This commit is contained in:
2025-12-29 22:27:33 +01:00
parent 3f610c1cb6
commit 7a853d4e43
96 changed files with 14790 additions and 111 deletions

View File

@@ -0,0 +1,169 @@
import { AdminTrustReasonCode } from './AdminTrustReasonCode';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('AdminTrustReasonCode', () => {
describe('create', () => {
it('should create valid reason codes', () => {
const validCodes = [
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_VOTE_OUTCOME_NEGATIVE',
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_REVERSAL_PENALTY',
'ADMIN_ACTION_RULE_CLARITY_BONUS',
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
];
validCodes.forEach(code => {
const reasonCode = AdminTrustReasonCode.create(code);
expect(reasonCode.value).toBe(code);
});
});
it('should throw error for empty string', () => {
expect(() => AdminTrustReasonCode.create('')).toThrow(IdentityDomainValidationError);
expect(() => AdminTrustReasonCode.create(' ')).toThrow(IdentityDomainValidationError);
});
it('should throw error for invalid reason code', () => {
expect(() => AdminTrustReasonCode.create('INVALID_CODE')).toThrow(IdentityDomainValidationError);
expect(() => AdminTrustReasonCode.create('admin_vote')).toThrow(IdentityDomainValidationError);
});
it('should trim whitespace from valid codes', () => {
const reasonCode = AdminTrustReasonCode.create(' ADMIN_VOTE_OUTCOME_POSITIVE ');
expect(reasonCode.value).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
});
});
describe('fromValue', () => {
it('should create from value without validation', () => {
const reasonCode = AdminTrustReasonCode.fromValue('ADMIN_VOTE_OUTCOME_POSITIVE');
expect(reasonCode.value).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
});
});
describe('equals', () => {
it('should return true for equal codes', () => {
const code1 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
const code2 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
expect(code1.equals(code2)).toBe(true);
});
it('should return false for different codes', () => {
const code1 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
const code2 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE');
expect(code1.equals(code2)).toBe(false);
});
});
describe('toString', () => {
it('should return the string value', () => {
const code = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
expect(code.toString()).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
});
});
describe('category methods', () => {
describe('isVoteOutcome', () => {
it('should return true for vote outcome codes', () => {
const voteCodes = [
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_VOTE_OUTCOME_NEGATIVE',
];
voteCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isVoteOutcome()).toBe(true);
});
});
it('should return false for non-vote outcome codes', () => {
const nonVoteCodes = [
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_REVERSAL_PENALTY',
];
nonVoteCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isVoteOutcome()).toBe(false);
});
});
});
describe('isSystemSignal', () => {
it('should return true for system signal codes', () => {
const systemCodes = [
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_REVERSAL_PENALTY',
'ADMIN_ACTION_RULE_CLARITY_BONUS',
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
];
systemCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isSystemSignal()).toBe(true);
});
});
it('should return false for non-system signal codes', () => {
const nonSystemCodes = [
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_VOTE_OUTCOME_NEGATIVE',
];
nonSystemCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isSystemSignal()).toBe(false);
});
});
});
describe('isPositive', () => {
it('should return true for positive impact codes', () => {
const positiveCodes = [
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_RULE_CLARITY_BONUS',
];
positiveCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isPositive()).toBe(true);
});
});
it('should return false for non-positive codes', () => {
const nonPositiveCodes = [
'ADMIN_VOTE_OUTCOME_NEGATIVE',
'ADMIN_ACTION_REVERSAL_PENALTY',
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
];
nonPositiveCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isPositive()).toBe(false);
});
});
});
describe('isNegative', () => {
it('should return true for negative impact codes', () => {
const negativeCodes = [
'ADMIN_VOTE_OUTCOME_NEGATIVE',
'ADMIN_ACTION_REVERSAL_PENALTY',
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
];
negativeCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isNegative()).toBe(true);
});
});
it('should return false for non-negative codes', () => {
const nonNegativeCodes = [
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_RULE_CLARITY_BONUS',
];
nonNegativeCodes.forEach(codeStr => {
const code = AdminTrustReasonCode.fromValue(codeStr as any);
expect(code.isNegative()).toBe(false);
});
});
});
});
});

View File

@@ -0,0 +1,112 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
/**
* Admin Trust Reason Code Value Object
*
* Stable machine codes for admin trust rating events to support:
* - Filtering and analytics
* - i18n translations
* - Consistent UI explanations
*
* Based on ratings-architecture-concept.md sections 5.2.1 and 5.2.2
*/
export type AdminTrustReasonCodeValue =
// Vote outcomes
| 'ADMIN_VOTE_OUTCOME_POSITIVE'
| 'ADMIN_VOTE_OUTCOME_NEGATIVE'
// System signals
| 'ADMIN_ACTION_SLA_BONUS'
| 'ADMIN_ACTION_REVERSAL_PENALTY'
| 'ADMIN_ACTION_RULE_CLARITY_BONUS'
| 'ADMIN_ACTION_ABUSE_REPORT_PENALTY';
export interface AdminTrustReasonCodeProps {
value: AdminTrustReasonCodeValue;
}
const VALID_REASON_CODES: AdminTrustReasonCodeValue[] = [
// Vote outcomes
'ADMIN_VOTE_OUTCOME_POSITIVE',
'ADMIN_VOTE_OUTCOME_NEGATIVE',
// System signals
'ADMIN_ACTION_SLA_BONUS',
'ADMIN_ACTION_REVERSAL_PENALTY',
'ADMIN_ACTION_RULE_CLARITY_BONUS',
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
];
export class AdminTrustReasonCode implements IValueObject<AdminTrustReasonCodeProps> {
readonly value: AdminTrustReasonCodeValue;
private constructor(value: AdminTrustReasonCodeValue) {
this.value = value;
}
static create(value: string): AdminTrustReasonCode {
if (!value || value.trim().length === 0) {
throw new IdentityDomainValidationError('AdminTrustReasonCode cannot be empty');
}
const trimmed = value.trim() as AdminTrustReasonCodeValue;
if (!VALID_REASON_CODES.includes(trimmed)) {
throw new IdentityDomainValidationError(
`Invalid admin trust reason code: ${value}. Valid options: ${VALID_REASON_CODES.join(', ')}`
);
}
return new AdminTrustReasonCode(trimmed);
}
static fromValue(value: AdminTrustReasonCodeValue): AdminTrustReasonCode {
return new AdminTrustReasonCode(value);
}
get props(): AdminTrustReasonCodeProps {
return { value: this.value };
}
equals(other: IValueObject<AdminTrustReasonCodeProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
/**
* Check if this is a vote-related reason code
*/
isVoteOutcome(): boolean {
return this.value === 'ADMIN_VOTE_OUTCOME_POSITIVE' ||
this.value === 'ADMIN_VOTE_OUTCOME_NEGATIVE';
}
/**
* Check if this is a system signal reason code
*/
isSystemSignal(): boolean {
return this.value === 'ADMIN_ACTION_SLA_BONUS' ||
this.value === 'ADMIN_ACTION_REVERSAL_PENALTY' ||
this.value === 'ADMIN_ACTION_RULE_CLARITY_BONUS' ||
this.value === 'ADMIN_ACTION_ABUSE_REPORT_PENALTY';
}
/**
* Check if this is a positive impact (bonus)
*/
isPositive(): boolean {
return this.value.endsWith('_BONUS') ||
this.value === 'ADMIN_VOTE_OUTCOME_POSITIVE';
}
/**
* Check if this is a negative impact (penalty)
*/
isNegative(): boolean {
return this.value.endsWith('_PENALTY') ||
this.value === 'ADMIN_VOTE_OUTCOME_NEGATIVE';
}
}

View File

@@ -0,0 +1,207 @@
import { DrivingReasonCode } from './DrivingReasonCode';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('DrivingReasonCode', () => {
describe('create', () => {
it('should create valid reason codes', () => {
const validCodes = [
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_POSITIONS_GAINED_BONUS',
'DRIVING_PACE_RELATIVE_GAIN',
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_MAJOR_CONTACT_PENALTY',
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
'DRIVING_DNS_PENALTY',
'DRIVING_DNF_PENALTY',
'DRIVING_DSQ_PENALTY',
'DRIVING_AFK_PENALTY',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
validCodes.forEach(code => {
const reasonCode = DrivingReasonCode.create(code);
expect(reasonCode.value).toBe(code);
});
});
it('should throw error for empty string', () => {
expect(() => DrivingReasonCode.create('')).toThrow(IdentityDomainValidationError);
expect(() => DrivingReasonCode.create(' ')).toThrow(IdentityDomainValidationError);
});
it('should throw error for invalid reason code', () => {
expect(() => DrivingReasonCode.create('INVALID_CODE')).toThrow(IdentityDomainValidationError);
expect(() => DrivingReasonCode.create('driving_finish')).toThrow(IdentityDomainValidationError);
});
it('should trim whitespace from valid codes', () => {
const reasonCode = DrivingReasonCode.create(' DRIVING_FINISH_STRENGTH_GAIN ');
expect(reasonCode.value).toBe('DRIVING_FINISH_STRENGTH_GAIN');
});
});
describe('fromValue', () => {
it('should create from value without validation', () => {
const reasonCode = DrivingReasonCode.fromValue('DRIVING_FINISH_STRENGTH_GAIN');
expect(reasonCode.value).toBe('DRIVING_FINISH_STRENGTH_GAIN');
});
});
describe('equals', () => {
it('should return true for equal codes', () => {
const code1 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
const code2 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
expect(code1.equals(code2)).toBe(true);
});
it('should return false for different codes', () => {
const code1 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
const code2 = DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY');
expect(code1.equals(code2)).toBe(false);
});
});
describe('toString', () => {
it('should return the string value', () => {
const code = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
expect(code.toString()).toBe('DRIVING_FINISH_STRENGTH_GAIN');
});
});
describe('category methods', () => {
describe('isPerformance', () => {
it('should return true for performance codes', () => {
const performanceCodes = [
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_POSITIONS_GAINED_BONUS',
'DRIVING_PACE_RELATIVE_GAIN',
];
performanceCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isPerformance()).toBe(true);
});
});
it('should return false for non-performance codes', () => {
const nonPerformanceCodes = [
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_DNS_PENALTY',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
nonPerformanceCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isPerformance()).toBe(false);
});
});
});
describe('isCleanDriving', () => {
it('should return true for clean driving codes', () => {
const cleanDrivingCodes = [
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_MAJOR_CONTACT_PENALTY',
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
];
cleanDrivingCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isCleanDriving()).toBe(true);
});
});
it('should return false for non-clean driving codes', () => {
const nonCleanDrivingCodes = [
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_DNS_PENALTY',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
nonCleanDrivingCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isCleanDriving()).toBe(false);
});
});
});
describe('isReliability', () => {
it('should return true for reliability codes', () => {
const reliabilityCodes = [
'DRIVING_DNS_PENALTY',
'DRIVING_DNF_PENALTY',
'DRIVING_DSQ_PENALTY',
'DRIVING_AFK_PENALTY',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
reliabilityCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isReliability()).toBe(true);
});
});
it('should return false for non-reliability codes', () => {
const nonReliabilityCodes = [
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_INCIDENTS_PENALTY',
];
nonReliabilityCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isReliability()).toBe(false);
});
});
});
describe('isPenalty', () => {
it('should return true for penalty codes', () => {
const penaltyCodes = [
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_MAJOR_CONTACT_PENALTY',
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
'DRIVING_DNS_PENALTY',
'DRIVING_DNF_PENALTY',
'DRIVING_DSQ_PENALTY',
'DRIVING_AFK_PENALTY',
];
penaltyCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isPenalty()).toBe(true);
});
});
it('should return false for non-penalty codes', () => {
const nonPenaltyCodes = [
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_POSITIONS_GAINED_BONUS',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
nonPenaltyCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isPenalty()).toBe(false);
});
});
});
describe('isBonus', () => {
it('should return true for bonus codes', () => {
const bonusCodes = [
'DRIVING_POSITIONS_GAINED_BONUS',
'DRIVING_SEASON_ATTENDANCE_BONUS',
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_PACE_RELATIVE_GAIN',
];
bonusCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isBonus()).toBe(true);
});
});
it('should return false for non-bonus codes', () => {
const nonBonusCodes = [
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_DNS_PENALTY',
];
nonBonusCodes.forEach(codeStr => {
const code = DrivingReasonCode.fromValue(codeStr as any);
expect(code.isBonus()).toBe(false);
});
});
});
});
});

View File

@@ -0,0 +1,133 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
/**
* Driving Reason Code Value Object
*
* Stable machine codes for driving rating events to support:
* - Filtering and analytics
* - i18n translations
* - Consistent UI explanations
*
* Based on ratings-architecture-concept.md section 5.1.2
*/
export type DrivingReasonCodeValue =
// Performance
| 'DRIVING_FINISH_STRENGTH_GAIN'
| 'DRIVING_POSITIONS_GAINED_BONUS'
| 'DRIVING_PACE_RELATIVE_GAIN'
// Clean driving
| 'DRIVING_INCIDENTS_PENALTY'
| 'DRIVING_MAJOR_CONTACT_PENALTY'
| 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'
// Reliability
| 'DRIVING_DNS_PENALTY'
| 'DRIVING_DNF_PENALTY'
| 'DRIVING_DSQ_PENALTY'
| 'DRIVING_AFK_PENALTY'
| 'DRIVING_SEASON_ATTENDANCE_BONUS';
export interface DrivingReasonCodeProps {
value: DrivingReasonCodeValue;
}
const VALID_REASON_CODES: DrivingReasonCodeValue[] = [
// Performance
'DRIVING_FINISH_STRENGTH_GAIN',
'DRIVING_POSITIONS_GAINED_BONUS',
'DRIVING_PACE_RELATIVE_GAIN',
// Clean driving
'DRIVING_INCIDENTS_PENALTY',
'DRIVING_MAJOR_CONTACT_PENALTY',
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
// Reliability
'DRIVING_DNS_PENALTY',
'DRIVING_DNF_PENALTY',
'DRIVING_DSQ_PENALTY',
'DRIVING_AFK_PENALTY',
'DRIVING_SEASON_ATTENDANCE_BONUS',
];
export class DrivingReasonCode implements IValueObject<DrivingReasonCodeProps> {
readonly value: DrivingReasonCodeValue;
private constructor(value: DrivingReasonCodeValue) {
this.value = value;
}
static create(value: string): DrivingReasonCode {
if (!value || value.trim().length === 0) {
throw new IdentityDomainValidationError('DrivingReasonCode cannot be empty');
}
const trimmed = value.trim() as DrivingReasonCodeValue;
if (!VALID_REASON_CODES.includes(trimmed)) {
throw new IdentityDomainValidationError(
`Invalid driving reason code: ${value}. Valid options: ${VALID_REASON_CODES.join(', ')}`
);
}
return new DrivingReasonCode(trimmed);
}
static fromValue(value: DrivingReasonCodeValue): DrivingReasonCode {
return new DrivingReasonCode(value);
}
get props(): DrivingReasonCodeProps {
return { value: this.value };
}
equals(other: IValueObject<DrivingReasonCodeProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
/**
* Check if this is a performance-related reason code
*/
isPerformance(): boolean {
return this.value === 'DRIVING_FINISH_STRENGTH_GAIN' ||
this.value === 'DRIVING_POSITIONS_GAINED_BONUS' ||
this.value === 'DRIVING_PACE_RELATIVE_GAIN';
}
/**
* Check if this is a clean driving-related reason code
*/
isCleanDriving(): boolean {
return this.value === 'DRIVING_INCIDENTS_PENALTY' ||
this.value === 'DRIVING_MAJOR_CONTACT_PENALTY' ||
this.value === 'DRIVING_PENALTY_INVOLVEMENT_PENALTY';
}
/**
* Check if this is a reliability-related reason code
*/
isReliability(): boolean {
return this.value === 'DRIVING_DNS_PENALTY' ||
this.value === 'DRIVING_DNF_PENALTY' ||
this.value === 'DRIVING_DSQ_PENALTY' ||
this.value === 'DRIVING_AFK_PENALTY' ||
this.value === 'DRIVING_SEASON_ATTENDANCE_BONUS';
}
/**
* Check if this is a penalty (negative impact)
*/
isPenalty(): boolean {
return this.value.endsWith('_PENALTY');
}
/**
* Check if this is a bonus (positive impact)
*/
isBonus(): boolean {
return this.value.endsWith('_BONUS') || this.value.endsWith('_GAIN');
}
}

View File

@@ -0,0 +1,99 @@
import { ExternalRating } from './ExternalRating';
import { GameKey } from './GameKey';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('ExternalRating', () => {
describe('create', () => {
it('should create valid external rating', () => {
const gameKey = GameKey.create('iracing');
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
expect(rating.gameKey.value).toBe('iracing');
expect(rating.type).toBe('iRating');
expect(rating.value).toBe(2500);
});
it('should create rating with safety rating', () => {
const gameKey = GameKey.create('iracing');
const rating = ExternalRating.create(gameKey, 'safetyRating', 2.5);
expect(rating.type).toBe('safetyRating');
expect(rating.value).toBe(2.5);
});
it('should throw for empty type', () => {
const gameKey = GameKey.create('iracing');
expect(() => ExternalRating.create(gameKey, '', 2500)).toThrow(IdentityDomainValidationError);
expect(() => ExternalRating.create(gameKey, ' ', 2500)).toThrow(IdentityDomainValidationError);
});
it('should throw for non-numeric value', () => {
const gameKey = GameKey.create('iracing');
expect(() => ExternalRating.create(gameKey, 'iRating', '2500' as unknown as number)).toThrow();
expect(() => ExternalRating.create(gameKey, 'iRating', null as unknown as number)).toThrow();
});
it('should trim whitespace from type', () => {
const gameKey = GameKey.create('iracing');
const rating = ExternalRating.create(gameKey, ' iRating ', 2500);
expect(rating.type).toBe('iRating');
});
});
describe('equals', () => {
it('should return true for same gameKey, type, and value', () => {
const gameKey1 = GameKey.create('iracing');
const gameKey2 = GameKey.create('iracing');
const rating1 = ExternalRating.create(gameKey1, 'iRating', 2500);
const rating2 = ExternalRating.create(gameKey2, 'iRating', 2500);
expect(rating1.equals(rating2)).toBe(true);
});
it('should return false for different gameKeys', () => {
const gameKey1 = GameKey.create('iracing');
const gameKey2 = GameKey.create('acc');
const rating1 = ExternalRating.create(gameKey1, 'iRating', 2500);
const rating2 = ExternalRating.create(gameKey2, 'iRating', 2500);
expect(rating1.equals(rating2)).toBe(false);
});
it('should return false for different types', () => {
const gameKey = GameKey.create('iracing');
const rating1 = ExternalRating.create(gameKey, 'iRating', 2500);
const rating2 = ExternalRating.create(gameKey, 'safetyRating', 2500);
expect(rating1.equals(rating2)).toBe(false);
});
it('should return false for different values', () => {
const gameKey = GameKey.create('iracing');
const rating1 = ExternalRating.create(gameKey, 'iRating', 2500);
const rating2 = ExternalRating.create(gameKey, 'iRating', 2600);
expect(rating1.equals(rating2)).toBe(false);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const gameKey = GameKey.create('iracing');
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
const props = rating.props;
expect(props.gameKey.value).toBe('iracing');
expect(props.type).toBe('iRating');
expect(props.value).toBe(2500);
});
});
describe('toString', () => {
it('should return string representation', () => {
const gameKey = GameKey.create('iracing');
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
expect(rating.toString()).toBe('iracing:iRating=2500');
});
});
});

View File

@@ -0,0 +1,54 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
import { GameKey } from './GameKey';
export interface ExternalRatingProps {
gameKey: GameKey;
type: string;
value: number;
}
export class ExternalRating implements IValueObject<ExternalRatingProps> {
readonly gameKey: GameKey;
readonly type: string;
readonly value: number;
private constructor(gameKey: GameKey, type: string, value: number) {
this.gameKey = gameKey;
this.type = type;
this.value = value;
}
static create(gameKey: GameKey, type: string, value: number): ExternalRating {
if (!type || type.trim().length === 0) {
throw new IdentityDomainValidationError('External rating type cannot be empty');
}
if (typeof value !== 'number' || isNaN(value)) {
throw new IdentityDomainValidationError('External rating value must be a valid number');
}
const trimmedType = type.trim();
return new ExternalRating(gameKey, trimmedType, value);
}
get props(): ExternalRatingProps {
return {
gameKey: this.gameKey,
type: this.type,
value: this.value,
};
}
equals(other: IValueObject<ExternalRatingProps>): boolean {
return (
this.gameKey.equals(other.props.gameKey) &&
this.type === other.props.type &&
this.value === other.props.value
);
}
toString(): string {
return `${this.gameKey.toString()}:${this.type}=${this.value}`;
}
}

View File

@@ -0,0 +1,217 @@
import { ExternalRatingProvenance } from './ExternalRatingProvenance';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('ExternalRatingProvenance', () => {
describe('create', () => {
it('should create a valid provenance with default verified=false', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
});
expect(provenance.source).toBe('iracing');
expect(provenance.lastSyncedAt).toBe(now);
expect(provenance.verified).toBe(false);
});
it('should create a valid provenance with verified=true', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
expect(provenance.verified).toBe(true);
});
it('should trim source string', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.create({
source: ' iracing ',
lastSyncedAt: now,
});
expect(provenance.source).toBe('iracing');
});
it('should throw error for empty source', () => {
const now = new Date();
expect(() =>
ExternalRatingProvenance.create({
source: '',
lastSyncedAt: now,
})
).toThrow(IdentityDomainValidationError);
expect(() =>
ExternalRatingProvenance.create({
source: ' ',
lastSyncedAt: now,
})
).toThrow(IdentityDomainValidationError);
});
it('should throw error for invalid date', () => {
expect(() =>
ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('invalid'),
})
).toThrow(IdentityDomainValidationError);
});
});
describe('restore', () => {
it('should restore provenance from stored props', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.restore({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
expect(provenance.source).toBe('iracing');
expect(provenance.lastSyncedAt).toBe(now);
expect(provenance.verified).toBe(true);
});
it('should default verified to false when not provided', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.restore({
source: 'iracing',
lastSyncedAt: now,
});
expect(provenance.verified).toBe(false);
});
});
describe('equals', () => {
it('should return true for identical provenance', () => {
const now = new Date();
const p1 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
const p2 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
expect(p1.equals(p2)).toBe(true);
});
it('should return false for different source', () => {
const now = new Date();
const p1 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
});
const p2 = ExternalRatingProvenance.create({
source: 'simracing',
lastSyncedAt: now,
});
expect(p1.equals(p2)).toBe(false);
});
it('should return false for different lastSyncedAt', () => {
const p1 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
});
const p2 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-02'),
});
expect(p1.equals(p2)).toBe(false);
});
it('should return false for different verified', () => {
const now = new Date();
const p1 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
const p2 = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: false,
});
expect(p1.equals(p2)).toBe(false);
});
});
describe('toString', () => {
it('should return string representation', () => {
const now = new Date('2024-01-01T00:00:00Z');
const provenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
expect(provenance.toString()).toBe('iracing:2024-01-01T00:00:00.000Z:verified');
});
});
describe('markVerified', () => {
it('should return new provenance with verified=true', () => {
const now = new Date();
const original = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: false,
});
const verified = original.markVerified();
expect(verified.verified).toBe(true);
expect(verified.source).toBe(original.source);
expect(verified.lastSyncedAt).toBe(original.lastSyncedAt);
expect(original.verified).toBe(false); // Original unchanged
});
});
describe('updateLastSyncedAt', () => {
it('should return new provenance with updated date', () => {
const now = new Date('2024-01-01');
const newDate = new Date('2024-01-02');
const original = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
const updated = original.updateLastSyncedAt(newDate);
expect(updated.lastSyncedAt).toBe(newDate);
expect(updated.source).toBe(original.source);
expect(updated.verified).toBe(original.verified);
expect(original.lastSyncedAt).toBe(now); // Original unchanged
});
});
describe('props', () => {
it('should return correct props object', () => {
const now = new Date();
const provenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: now,
verified: true,
});
const props = provenance.props;
expect(props.source).toBe('iracing');
expect(props.lastSyncedAt).toBe(now);
expect(props.verified).toBe(true);
});
});
});

View File

@@ -0,0 +1,67 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface ExternalRatingProvenanceProps {
source: string;
lastSyncedAt: Date;
verified?: boolean;
}
export class ExternalRatingProvenance implements IValueObject<ExternalRatingProvenanceProps> {
readonly source: string;
readonly lastSyncedAt: Date;
readonly verified: boolean;
private constructor(source: string, lastSyncedAt: Date, verified: boolean) {
this.source = source;
this.lastSyncedAt = lastSyncedAt;
this.verified = verified;
}
static create(props: ExternalRatingProvenanceProps): ExternalRatingProvenance {
if (!props.source || props.source.trim().length === 0) {
throw new IdentityDomainValidationError('Provenance source cannot be empty');
}
if (!props.lastSyncedAt || isNaN(props.lastSyncedAt.getTime())) {
throw new IdentityDomainValidationError('Provenance lastSyncedAt must be a valid date');
}
const trimmedSource = props.source.trim();
const verified = props.verified ?? false;
return new ExternalRatingProvenance(trimmedSource, props.lastSyncedAt, verified);
}
static restore(props: ExternalRatingProvenanceProps): ExternalRatingProvenance {
return new ExternalRatingProvenance(props.source, props.lastSyncedAt, props.verified ?? false);
}
get props(): ExternalRatingProvenanceProps {
return {
source: this.source,
lastSyncedAt: this.lastSyncedAt,
verified: this.verified,
};
}
equals(other: IValueObject<ExternalRatingProvenanceProps>): boolean {
return (
this.source === other.props.source &&
this.lastSyncedAt.getTime() === other.props.lastSyncedAt.getTime() &&
this.verified === other.props.verified
);
}
toString(): string {
return `${this.source}:${this.lastSyncedAt.toISOString()}:${this.verified ? 'verified' : 'unverified'}`;
}
markVerified(): ExternalRatingProvenance {
return new ExternalRatingProvenance(this.source, this.lastSyncedAt, true);
}
updateLastSyncedAt(date: Date): ExternalRatingProvenance {
return new ExternalRatingProvenance(this.source, date, this.verified);
}
}

View File

@@ -0,0 +1,56 @@
import { GameKey } from './GameKey';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('GameKey', () => {
describe('create', () => {
it('should create valid game keys', () => {
expect(GameKey.create('iracing').value).toBe('iracing');
expect(GameKey.create('acc').value).toBe('acc');
expect(GameKey.create('f1').value).toBe('f1');
});
it('should throw for invalid game key', () => {
expect(() => GameKey.create('')).toThrow(IdentityDomainValidationError);
expect(() => GameKey.create(' ')).toThrow(IdentityDomainValidationError);
expect(() => GameKey.create('invalid game')).toThrow(IdentityDomainValidationError);
});
it('should trim whitespace', () => {
const key = GameKey.create(' iracing ');
expect(key.value).toBe('iracing');
});
it('should accept lowercase', () => {
const key = GameKey.create('iracing');
expect(key.value).toBe('iracing');
});
});
describe('equals', () => {
it('should return true for same value', () => {
const key1 = GameKey.create('iracing');
const key2 = GameKey.create('iracing');
expect(key1.equals(key2)).toBe(true);
});
it('should return false for different values', () => {
const key1 = GameKey.create('iracing');
const key2 = GameKey.create('acc');
expect(key1.equals(key2)).toBe(false);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const key = GameKey.create('iracing');
expect(key.props.value).toBe('iracing');
});
});
describe('toString', () => {
it('should return string representation', () => {
const key = GameKey.create('iracing');
expect(key.toString()).toBe('iracing');
});
});
});

View File

@@ -0,0 +1,43 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface GameKeyProps {
value: string;
}
export class GameKey implements IValueObject<GameKeyProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value: string): GameKey {
if (!value || value.trim().length === 0) {
throw new IdentityDomainValidationError('GameKey cannot be empty');
}
const trimmed = value.trim();
// Game keys should be lowercase alphanumeric with optional underscores/hyphens
const gameKeyRegex = /^[a-z0-9][a-z0-9_-]*$/;
if (!gameKeyRegex.test(trimmed)) {
throw new IdentityDomainValidationError(
`Invalid game key: ${value}. Must be lowercase alphanumeric with optional underscores/hyphens`
);
}
return new GameKey(trimmed);
}
get props(): GameKeyProps {
return { value: this.value };
}
equals(other: IValueObject<GameKeyProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,110 @@
import { RatingDelta } from './RatingDelta';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('RatingDelta', () => {
describe('create', () => {
it('should create valid delta values', () => {
expect(RatingDelta.create(0).value).toBe(0);
expect(RatingDelta.create(10).value).toBe(10);
expect(RatingDelta.create(-10).value).toBe(-10);
expect(RatingDelta.create(100).value).toBe(100);
expect(RatingDelta.create(-100).value).toBe(-100);
expect(RatingDelta.create(50.5).value).toBe(50.5);
expect(RatingDelta.create(-50.5).value).toBe(-50.5);
});
it('should throw for values outside range', () => {
expect(() => RatingDelta.create(100.1)).toThrow(IdentityDomainValidationError);
expect(() => RatingDelta.create(-100.1)).toThrow(IdentityDomainValidationError);
expect(() => RatingDelta.create(101)).toThrow(IdentityDomainValidationError);
expect(() => RatingDelta.create(-101)).toThrow(IdentityDomainValidationError);
});
it('should accept zero', () => {
const delta = RatingDelta.create(0);
expect(delta.value).toBe(0);
});
it('should throw for non-numeric values', () => {
expect(() => RatingDelta.create('50' as unknown as number)).toThrow();
expect(() => RatingDelta.create(null as unknown as number)).toThrow();
expect(() => RatingDelta.create(undefined as unknown as number)).toThrow();
});
});
describe('equals', () => {
it('should return true for same value', () => {
const delta1 = RatingDelta.create(10);
const delta2 = RatingDelta.create(10);
expect(delta1.equals(delta2)).toBe(true);
});
it('should return false for different values', () => {
const delta1 = RatingDelta.create(10);
const delta2 = RatingDelta.create(-10);
expect(delta1.equals(delta2)).toBe(false);
});
it('should handle decimal comparisons', () => {
const delta1 = RatingDelta.create(50.5);
const delta2 = RatingDelta.create(50.5);
expect(delta1.equals(delta2)).toBe(true);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const delta = RatingDelta.create(10);
expect(delta.props.value).toBe(10);
});
});
describe('toNumber', () => {
it('should return numeric value', () => {
const delta = RatingDelta.create(50.5);
expect(delta.toNumber()).toBe(50.5);
});
});
describe('toString', () => {
it('should return string representation', () => {
const delta = RatingDelta.create(50.5);
expect(delta.toString()).toBe('50.5');
});
});
describe('isPositive', () => {
it('should return true for positive deltas', () => {
expect(RatingDelta.create(1).isPositive()).toBe(true);
expect(RatingDelta.create(100).isPositive()).toBe(true);
});
it('should return false for zero and negative deltas', () => {
expect(RatingDelta.create(0).isPositive()).toBe(false);
expect(RatingDelta.create(-1).isPositive()).toBe(false);
});
});
describe('isNegative', () => {
it('should return true for negative deltas', () => {
expect(RatingDelta.create(-1).isNegative()).toBe(true);
expect(RatingDelta.create(-100).isNegative()).toBe(true);
});
it('should return false for zero and positive deltas', () => {
expect(RatingDelta.create(0).isNegative()).toBe(false);
expect(RatingDelta.create(1).isNegative()).toBe(false);
});
});
describe('isZero', () => {
it('should return true for zero delta', () => {
expect(RatingDelta.create(0).isZero()).toBe(true);
});
it('should return false for non-zero deltas', () => {
expect(RatingDelta.create(1).isZero()).toBe(false);
expect(RatingDelta.create(-1).isZero()).toBe(false);
});
});
});

View File

@@ -0,0 +1,56 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface RatingDeltaProps {
value: number;
}
export class RatingDelta implements IValueObject<RatingDeltaProps> {
readonly value: number;
private constructor(value: number) {
this.value = value;
}
static create(value: number): RatingDelta {
if (typeof value !== 'number' || isNaN(value)) {
throw new IdentityDomainValidationError('Rating delta must be a valid number');
}
if (value < -100 || value > 100) {
throw new IdentityDomainValidationError(
`Rating delta must be between -100 and 100, got: ${value}`
);
}
return new RatingDelta(value);
}
get props(): RatingDeltaProps {
return { value: this.value };
}
equals(other: IValueObject<RatingDeltaProps>): boolean {
return this.value === other.props.value;
}
toNumber(): number {
return this.value;
}
toString(): string {
return this.value.toString();
}
isPositive(): boolean {
return this.value > 0;
}
isNegative(): boolean {
return this.value < 0;
}
isZero(): boolean {
return this.value === 0;
}
}

View File

@@ -0,0 +1,55 @@
import { RatingDimensionKey } from './RatingDimensionKey';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('RatingDimensionKey', () => {
describe('create', () => {
it('should create valid dimension keys', () => {
expect(RatingDimensionKey.create('driving').value).toBe('driving');
expect(RatingDimensionKey.create('adminTrust').value).toBe('adminTrust');
expect(RatingDimensionKey.create('stewardTrust').value).toBe('stewardTrust');
expect(RatingDimensionKey.create('broadcasterTrust').value).toBe('broadcasterTrust');
});
it('should throw for invalid dimension key', () => {
expect(() => RatingDimensionKey.create('invalid')).toThrow(IdentityDomainValidationError);
expect(() => RatingDimensionKey.create('driving ')).toThrow(IdentityDomainValidationError);
expect(() => RatingDimensionKey.create('')).toThrow(IdentityDomainValidationError);
});
it('should throw for empty string', () => {
expect(() => RatingDimensionKey.create('')).toThrow(IdentityDomainValidationError);
});
it('should throw for whitespace', () => {
expect(() => RatingDimensionKey.create(' ')).toThrow(IdentityDomainValidationError);
});
});
describe('equals', () => {
it('should return true for same value', () => {
const key1 = RatingDimensionKey.create('driving');
const key2 = RatingDimensionKey.create('driving');
expect(key1.equals(key2)).toBe(true);
});
it('should return false for different values', () => {
const key1 = RatingDimensionKey.create('driving');
const key2 = RatingDimensionKey.create('adminTrust');
expect(key1.equals(key2)).toBe(false);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const key = RatingDimensionKey.create('driving');
expect(key.props.value).toBe('driving');
});
});
describe('toString', () => {
it('should return string representation', () => {
const key = RatingDimensionKey.create('driving');
expect(key.toString()).toBe('driving');
});
});
});

View File

@@ -0,0 +1,49 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface RatingDimensionKeyProps {
value: 'driving' | 'adminTrust' | 'stewardTrust' | 'broadcasterTrust';
}
const VALID_DIMENSIONS = ['driving', 'adminTrust', 'stewardTrust', 'broadcasterTrust'] as const;
export class RatingDimensionKey implements IValueObject<RatingDimensionKeyProps> {
readonly value: RatingDimensionKeyProps['value'];
private constructor(value: RatingDimensionKeyProps['value']) {
this.value = value;
}
static create(value: string): RatingDimensionKey {
if (!value || value.trim().length === 0) {
throw new IdentityDomainValidationError('Rating dimension key cannot be empty');
}
// Strict validation: no leading/trailing whitespace allowed
if (value !== value.trim()) {
throw new IdentityDomainValidationError(
`Rating dimension key cannot have leading or trailing whitespace: "${value}"`
);
}
if (!VALID_DIMENSIONS.includes(value as RatingDimensionKeyProps['value'])) {
throw new IdentityDomainValidationError(
`Invalid rating dimension key: ${value}. Valid options: ${VALID_DIMENSIONS.join(', ')}`
);
}
return new RatingDimensionKey(value as RatingDimensionKeyProps['value']);
}
get props(): RatingDimensionKeyProps {
return { value: this.value };
}
equals(other: IValueObject<RatingDimensionKeyProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,76 @@
import { RatingEventId } from './RatingEventId';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('RatingEventId', () => {
describe('create', () => {
it('should create valid UUID v4', () => {
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
const id = RatingEventId.create(validUuid);
expect(id.value).toBe(validUuid);
});
it('should throw for invalid UUID', () => {
expect(() => RatingEventId.create('not-a-uuid')).toThrow(IdentityDomainValidationError);
expect(() => RatingEventId.create('123e4567-e89b-12d3-a456')).toThrow(IdentityDomainValidationError);
expect(() => RatingEventId.create('')).toThrow(IdentityDomainValidationError);
});
it('should throw for empty string', () => {
expect(() => RatingEventId.create('')).toThrow(IdentityDomainValidationError);
});
it('should throw for whitespace', () => {
expect(() => RatingEventId.create(' ')).toThrow(IdentityDomainValidationError);
});
it('should accept UUID with uppercase', () => {
const uuid = '123E4567-E89B-12D3-A456-426614174000';
const id = RatingEventId.create(uuid);
expect(id.value).toBe(uuid);
});
});
describe('generate', () => {
it('should generate a valid UUID', () => {
const id = RatingEventId.generate();
expect(id.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
});
it('should generate unique IDs', () => {
const id1 = RatingEventId.generate();
const id2 = RatingEventId.generate();
expect(id1.equals(id2)).toBe(false);
});
});
describe('equals', () => {
it('should return true for same UUID', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id1 = RatingEventId.create(uuid);
const id2 = RatingEventId.create(uuid);
expect(id1.equals(id2)).toBe(true);
});
it('should return false for different UUIDs', () => {
const id1 = RatingEventId.create('123e4567-e89b-12d3-a456-426614174000');
const id2 = RatingEventId.create('123e4567-e89b-12d3-a456-426614174001');
expect(id1.equals(id2)).toBe(false);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id = RatingEventId.create(uuid);
expect(id.props.value).toBe(uuid);
});
});
describe('toString', () => {
it('should return string representation', () => {
const uuid = '123e4567-e89b-12d3-a456-426614174000';
const id = RatingEventId.create(uuid);
expect(id.toString()).toBe(uuid);
});
});
});

View File

@@ -0,0 +1,48 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
import { v4 as uuidv4 } from 'uuid';
export interface RatingEventIdProps {
value: string;
}
export class RatingEventId implements IValueObject<RatingEventIdProps> {
readonly value: string;
private constructor(value: string) {
this.value = value;
}
static create(value: string): RatingEventId {
if (!value || value.trim().length === 0) {
throw new IdentityDomainValidationError('RatingEventId cannot be empty');
}
const trimmed = value.trim();
// Basic UUID v4 validation
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(trimmed)) {
throw new IdentityDomainValidationError(
`Invalid UUID format: ${value}`
);
}
return new RatingEventId(trimmed);
}
static generate(): RatingEventId {
return new RatingEventId(uuidv4());
}
get props(): RatingEventIdProps {
return { value: this.value };
}
equals(other: IValueObject<RatingEventIdProps>): boolean {
return this.value === other.props.value;
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,134 @@
import { RatingReference } from './RatingReference';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('RatingReference', () => {
describe('create', () => {
it('should create valid reference for race', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.type).toBe('race');
expect(ref.id).toBe('race-123');
});
it('should create valid reference for penalty', () => {
const ref = RatingReference.create('penalty', 'penalty-456');
expect(ref.type).toBe('penalty');
expect(ref.id).toBe('penalty-456');
});
it('should create valid reference for vote', () => {
const ref = RatingReference.create('vote', 'vote-789');
expect(ref.type).toBe('vote');
expect(ref.id).toBe('vote-789');
});
it('should create valid reference for adminAction', () => {
const ref = RatingReference.create('adminAction', 'admin-101');
expect(ref.type).toBe('adminAction');
expect(ref.id).toBe('admin-101');
});
it('should throw for invalid type', () => {
expect(() => RatingReference.create('invalid' as 'race', 'id-123')).toThrow(IdentityDomainValidationError);
});
it('should throw for empty type', () => {
expect(() => RatingReference.create('' as 'race', 'id-123')).toThrow(IdentityDomainValidationError);
});
it('should throw for empty id', () => {
expect(() => RatingReference.create('race', '')).toThrow(IdentityDomainValidationError);
});
it('should throw for whitespace id', () => {
expect(() => RatingReference.create('race', ' ')).toThrow(IdentityDomainValidationError);
});
it('should trim whitespace from id', () => {
const ref = RatingReference.create('race', ' race-123 ');
expect(ref.id).toBe('race-123');
});
});
describe('equals', () => {
it('should return true for same type and id', () => {
const ref1 = RatingReference.create('race', 'race-123');
const ref2 = RatingReference.create('race', 'race-123');
expect(ref1.equals(ref2)).toBe(true);
});
it('should return false for different types', () => {
const ref1 = RatingReference.create('race', 'race-123');
const ref2 = RatingReference.create('penalty', 'race-123');
expect(ref1.equals(ref2)).toBe(false);
});
it('should return false for different ids', () => {
const ref1 = RatingReference.create('race', 'race-123');
const ref2 = RatingReference.create('race', 'race-456');
expect(ref1.equals(ref2)).toBe(false);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.props.type).toBe('race');
expect(ref.props.id).toBe('race-123');
});
});
describe('toString', () => {
it('should return string representation', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.toString()).toBe('race:race-123');
});
});
describe('isRace', () => {
it('should return true for race type', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.isRace()).toBe(true);
});
it('should return false for other types', () => {
const ref = RatingReference.create('penalty', 'penalty-123');
expect(ref.isRace()).toBe(false);
});
});
describe('isPenalty', () => {
it('should return true for penalty type', () => {
const ref = RatingReference.create('penalty', 'penalty-123');
expect(ref.isPenalty()).toBe(true);
});
it('should return false for other types', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.isPenalty()).toBe(false);
});
});
describe('isVote', () => {
it('should return true for vote type', () => {
const ref = RatingReference.create('vote', 'vote-123');
expect(ref.isVote()).toBe(true);
});
it('should return false for other types', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.isVote()).toBe(false);
});
});
describe('isAdminAction', () => {
it('should return true for adminAction type', () => {
const ref = RatingReference.create('adminAction', 'admin-123');
expect(ref.isAdminAction()).toBe(true);
});
it('should return false for other types', () => {
const ref = RatingReference.create('race', 'race-123');
expect(ref.isAdminAction()).toBe(false);
});
});
});

View File

@@ -0,0 +1,64 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export type RatingReferenceType = 'race' | 'penalty' | 'vote' | 'adminAction';
export interface RatingReferenceProps {
type: RatingReferenceType;
id: string;
}
const VALID_TYPES: RatingReferenceType[] = ['race', 'penalty', 'vote', 'adminAction'];
export class RatingReference implements IValueObject<RatingReferenceProps> {
readonly type: RatingReferenceType;
readonly id: string;
private constructor(type: RatingReferenceType, id: string) {
this.type = type;
this.id = id;
}
static create(type: RatingReferenceType, id: string): RatingReference {
if (!type || !VALID_TYPES.includes(type)) {
throw new IdentityDomainValidationError(
`Invalid rating reference type: ${type}. Valid types: ${VALID_TYPES.join(', ')}`
);
}
if (!id || id.trim().length === 0) {
throw new IdentityDomainValidationError('Rating reference ID cannot be empty');
}
const trimmedId = id.trim();
return new RatingReference(type, trimmedId);
}
get props(): RatingReferenceProps {
return { type: this.type, id: this.id };
}
equals(other: IValueObject<RatingReferenceProps>): boolean {
return this.type === other.props.type && this.id === other.props.id;
}
toString(): string {
return `${this.type}:${this.id}`;
}
isRace(): boolean {
return this.type === 'race';
}
isPenalty(): boolean {
return this.type === 'penalty';
}
isVote(): boolean {
return this.type === 'vote';
}
isAdminAction(): boolean {
return this.type === 'adminAction';
}
}

View File

@@ -0,0 +1,75 @@
import { RatingValue } from './RatingValue';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('RatingValue', () => {
describe('create', () => {
it('should create valid rating values', () => {
expect(RatingValue.create(0).value).toBe(0);
expect(RatingValue.create(50).value).toBe(50);
expect(RatingValue.create(100).value).toBe(100);
expect(RatingValue.create(75.5).value).toBe(75.5);
});
it('should throw for values below 0', () => {
expect(() => RatingValue.create(-1)).toThrow(IdentityDomainValidationError);
expect(() => RatingValue.create(-0.1)).toThrow(IdentityDomainValidationError);
});
it('should throw for values above 100', () => {
expect(() => RatingValue.create(100.1)).toThrow(IdentityDomainValidationError);
expect(() => RatingValue.create(101)).toThrow(IdentityDomainValidationError);
});
it('should accept decimal values', () => {
const value = RatingValue.create(75.5);
expect(value.value).toBe(75.5);
});
it('should throw for non-numeric values', () => {
expect(() => RatingValue.create('50' as unknown as number)).toThrow();
expect(() => RatingValue.create(null as unknown as number)).toThrow();
expect(() => RatingValue.create(undefined as unknown as number)).toThrow();
});
});
describe('equals', () => {
it('should return true for same value', () => {
const val1 = RatingValue.create(50);
const val2 = RatingValue.create(50);
expect(val1.equals(val2)).toBe(true);
});
it('should return false for different values', () => {
const val1 = RatingValue.create(50);
const val2 = RatingValue.create(60);
expect(val1.equals(val2)).toBe(false);
});
it('should handle decimal comparisons', () => {
const val1 = RatingValue.create(75.5);
const val2 = RatingValue.create(75.5);
expect(val1.equals(val2)).toBe(true);
});
});
describe('props', () => {
it('should expose props correctly', () => {
const value = RatingValue.create(50);
expect(value.props.value).toBe(50);
});
});
describe('toNumber', () => {
it('should return numeric value', () => {
const value = RatingValue.create(75.5);
expect(value.toNumber()).toBe(75.5);
});
});
describe('toString', () => {
it('should return string representation', () => {
const value = RatingValue.create(75.5);
expect(value.toString()).toBe('75.5');
});
});
});

View File

@@ -0,0 +1,44 @@
import type { IValueObject } from '@core/shared/domain';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface RatingValueProps {
value: number;
}
export class RatingValue implements IValueObject<RatingValueProps> {
readonly value: number;
private constructor(value: number) {
this.value = value;
}
static create(value: number): RatingValue {
if (typeof value !== 'number' || isNaN(value)) {
throw new IdentityDomainValidationError('Rating value must be a valid number');
}
if (value < 0 || value > 100) {
throw new IdentityDomainValidationError(
`Rating value must be between 0 and 100, got: ${value}`
);
}
return new RatingValue(value);
}
get props(): RatingValueProps {
return { value: this.value };
}
equals(other: IValueObject<RatingValueProps>): boolean {
return this.value === other.props.value;
}
toNumber(): number {
return this.value;
}
toString(): string {
return this.value.toString();
}
}

View File

@@ -27,6 +27,7 @@ export interface UserRatingProps {
trust: RatingDimension;
fairness: RatingDimension;
overallReputation: number;
calculatorVersion?: string;
createdAt: Date;
updatedAt: Date;
}
@@ -82,6 +83,10 @@ export class UserRating implements IValueObject<UserRatingProps> {
return this.props.updatedAt;
}
get calculatorVersion(): string | undefined {
return this.props.calculatorVersion;
}
static create(userId: string): UserRating {
if (!userId || userId.trim().length === 0) {
throw new Error('UserRating userId is required');
@@ -96,6 +101,7 @@ export class UserRating implements IValueObject<UserRatingProps> {
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
overallReputation: 50,
calculatorVersion: '1.0',
createdAt: now,
updatedAt: now,
});