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,467 @@
import { AdminVoteSession } from './AdminVoteSession';
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
describe('AdminVoteSession', () => {
const now = new Date('2025-01-01T00:00:00Z');
const tomorrow = new Date('2025-01-02T00:00:00Z');
const dayAfter = new Date('2025-01-03T00:00:00Z');
describe('create', () => {
it('should create a valid vote session', () => {
const session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2', 'user-3'],
});
expect(session.id).toBe('vote-123');
expect(session.leagueId).toBe('league-456');
expect(session.adminId).toBe('admin-789');
expect(session.startDate).toEqual(now);
expect(session.endDate).toEqual(tomorrow);
expect(session.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']);
expect(session.votes).toEqual([]);
expect(session.closed).toBe(false);
expect(session.outcome).toBeUndefined();
});
it('should throw error for missing voteSessionId', () => {
expect(() => AdminVoteSession.create({
voteSessionId: '',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
})).toThrow(IdentityDomainValidationError);
});
it('should throw error for missing leagueId', () => {
expect(() => AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: '',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
})).toThrow(IdentityDomainValidationError);
});
it('should throw error for missing adminId', () => {
expect(() => AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: '',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
})).toThrow(IdentityDomainValidationError);
});
it('should throw error for invalid date range', () => {
expect(() => AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: tomorrow,
endDate: now, // End before start
eligibleVoters: ['user-1'],
})).toThrow(IdentityDomainInvariantError);
});
it('should throw error for empty eligible voters', () => {
expect(() => AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: [],
})).toThrow(IdentityDomainValidationError);
});
it('should throw error for duplicate eligible voters', () => {
expect(() => AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2', 'user-1'],
})).toThrow(IdentityDomainInvariantError);
});
it('should accept optional votes and outcome', () => {
const session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2'],
votes: [
{ voterId: 'user-1', positive: true, votedAt: now },
],
closed: true,
outcome: {
percentPositive: 100,
count: { positive: 1, negative: 0, total: 1 },
eligibleVoterCount: 2,
participationRate: 50,
outcome: 'positive',
},
});
expect(session.votes.length).toBe(1);
expect(session.closed).toBe(true);
expect(session.outcome).toBeDefined();
});
});
describe('rehydrate', () => {
it('should rehydrate from persisted data', () => {
const session = AdminVoteSession.rehydrate({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2'],
votes: [{ voterId: 'user-1', positive: true, votedAt: now }],
closed: true,
outcome: {
percentPositive: 100,
count: { positive: 1, negative: 0, total: 1 },
eligibleVoterCount: 2,
participationRate: 50,
outcome: 'positive',
},
createdAt: now,
updatedAt: tomorrow,
});
expect(session.id).toBe('vote-123');
expect(session.closed).toBe(true);
expect(session.votes.length).toBe(1);
});
});
describe('castVote', () => {
let session: AdminVoteSession;
beforeEach(() => {
session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2', 'user-3'],
});
});
it('should allow eligible voter to cast positive vote', () => {
session.castVote('user-1', true, now);
expect(session.votes.length).toBe(1);
const vote = session.votes[0];
expect(vote).toBeDefined();
expect(vote!.voterId).toBe('user-1');
expect(vote!.positive).toBe(true);
expect(vote!.votedAt).toEqual(now);
});
it('should allow eligible voter to cast negative vote', () => {
session.castVote('user-1', false, now);
expect(session.votes.length).toBe(1);
const vote = session.votes[0];
expect(vote).toBeDefined();
expect(vote!.positive).toBe(false);
});
it('should throw error if voter is not eligible', () => {
expect(() => session.castVote('user-999', true, now))
.toThrow(IdentityDomainInvariantError);
});
it('should throw error if voter already voted', () => {
session.castVote('user-1', true, now);
expect(() => session.castVote('user-1', false, now))
.toThrow(IdentityDomainInvariantError);
});
it('should throw error if session is closed', () => {
session.close();
expect(() => session.castVote('user-1', true, now))
.toThrow(IdentityDomainInvariantError);
});
it('should throw error if vote is outside voting window', () => {
const beforeStart = new Date('2024-12-31T23:59:59Z');
const afterEnd = new Date('2025-01-02T00:00:01Z');
expect(() => session.castVote('user-1', true, beforeStart))
.toThrow(IdentityDomainInvariantError);
expect(() => session.castVote('user-1', true, afterEnd))
.toThrow(IdentityDomainInvariantError);
});
it('should update updatedAt timestamp', () => {
const originalUpdatedAt = session.updatedAt;
const voteTime = new Date(now.getTime() + 1000);
session.castVote('user-1', true, voteTime);
expect(session.updatedAt).not.toEqual(originalUpdatedAt);
});
});
describe('close', () => {
let session: AdminVoteSession;
beforeEach(() => {
session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'],
});
});
it('should close session and calculate positive outcome', () => {
session.castVote('user-1', true, now);
session.castVote('user-2', true, now);
session.castVote('user-3', false, now);
const outcome = session.close();
expect(session.closed).toBe(true);
expect(outcome).toEqual({
percentPositive: 66.67,
count: { positive: 2, negative: 1, total: 3 },
eligibleVoterCount: 4,
participationRate: 75,
outcome: 'positive',
});
});
it('should calculate negative outcome', () => {
session.castVote('user-1', false, now);
session.castVote('user-2', false, now);
session.castVote('user-3', true, now);
const outcome = session.close();
expect(outcome.outcome).toBe('negative');
expect(outcome.percentPositive).toBe(33.33);
});
it('should calculate tie outcome', () => {
session.castVote('user-1', true, now);
session.castVote('user-2', false, now);
const outcome = session.close();
expect(outcome.outcome).toBe('tie');
expect(outcome.percentPositive).toBe(50);
});
it('should handle no votes', () => {
const outcome = session.close();
expect(outcome).toEqual({
percentPositive: 0,
count: { positive: 0, negative: 0, total: 0 },
eligibleVoterCount: 4,
participationRate: 0,
outcome: 'tie',
});
});
it('should throw error if already closed', () => {
session.close();
expect(() => session.close()).toThrow(IdentityDomainInvariantError);
});
it('should throw error if closed outside voting window', () => {
const pastSession = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: new Date('2024-01-01'),
endDate: new Date('2024-01-02'),
eligibleVoters: ['user-1'],
});
expect(() => pastSession.close()).toThrow(IdentityDomainInvariantError);
});
it('should round percentPositive to 2 decimal places', () => {
session.castVote('user-1', true, now);
session.castVote('user-2', true, now);
session.castVote('user-3', true, now);
session.castVote('user-4', false, now);
const outcome = session.close();
expect(outcome.percentPositive).toBe(75.00);
});
});
describe('helper methods', () => {
let session: AdminVoteSession;
beforeEach(() => {
session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2'],
});
});
describe('hasVoted', () => {
it('should return true if voter has voted', () => {
session.castVote('user-1', true, now);
expect(session.hasVoted('user-1')).toBe(true);
});
it('should return false if voter has not voted', () => {
expect(session.hasVoted('user-1')).toBe(false);
});
});
describe('getVote', () => {
it('should return vote if exists', () => {
session.castVote('user-1', true, now);
const vote = session.getVote('user-1');
expect(vote).toBeDefined();
expect(vote?.voterId).toBe('user-1');
expect(vote?.positive).toBe(true);
});
it('should return undefined if vote does not exist', () => {
expect(session.getVote('user-1')).toBeUndefined();
});
});
describe('getVoteCount', () => {
it('should return correct count', () => {
expect(session.getVoteCount()).toBe(0);
session.castVote('user-1', true, now);
expect(session.getVoteCount()).toBe(1);
session.castVote('user-2', false, now);
expect(session.getVoteCount()).toBe(2);
});
});
describe('isVotingWindowOpen', () => {
it('should return true during voting window', () => {
expect(session.isVotingWindowOpen(now)).toBe(true);
const midPoint = new Date((now.getTime() + tomorrow.getTime()) / 2);
expect(session.isVotingWindowOpen(midPoint)).toBe(true);
});
it('should return false before voting window', () => {
const before = new Date('2024-12-31T23:59:59Z');
expect(session.isVotingWindowOpen(before)).toBe(false);
});
it('should return false after voting window', () => {
const after = new Date('2025-01-02T00:00:01Z');
expect(session.isVotingWindowOpen(after)).toBe(false);
});
it('should return false if session is closed', () => {
session.close();
expect(session.isVotingWindowOpen(now)).toBe(false);
});
});
});
describe('toJSON', () => {
it('should serialize to JSON correctly', () => {
const session = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1', 'user-2'],
});
session.castVote('user-1', true, now);
session.close();
const json = session.toJSON();
expect(json.voteSessionId).toBe('vote-123');
expect(json.leagueId).toBe('league-456');
expect(json.adminId).toBe('admin-789');
expect(json.closed).toBe(true);
expect(json.votes).toHaveLength(1);
expect(json.outcome).toBeDefined();
});
});
describe('equals', () => {
it('should return true for same ID', () => {
const session1 = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
});
const session2 = AdminVoteSession.rehydrate({
voteSessionId: 'vote-123',
leagueId: 'league-789', // Different
adminId: 'admin-999', // Different
startDate: tomorrow, // Different
endDate: dayAfter, // Different
eligibleVoters: ['user-999'], // Different
});
expect(session1.equals(session2)).toBe(true);
});
it('should return false for different IDs', () => {
const session1 = AdminVoteSession.create({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
});
const session2 = AdminVoteSession.create({
voteSessionId: 'vote-456',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now,
endDate: tomorrow,
eligibleVoters: ['user-1'],
});
expect(session1.equals(session2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,295 @@
import type { IEntity } from '@core/shared/domain';
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
export interface AdminVote {
voterId: string;
positive: boolean;
votedAt: Date;
}
export interface AdminVoteOutcome {
percentPositive: number;
count: {
positive: number;
negative: number;
total: number;
};
eligibleVoterCount: number;
participationRate: number;
outcome: 'positive' | 'negative' | 'tie';
}
export interface AdminVoteSessionProps {
voteSessionId: string;
leagueId: string;
adminId: string;
startDate: Date;
endDate: Date;
eligibleVoters: string[]; // User IDs
votes?: AdminVote[];
closed?: boolean;
outcome?: AdminVoteOutcome;
createdAt?: Date;
updatedAt?: Date;
}
/**
* AdminVoteSession Entity
*
* Aggregate root for admin vote sessions scoped to a league.
* Controls who can vote, deduplication, time windows, and closure.
* Emits outcome events that convert to rating ledger events.
*
* Based on ratings-architecture-concept.md sections 5.2.1 and 7.1.1
*/
export class AdminVoteSession implements IEntity<string> {
readonly id: string;
readonly leagueId: string;
readonly adminId: string;
readonly startDate: Date;
readonly endDate: Date;
readonly eligibleVoters: string[];
private _votes: AdminVote[];
private _closed: boolean;
private _outcome: AdminVoteOutcome | undefined;
readonly createdAt: Date;
private _updatedAt: Date;
private constructor(props: AdminVoteSessionProps) {
this.id = props.voteSessionId;
this.leagueId = props.leagueId;
this.adminId = props.adminId;
this.startDate = props.startDate;
this.endDate = props.endDate;
this.eligibleVoters = props.eligibleVoters;
this._votes = props.votes || [];
this._closed = props.closed || false;
this._outcome = props.outcome;
this.createdAt = props.createdAt || new Date();
this._updatedAt = props.updatedAt || new Date();
}
static create(props: AdminVoteSessionProps): AdminVoteSession {
// Validate required fields
if (!props.voteSessionId || props.voteSessionId.trim().length === 0) {
throw new IdentityDomainValidationError('voteSessionId is required');
}
if (!props.leagueId || props.leagueId.trim().length === 0) {
throw new IdentityDomainValidationError('leagueId is required');
}
if (!props.adminId || props.adminId.trim().length === 0) {
throw new IdentityDomainValidationError('adminId is required');
}
if (!props.startDate || !props.endDate) {
throw new IdentityDomainValidationError('startDate and endDate are required');
}
if (props.startDate >= props.endDate) {
throw new IdentityDomainInvariantError('startDate must be before endDate');
}
if (!props.eligibleVoters || props.eligibleVoters.length === 0) {
throw new IdentityDomainValidationError('At least one eligible voter is required');
}
// Validate no duplicate eligible voters
const uniqueVoters = new Set(props.eligibleVoters);
if (uniqueVoters.size !== props.eligibleVoters.length) {
throw new IdentityDomainInvariantError('Duplicate eligible voters are not allowed');
}
// Validate votes if provided
if (props.votes) {
const voterIds = new Set<string>();
for (const vote of props.votes) {
if (!vote.voterId || vote.voterId.trim().length === 0) {
throw new IdentityDomainValidationError('Vote voterId is required');
}
if (!props.eligibleVoters.includes(vote.voterId)) {
throw new IdentityDomainInvariantError(`Voter ${vote.voterId} is not eligible`);
}
if (voterIds.has(vote.voterId)) {
throw new IdentityDomainInvariantError(`Duplicate vote from voter ${vote.voterId}`);
}
if (!vote.votedAt) {
throw new IdentityDomainValidationError('Vote timestamp is required');
}
voterIds.add(vote.voterId);
}
}
// Validate outcome if provided
if (props.outcome) {
if (props.outcome.percentPositive < 0 || props.outcome.percentPositive > 100) {
throw new IdentityDomainValidationError('percentPositive must be between 0 and 100');
}
if (props.outcome.eligibleVoterCount !== props.eligibleVoters.length) {
throw new IdentityDomainInvariantError('eligibleVoterCount must match eligibleVoters length');
}
}
return new AdminVoteSession(props);
}
static rehydrate(props: AdminVoteSessionProps): AdminVoteSession {
// Rehydration assumes data is already validated (from persistence)
return new AdminVoteSession(props);
}
// Getters
get votes(): AdminVote[] {
return [...this._votes];
}
get closed(): boolean {
return this._closed;
}
get outcome(): AdminVoteOutcome | undefined {
return this._outcome;
}
get updatedAt(): Date {
return this._updatedAt;
}
/**
* Cast a vote in this session
* @param voterId - The user ID of the voter
* @param positive - Whether the vote is positive (true) or negative (false)
* @param votedAt - When the vote was cast (optional, defaults to now)
* @throws Error if session is closed, voter is not eligible, or already voted
*/
castVote(voterId: string, positive: boolean, votedAt: Date = new Date()): void {
if (this._closed) {
throw new IdentityDomainInvariantError('Cannot cast vote: session is closed');
}
if (!this.eligibleVoters.includes(voterId)) {
throw new IdentityDomainInvariantError(`Voter ${voterId} is not eligible for this session`);
}
if (this._votes.some(v => v.voterId === voterId)) {
throw new IdentityDomainInvariantError(`Voter ${voterId} has already voted`);
}
if (votedAt < this.startDate || votedAt > this.endDate) {
throw new IdentityDomainInvariantError('Vote timestamp is outside the voting window');
}
this._votes.push({
voterId,
positive,
votedAt,
});
this._updatedAt = new Date();
}
/**
* Close the vote session and calculate outcome
* @throws Error if session is already closed
* @returns The calculated outcome
*/
close(): AdminVoteOutcome {
if (this._closed) {
throw new IdentityDomainInvariantError('Session is already closed');
}
const now = new Date();
if (now < this.startDate || now > this.endDate) {
throw new IdentityDomainInvariantError('Cannot close session outside the voting window');
}
const positiveVotes = this._votes.filter(v => v.positive).length;
const negativeVotes = this._votes.filter(v => !v.positive).length;
const totalVotes = this._votes.length;
const eligibleVoterCount = this.eligibleVoters.length;
const percentPositive = totalVotes > 0 ? (positiveVotes / totalVotes) * 100 : 0;
const participationRate = (totalVotes / eligibleVoterCount) * 100;
let outcome: 'positive' | 'negative' | 'tie';
if (totalVotes === 0) {
outcome = 'tie';
} else if (positiveVotes > negativeVotes) {
outcome = 'positive';
} else if (negativeVotes > positiveVotes) {
outcome = 'negative';
} else {
outcome = 'tie';
}
this._outcome = {
percentPositive: Math.round(percentPositive * 100) / 100, // Round to 2 decimal places
count: {
positive: positiveVotes,
negative: negativeVotes,
total: totalVotes,
},
eligibleVoterCount,
participationRate: Math.round(participationRate * 100) / 100,
outcome,
};
this._closed = true;
this._updatedAt = now;
return this._outcome;
}
/**
* Check if a voter has already voted
*/
hasVoted(voterId: string): boolean {
return this._votes.some(v => v.voterId === voterId);
}
/**
* Get vote by voter ID
*/
getVote(voterId: string): AdminVote | undefined {
return this._votes.find(v => v.voterId === voterId);
}
/**
* Get count of votes cast
*/
getVoteCount(): number {
return this._votes.length;
}
/**
* Check if session is within voting window
*/
isVotingWindowOpen(now: Date = new Date()): boolean {
return now >= this.startDate && now <= this.endDate && !this._closed;
}
equals(other: IEntity<string>): boolean {
return this.id === other.id;
}
toJSON(): Record<string, unknown> {
return {
voteSessionId: this.id,
leagueId: this.leagueId,
adminId: this.adminId,
startDate: this.startDate.toISOString(),
endDate: this.endDate.toISOString(),
eligibleVoters: this.eligibleVoters,
votes: this._votes.map(v => ({
voterId: v.voterId,
positive: v.positive,
votedAt: v.votedAt.toISOString(),
})),
closed: this._closed,
outcome: this._outcome,
createdAt: this.createdAt.toISOString(),
updatedAt: this._updatedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,410 @@
import { ExternalGameRatingProfile } from './ExternalGameRatingProfile';
import { UserId } from '../value-objects/UserId';
import { GameKey } from '../value-objects/GameKey';
import { ExternalRating } from '../value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../value-objects/ExternalRatingProvenance';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
describe('ExternalGameRatingProfile', () => {
let userId: UserId;
let gameKey: GameKey;
let ratings: Map<string, ExternalRating>;
let provenance: ExternalRatingProvenance;
beforeEach(() => {
userId = UserId.fromString('user-123');
gameKey = GameKey.create('iracing');
ratings = new Map([
['safety', ExternalRating.create(gameKey, 'safety', 85.5)],
['skill', ExternalRating.create(gameKey, 'skill', 92.0)],
]);
provenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
verified: true,
});
});
describe('create', () => {
it('should create a valid profile', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
expect(profile.userId).toBe(userId);
expect(profile.gameKey).toBe(gameKey);
expect(profile.ratings.size).toBe(2);
expect(profile.provenance).toBe(provenance);
});
it('should allow empty ratings map', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings: new Map(),
provenance,
});
expect(profile.hasRatings()).toBe(false);
expect(profile.ratings.size).toBe(0);
});
it('should throw error if rating gameKey does not match profile gameKey', () => {
const wrongGameKey = GameKey.create('assetto');
const wrongRatings = new Map([
['safety', ExternalRating.create(wrongGameKey, 'safety', 85.5)],
]);
expect(() =>
ExternalGameRatingProfile.create({
userId,
gameKey,
ratings: wrongRatings,
provenance,
})
).toThrow(IdentityDomainValidationError);
});
});
describe('restore', () => {
it('should restore profile from stored data', () => {
const profile = ExternalGameRatingProfile.restore({
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
{ type: 'skill', gameKey: 'iracing', value: 92.0 },
],
provenance: {
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
verified: true,
},
});
expect(profile.userId.toString()).toBe('user-123');
expect(profile.gameKey.toString()).toBe('iracing');
expect(profile.ratings.size).toBe(2);
expect(profile.provenance.source).toBe('iracing');
expect(profile.provenance.verified).toBe(true);
});
it('should handle missing verified flag in provenance', () => {
const profile = ExternalGameRatingProfile.restore({
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
],
provenance: {
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
},
});
expect(profile.provenance.verified).toBe(false);
});
});
describe('getRatingByType', () => {
it('should return rating for existing type', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const rating = profile.getRatingByType('safety');
expect(rating).toBeDefined();
expect(rating?.type).toBe('safety');
expect(rating?.value).toBe(85.5);
});
it('should return undefined for non-existing type', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const rating = profile.getRatingByType('nonexistent');
expect(rating).toBeUndefined();
});
});
describe('getRatingTypes', () => {
it('should return all rating types', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const types = profile.getRatingTypes();
expect(types).toHaveLength(2);
expect(types).toContain('safety');
expect(types).toContain('skill');
});
});
describe('hasRatings', () => {
it('should return true when has ratings', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
expect(profile.hasRatings()).toBe(true);
});
it('should return false when no ratings', () => {
const emptyRatings = new Map<string, ExternalRating>();
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings: emptyRatings,
provenance,
});
expect(profile.hasRatings()).toBe(false);
});
});
describe('updateRatings', () => {
it('should update ratings and provenance', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const newRatings = new Map([
['safety', ExternalRating.create(gameKey, 'safety', 90.0)],
['newType', ExternalRating.create(gameKey, 'newType', 88.0)],
]);
const newProvenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-02'),
verified: false,
});
profile.updateRatings(newRatings, newProvenance);
expect(profile.ratings.size).toBe(2);
expect(profile.getRatingByType('safety')?.value).toBe(90.0);
expect(profile.getRatingByType('newType')?.value).toBe(88.0);
expect(profile.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
});
it('should throw error when updating with mismatched gameKey', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const wrongGameKey = GameKey.create('assetto');
const wrongRatings = new Map([
['safety', ExternalRating.create(wrongGameKey, 'safety', 90.0)],
]);
expect(() =>
profile.updateRatings(
wrongRatings,
ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date(),
})
)
).toThrow(IdentityDomainValidationError);
});
});
describe('markVerified', () => {
it('should mark provenance as verified', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance: ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date(),
verified: false,
}),
});
profile.markVerified();
expect(profile.provenance.verified).toBe(true);
});
});
describe('updateLastSyncedAt', () => {
it('should update last synced timestamp', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const newDate = new Date('2024-01-03');
profile.updateLastSyncedAt(newDate);
expect(profile.provenance.lastSyncedAt).toEqual(newDate);
});
});
describe('toSummary', () => {
it('should return summary object', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const summary = profile.toSummary();
expect(summary.userId).toBe('user-123');
expect(summary.gameKey).toBe('iracing');
expect(summary.ratingCount).toBe(2);
expect(summary.ratingTypes).toEqual(['safety', 'skill']);
expect(summary.source).toBe('iracing');
expect(summary.verified).toBe(true);
});
});
describe('toJSON', () => {
it('should serialize to JSON format', () => {
const profile = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const json = profile.toJSON();
expect(json.userId).toBe('user-123');
expect(json.gameKey).toBe('iracing');
expect(json.ratings).toHaveLength(2);
expect(json.provenance.source).toBe('iracing');
expect(json.provenance.verified).toBe(true);
});
});
describe('equals', () => {
it('should return true for identical profiles', () => {
const profile1 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const profile2 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
expect(profile1.equals(profile2)).toBe(true);
});
it('should return false for different userId', () => {
const profile1 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const profile2 = ExternalGameRatingProfile.create({
userId: UserId.fromString('user-456'),
gameKey,
ratings,
provenance,
});
expect(profile1.equals(profile2)).toBe(false);
});
it('should return false for different gameKey', () => {
const profile1 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const differentGameKey = GameKey.create('assetto');
const differentRatings = new Map([
['safety', ExternalRating.create(differentGameKey, 'safety', 85.5)],
]);
const profile2 = ExternalGameRatingProfile.create({
userId,
gameKey: differentGameKey,
ratings: differentRatings,
provenance,
});
expect(profile1.equals(profile2)).toBe(false);
});
it('should return false for different ratings', () => {
const profile1 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const differentRatings = new Map([
['safety', ExternalRating.create(gameKey, 'safety', 99.0)],
]);
const profile2 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings: differentRatings,
provenance,
});
expect(profile1.equals(profile2)).toBe(false);
});
it('should return false for different provenance', () => {
const profile1 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance,
});
const differentProvenance = ExternalRatingProvenance.create({
source: 'different',
lastSyncedAt: new Date(),
});
const profile2 = ExternalGameRatingProfile.create({
userId,
gameKey,
ratings,
provenance: differentProvenance,
});
expect(profile1.equals(profile2)).toBe(false);
});
});
});

View File

@@ -0,0 +1,233 @@
import { Entity } from '@core/shared/domain';
import { UserId } from '../value-objects/UserId';
import { GameKey } from '../value-objects/GameKey';
import { ExternalRating } from '../value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../value-objects/ExternalRatingProvenance';
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
export interface ExternalGameRatingProfileProps {
userId: UserId;
gameKey: GameKey;
ratings: Map<string, ExternalRating>; // type -> rating
provenance: ExternalRatingProvenance;
}
export class ExternalGameRatingProfile extends Entity<UserId> {
private readonly _userId: UserId;
private readonly _gameKey: GameKey;
private _ratings: Map<string, ExternalRating>;
private _provenance: ExternalRatingProvenance;
private constructor(props: ExternalGameRatingProfileProps) {
super(props.userId);
this._userId = props.userId;
this._gameKey = props.gameKey;
this._ratings = props.ratings;
this._provenance = props.provenance;
}
static create(props: ExternalGameRatingProfileProps): ExternalGameRatingProfile {
if (!props.userId || !props.gameKey || !props.ratings || !props.provenance) {
throw new IdentityDomainValidationError('All properties are required');
}
// Note: Empty ratings map is allowed for initial creation
// The entity can be created with no ratings and updated later
// Validate that all ratings match the gameKey
for (const [type, rating] of props.ratings.entries()) {
if (!rating.gameKey.equals(props.gameKey)) {
throw new IdentityDomainValidationError(
`Rating type ${type} has mismatched gameKey`
);
}
}
return new ExternalGameRatingProfile(props);
}
static restore(props: {
userId: string;
gameKey: string;
ratings: Array<{ type: string; gameKey: string; value: number }>;
provenance: {
source: string;
lastSyncedAt: Date;
verified?: boolean;
};
}): ExternalGameRatingProfile {
const userId = UserId.fromString(props.userId);
const gameKey = GameKey.create(props.gameKey);
const ratingsMap = new Map<string, ExternalRating>();
for (const ratingData of props.ratings) {
const ratingGameKey = GameKey.create(ratingData.gameKey);
const rating = ExternalRating.create(ratingGameKey, ratingData.type, ratingData.value);
ratingsMap.set(ratingData.type, rating);
}
const provenance = ExternalRatingProvenance.restore(props.provenance);
return new ExternalGameRatingProfile({
userId,
gameKey,
ratings: ratingsMap,
provenance,
});
}
get userId(): UserId {
return this._userId;
}
get gameKey(): GameKey {
return this._gameKey;
}
get ratings(): ReadonlyMap<string, ExternalRating> {
return new Map(this._ratings);
}
get provenance(): ExternalRatingProvenance {
return this._provenance;
}
/**
* Update ratings and provenance with latest data
*/
updateRatings(
newRatings: Map<string, ExternalRating>,
newProvenance: ExternalRatingProvenance
): void {
// Validate all new ratings match the gameKey
for (const [type, rating] of newRatings.entries()) {
if (!rating.gameKey.equals(this._gameKey)) {
throw new IdentityDomainValidationError(
`Rating type ${type} has mismatched gameKey`
);
}
}
this._ratings = newRatings;
this._provenance = newProvenance;
}
/**
* Get a specific rating by type
*/
getRatingByType(type: string): ExternalRating | undefined {
return this._ratings.get(type);
}
/**
* Get all rating types
*/
getRatingTypes(): string[] {
return Array.from(this._ratings.keys());
}
/**
* Check if profile has any ratings
*/
hasRatings(): boolean {
return this._ratings.size > 0;
}
/**
* Mark provenance as verified
*/
markVerified(): void {
this._provenance = this._provenance.markVerified();
}
/**
* Update last synced timestamp
*/
updateLastSyncedAt(date: Date): void {
this._provenance = this._provenance.updateLastSyncedAt(date);
}
/**
* Get summary for display
*/
toSummary(): {
userId: string;
gameKey: string;
ratingCount: number;
ratingTypes: string[];
source: string;
lastSyncedAt: Date;
verified: boolean;
} {
return {
userId: this._userId.toString(),
gameKey: this._gameKey.toString(),
ratingCount: this._ratings.size,
ratingTypes: this.getRatingTypes(),
source: this._provenance.source,
lastSyncedAt: this._provenance.lastSyncedAt,
verified: this._provenance.verified,
};
}
/**
* Serialize for storage
*/
toJSON(): {
userId: string;
gameKey: string;
ratings: Array<{ type: string; gameKey: string; value: number }>;
provenance: {
source: string;
lastSyncedAt: Date;
verified: boolean;
};
} {
return {
userId: this._userId.toString(),
gameKey: this._gameKey.toString(),
ratings: Array.from(this._ratings.entries()).map(([type, rating]) => ({
type,
gameKey: rating.gameKey.toString(),
value: rating.value,
})),
provenance: {
source: this._provenance.source,
lastSyncedAt: this._provenance.lastSyncedAt,
verified: this._provenance.verified,
},
};
}
equals(other: ExternalGameRatingProfile): boolean {
if (!(other instanceof ExternalGameRatingProfile)) {
return false;
}
if (!this._userId.equals(other._userId)) {
return false;
}
if (!this._gameKey.equals(other._gameKey)) {
return false;
}
if (!this._provenance.equals(other._provenance)) {
return false;
}
// Compare ratings maps
if (this._ratings.size !== other._ratings.size) {
return false;
}
for (const [type, rating] of this._ratings.entries()) {
const otherRating = other._ratings.get(type);
if (!otherRating || !rating.equals(otherRating)) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,174 @@
import { RatingEvent } from './RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
describe('RatingEvent', () => {
const validProps = {
id: RatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(10),
occurredAt: new Date('2024-01-01T00:00:00Z'),
createdAt: new Date('2024-01-01T00:00:00Z'),
source: {
type: 'race' as const,
id: 'race-456',
},
reason: {
code: 'DRIVING_FINISH_STRENGTH_GAIN',
summary: 'Finished 3rd in strong field',
details: { position: 3, fieldStrength: 2500 },
},
visibility: {
public: true,
redactedFields: [] as string[],
},
version: 1,
};
describe('create', () => {
it('should create a valid rating event', () => {
const event = RatingEvent.create(validProps);
expect(event.id.value).toBe(validProps.id.value);
expect(event.userId).toBe(validProps.userId);
expect(event.dimension.value).toBe('driving');
expect(event.delta.value).toBe(10);
expect(event.occurredAt).toEqual(validProps.occurredAt);
expect(event.source.type).toBe('race');
expect(event.reason.code).toBe('DRIVING_FINISH_STRENGTH_GAIN');
expect(event.visibility.public).toBe(true);
expect(event.version).toBe(1);
});
it('should create event with optional weight', () => {
const props = { ...validProps, weight: 2 };
const event = RatingEvent.create(props);
expect(event.weight).toBe(2);
});
it('should create event with non-public visibility', () => {
const props = {
...validProps,
visibility: { public: false, redactedFields: ['reason.summary'] },
};
const event = RatingEvent.create(props);
expect(event.visibility.public).toBe(false);
expect(event.visibility.redactedFields).toEqual(['reason.summary']);
});
it('should throw for missing userId', () => {
const props = { ...validProps, userId: '' };
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
});
it('should throw for missing dimension', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { dimension: _dimension, ...rest } = validProps;
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
});
it('should throw for missing delta', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { delta: _delta, ...rest } = validProps;
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
});
it('should throw for missing source', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { source: _source, ...rest } = validProps;
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
});
it('should throw for missing reason', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { reason: _reason, ...rest } = validProps;
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
});
it('should throw for missing visibility', () => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { visibility: _visibility, ...rest } = validProps;
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
});
it('should throw for invalid version', () => {
const props = { ...validProps, version: 0 };
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
});
it('should throw for future occurredAt', () => {
const futureDate = new Date(Date.now() + 86400000);
const props = { ...validProps, occurredAt: futureDate };
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
});
it('should throw for future createdAt', () => {
const futureDate = new Date(Date.now() + 86400000);
const props = { ...validProps, createdAt: futureDate };
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
});
it('should throw for occurredAt after createdAt', () => {
const props = {
...validProps,
occurredAt: new Date('2024-01-02T00:00:00Z'),
createdAt: new Date('2024-01-01T00:00:00Z'),
};
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainInvariantError);
});
});
describe('rehydrate', () => {
it('should rehydrate event from stored data', () => {
const event = RatingEvent.rehydrate(validProps);
expect(event.id.value).toBe(validProps.id.value);
expect(event.userId).toBe(validProps.userId);
expect(event.dimension.value).toBe('driving');
});
it('should rehydrate with optional weight', () => {
const props = { ...validProps, weight: 2 };
const event = RatingEvent.rehydrate(props);
expect(event.weight).toBe(2);
});
});
describe('equals', () => {
it('should return true for same ID', () => {
const event1 = RatingEvent.create(validProps);
const event2 = RatingEvent.rehydrate(validProps);
expect(event1.equals(event2)).toBe(true);
});
it('should return false for different IDs', () => {
const event1 = RatingEvent.create(validProps);
const event2 = RatingEvent.create({
...validProps,
id: RatingEventId.create('123e4567-e89b-12d3-a456-426614174001'),
});
expect(event1.equals(event2)).toBe(false);
});
});
describe('toJSON', () => {
it('should return plain object representation', () => {
const event = RatingEvent.create(validProps);
const json = event.toJSON();
expect(json.id).toBe(validProps.id.value);
expect(json.userId).toBe(validProps.userId);
expect(json.dimension).toBe('driving');
expect(json.delta).toBe(10);
expect(json.source).toEqual({ type: 'race', id: 'race-456' });
});
});
});

View File

@@ -0,0 +1,140 @@
import type { IEntity } from '@core/shared/domain';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
export interface RatingEventSource {
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
id: string;
}
export interface RatingEventReason {
code: string;
summary: string;
details: Record<string, unknown>;
}
export interface RatingEventVisibility {
public: boolean;
redactedFields: string[];
}
export interface RatingEventProps {
id: RatingEventId;
userId: string;
dimension: RatingDimensionKey;
delta: RatingDelta;
weight?: number;
occurredAt: Date;
createdAt: Date;
source: RatingEventSource;
reason: RatingEventReason;
visibility: RatingEventVisibility;
version: number;
}
export class RatingEvent implements IEntity<RatingEventId> {
readonly id: RatingEventId;
readonly userId: string;
readonly dimension: RatingDimensionKey;
readonly delta: RatingDelta;
readonly weight: number | undefined;
readonly occurredAt: Date;
readonly createdAt: Date;
readonly source: RatingEventSource;
readonly reason: RatingEventReason;
readonly visibility: RatingEventVisibility;
readonly version: number;
private constructor(props: RatingEventProps) {
this.id = props.id;
this.userId = props.userId;
this.dimension = props.dimension;
this.delta = props.delta;
this.weight = props.weight;
this.occurredAt = props.occurredAt;
this.createdAt = props.createdAt;
this.source = props.source;
this.reason = props.reason;
this.visibility = props.visibility;
this.version = props.version;
}
static create(props: RatingEventProps): RatingEvent {
// Validate required fields
if (!props.userId || props.userId.trim().length === 0) {
throw new IdentityDomainValidationError('userId is required');
}
if (!props.dimension) {
throw new IdentityDomainValidationError('dimension is required');
}
if (!props.delta) {
throw new IdentityDomainValidationError('delta is required');
}
if (!props.source) {
throw new IdentityDomainValidationError('source is required');
}
if (!props.reason) {
throw new IdentityDomainValidationError('reason is required');
}
if (!props.visibility) {
throw new IdentityDomainValidationError('visibility is required');
}
if (!props.version || props.version < 1) {
throw new IdentityDomainValidationError('version must be a positive integer');
}
// Validate dates
const now = new Date();
if (props.occurredAt > now) {
throw new IdentityDomainValidationError('occurredAt cannot be in the future');
}
if (props.createdAt > now) {
throw new IdentityDomainValidationError('createdAt cannot be in the future');
}
if (props.occurredAt > props.createdAt) {
throw new IdentityDomainInvariantError('occurredAt must be before or equal to createdAt');
}
// Validate weight if provided
if (props.weight !== undefined && (props.weight <= 0 || !Number.isFinite(props.weight))) {
throw new IdentityDomainValidationError('weight must be a positive number');
}
return new RatingEvent(props);
}
static rehydrate(props: RatingEventProps): RatingEvent {
// Rehydration assumes data is already validated (from persistence)
return new RatingEvent(props);
}
equals(other: IEntity<RatingEventId>): boolean {
return this.id.equals(other.id);
}
toJSON(): Record<string, unknown> {
return {
id: this.id.value,
userId: this.userId,
dimension: this.dimension.value,
delta: this.delta.value,
weight: this.weight,
occurredAt: this.occurredAt.toISOString(),
createdAt: this.createdAt.toISOString(),
source: this.source,
reason: this.reason,
visibility: this.visibility,
version: this.version,
};
}
}