rating
This commit is contained in:
467
core/identity/domain/entities/AdminVoteSession.test.ts
Normal file
467
core/identity/domain/entities/AdminVoteSession.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
295
core/identity/domain/entities/AdminVoteSession.ts
Normal file
295
core/identity/domain/entities/AdminVoteSession.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
410
core/identity/domain/entities/ExternalGameRatingProfile.test.ts
Normal file
410
core/identity/domain/entities/ExternalGameRatingProfile.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
core/identity/domain/entities/ExternalGameRatingProfile.ts
Normal file
233
core/identity/domain/entities/ExternalGameRatingProfile.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
174
core/identity/domain/entities/RatingEvent.test.ts
Normal file
174
core/identity/domain/entities/RatingEvent.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
140
core/identity/domain/entities/RatingEvent.ts
Normal file
140
core/identity/domain/entities/RatingEvent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user