rating
This commit is contained in:
169
core/identity/domain/value-objects/AdminTrustReasonCode.test.ts
Normal file
169
core/identity/domain/value-objects/AdminTrustReasonCode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
112
core/identity/domain/value-objects/AdminTrustReasonCode.ts
Normal file
112
core/identity/domain/value-objects/AdminTrustReasonCode.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
207
core/identity/domain/value-objects/DrivingReasonCode.test.ts
Normal file
207
core/identity/domain/value-objects/DrivingReasonCode.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
133
core/identity/domain/value-objects/DrivingReasonCode.ts
Normal file
133
core/identity/domain/value-objects/DrivingReasonCode.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
99
core/identity/domain/value-objects/ExternalRating.test.ts
Normal file
99
core/identity/domain/value-objects/ExternalRating.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
54
core/identity/domain/value-objects/ExternalRating.ts
Normal file
54
core/identity/domain/value-objects/ExternalRating.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
56
core/identity/domain/value-objects/GameKey.test.ts
Normal file
56
core/identity/domain/value-objects/GameKey.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
43
core/identity/domain/value-objects/GameKey.ts
Normal file
43
core/identity/domain/value-objects/GameKey.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
110
core/identity/domain/value-objects/RatingDelta.test.ts
Normal file
110
core/identity/domain/value-objects/RatingDelta.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
core/identity/domain/value-objects/RatingDelta.ts
Normal file
56
core/identity/domain/value-objects/RatingDelta.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
core/identity/domain/value-objects/RatingDimensionKey.ts
Normal file
49
core/identity/domain/value-objects/RatingDimensionKey.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
core/identity/domain/value-objects/RatingEventId.test.ts
Normal file
76
core/identity/domain/value-objects/RatingEventId.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
48
core/identity/domain/value-objects/RatingEventId.ts
Normal file
48
core/identity/domain/value-objects/RatingEventId.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
134
core/identity/domain/value-objects/RatingReference.test.ts
Normal file
134
core/identity/domain/value-objects/RatingReference.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
64
core/identity/domain/value-objects/RatingReference.ts
Normal file
64
core/identity/domain/value-objects/RatingReference.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
75
core/identity/domain/value-objects/RatingValue.test.ts
Normal file
75
core/identity/domain/value-objects/RatingValue.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
44
core/identity/domain/value-objects/RatingValue.ts
Normal file
44
core/identity/domain/value-objects/RatingValue.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user