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,111 @@
import { IExternalGameRatingRepository, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
/**
* In-Memory Implementation: IExternalGameRatingRepository
*
* For testing and development purposes.
*/
export class InMemoryExternalGameRatingRepository implements IExternalGameRatingRepository {
private profiles: Map<string, ExternalGameRatingProfile> = new Map();
private getKey(userId: string, gameKey: string): string {
return `${userId}|${gameKey}`;
}
async findByUserIdAndGameKey(
userId: string,
gameKey: string
): Promise<ExternalGameRatingProfile | null> {
const key = this.getKey(userId, gameKey);
return this.profiles.get(key) || null;
}
async findByUserId(userId: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.userId.toString() === userId
);
}
async findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.gameKey.toString() === gameKey
);
}
async save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile> {
const key = this.getKey(profile.userId.toString(), profile.gameKey.toString());
this.profiles.set(key, profile);
return profile;
}
async saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]> {
for (const profile of profiles) {
await this.save(profile);
}
return profiles;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.delete(key);
}
async exists(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.has(key);
}
async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>> {
const allProfiles = await this.findByUserId(userId);
// Apply filters
let filtered = allProfiles;
if (options?.filter) {
const filter = options.filter;
if (filter.gameKeys) {
filtered = filtered.filter(p => filter.gameKeys!.includes(p.gameKey.toString()));
}
if (filter.sources) {
filtered = filtered.filter(p => filter.sources!.includes(p.provenance.source));
}
if (filter.verified !== undefined) {
filtered = filtered.filter(p => p.provenance.verified === filter.verified);
}
if (filter.lastSyncedAfter) {
filtered = filtered.filter(p => p.provenance.lastSyncedAt >= filter.lastSyncedAfter!);
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
// Helper method for testing
clear(): void {
this.profiles.clear();
}
// Helper method for testing
getAll(): ExternalGameRatingProfile[] {
return Array.from(this.profiles.values());
}
}

View File

@@ -0,0 +1,39 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: ExternalGameRatingProfile
*
* Stores external game rating profiles per user and game.
* Uses JSONB for ratings map and provenance data.
*/
@Entity({ name: 'external_game_rating_profiles' })
@Index(['userId', 'gameKey'], { unique: true })
@Index(['userId'])
@Index(['gameKey'])
export class ExternalGameRatingProfileOrmEntity {
@PrimaryColumn({ type: 'text' })
userId!: string;
@PrimaryColumn({ type: 'text' })
gameKey!: string;
@Column({ type: 'jsonb' })
ratings!: Array<{
type: string;
gameKey: string;
value: number;
}>;
@Column({ type: 'jsonb' })
provenance!: {
source: string;
lastSyncedAt: Date;
verified: boolean;
};
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,57 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: RatingEvent
*
* Stores rating events in the ledger with indexes for efficient querying
* by userId and ordering by occurredAt for snapshot computation.
*/
@Entity({ name: 'rating_events' })
@Index(['userId', 'occurredAt', 'createdAt', 'id'], { unique: true })
export class RatingEventOrmEntity {
@PrimaryColumn({ type: 'text' })
id!: string;
@Index()
@Column({ type: 'text' })
userId!: string;
@Index()
@Column({ type: 'text' })
dimension!: string;
@Column({ type: 'double precision' })
delta!: number;
@Column({ type: 'double precision', nullable: true })
weight?: number;
@Index()
@Column({ type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'jsonb' })
source!: {
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
id: string;
};
@Column({ type: 'jsonb' })
reason!: {
code: string;
summary: string;
details: Record<string, unknown>;
};
@Column({ type: 'jsonb' })
visibility!: {
public: boolean;
redactedFields: string[];
};
@Column({ type: 'integer' })
version!: number;
}

View File

@@ -0,0 +1,71 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
/**
* ORM Entity: UserRating
*
* Stores the current rating snapshot per user.
* Uses JSONB for dimension data to keep schema flexible.
*/
@Entity({ name: 'user_ratings' })
@Index(['userId'], { unique: true })
export class UserRatingOrmEntity {
@PrimaryColumn({ type: 'text' })
userId!: string;
@Column({ type: 'jsonb' })
driver!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
admin!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
steward!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
trust!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'jsonb' })
fairness!: {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: Date;
};
@Column({ type: 'double precision' })
overallReputation!: number;
@Column({ type: 'text', nullable: true })
calculatorVersion?: string;
@Column({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,107 @@
import { ExternalGameRatingProfileOrmMapper } from './ExternalGameRatingProfileOrmMapper';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { UserId } from '@core/identity/domain/value-objects/UserId';
import { GameKey } from '@core/identity/domain/value-objects/GameKey';
import { ExternalRating } from '@core/identity/domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '@core/identity/domain/value-objects/ExternalRatingProvenance';
describe('ExternalGameRatingProfileOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain entity', () => {
const now = new Date('2024-01-01');
const entity = new ExternalGameRatingProfileOrmEntity();
entity.userId = 'user-123';
entity.gameKey = 'iracing';
entity.ratings = [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
{ type: 'skill', gameKey: 'iracing', value: 92.0 },
];
entity.provenance = {
source: 'iracing',
lastSyncedAt: now,
verified: true,
};
entity.createdAt = now;
entity.updatedAt = now;
const domain = ExternalGameRatingProfileOrmMapper.toDomain(entity);
expect(domain.userId.toString()).toBe('user-123');
expect(domain.gameKey.toString()).toBe('iracing');
expect(domain.ratings.size).toBe(2);
expect(domain.provenance.source).toBe('iracing');
expect(domain.provenance.verified).toBe(true);
});
});
describe('toOrmEntity', () => {
it('should convert domain entity to ORM entity', () => {
const domain = createTestDomainEntity('user-123', 'iracing');
const entity = ExternalGameRatingProfileOrmMapper.toOrmEntity(domain);
expect(entity.userId).toBe('user-123');
expect(entity.gameKey).toBe('iracing');
expect(entity.ratings).toHaveLength(2);
expect(entity.provenance.source).toBe('iracing');
expect(entity.provenance.verified).toBe(false);
expect(entity.createdAt).toBeInstanceOf(Date);
expect(entity.updatedAt).toBeInstanceOf(Date);
});
});
describe('updateOrmEntity', () => {
it('should update existing ORM entity from domain', () => {
const existingEntity = new ExternalGameRatingProfileOrmEntity();
existingEntity.userId = 'user-123';
existingEntity.gameKey = 'iracing';
existingEntity.ratings = [
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
];
existingEntity.provenance = {
source: 'iracing',
lastSyncedAt: new Date('2024-01-01'),
verified: false,
};
existingEntity.createdAt = new Date('2024-01-01');
existingEntity.updatedAt = new Date('2024-01-01');
const domain = createTestDomainEntity('user-123', 'iracing');
// Update domain with new data
domain.updateLastSyncedAt(new Date('2024-01-02'));
domain.markVerified();
const updated = ExternalGameRatingProfileOrmMapper.updateOrmEntity(existingEntity, domain);
expect(updated.ratings).toHaveLength(2);
expect(updated.provenance.verified).toBe(true);
expect(updated.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
// updatedAt should be updated (may be same timestamp if test runs fast)
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(existingEntity.updatedAt.getTime());
// createdAt should be preserved from existing entity
expect(updated.createdAt).toEqual(existingEntity.createdAt);
});
});
function createTestDomainEntity(userId: string, gameKey: string): ExternalGameRatingProfile {
const user = UserId.fromString(userId);
const game = GameKey.create(gameKey);
const ratings = new Map([
['safety', ExternalRating.create(game, 'safety', 85.5)],
['skill', ExternalRating.create(game, 'skill', 92.0)],
]);
const provenance = ExternalRatingProvenance.create({
source: gameKey,
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
return ExternalGameRatingProfile.create({
userId: user,
gameKey: game,
ratings,
provenance,
});
}
});

View File

@@ -0,0 +1,85 @@
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
/**
* Mapper: ExternalGameRatingProfileOrmMapper
*
* Converts between ExternalGameRatingProfile domain entity and
* ExternalGameRatingProfileOrmEntity.
*/
export class ExternalGameRatingProfileOrmMapper {
/**
* Convert ORM entity to domain entity
*/
static toDomain(entity: ExternalGameRatingProfileOrmEntity): ExternalGameRatingProfile {
return ExternalGameRatingProfile.restore({
userId: entity.userId,
gameKey: entity.gameKey,
ratings: entity.ratings.map(r => ({
type: r.type,
gameKey: r.gameKey,
value: r.value,
})),
provenance: {
source: entity.provenance.source,
lastSyncedAt: entity.provenance.lastSyncedAt,
verified: entity.provenance.verified,
},
});
}
/**
* Convert domain entity to ORM entity
*/
static toOrmEntity(domain: ExternalGameRatingProfile): ExternalGameRatingProfileOrmEntity {
const entity = new ExternalGameRatingProfileOrmEntity();
entity.userId = domain.userId.toString();
entity.gameKey = domain.gameKey.toString();
// Convert ratings map to array
entity.ratings = Array.from(domain.ratings.entries()).map(([type, rating]) => ({
type,
gameKey: rating.gameKey.toString(),
value: rating.value,
}));
// Convert provenance
entity.provenance = {
source: domain.provenance.source,
lastSyncedAt: domain.provenance.lastSyncedAt,
verified: domain.provenance.verified,
};
// Set timestamps (use current time for new entities)
const now = new Date();
entity.createdAt = now;
entity.updatedAt = now;
return entity;
}
/**
* Update existing ORM entity from domain entity
*/
static updateOrmEntity(
entity: ExternalGameRatingProfileOrmEntity,
domain: ExternalGameRatingProfile
): ExternalGameRatingProfileOrmEntity {
entity.ratings = Array.from(domain.ratings.entries()).map(([type, rating]) => ({
type,
gameKey: rating.gameKey.toString(),
value: rating.value,
}));
entity.provenance = {
source: domain.provenance.source,
lastSyncedAt: domain.provenance.lastSyncedAt,
verified: domain.provenance.verified,
};
entity.updatedAt = new Date();
return entity;
}
}

View File

@@ -0,0 +1,145 @@
/**
* Unit tests for RatingEventOrmMapper
*/
import { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '@core/identity/domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '@core/identity/domain/value-objects/RatingDelta';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
import { RatingEventOrmMapper } from './RatingEventOrmMapper';
describe('RatingEventOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain entity', () => {
const entity = new RatingEventOrmEntity();
entity.id = '123e4567-e89b-12d3-a456-426614174000';
entity.userId = 'user-1';
entity.dimension = 'driving';
entity.delta = 5.5;
entity.weight = 1;
entity.occurredAt = new Date('2024-01-01T10:00:00Z');
entity.createdAt = new Date('2024-01-01T10:00:00Z');
entity.source = { type: 'race', id: 'race-1' };
entity.reason = { code: 'TEST', summary: 'Test event', details: {} };
entity.visibility = { public: true, redactedFields: [] };
entity.version = 1;
const domain = RatingEventOrmMapper.toDomain(entity);
expect(domain.id.value).toBe(entity.id);
expect(domain.userId).toBe(entity.userId);
expect(domain.dimension.value).toBe(entity.dimension);
expect(domain.delta.value).toBe(entity.delta);
expect(domain.weight).toBe(entity.weight);
expect(domain.occurredAt).toEqual(entity.occurredAt);
expect(domain.createdAt).toEqual(entity.createdAt);
expect(domain.source).toEqual(entity.source);
expect(domain.reason).toEqual(entity.reason);
expect(domain.visibility).toEqual(entity.visibility);
expect(domain.version).toBe(entity.version);
});
it('should handle optional weight', () => {
const entity = new RatingEventOrmEntity();
entity.id = '123e4567-e89b-12d3-a456-426614174000';
entity.userId = 'user-1';
entity.dimension = 'driving';
entity.delta = 5.5;
entity.occurredAt = new Date('2024-01-01T10:00:00Z');
entity.createdAt = new Date('2024-01-01T10:00:00Z');
entity.source = { type: 'race', id: 'race-1' };
entity.reason = { code: 'TEST', summary: 'Test', details: {} };
entity.visibility = { public: true, redactedFields: [] };
entity.version = 1;
const domain = RatingEventOrmMapper.toDomain(entity);
expect(domain.weight).toBeUndefined();
});
});
describe('toOrmEntity', () => {
it('should convert domain entity to ORM entity', () => {
const domain = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5.5),
weight: 1,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(domain);
expect(entity.id).toBe(domain.id.value);
expect(entity.userId).toBe(domain.userId);
expect(entity.dimension).toBe(domain.dimension.value);
expect(entity.delta).toBe(domain.delta.value);
expect(entity.weight).toBe(domain.weight);
expect(entity.occurredAt).toEqual(domain.occurredAt);
expect(entity.createdAt).toEqual(domain.createdAt);
expect(entity.source).toEqual(domain.source);
expect(entity.reason).toEqual(domain.reason);
expect(entity.visibility).toEqual(domain.visibility);
expect(entity.version).toBe(domain.version);
});
it('should handle optional weight', () => {
const domain = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5.5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(domain);
expect(entity.weight).toBeUndefined();
});
});
describe('round-trip', () => {
it('should preserve data through domain -> orm -> domain conversion', () => {
const original = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(7.5),
weight: 0.5,
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Finished 3rd', details: { position: 3 } },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const entity = RatingEventOrmMapper.toOrmEntity(original);
const restored = RatingEventOrmMapper.toDomain(entity);
expect(restored.id.equals(original.id)).toBe(true);
expect(restored.userId).toBe(original.userId);
expect(restored.dimension.value).toBe(original.dimension.value);
expect(restored.delta.value).toBe(original.delta.value);
expect(restored.weight).toBe(original.weight);
expect(restored.occurredAt).toEqual(original.occurredAt);
expect(restored.createdAt).toEqual(original.createdAt);
expect(restored.source).toEqual(original.source);
expect(restored.reason).toEqual(original.reason);
expect(restored.visibility).toEqual(original.visibility);
expect(restored.version).toBe(original.version);
});
});
});

View File

@@ -0,0 +1,57 @@
import { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '@core/identity/domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '@core/identity/domain/value-objects/RatingDelta';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
/**
* Mapper: RatingEventOrmMapper
*
* Converts between RatingEvent domain entity and RatingEventOrmEntity.
*/
export class RatingEventOrmMapper {
/**
* Convert ORM entity to domain entity
*/
static toDomain(entity: RatingEventOrmEntity): RatingEvent {
const props: any = {
id: RatingEventId.create(entity.id),
userId: entity.userId,
dimension: RatingDimensionKey.create(entity.dimension),
delta: RatingDelta.create(entity.delta),
occurredAt: entity.occurredAt,
createdAt: entity.createdAt,
source: entity.source,
reason: entity.reason,
visibility: entity.visibility,
version: entity.version,
};
if (entity.weight !== undefined && entity.weight !== null) {
props.weight = entity.weight;
}
return RatingEvent.rehydrate(props);
}
/**
* Convert domain entity to ORM entity
*/
static toOrmEntity(domain: RatingEvent): RatingEventOrmEntity {
const entity = new RatingEventOrmEntity();
entity.id = domain.id.value;
entity.userId = domain.userId;
entity.dimension = domain.dimension.value;
entity.delta = domain.delta.value;
if (domain.weight !== undefined) {
entity.weight = domain.weight;
}
entity.occurredAt = domain.occurredAt;
entity.createdAt = domain.createdAt;
entity.source = domain.source;
entity.reason = domain.reason;
entity.visibility = domain.visibility;
entity.version = domain.version;
return entity;
}
}

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for UserRatingOrmMapper
*/
import { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
import { UserRatingOrmMapper } from './UserRatingOrmMapper';
describe('UserRatingOrmMapper', () => {
describe('toDomain', () => {
it('should convert ORM entity to domain value object', () => {
const now = new Date('2024-01-01T10:00:00Z');
const entity = new UserRatingOrmEntity();
entity.userId = 'user-1';
entity.driver = { value: 75, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now };
entity.admin = { value: 60, confidence: 0.3, sampleSize: 5, trend: 'stable', lastUpdated: now };
entity.steward = { value: 80, confidence: 0.4, sampleSize: 8, trend: 'falling', lastUpdated: now };
entity.trust = { value: 70, confidence: 0.6, sampleSize: 12, trend: 'rising', lastUpdated: now };
entity.fairness = { value: 85, confidence: 0.7, sampleSize: 15, trend: 'stable', lastUpdated: now };
entity.overallReputation = 74;
entity.calculatorVersion = '1.0';
entity.createdAt = now;
entity.updatedAt = now;
const domain = UserRatingOrmMapper.toDomain(entity);
expect(domain.userId).toBe(entity.userId);
expect(domain.driver).toEqual(entity.driver);
expect(domain.admin).toEqual(entity.admin);
expect(domain.steward).toEqual(entity.steward);
expect(domain.trust).toEqual(entity.trust);
expect(domain.fairness).toEqual(entity.fairness);
expect(domain.overallReputation).toBe(entity.overallReputation);
expect(domain.calculatorVersion).toBe(entity.calculatorVersion);
expect(domain.createdAt).toEqual(entity.createdAt);
expect(domain.updatedAt).toEqual(entity.updatedAt);
});
it('should handle optional calculatorVersion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const entity = new UserRatingOrmEntity();
entity.userId = 'user-1';
entity.driver = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.admin = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.steward = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.trust = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.fairness = { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now };
entity.overallReputation = 50;
entity.createdAt = now;
entity.updatedAt = now;
const domain = UserRatingOrmMapper.toDomain(entity);
expect(domain.calculatorVersion).toBeUndefined();
});
});
describe('toOrmEntity', () => {
it('should convert domain value object to ORM entity', () => {
const domain = UserRating.create('user-1');
const updated = domain.updateDriverRating(75);
const entity = UserRatingOrmMapper.toOrmEntity(updated);
expect(entity.userId).toBe(updated.userId);
expect(entity.driver).toEqual(updated.driver);
expect(entity.admin).toEqual(updated.admin);
expect(entity.steward).toEqual(updated.steward);
expect(entity.trust).toEqual(updated.trust);
expect(entity.fairness).toEqual(updated.fairness);
expect(entity.overallReputation).toBe(updated.overallReputation);
expect(entity.calculatorVersion).toBe(updated.calculatorVersion);
expect(entity.createdAt).toEqual(updated.createdAt);
expect(entity.updatedAt).toEqual(updated.updatedAt);
});
it('should handle optional calculatorVersion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const domain = UserRating.restore({
userId: 'user-1',
driver: { value: 60, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now },
admin: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
steward: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
trust: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
fairness: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: now },
overallReputation: 55,
createdAt: now,
updatedAt: now,
});
const entity = UserRatingOrmMapper.toOrmEntity(domain);
expect(entity.calculatorVersion).toBeUndefined();
});
});
describe('round-trip', () => {
it('should preserve data through domain -> orm -> domain conversion', () => {
const now = new Date('2024-01-01T10:00:00Z');
const original = UserRating.restore({
userId: 'user-1',
driver: { value: 75, confidence: 0.5, sampleSize: 10, trend: 'rising', lastUpdated: now },
admin: { value: 60, confidence: 0.3, sampleSize: 5, trend: 'stable', lastUpdated: now },
steward: { value: 80, confidence: 0.4, sampleSize: 8, trend: 'falling', lastUpdated: now },
trust: { value: 70, confidence: 0.6, sampleSize: 12, trend: 'rising', lastUpdated: now },
fairness: { value: 85, confidence: 0.7, sampleSize: 15, trend: 'stable', lastUpdated: now },
overallReputation: 74,
calculatorVersion: '1.0',
createdAt: now,
updatedAt: now,
});
const entity = UserRatingOrmMapper.toOrmEntity(original);
const restored = UserRatingOrmMapper.toDomain(entity);
expect(restored.userId).toBe(original.userId);
expect(restored.driver).toEqual(original.driver);
expect(restored.admin).toEqual(original.admin);
expect(restored.steward).toEqual(original.steward);
expect(restored.trust).toEqual(original.trust);
expect(restored.fairness).toEqual(original.fairness);
expect(restored.overallReputation).toBe(original.overallReputation);
expect(restored.calculatorVersion).toBe(original.calculatorVersion);
expect(restored.createdAt).toEqual(original.createdAt);
expect(restored.updatedAt).toEqual(original.updatedAt);
});
});
});

View File

@@ -0,0 +1,52 @@
import { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
/**
* Mapper: UserRatingOrmMapper
*
* Converts between UserRating value object and UserRatingOrmEntity.
*/
export class UserRatingOrmMapper {
/**
* Convert ORM entity to domain value object
*/
static toDomain(entity: UserRatingOrmEntity): UserRating {
const props: any = {
userId: entity.userId,
driver: entity.driver,
admin: entity.admin,
steward: entity.steward,
trust: entity.trust,
fairness: entity.fairness,
overallReputation: entity.overallReputation,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
if (entity.calculatorVersion !== undefined && entity.calculatorVersion !== null) {
props.calculatorVersion = entity.calculatorVersion;
}
return UserRating.restore(props);
}
/**
* Convert domain value object to ORM entity
*/
static toOrmEntity(domain: UserRating): UserRatingOrmEntity {
const entity = new UserRatingOrmEntity();
entity.userId = domain.userId;
entity.driver = domain.driver;
entity.admin = domain.admin;
entity.steward = domain.steward;
entity.trust = domain.trust;
entity.fairness = domain.fairness;
entity.overallReputation = domain.overallReputation;
if (domain.calculatorVersion !== undefined) {
entity.calculatorVersion = domain.calculatorVersion;
}
entity.createdAt = domain.createdAt;
entity.updatedAt = domain.updatedAt;
return entity;
}
}

View File

@@ -0,0 +1,153 @@
import { Repository } from 'typeorm';
import { IExternalGameRatingRepository, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '@core/identity/domain/entities/ExternalGameRatingProfile';
import { ExternalGameRatingProfileOrmEntity } from '../entities/ExternalGameRatingProfileOrmEntity';
import { ExternalGameRatingProfileOrmMapper } from '../mappers/ExternalGameRatingProfileOrmMapper';
/**
* TypeORM Implementation: IExternalGameRatingRepository
*
* Repository for external game rating profiles using TypeORM.
* Implements store/display operations only, no compute.
*/
export class TypeOrmExternalGameRatingRepository implements IExternalGameRatingRepository {
constructor(
private readonly repository: Repository<ExternalGameRatingProfileOrmEntity>
) {}
async findByUserIdAndGameKey(
userId: string,
gameKey: string
): Promise<ExternalGameRatingProfile | null> {
const entity = await this.repository.findOne({
where: { userId, gameKey },
});
if (!entity) {
return null;
}
return ExternalGameRatingProfileOrmMapper.toDomain(entity);
}
async findByUserId(userId: string): Promise<ExternalGameRatingProfile[]> {
const entities = await this.repository.find({
where: { userId },
order: { updatedAt: 'DESC' },
});
return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
}
async findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]> {
const entities = await this.repository.find({
where: { gameKey },
order: { updatedAt: 'DESC' },
});
return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
}
async save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile> {
const existing = await this.repository.findOne({
where: {
userId: profile.userId.toString(),
gameKey: profile.gameKey.toString(),
},
});
let entity: ExternalGameRatingProfileOrmEntity;
if (existing) {
// Update existing
entity = ExternalGameRatingProfileOrmMapper.updateOrmEntity(existing, profile);
} else {
// Create new
entity = ExternalGameRatingProfileOrmMapper.toOrmEntity(profile);
}
const saved = await this.repository.save(entity);
return ExternalGameRatingProfileOrmMapper.toDomain(saved);
}
async saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]> {
const results: ExternalGameRatingProfile[] = [];
for (const profile of profiles) {
const saved = await this.save(profile);
results.push(saved);
}
return results;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
const result = await this.repository.delete({ userId, gameKey });
return (result.affected ?? 0) > 0;
}
async exists(userId: string, gameKey: string): Promise<boolean> {
const count = await this.repository.count({
where: { userId, gameKey },
});
return count > 0;
}
async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>> {
const query = this.repository.createQueryBuilder('profile')
.where('profile.userId = :userId', { userId });
// Apply filters
if (options?.filter) {
const filter = options.filter;
if (filter.gameKeys) {
query.andWhere('profile.gameKey IN (:...gameKeys)', { gameKeys: filter.gameKeys });
}
if (filter.sources) {
query.andWhere('profile.provenanceSource IN (:...sources)', { sources: filter.sources });
}
if (filter.verified !== undefined) {
query.andWhere('profile.provenanceVerified = :verified', { verified: filter.verified });
}
if (filter.lastSyncedAfter) {
query.andWhere('profile.provenanceLastSyncedAt >= :lastSyncedAfter', { lastSyncedAfter: filter.lastSyncedAfter });
}
}
// Get total count
const total = await query.getCount();
// Apply pagination
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
query
.orderBy('profile.updatedAt', 'DESC')
.limit(limit)
.offset(offset);
const entities = await query.getMany();
const items = entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity));
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}

View File

@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmRatingEventRepository } from './TypeOrmRatingEventRepository';
describe('TypeOrmRatingEventRepository', () => {
it('constructor works with injected dependencies', () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
expect(repo).toBeInstanceOf(TypeOrmRatingEventRepository);
});
it('save: works with mocked TypeORM', async () => {
const save = vi.fn().mockResolvedValue({});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ save }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
// Create a mock event (we're testing the repository wiring, not the mapper)
const mockEvent = {
id: { value: 'test-id' },
userId: 'user-1',
dimension: { value: 'driving' },
delta: { value: 5 },
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
} as any;
// Mock the mapper
vi.doMock('../mappers/RatingEventOrmMapper', () => ({
RatingEventOrmMapper: {
toOrmEntity: vi.fn().mockReturnValue({ id: 'test-id' }),
},
}));
const result = await repo.save(mockEvent);
expect(result).toBe(mockEvent);
expect(save).toHaveBeenCalled();
});
it('findByUserId: returns empty array with mocked DB', async () => {
const getMany = vi.fn().mockResolvedValue([]);
const createQueryBuilder = vi.fn().mockReturnValue({
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
addOrderBy: vi.fn().mockReturnThis(),
andWhere: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
getMany,
});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ createQueryBuilder }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.findByUserId('user-1');
expect(result).toEqual([]);
expect(createQueryBuilder).toHaveBeenCalled();
});
it('getAllByUserId: works with mocked DB', async () => {
const getMany = vi.fn().mockResolvedValue([]);
const createQueryBuilder = vi.fn().mockReturnValue({
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
addOrderBy: vi.fn().mockReturnThis(),
getMany,
});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ createQueryBuilder }),
} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.getAllByUserId('user-1');
expect(result).toEqual([]);
expect(createQueryBuilder).toHaveBeenCalled();
});
it('findByIds: handles empty array', async () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmRatingEventRepository(dataSource);
const result = await repo.findByIds([]);
expect(result).toEqual([]);
});
});

View File

@@ -0,0 +1,151 @@
import type { DataSource } from 'typeorm';
import type { IRatingEventRepository, FindByUserIdOptions, PaginatedQueryOptions, PaginatedResult } from '@core/identity/domain/repositories/IRatingEventRepository';
import type { RatingEvent } from '@core/identity/domain/entities/RatingEvent';
import type { RatingEventId } from '@core/identity/domain/value-objects/RatingEventId';
import { RatingEventOrmEntity } from '../entities/RatingEventOrmEntity';
import { RatingEventOrmMapper } from '../mappers/RatingEventOrmMapper';
/**
* TypeORM Implementation: IRatingEventRepository
*
* Persists rating events in the ledger with efficient querying by userId
* and ordering for snapshot computation.
*/
export class TypeOrmRatingEventRepository implements IRatingEventRepository {
constructor(private readonly dataSource: DataSource) {}
async save(event: RatingEvent): Promise<RatingEvent> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const entity = RatingEventOrmMapper.toOrmEntity(event);
await repo.save(entity);
return event;
}
async findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const query = repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC');
if (options?.afterId) {
query.andWhere('event.id > :afterId', { afterId: options.afterId.value });
}
if (options?.limit) {
query.limit(options.limit);
}
const entities = await query.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
if (ids.length === 0) {
return [];
}
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const idValues = ids.map(id => id.value);
const entities = await repo
.createQueryBuilder('event')
.where('event.id IN (:...ids)', { ids: idValues })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const entities = await repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId })
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.getMany();
return entities.map(entity => RatingEventOrmMapper.toDomain(entity));
}
async findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>> {
const repo = this.dataSource.getRepository(RatingEventOrmEntity);
const query = repo
.createQueryBuilder('event')
.where('event.userId = :userId', { userId });
// Apply filters
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
query.andWhere('event.dimension IN (:...dimensions)', { dimensions: filter.dimensions });
}
if (filter.sourceTypes) {
query.andWhere('event.sourceType IN (:...sourceTypes)', { sourceTypes: filter.sourceTypes });
}
if (filter.from) {
query.andWhere('event.occurredAt >= :from', { from: filter.from });
}
if (filter.to) {
query.andWhere('event.occurredAt <= :to', { to: filter.to });
}
if (filter.reasonCodes) {
query.andWhere('event.reasonCode IN (:...reasonCodes)', { reasonCodes: filter.reasonCodes });
}
if (filter.visibility) {
query.andWhere('event.visibility = :visibility', { visibility: filter.visibility });
}
}
// Get total count
const total = await query.getCount();
// Apply pagination
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
query
.orderBy('event.occurredAt', 'ASC')
.addOrderBy('event.createdAt', 'ASC')
.addOrderBy('event.id', 'ASC')
.limit(limit)
.offset(offset);
const entities = await query.getMany();
const items = entities.map(entity => RatingEventOrmMapper.toDomain(entity));
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest';
import type { DataSource } from 'typeorm';
import { TypeOrmUserRatingRepository } from './TypeOrmUserRatingRepository';
describe('TypeOrmUserRatingRepository', () => {
it('constructor works with injected dependencies', () => {
const dataSource = {} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
expect(repo).toBeInstanceOf(TypeOrmUserRatingRepository);
});
it('findByUserId: returns null when not found', async () => {
const findOne = vi.fn().mockResolvedValue(null);
const dataSource = {
getRepository: vi.fn().mockReturnValue({ findOne }),
} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
const result = await repo.findByUserId('user-1');
expect(result).toBeNull();
expect(findOne).toHaveBeenCalledWith({ where: { userId: 'user-1' } });
});
it('save: works with mocked TypeORM', async () => {
const save = vi.fn().mockResolvedValue({});
const dataSource = {
getRepository: vi.fn().mockReturnValue({ save }),
} as unknown as DataSource;
const repo = new TypeOrmUserRatingRepository(dataSource);
const mockRating = {
userId: 'user-1',
driver: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
admin: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
steward: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
trust: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
fairness: { value: 50, confidence: 0, sampleSize: 0, trend: 'stable', lastUpdated: new Date() },
overallReputation: 50,
createdAt: new Date(),
updatedAt: new Date(),
} as any;
const result = await repo.save(mockRating);
expect(result).toBe(mockRating);
expect(save).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,34 @@
import type { DataSource } from 'typeorm';
import type { IUserRatingRepository } from '@core/identity/domain/repositories/IUserRatingRepository';
import type { UserRating } from '@core/identity/domain/value-objects/UserRating';
import { UserRatingOrmEntity } from '../entities/UserRatingOrmEntity';
import { UserRatingOrmMapper } from '../mappers/UserRatingOrmMapper';
/**
* TypeORM Implementation: IUserRatingRepository
*
* Persists and retrieves UserRating snapshots for fast reads.
*/
export class TypeOrmUserRatingRepository implements IUserRatingRepository {
constructor(private readonly dataSource: DataSource) {}
async findByUserId(userId: string): Promise<UserRating | null> {
const repo = this.dataSource.getRepository(UserRatingOrmEntity);
const entity = await repo.findOne({ where: { userId } });
if (!entity) {
return null;
}
return UserRatingOrmMapper.toDomain(entity);
}
async save(userRating: UserRating): Promise<UserRating> {
const repo = this.dataSource.getRepository(UserRatingOrmEntity);
const entity = UserRatingOrmMapper.toOrmEntity(userRating);
await repo.save(entity);
return userRating;
}
}

View File

@@ -0,0 +1,198 @@
import 'reflect-metadata';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { DataSource } from 'typeorm';
import { RatingEventOrmEntity } from '@adapters/identity/persistence/typeorm/entities/RatingEventOrmEntity';
import { UserRatingOrmEntity } from '@adapters/identity/persistence/typeorm/entities/UserRatingOrmEntity';
import { TypeOrmRatingEventRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository';
import { TypeOrmUserRatingRepository } from '@adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository';
import { AppendRatingEventsUseCase } from '@core/identity/application/use-cases/AppendRatingEventsUseCase';
import { RecomputeUserRatingSnapshotUseCase } from '@core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase';
const databaseUrl = process.env.DATABASE_URL;
const describeIfDatabase = databaseUrl ? describe : describe.skip;
describeIfDatabase('TypeORM Identity Rating repositories (postgres slice)', () => {
let dataSource: DataSource;
let eventRepo: TypeOrmRatingEventRepository;
let ratingRepo: TypeOrmUserRatingRepository;
let appendUseCase: AppendRatingEventsUseCase;
let recomputeUseCase: RecomputeUserRatingSnapshotUseCase;
beforeAll(async () => {
if (!databaseUrl) {
throw new Error('DATABASE_URL is required to run postgres integration tests');
}
dataSource = new DataSource({
type: 'postgres',
url: databaseUrl,
entities: [RatingEventOrmEntity, UserRatingOrmEntity],
synchronize: true,
});
await dataSource.initialize();
// Initialize repositories
eventRepo = new TypeOrmRatingEventRepository(dataSource);
ratingRepo = new TypeOrmUserRatingRepository(dataSource);
// Initialize use cases
appendUseCase = new AppendRatingEventsUseCase(eventRepo, ratingRepo);
recomputeUseCase = new RecomputeUserRatingSnapshotUseCase(eventRepo, ratingRepo);
});
afterAll(async () => {
if (dataSource?.isInitialized) {
// Clean up test data
await dataSource.getRepository(RatingEventOrmEntity).clear();
await dataSource.getRepository(UserRatingOrmEntity).clear();
await dataSource.destroy();
}
});
it('should complete full flow: append events -> persist -> recompute snapshot', async () => {
const userId = `test-user-${Date.now()}`;
// Step 1: Append rating events from race results
const appendResult = await appendUseCase.execute({
userId,
raceId: 'race-integration-test',
raceResults: [
{
position: 3,
totalDrivers: 10,
startPosition: 5,
incidents: 1,
fieldStrength: 1500,
status: 'finished',
},
{
position: 1,
totalDrivers: 10,
startPosition: 2,
incidents: 0,
fieldStrength: 1500,
status: 'finished',
},
],
});
// Verify events were saved
expect(appendResult.events.length).toBeGreaterThan(0);
expect(appendResult.snapshotUpdated).toBe(true);
// Step 2: Verify events are in database
const eventsInDb = await eventRepo.getAllByUserId(userId);
expect(eventsInDb.length).toBeGreaterThan(0);
expect(eventsInDb.length).toBe(appendResult.events.length);
// Step 3: Verify snapshot was created
const snapshotFromDb = await ratingRepo.findByUserId(userId);
expect(snapshotFromDb).not.toBeNull();
expect(snapshotFromDb!.userId).toBe(userId);
expect(snapshotFromDb!.driver.value).toBeGreaterThan(50); // Should have increased
// Step 4: Recompute snapshot manually
const recomputeResult = await recomputeUseCase.execute({ userId });
// Verify recomputed snapshot
expect(recomputeResult.snapshot.userId).toBe(userId);
expect(recomputeResult.snapshot.driver.value).toBeGreaterThan(50);
expect(recomputeResult.snapshot.driver.sampleSize).toBeGreaterThan(0);
// Step 5: Verify recomputed snapshot matches what's in DB
const finalSnapshot = await ratingRepo.findByUserId(userId);
expect(finalSnapshot!.driver.value).toBe(recomputeResult.snapshot.driver.value);
});
it('should handle direct event creation and recompute', async () => {
const userId = `test-user-direct-${Date.now()}`;
// Append direct events
const appendResult = await appendUseCase.execute({
userId,
events: [
{
userId,
dimension: 'driving',
delta: 8,
sourceType: 'race',
sourceId: 'race-direct',
reasonCode: 'DRIVING_FINISH_STRENGTH_GAIN',
reasonSummary: 'Excellent finish',
},
{
userId,
dimension: 'driving',
delta: -2,
weight: 0.5,
sourceType: 'penalty',
sourceId: 'penalty-1',
reasonCode: 'DRIVING_INCIDENTS_PENALTY',
reasonSummary: 'Minor incident',
},
],
});
expect(appendResult.events).toHaveLength(2);
expect(appendResult.snapshotUpdated).toBe(true);
// Verify events
const events = await eventRepo.getAllByUserId(userId);
expect(events).toHaveLength(2);
// Verify snapshot
const snapshot = await ratingRepo.findByUserId(userId);
expect(snapshot).not.toBeNull();
expect(snapshot!.driver.value).toBeGreaterThan(50);
expect(snapshot!.driver.sampleSize).toBe(2);
});
it('should handle empty race results gracefully', async () => {
const userId = `test-user-empty-${Date.now()}`;
const result = await appendUseCase.execute({
userId,
raceId: 'race-empty',
raceResults: [],
});
expect(result.events).toHaveLength(0);
expect(result.snapshotUpdated).toBe(false);
// No snapshot should exist
const snapshot = await ratingRepo.findByUserId(userId);
expect(snapshot).toBeNull();
});
it('should handle DNF/DNS/DSQ/AFK statuses', async () => {
const userId = `test-user-status-${Date.now()}`;
const result = await appendUseCase.execute({
userId,
raceId: 'race-status-test',
raceResults: [
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 2, fieldStrength: 1500, status: 'dnf' },
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 0, fieldStrength: 1500, status: 'dns' },
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 5, fieldStrength: 1500, status: 'dsq' },
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 1, fieldStrength: 1500, status: 'afk' },
],
});
expect(result.events.length).toBeGreaterThan(0);
expect(result.snapshotUpdated).toBe(true);
// Verify events were created for each status
const events = await eventRepo.getAllByUserId(userId);
expect(events.length).toBeGreaterThan(0);
// Verify snapshot exists and has penalties
const snapshot = await ratingRepo.findByUserId(userId);
expect(snapshot).not.toBeNull();
expect(snapshot!.driver.value).toBeLessThan(50); // Should have decreased due to penalties
});
});

View File

@@ -0,0 +1,73 @@
/**
* DTOs for Admin Vote Session Use Cases
*/
/**
* Input for OpenAdminVoteSessionUseCase
*/
export interface OpenAdminVoteSessionInput {
voteSessionId: string;
leagueId: string;
adminId: string;
startDate: string; // ISO date string
endDate: string; // ISO date string
eligibleVoters: string[]; // User IDs
}
/**
* Output for OpenAdminVoteSessionUseCase
*/
export interface OpenAdminVoteSessionOutput {
success: boolean;
voteSessionId: string;
errors?: string[];
}
/**
* Input for CastAdminVoteUseCase
*/
export interface CastAdminVoteInput {
voteSessionId: string;
voterId: string;
positive: boolean; // true = positive vote, false = negative vote
votedAt?: string; // ISO date string (optional, defaults to now)
}
/**
* Output for CastAdminVoteUseCase
*/
export interface CastAdminVoteOutput {
success: boolean;
voteSessionId: string;
voterId: string;
errors?: string[];
}
/**
* Input for CloseAdminVoteSessionUseCase
*/
export interface CloseAdminVoteSessionInput {
voteSessionId: string;
adminId: string; // For validation
}
/**
* Output for CloseAdminVoteSessionUseCase
*/
export interface CloseAdminVoteSessionOutput {
success: boolean;
voteSessionId: string;
outcome?: {
percentPositive: number;
count: {
positive: number;
negative: number;
total: number;
};
eligibleVoterCount: number;
participationRate: number;
outcome: 'positive' | 'negative' | 'tie';
};
eventsCreated?: number;
errors?: string[];
}

View File

@@ -0,0 +1,18 @@
/**
* DTO: CreateRatingEventDto
*
* Input for creating a rating event from external sources
*/
export interface CreateRatingEventDto {
userId: string;
dimension: string;
delta: number;
weight?: number;
sourceType: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
sourceId: string;
reasonCode: string;
reasonSummary: string;
reasonDetails?: Record<string, unknown>;
occurredAt?: string; // ISO date string
}

View File

@@ -0,0 +1,45 @@
/**
* DTO: EligibilityFilterDto
*
* DSL-based eligibility filter for league/competition rules.
* Example: "platform.driving >= 55 OR external.iracing.iRating between 2000 2500"
*/
export interface EligibilityFilterDto {
/**
* DSL expression for eligibility rules
* Supports:
* - Comparisons: >=, <=, >, <, =, !=, between
* - Logical: AND, OR, NOT
* - Dimensions: platform.{dimension}, external.{game}.{type}
*
* Examples:
* - "platform.driving >= 55"
* - "external.iracing.iRating between 2000 2500"
* - "platform.driving >= 55 AND external.iracing.iRating >= 2000"
* - "platform.driving >= 55 OR external.iracing.iRating between 2000 2500"
*/
dsl: string;
/**
* Optional context for evaluation
*/
context?: {
userId?: string;
leagueId?: string;
[key: string]: unknown;
};
}
export interface EligibilityCondition {
target: 'platform' | 'external';
dimension?: string; // e.g., 'driving', 'admin', 'iRating'
game?: string; // e.g., 'iracing'
operator: string; // '>=', '<=', '>', '<', '=', '!=', 'between'
expected: number | [number, number];
}
export interface ParsedEligibilityFilter {
conditions: EligibilityCondition[];
logicalOperator: 'AND' | 'OR';
}

View File

@@ -0,0 +1,68 @@
/**
* DTO: EvaluationResultDto
*
* Result of DSL eligibility evaluation with explainable reasons.
*/
export interface EvaluationReason {
/**
* What was evaluated
*/
target: string; // e.g., 'platform.driving', 'external.iracing.iRating'
/**
* Operator used
*/
operator: string; // e.g., '>=', 'between'
/**
* Expected threshold/range
*/
expected: number | [number, number];
/**
* Actual value found
*/
actual: number;
/**
* Whether this condition failed
*/
failed: boolean;
/**
* Human-readable explanation
*/
message?: string;
}
export interface EvaluationResultDto {
/**
* Overall eligibility status
*/
eligible: boolean;
/**
* Individual condition results
*/
reasons: EvaluationReason[];
/**
* Summary message
*/
summary: string;
/**
* Timestamp of evaluation
*/
evaluatedAt: string; // ISO date string
/**
* Optional metadata
*/
metadata?: {
userId?: string;
filter?: string;
[key: string]: unknown;
};
}

View File

@@ -0,0 +1,51 @@
/**
* DTO: LedgerEntryDto
*
* Simplified rating event for ledger display/query.
* Pragmatic read model - direct repo DTOs, no domain logic.
*/
export interface LedgerEntryDto {
id: string;
userId: string;
dimension: string; // 'driving', 'admin', 'steward', 'trust', 'fairness'
delta: number; // positive or negative change
weight?: number;
occurredAt: string; // ISO date string
createdAt: string; // ISO date string
source: {
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
id: string;
};
reason: {
code: string;
summary: string;
details: Record<string, unknown>;
};
visibility: {
public: boolean;
redactedFields: string[];
};
}
export interface LedgerFilter {
dimensions?: string[]; // Filter by dimension keys
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
from?: string; // ISO date string
to?: string; // ISO date string
reasonCodes?: string[];
}
export interface PaginatedLedgerResult {
entries: LedgerEntryDto[];
pagination: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
nextOffset?: number | null;
};
}

View File

@@ -0,0 +1,56 @@
/**
* DTO: RatingSummaryDto
*
* Comprehensive rating summary with platform and external game ratings.
* Pragmatic read model - direct repo DTOs, no domain logic.
*/
export interface PlatformRatingDimension {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: string; // ISO date string
}
export interface ExternalGameRating {
gameKey: string;
type: string;
value: number;
lastUpdated: string; // ISO date string
}
export interface ExternalGameRatings {
gameKey: string;
ratings: Map<string, number>; // type -> value
source: string;
lastSyncedAt: string; // ISO date string
verified: boolean;
}
export interface RatingSummaryDto {
userId: string;
// Platform ratings (from internal calculations)
platform: {
driving: PlatformRatingDimension;
admin: PlatformRatingDimension;
steward: PlatformRatingDimension;
trust: PlatformRatingDimension;
fairness: PlatformRatingDimension;
overallReputation: number;
};
// External game ratings (from third-party sources)
external: {
// gameKey -> { type -> value }
[gameKey: string]: {
[type: string]: number;
};
};
// Timestamps
createdAt: string; // ISO date string
updatedAt: string; // ISO date string
lastRatingEventAt?: string; // ISO date string (optional)
}

View File

@@ -0,0 +1,17 @@
/**
* DTO: RecordRaceRatingEventsDto
*
* Input for RecordRaceRatingEventsUseCase
*/
export interface RecordRaceRatingEventsInput {
raceId: string;
}
export interface RecordRaceRatingEventsOutput {
success: boolean;
raceId: string;
eventsCreated: number;
driversUpdated: string[];
errors: string[];
}

View File

@@ -0,0 +1,32 @@
/**
* DTOs for UpsertExternalGameRatingUseCase
*/
export interface UpsertExternalGameRatingInput {
userId: string;
gameKey: string;
ratings: Array<{
type: string;
value: number;
}>;
provenance: {
source: string;
lastSyncedAt: string; // ISO 8601
verified?: boolean;
};
}
export interface UpsertExternalGameRatingOutput {
success: boolean;
profile: {
userId: string;
gameKey: string;
ratingCount: number;
ratingTypes: string[];
source: string;
lastSyncedAt: string;
verified: boolean;
};
action: 'created' | 'updated';
errors?: string[];
}

View File

@@ -0,0 +1,26 @@
/**
* DTO: UserRatingDto
*
* Output for user rating snapshot
*/
export interface RatingDimensionDto {
value: number;
confidence: number;
sampleSize: number;
trend: 'rising' | 'stable' | 'falling';
lastUpdated: string; // ISO date string
}
export interface UserRatingDto {
userId: string;
driver: RatingDimensionDto;
admin: RatingDimensionDto;
steward: RatingDimensionDto;
trust: RatingDimensionDto;
fairness: RatingDimensionDto;
overallReputation: number;
calculatorVersion?: string;
createdAt: string; // ISO date string
updatedAt: string; // ISO date string
}

View File

@@ -0,0 +1,13 @@
/**
* DTOs Index
*
* Export all DTO types
*/
export type { RatingSummaryDto, PlatformRatingDimension, ExternalGameRating, ExternalGameRatings } from './RatingSummaryDto';
export type { LedgerEntryDto, LedgerFilter, PaginatedLedgerResult } from './LedgerEntryDto';
export type { EligibilityFilterDto, EligibilityCondition, ParsedEligibilityFilter } from './EligibilityFilterDto';
export type { EvaluationResultDto, EvaluationReason } from './EvaluationResultDto';
// Existing DTOs
export type { UserRatingDto, RatingDimensionDto } from './UserRatingDto';

View File

@@ -0,0 +1,33 @@
/**
* Port: IRaceResultsProvider
*
* Provider interface for race results data needed for rating calculations.
* This is an application layer port that bridges racing context to identity context.
*/
export interface RaceResultData {
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number; // Optional strength of field
}
export interface RaceResultsData {
raceId: string;
results: RaceResultData[];
}
export interface IRaceResultsProvider {
/**
* Get race results by race ID
* Returns null if race not found or no results
*/
getRaceResults(raceId: string): Promise<RaceResultsData | null>;
/**
* Check if race results exist for a race
*/
hasRaceResults(raceId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,193 @@
/**
* Tests for GetLeagueEligibilityPreviewQuery
*/
import { GetLeagueEligibilityPreviewQuery, GetLeagueEligibilityPreviewQueryHandler } from './GetLeagueEligibilityPreviewQuery';
import { UserRating } from '../../domain/value-objects/UserRating';
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
import { GameKey } from '../../domain/value-objects/GameKey';
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
describe('GetLeagueEligibilityPreviewQuery', () => {
let mockUserRatingRepo: any;
let mockExternalRatingRepo: any;
let handler: GetLeagueEligibilityPreviewQueryHandler;
beforeEach(() => {
mockUserRatingRepo = {
findByUserId: jest.fn(),
};
mockExternalRatingRepo = {
findByUserId: jest.fn(),
};
handler = new GetLeagueEligibilityPreviewQueryHandler(
mockUserRatingRepo,
mockExternalRatingRepo
);
});
describe('execute', () => {
it('should evaluate simple platform eligibility - eligible', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'platform.driving >= 55';
const userRating = UserRating.create(userId);
// Update driving to 65
const updatedRating = userRating.updateDriverRating(65);
mockUserRatingRepo.findByUserId.mockResolvedValue(updatedRating);
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.eligible).toBe(true);
expect(result.reasons).toHaveLength(1);
expect(result.reasons[0].target).toBe('platform.driving');
expect(result.reasons[0].failed).toBe(false);
});
it('should evaluate simple platform eligibility - not eligible', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'platform.driving >= 75';
const userRating = UserRating.create(userId);
// Driving is 50 by default
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.eligible).toBe(false);
expect(result.reasons[0].failed).toBe(true);
});
it('should evaluate external rating eligibility', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'external.iracing.iRating between 2000 2500';
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
const gameKey = GameKey.create('iracing');
const profile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey,
ratings: new Map([
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
]),
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
});
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.eligible).toBe(true);
expect(result.reasons[0].target).toBe('external.iracing.iRating');
});
it('should evaluate complex AND conditions', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'platform.driving >= 55 AND external.iracing.iRating >= 2000';
const userRating = UserRating.create(userId);
const updatedRating = userRating.updateDriverRating(65);
mockUserRatingRepo.findByUserId.mockResolvedValue(updatedRating);
const gameKey = GameKey.create('iracing');
const profile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey,
ratings: new Map([
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
]),
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
});
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.eligible).toBe(true);
expect(result.reasons).toHaveLength(2);
expect(result.reasons.every(r => !r.failed)).toBe(true);
});
it('should evaluate OR conditions with at least one passing', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'platform.driving >= 75 OR external.iracing.iRating >= 2000';
const userRating = UserRating.create(userId);
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
const gameKey = GameKey.create('iracing');
const profile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey,
ratings: new Map([
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
]),
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
});
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.eligible).toBe(true);
expect(result.reasons.filter(r => !r.failed)).toHaveLength(1);
});
it('should include context in metadata', async () => {
const userId = 'user-123';
const leagueId = 'league-456';
const rules = 'platform.driving >= 55';
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
const query: GetLeagueEligibilityPreviewQuery = {
userId,
leagueId,
eligibilityRules: rules,
};
const result = await handler.execute(query);
expect(result.metadata?.userId).toBe(userId);
expect(result.metadata?.filter).toBe(rules);
});
});
});

View File

@@ -0,0 +1,72 @@
/**
* Query: GetLeagueEligibilityPreviewQuery
*
* Preview eligibility for a league based on DSL rules.
* Uses EligibilityEvaluator to provide explainable results.
*/
import { EvaluationResultDto } from '../dtos/EvaluationResultDto';
import { EligibilityFilterDto } from '../dtos/EligibilityFilterDto';
import { EligibilityEvaluator, RatingData } from '../../domain/services/EligibilityEvaluator';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
export interface GetLeagueEligibilityPreviewQuery {
userId: string;
leagueId: string;
eligibilityRules: string; // DSL expression
}
export class GetLeagueEligibilityPreviewQueryHandler {
private readonly evaluator: EligibilityEvaluator;
constructor(
private readonly userRatingRepo: IUserRatingRepository,
private readonly externalRatingRepo: IExternalGameRatingRepository
) {
this.evaluator = new EligibilityEvaluator();
}
async execute(query: GetLeagueEligibilityPreviewQuery): Promise<EvaluationResultDto> {
const { userId, leagueId, eligibilityRules } = query;
// Fetch user's rating data
const userRating = await this.userRatingRepo.findByUserId(userId);
const externalProfiles = await this.externalRatingRepo.findByUserId(userId);
// Build rating data for evaluation
const ratingData: RatingData = {
platform: {
driving: userRating?.driver.value || 0,
admin: userRating?.admin.value || 0,
steward: userRating?.steward.value || 0,
trust: userRating?.trust.value || 0,
fairness: userRating?.fairness.value || 0,
},
external: {},
};
// Add external ratings
for (const profile of externalProfiles) {
const gameKey = profile.gameKey.toString();
ratingData.external[gameKey] = {};
// Convert Map to array and iterate
const ratingsArray = Array.from(profile.ratings.entries());
for (const [type, rating] of ratingsArray) {
ratingData.external[gameKey][type] = rating.value;
}
}
// Evaluate eligibility
const filter: EligibilityFilterDto = {
dsl: eligibilityRules,
context: {
userId,
leagueId,
},
};
return this.evaluator.evaluate(filter, ratingData);
}
}

View File

@@ -0,0 +1,89 @@
/**
* Query: GetUserRatingLedgerQuery
*
* Paginated/filtered query for user rating events (ledger).
*/
import { LedgerEntryDto, LedgerFilter, PaginatedLedgerResult } from '../dtos/LedgerEntryDto';
import { IRatingEventRepository, PaginatedQueryOptions, RatingEventFilter } from '../../domain/repositories/IRatingEventRepository';
export interface GetUserRatingLedgerQuery {
userId: string;
limit?: number;
offset?: number;
filter?: LedgerFilter;
}
export class GetUserRatingLedgerQueryHandler {
constructor(
private readonly ratingEventRepo: IRatingEventRepository
) {}
async execute(query: GetUserRatingLedgerQuery): Promise<PaginatedLedgerResult> {
const { userId, limit = 20, offset = 0, filter } = query;
// Build repo options
const repoOptions: PaginatedQueryOptions = {
limit,
offset,
};
// Add filter if provided
if (filter) {
const ratingEventFilter: RatingEventFilter = {};
if (filter.dimensions) {
ratingEventFilter.dimensions = filter.dimensions;
}
if (filter.sourceTypes) {
ratingEventFilter.sourceTypes = filter.sourceTypes;
}
if (filter.from) {
ratingEventFilter.from = new Date(filter.from);
}
if (filter.to) {
ratingEventFilter.to = new Date(filter.to);
}
if (filter.reasonCodes) {
ratingEventFilter.reasonCodes = filter.reasonCodes;
}
repoOptions.filter = ratingEventFilter;
}
// Query repository
const result = await this.ratingEventRepo.findEventsPaginated(userId, repoOptions);
// Convert domain entities to DTOs
const entries: LedgerEntryDto[] = result.items.map(event => {
const dto: LedgerEntryDto = {
id: event.id.value,
userId: event.userId,
dimension: event.dimension.value,
delta: event.delta.value,
occurredAt: event.occurredAt.toISOString(),
createdAt: event.createdAt.toISOString(),
source: event.source,
reason: event.reason,
visibility: event.visibility,
};
if (event.weight !== undefined) {
dto.weight = event.weight;
}
return dto;
});
const nextOffset = result.nextOffset !== undefined ? result.nextOffset : null;
return {
entries,
pagination: {
total: result.total,
limit: result.limit,
offset: result.offset,
hasMore: result.hasMore,
nextOffset,
},
};
}
}

View File

@@ -0,0 +1,170 @@
/**
* Tests for GetUserRatingsSummaryQuery
*/
import { GetUserRatingsSummaryQuery, GetUserRatingsSummaryQueryHandler } from './GetUserRatingsSummaryQuery';
import { UserRating } from '../../domain/value-objects/UserRating';
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
import { GameKey } from '../../domain/value-objects/GameKey';
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
describe('GetUserRatingsSummaryQuery', () => {
let mockUserRatingRepo: any;
let mockExternalRatingRepo: any;
let mockRatingEventRepo: any;
let handler: GetUserRatingsSummaryQueryHandler;
beforeEach(() => {
mockUserRatingRepo = {
findByUserId: jest.fn(),
};
mockExternalRatingRepo = {
findByUserId: jest.fn(),
};
mockRatingEventRepo = {
getAllByUserId: jest.fn(),
};
handler = new GetUserRatingsSummaryQueryHandler(
mockUserRatingRepo,
mockExternalRatingRepo,
mockRatingEventRepo
);
});
describe('execute', () => {
it('should return summary with platform and external ratings', async () => {
const userId = 'user-123';
// Mock user rating
const userRating = UserRating.create(userId);
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
// Mock external ratings
const gameKey = GameKey.create('iracing');
const profile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey,
ratings: new Map([
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
['safetyRating', ExternalRating.create(gameKey, 'safetyRating', 4.5)],
]),
provenance: ExternalRatingProvenance.create('iRacing API', new Date()),
});
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
// Mock rating events
const event = RatingEvent.create({
id: RatingEventId.create(),
userId,
dimension: RatingDimensionKey.create('driver'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01'),
createdAt: new Date('2024-01-01'),
source: { type: 'race', id: 'race-123' },
reason: { code: 'RACE_FINISH', summary: 'Good race', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
mockRatingEventRepo.getAllByUserId.mockResolvedValue([event]);
const query: GetUserRatingsSummaryQuery = { userId };
const result = await handler.execute(query);
expect(result.userId).toBe(userId);
expect(result.platform.driving.value).toBe(50); // Default
expect(result.platform.overallReputation).toBe(50);
expect(result.external.iracing.iRating).toBe(2200);
expect(result.external.iracing.safetyRating).toBe(4.5);
expect(result.lastRatingEventAt).toBe('2024-01-01T00:00:00.000Z');
});
it('should handle missing user rating gracefully', async () => {
const userId = 'user-123';
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
const query: GetUserRatingsSummaryQuery = { userId };
const result = await handler.execute(query);
expect(result.userId).toBe(userId);
expect(result.platform.driving.value).toBe(0);
expect(result.platform.overallReputation).toBe(0);
expect(result.external).toEqual({});
expect(result.lastRatingEventAt).toBeUndefined();
});
it('should handle multiple external game profiles', async () => {
const userId = 'user-123';
mockUserRatingRepo.findByUserId.mockResolvedValue(UserRating.create(userId));
// Multiple game profiles
const iracingProfile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey: GameKey.create('iracing'),
ratings: new Map([
['iRating', ExternalRating.create(GameKey.create('iracing'), 'iRating', 2200)],
]),
provenance: ExternalRatingProvenance.create('iRacing API', new Date()),
});
const assettoProfile = ExternalGameRatingProfile.create({
userId: { toString: () => userId } as any,
gameKey: GameKey.create('assetto'),
ratings: new Map([
['rating', ExternalRating.create(GameKey.create('assetto'), 'rating', 85)],
]),
provenance: ExternalRatingProvenance.create('Assetto API', new Date()),
});
mockExternalRatingRepo.findByUserId.mockResolvedValue([iracingProfile, assettoProfile]);
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
const query: GetUserRatingsSummaryQuery = { userId };
const result = await handler.execute(query);
expect(result.external.iracing.iRating).toBe(2200);
expect(result.external.assetto.rating).toBe(85);
});
it('should handle empty external ratings', async () => {
const userId = 'user-123';
mockUserRatingRepo.findByUserId.mockResolvedValue(UserRating.create(userId));
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
const query: GetUserRatingsSummaryQuery = { userId };
const result = await handler.execute(query);
expect(result.external).toEqual({});
});
it('should use current date for timestamps when no user rating exists', async () => {
const userId = 'user-123';
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
const beforeQuery = new Date();
const query: GetUserRatingsSummaryQuery = { userId };
const result = await handler.execute(query);
const afterQuery = new Date();
// Should have valid ISO date strings
expect(new Date(result.createdAt).getTime()).toBeGreaterThanOrEqual(beforeQuery.getTime());
expect(new Date(result.createdAt).getTime()).toBeLessThanOrEqual(afterQuery.getTime());
expect(new Date(result.updatedAt).getTime()).toBeGreaterThanOrEqual(beforeQuery.getTime());
expect(new Date(result.updatedAt).getTime()).toBeLessThanOrEqual(afterQuery.getTime());
});
});
});

View File

@@ -0,0 +1,118 @@
/**
* Query: GetUserRatingsSummaryQuery
*
* Fast read query for user rating summary.
* Combines platform snapshots and external game ratings.
*/
import { RatingSummaryDto } from '../dtos/RatingSummaryDto';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
export interface GetUserRatingsSummaryQuery {
userId: string;
}
export class GetUserRatingsSummaryQueryHandler {
constructor(
private readonly userRatingRepo: IUserRatingRepository,
private readonly externalRatingRepo: IExternalGameRatingRepository,
private readonly ratingEventRepo: IRatingEventRepository
) {}
async execute(query: GetUserRatingsSummaryQuery): Promise<RatingSummaryDto> {
const { userId } = query;
// Fetch platform rating snapshot
const userRating = await this.userRatingRepo.findByUserId(userId);
// Fetch all external game ratings
const externalProfiles = await this.externalRatingRepo.findByUserId(userId);
// Get last event timestamp if available
let lastRatingEventAt: string | undefined;
if (userRating) {
// Get all events to find the most recent one
const events = await this.ratingEventRepo.getAllByUserId(userId);
if (events.length > 0) {
const lastEvent = events[events.length - 1];
if (lastEvent) {
lastRatingEventAt = lastEvent.occurredAt.toISOString();
}
}
}
// Build platform rating dimensions
const platform = {
driving: {
value: userRating?.driver.value || 0,
confidence: userRating?.driver.confidence || 0,
sampleSize: userRating?.driver.sampleSize || 0,
trend: userRating?.driver.trend || 'stable',
lastUpdated: userRating?.driver.lastUpdated?.toISOString() || new Date(0).toISOString(),
},
admin: {
value: userRating?.admin.value || 0,
confidence: userRating?.admin.confidence || 0,
sampleSize: userRating?.admin.sampleSize || 0,
trend: userRating?.admin.trend || 'stable',
lastUpdated: userRating?.admin.lastUpdated?.toISOString() || new Date(0).toISOString(),
},
steward: {
value: userRating?.steward.value || 0,
confidence: userRating?.steward.confidence || 0,
sampleSize: userRating?.steward.sampleSize || 0,
trend: userRating?.steward.trend || 'stable',
lastUpdated: userRating?.steward.lastUpdated?.toISOString() || new Date(0).toISOString(),
},
trust: {
value: userRating?.trust.value || 0,
confidence: userRating?.trust.confidence || 0,
sampleSize: userRating?.trust.sampleSize || 0,
trend: userRating?.trust.trend || 'stable',
lastUpdated: userRating?.trust.lastUpdated?.toISOString() || new Date(0).toISOString(),
},
fairness: {
value: userRating?.fairness.value || 0,
confidence: userRating?.fairness.confidence || 0,
sampleSize: userRating?.fairness.sampleSize || 0,
trend: userRating?.fairness.trend || 'stable',
lastUpdated: userRating?.fairness.lastUpdated?.toISOString() || new Date(0).toISOString(),
},
overallReputation: userRating?.overallReputation || 0,
};
// Build external ratings map
const external: { [gameKey: string]: { [type: string]: number } } = {};
for (const profile of externalProfiles) {
const gameKey = profile.gameKey.toString();
external[gameKey] = {};
// Convert Map to array and iterate
const ratingsArray = Array.from(profile.ratings.entries());
for (const [type, rating] of ratingsArray) {
external[gameKey][type] = rating.value;
}
}
// Get timestamps
const createdAt = userRating?.createdAt?.toISOString() || new Date().toISOString();
const updatedAt = userRating?.updatedAt?.toISOString() || new Date().toISOString();
const result: RatingSummaryDto = {
userId,
platform,
external,
createdAt,
updatedAt,
};
if (lastRatingEventAt) {
result.lastRatingEventAt = lastRatingEventAt;
}
return result;
}
}

View File

@@ -0,0 +1,17 @@
/**
* Queries Index
*
* Export all query handlers and related types
*/
// GetUserRatingsSummaryQuery
export type { GetUserRatingsSummaryQuery } from './GetUserRatingsSummaryQuery';
export { GetUserRatingsSummaryQueryHandler } from './GetUserRatingsSummaryQuery';
// GetUserRatingLedgerQuery
export type { GetUserRatingLedgerQuery } from './GetUserRatingLedgerQuery';
export { GetUserRatingLedgerQueryHandler } from './GetUserRatingLedgerQuery';
// GetLeagueEligibilityPreviewQuery
export type { GetLeagueEligibilityPreviewQuery } from './GetLeagueEligibilityPreviewQuery';
export { GetLeagueEligibilityPreviewQueryHandler } from './GetLeagueEligibilityPreviewQuery';

View File

@@ -0,0 +1,707 @@
import { OpenAdminVoteSessionUseCase } from './OpenAdminVoteSessionUseCase';
import { CastAdminVoteUseCase } from './CastAdminVoteUseCase';
import { CloseAdminVoteSessionUseCase } from './CloseAdminVoteSessionUseCase';
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { UserRating } from '../../domain/value-objects/UserRating';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
// Mock Repository
class MockAdminVoteSessionRepository {
private sessions: Map<string, AdminVoteSession> = new Map();
async save(session: AdminVoteSession): Promise<AdminVoteSession> {
this.sessions.set(session.id, session);
return session;
}
async findById(id: string): Promise<AdminVoteSession | null> {
return this.sessions.get(id) || null;
}
async findActiveForAdmin(adminId: string, leagueId: string): Promise<AdminVoteSession[]> {
const now = new Date();
return Array.from(this.sessions.values()).filter(
s => s.adminId === adminId &&
s.leagueId === leagueId &&
s.isVotingWindowOpen(now) &&
!s.closed
);
}
async findByAdminAndLeague(adminId: string, leagueId: string): Promise<AdminVoteSession[]> {
return Array.from(this.sessions.values()).filter(
s => s.adminId === adminId && s.leagueId === leagueId
);
}
async findByLeague(leagueId: string): Promise<AdminVoteSession[]> {
return Array.from(this.sessions.values()).filter(
s => s.leagueId === leagueId
);
}
async findClosedUnprocessed(): Promise<AdminVoteSession[]> {
return Array.from(this.sessions.values()).filter(
s => s.closed && s.outcome !== undefined
);
}
}
class MockRatingEventRepository {
private events: Map<string, RatingEvent> = new Map();
async save(event: RatingEvent): Promise<RatingEvent> {
this.events.set(event.id.value, event);
return event;
}
async findByUserId(userId: string): Promise<RatingEvent[]> {
return Array.from(this.events.values()).filter(e => e.userId === userId);
}
async findByIds(ids: string[]): Promise<RatingEvent[]> {
return Array.from(this.events.values()).filter(e => ids.includes(e.id.value));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
return Array.from(this.events.values()).filter(e => e.userId === userId);
}
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
const allEvents = await this.findByUserId(userId);
// Apply filters
let filtered = allEvents;
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
}
if (filter.sourceTypes) {
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
}
if (filter.from) {
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
}
if (filter.to) {
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
}
if (filter.reasonCodes) {
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
}
if (filter.visibility) {
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
class MockUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async save(rating: UserRating): Promise<UserRating> {
this.ratings.set(rating.userId, rating);
return rating;
}
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) || null;
}
}
// Mock AppendRatingEventsUseCase
class MockAppendRatingEventsUseCase {
constructor(
private ratingEventRepository: any,
private userRatingRepository: any
) {}
async execute(input: any): Promise<any> {
const events: RatingEvent[] = [];
// Create events from input
for (const eventDto of input.events || []) {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: eventDto.userId,
dimension: RatingDimensionKey.create(eventDto.dimension),
delta: RatingDelta.create(eventDto.delta),
weight: eventDto.weight,
occurredAt: new Date(eventDto.occurredAt),
createdAt: new Date(),
source: { type: eventDto.sourceType, id: eventDto.sourceId },
reason: {
code: eventDto.reasonCode,
summary: eventDto.reasonSummary,
details: eventDto.reasonDetails || {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
});
events.push(event);
await this.ratingEventRepository.save(event);
}
// Recompute snapshot
if (events.length > 0) {
const allEvents = await this.ratingEventRepository.getAllByUserId(input.userId);
// Simplified snapshot calculation
const totalDelta = allEvents.reduce((sum, e) => sum + e.delta.value, 0);
const snapshot = UserRating.create({
userId: input.userId,
driver: { value: Math.max(0, Math.min(100, 50 + totalDelta)) },
adminTrust: { value: 50 },
stewardTrust: { value: 50 },
broadcasterTrust: { value: 50 },
lastUpdated: new Date(),
});
await this.userRatingRepository.save(snapshot);
}
return {
events: events.map(e => e.id.value),
snapshotUpdated: events.length > 0,
};
}
}
describe('Admin Vote Session Use Cases', () => {
let mockSessionRepo: MockAdminVoteSessionRepository;
let mockEventRepo: MockRatingEventRepository;
let mockRatingRepo: MockUserRatingRepository;
let mockAppendUseCase: MockAppendRatingEventsUseCase;
let openUseCase: OpenAdminVoteSessionUseCase;
let castUseCase: CastAdminVoteUseCase;
let closeUseCase: CloseAdminVoteSessionUseCase;
const now = new Date('2025-01-01T00:00:00Z');
const tomorrow = new Date('2025-01-02T00:00:00Z');
beforeEach(() => {
mockSessionRepo = new MockAdminVoteSessionRepository();
mockEventRepo = new MockRatingEventRepository();
mockRatingRepo = new MockUserRatingRepository();
mockAppendUseCase = new MockAppendRatingEventsUseCase(mockEventRepo, mockRatingRepo);
openUseCase = new OpenAdminVoteSessionUseCase(mockSessionRepo);
castUseCase = new CastAdminVoteUseCase(mockSessionRepo);
closeUseCase = new CloseAdminVoteSessionUseCase(
mockSessionRepo,
mockEventRepo,
mockRatingRepo,
mockAppendUseCase
);
// Mock Date.now to return our test time
jest.spyOn(Date, 'now').mockReturnValue(now.getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('OpenAdminVoteSessionUseCase', () => {
it('should successfully open a vote session', async () => {
const input = {
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1', 'user-2', 'user-3'],
};
const result = await openUseCase.execute(input);
expect(result.success).toBe(true);
expect(result.voteSessionId).toBe('vote-123');
// Verify session was saved
const saved = await mockSessionRepo.findById('vote-123');
expect(saved).toBeDefined();
expect(saved!.adminId).toBe('admin-789');
expect(saved!.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']);
});
it('should reject duplicate session IDs', async () => {
const input = {
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1'],
};
await openUseCase.execute(input);
const result = await openUseCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session with this ID already exists');
});
it('should reject overlapping sessions', async () => {
// Create first session
await openUseCase.execute({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1'],
});
// Try to create overlapping session
const result = await openUseCase.execute({
voteSessionId: 'vote-456',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: new Date('2025-01-01T12:00:00Z').toISOString(),
endDate: new Date('2025-01-02T12:00:00Z').toISOString(),
eligibleVoters: ['user-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Active vote session already exists for this admin in this league with overlapping dates');
});
it('should validate required fields', async () => {
const result = await openUseCase.execute({
voteSessionId: '',
leagueId: '',
adminId: '',
startDate: '',
endDate: '',
eligibleVoters: [],
});
expect(result.success).toBe(false);
expect(result.errors?.length).toBeGreaterThan(0);
});
it('should validate date order', async () => {
const result = await openUseCase.execute({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: tomorrow.toISOString(),
endDate: now.toISOString(),
eligibleVoters: ['user-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('startDate must be before endDate');
});
it('should validate duplicate eligible voters', async () => {
const result = await openUseCase.execute({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1', 'user-2', 'user-1'],
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Duplicate eligible voters are not allowed');
});
});
describe('CastAdminVoteUseCase', () => {
beforeEach(async () => {
// Create a session for voting tests
await openUseCase.execute({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1', 'user-2', 'user-3'],
});
});
it('should allow eligible voter to cast positive vote', async () => {
const result = await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: true,
});
expect(result.success).toBe(true);
expect(result.voterId).toBe('user-1');
const session = await mockSessionRepo.findById('vote-123');
expect(session!.votes.length).toBe(1);
expect(session!.votes[0].voterId).toBe('user-1');
expect(session!.votes[0].positive).toBe(true);
});
it('should allow eligible voter to cast negative vote', async () => {
const result = await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: false,
});
expect(result.success).toBe(true);
const session = await mockSessionRepo.findById('vote-123');
expect(session!.votes[0].positive).toBe(false);
});
it('should reject non-eligible voter', async () => {
const result = await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-999',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Voter user-999 is not eligible for this session');
});
it('should prevent duplicate votes', async () => {
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: true,
});
const result = await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: false,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Voter user-1 has already voted');
});
it('should reject votes after session closes', async () => {
// Close the session first
await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
const result = await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-2',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Session is closed');
});
it('should reject votes outside voting window', async () => {
// Create session in future
const futureStart = new Date('2025-02-01T00:00:00Z');
const futureEnd = new Date('2025-02-02T00:00:00Z');
await openUseCase.execute({
voteSessionId: 'vote-future',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: futureStart.toISOString(),
endDate: futureEnd.toISOString(),
eligibleVoters: ['user-1'],
});
const result = await castUseCase.execute({
voteSessionId: 'vote-future',
voterId: 'user-1',
positive: true,
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session is not open for voting');
});
});
describe('CloseAdminVoteSessionUseCase', () => {
beforeEach(async () => {
// Create a session for closing tests
await openUseCase.execute({
voteSessionId: 'vote-123',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'],
});
});
it('should close session and create positive outcome events', async () => {
// Cast votes
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: true,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-2',
positive: true,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-3',
positive: false,
});
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
expect(result.success).toBe(true);
expect(result.outcome).toBeDefined();
expect(result.outcome!.outcome).toBe('positive');
expect(result.outcome!.percentPositive).toBe(66.67);
expect(result.outcome!.count.total).toBe(3);
expect(result.eventsCreated).toBe(1);
// Verify session is closed
const session = await mockSessionRepo.findById('vote-123');
expect(session!.closed).toBe(true);
expect(session!.outcome).toBeDefined();
// Verify rating events were created
const events = await mockEventRepo.findByUserId('admin-789');
expect(events.length).toBe(1);
expect(events[0].dimension.value).toBe('adminTrust');
expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
});
it('should create negative outcome events', async () => {
// Cast mostly negative votes
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: false,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-2',
positive: false,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-3',
positive: true,
});
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
expect(result.success).toBe(true);
expect(result.outcome!.outcome).toBe('negative');
expect(result.outcome!.percentPositive).toBe(33.33);
expect(result.eventsCreated).toBe(1);
const events = await mockEventRepo.findByUserId('admin-789');
expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_NEGATIVE');
expect(events[0].delta.value).toBeLessThan(0);
});
it('should handle tie outcome', async () => {
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: true,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-2',
positive: false,
});
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
expect(result.success).toBe(true);
expect(result.outcome!.outcome).toBe('tie');
expect(result.eventsCreated).toBe(0); // No events for tie
});
it('should handle no votes', async () => {
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
expect(result.success).toBe(true);
expect(result.outcome!.outcome).toBe('tie');
expect(result.outcome!.participationRate).toBe(0);
expect(result.eventsCreated).toBe(0);
});
it('should reject closing already closed session', async () => {
await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Vote session is already closed');
});
it('should reject non-owner trying to close', async () => {
const result = await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'wrong-admin',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Admin does not own this vote session');
});
it('should reject closing outside voting window', async () => {
// Create session in future
const futureStart = new Date('2025-02-01T00:00:00Z');
const futureEnd = new Date('2025-02-02T00:00:00Z');
await openUseCase.execute({
voteSessionId: 'vote-future',
leagueId: 'league-456',
adminId: 'admin-789',
startDate: futureStart.toISOString(),
endDate: futureEnd.toISOString(),
eligibleVoters: ['user-1'],
});
const result = await closeUseCase.execute({
voteSessionId: 'vote-future',
adminId: 'admin-789',
});
expect(result.success).toBe(false);
expect(result.errors).toContain('Cannot close session outside the voting window');
});
it('should update admin rating snapshot', async () => {
// Cast votes
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-1',
positive: true,
});
await castUseCase.execute({
voteSessionId: 'vote-123',
voterId: 'user-2',
positive: true,
});
await closeUseCase.execute({
voteSessionId: 'vote-123',
adminId: 'admin-789',
});
// Check snapshot was updated
const snapshot = await mockRatingRepo.findByUserId('admin-789');
expect(snapshot).toBeDefined();
expect(snapshot!.adminTrust.value).toBeGreaterThan(50); // Should have increased
});
});
describe('Integration: Full vote flow', () => {
it('should complete full flow: open -> cast votes -> close -> events', async () => {
// 1. Open session
const openResult = await openUseCase.execute({
voteSessionId: 'vote-full',
leagueId: 'league-full',
adminId: 'admin-full',
startDate: now.toISOString(),
endDate: tomorrow.toISOString(),
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4', 'user-5'],
});
expect(openResult.success).toBe(true);
// 2. Cast votes
const votes = [
{ voterId: 'user-1', positive: true },
{ voterId: 'user-2', positive: true },
{ voterId: 'user-3', positive: true },
{ voterId: 'user-4', positive: false },
];
for (const vote of votes) {
const castResult = await castUseCase.execute({
voteSessionId: 'vote-full',
voterId: vote.voterId,
positive: vote.positive,
});
expect(castResult.success).toBe(true);
}
// 3. Close session
const closeResult = await closeUseCase.execute({
voteSessionId: 'vote-full',
adminId: 'admin-full',
});
expect(closeResult.success).toBe(true);
expect(closeResult.outcome).toEqual({
percentPositive: 75,
count: { positive: 3, negative: 1, total: 4 },
eligibleVoterCount: 5,
participationRate: 80,
outcome: 'positive',
});
expect(closeResult.eventsCreated).toBe(1);
// 4. Verify events in ledger
const events = await mockEventRepo.findByUserId('admin-full');
expect(events.length).toBe(1);
const event = events[0];
expect(event.userId).toBe('admin-full');
expect(event.dimension.value).toBe('adminTrust');
expect(event.source.type).toBe('vote');
expect(event.source.id).toBe('vote-full');
expect(event.reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
expect(event.reason.summary).toContain('75% positive');
expect(event.weight).toBe(4); // vote count
// 5. Verify snapshot
const snapshot = await mockRatingRepo.findByUserId('admin-full');
expect(snapshot).toBeDefined();
expect(snapshot!.adminTrust.value).toBeGreaterThan(50);
});
});
});

View File

@@ -0,0 +1,177 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { AppendRatingEventsUseCase, AppendRatingEventsInput } from './AppendRatingEventsUseCase';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { UserRating } from '../../domain/value-objects/UserRating';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
describe('AppendRatingEventsUseCase', () => {
let mockEventRepo: Partial<IRatingEventRepository>;
let mockRatingRepo: Partial<IUserRatingRepository>;
beforeEach(() => {
mockEventRepo = {
save: vi.fn(),
getAllByUserId: vi.fn().mockResolvedValue([]),
};
mockRatingRepo = {
save: vi.fn(),
};
});
it('should be constructed with repositories', () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
expect(useCase).toBeInstanceOf(AppendRatingEventsUseCase);
});
it('should handle empty input (no events)', async () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
};
const result = await useCase.execute(input);
expect(result.events).toEqual([]);
expect(result.snapshotUpdated).toBe(false);
expect(mockEventRepo.save).not.toHaveBeenCalled();
expect(mockRatingRepo.save).not.toHaveBeenCalled();
});
it('should create and save events from direct input', async () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
events: [
{
userId: 'user-1',
dimension: 'driving',
delta: 5,
sourceType: 'race',
sourceId: 'race-1',
reasonCode: 'TEST',
reasonSummary: 'Test event',
},
],
};
const result = await useCase.execute(input);
expect(result.events).toHaveLength(1);
expect(result.snapshotUpdated).toBe(true);
expect(mockEventRepo.save).toHaveBeenCalledTimes(1);
expect(mockRatingRepo.save).toHaveBeenCalledTimes(1);
});
it('should create events from race results using factory', async () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
raceId: 'race-123',
raceResults: [
{
position: 3,
totalDrivers: 10,
startPosition: 5,
incidents: 1,
fieldStrength: 1500,
status: 'finished',
},
],
};
const result = await useCase.execute(input);
expect(result.events.length).toBeGreaterThan(0);
expect(result.snapshotUpdated).toBe(true);
expect(mockEventRepo.save).toHaveBeenCalled();
expect(mockRatingRepo.save).toHaveBeenCalled();
});
it('should handle multiple race results', async () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
raceId: 'race-123',
raceResults: [
{ position: 3, totalDrivers: 10, startPosition: 5, incidents: 1, fieldStrength: 1500, status: 'finished' },
{ position: 1, totalDrivers: 10, startPosition: 2, incidents: 0, fieldStrength: 1500, status: 'finished' },
],
};
const result = await useCase.execute(input);
expect(result.events.length).toBeGreaterThan(2); // Multiple events per race
expect(result.snapshotUpdated).toBe(true);
});
it('should handle DNF status', async () => {
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
raceId: 'race-123',
raceResults: [
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 2, fieldStrength: 1500, status: 'dnf' },
],
};
const result = await useCase.execute(input);
expect(result.events.length).toBeGreaterThan(0);
expect(result.snapshotUpdated).toBe(true);
expect(mockEventRepo.save).toHaveBeenCalled();
});
it('should not update snapshot if no events were saved', async () => {
// Mock save to throw or do nothing
const saveMock = vi.fn().mockResolvedValue({});
const getAllMock = vi.fn().mockResolvedValue([]);
mockEventRepo = {
save: saveMock,
getAllByUserId: getAllMock,
};
const useCase = new AppendRatingEventsUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const input: AppendRatingEventsInput = {
userId: 'user-1',
events: [], // Empty
};
const result = await useCase.execute(input);
expect(result.events).toEqual([]);
expect(result.snapshotUpdated).toBe(false);
expect(mockRatingRepo.save).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,123 @@
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
import { CreateRatingEventDto } from '../dtos/CreateRatingEventDto';
/**
* Input for AppendRatingEventsUseCase
*/
export interface AppendRatingEventsInput {
userId: string;
events?: CreateRatingEventDto[]; // Optional: direct event creation
// Alternative: raceId, penaltyId, etc. for factory-based creation
raceId?: string;
raceResults?: Array<{
position: number;
totalDrivers: number;
startPosition: number;
incidents: number;
fieldStrength: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
}>;
}
/**
* Output for AppendRatingEventsUseCase
*/
export interface AppendRatingEventsOutput {
events: string[]; // Event IDs
snapshotUpdated: boolean;
}
/**
* Use Case: AppendRatingEventsUseCase
*
* Appends rating events to the ledger and recomputes the snapshot.
* Follows CQRS Light: command side operation.
*/
export class AppendRatingEventsUseCase {
constructor(
private readonly ratingEventRepository: IRatingEventRepository,
private readonly userRatingRepository: IUserRatingRepository,
) {}
async execute(input: AppendRatingEventsInput): Promise<AppendRatingEventsOutput> {
const eventsToSave = [];
// 1. Create events from direct input
if (input.events) {
for (const eventDto of input.events) {
const event = this.createEventFromDto(eventDto);
eventsToSave.push(event);
}
}
// 2. Create events from race results (using factory)
if (input.raceId && input.raceResults) {
for (const result of input.raceResults) {
const raceEvents = RatingEventFactory.createFromRaceFinish({
userId: input.userId,
raceId: input.raceId,
position: result.position,
totalDrivers: result.totalDrivers,
startPosition: result.startPosition,
incidents: result.incidents,
fieldStrength: result.fieldStrength,
status: result.status,
});
eventsToSave.push(...raceEvents);
}
}
// 3. Save all events to ledger
const savedEventIds: string[] = [];
for (const event of eventsToSave) {
await this.ratingEventRepository.save(event);
savedEventIds.push(event.id.value);
}
// 4. Recompute snapshot if events were saved
let snapshotUpdated = false;
if (savedEventIds.length > 0) {
const allEvents = await this.ratingEventRepository.getAllByUserId(input.userId);
const snapshot = RatingSnapshotCalculator.calculate(input.userId, allEvents);
await this.userRatingRepository.save(snapshot);
snapshotUpdated = true;
}
return {
events: savedEventIds,
snapshotUpdated,
};
}
private createEventFromDto(dto: CreateRatingEventDto) {
const props: any = {
id: RatingEventId.generate(),
userId: dto.userId,
dimension: RatingDimensionKey.create(dto.dimension),
delta: RatingDelta.create(dto.delta),
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(),
createdAt: new Date(),
source: { type: dto.sourceType, id: dto.sourceId },
reason: {
code: dto.reasonCode,
summary: dto.reasonSummary,
details: dto.reasonDetails || {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
};
if (dto.weight !== undefined) {
props.weight = dto.weight;
}
return RatingEvent.create(props);
}
}

View File

@@ -0,0 +1,99 @@
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
import { CastAdminVoteInput, CastAdminVoteOutput } from '../dtos/AdminVoteSessionDto';
/**
* Use Case: CastAdminVoteUseCase
*
* Casts a vote in an active admin vote session.
* Follows CQRS Light: command side operation.
*
* Per plans section 7.1.1
*/
export class CastAdminVoteUseCase {
constructor(
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
) {}
async execute(input: CastAdminVoteInput): Promise<CastAdminVoteOutput> {
try {
// Validate input
const errors = this.validateInput(input);
if (errors.length > 0) {
return {
success: false,
voteSessionId: input.voteSessionId,
voterId: input.voterId,
errors,
};
}
// Load the vote session
const session = await this.adminVoteSessionRepository.findById(input.voteSessionId);
if (!session) {
return {
success: false,
voteSessionId: input.voteSessionId,
voterId: input.voterId,
errors: ['Vote session not found'],
};
}
// Check if session is open
const voteTime = input.votedAt ? new Date(input.votedAt) : new Date();
if (!session.isVotingWindowOpen(voteTime)) {
return {
success: false,
voteSessionId: input.voteSessionId,
voterId: input.voterId,
errors: ['Vote session is not open for voting'],
};
}
// Cast the vote
session.castVote(input.voterId, input.positive, voteTime);
// Save updated session
await this.adminVoteSessionRepository.save(session);
return {
success: true,
voteSessionId: input.voteSessionId,
voterId: input.voterId,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
voteSessionId: input.voteSessionId,
voterId: input.voterId,
errors: [`Failed to cast vote: ${errorMsg}`],
};
}
}
private validateInput(input: CastAdminVoteInput): string[] {
const errors: string[] = [];
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
errors.push('voteSessionId is required');
}
if (!input.voterId || input.voterId.trim().length === 0) {
errors.push('voterId is required');
}
if (typeof input.positive !== 'boolean') {
errors.push('positive must be a boolean value');
}
if (input.votedAt) {
const votedAt = new Date(input.votedAt);
if (isNaN(votedAt.getTime())) {
errors.push('votedAt must be a valid date if provided');
}
}
return errors;
}
}

View File

@@ -0,0 +1,163 @@
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { AdminTrustRatingCalculator } from '../../domain/services/AdminTrustRatingCalculator';
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
import { CloseAdminVoteSessionInput, CloseAdminVoteSessionOutput } from '../dtos/AdminVoteSessionDto';
/**
* Use Case: CloseAdminVoteSessionUseCase
*
* Closes an admin vote session and generates rating events.
* This is the key use case that triggers events on close per plans section 7.1.1.
*
* Flow:
* 1. Load and validate session
* 2. Close session and calculate outcome
* 3. Create rating events from outcome
* 4. Append events to ledger for each affected admin
* 5. Recompute snapshots
*
* Per plans section 7.1.1 and 10.2
*/
export class CloseAdminVoteSessionUseCase {
constructor(
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
private readonly ratingEventRepository: IRatingEventRepository,
private readonly userRatingRepository: IUserRatingRepository,
private readonly appendRatingEventsUseCase: any, // Will be typed properly in integration
) {}
async execute(input: CloseAdminVoteSessionInput): Promise<CloseAdminVoteSessionOutput> {
try {
// Validate input
const errors = this.validateInput(input);
if (errors.length > 0) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors,
};
}
// Load the vote session
const session = await this.adminVoteSessionRepository.findById(input.voteSessionId);
if (!session) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Vote session not found'],
};
}
// Validate admin ownership
if (session.adminId !== input.adminId) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Admin does not own this vote session'],
};
}
// Check if already closed
if (session.closed) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Vote session is already closed'],
};
}
// Check if within voting window
const now = new Date();
if (now < session.startDate || now > session.endDate) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Cannot close session outside the voting window'],
};
}
// Close session and calculate outcome
const outcome = session.close();
// Save closed session
await this.adminVoteSessionRepository.save(session);
// Create rating events from outcome
// Per plans: events are created for the admin being voted on
const eventsCreated = await this.createRatingEvents(session, outcome);
return {
success: true,
voteSessionId: input.voteSessionId,
outcome: {
percentPositive: outcome.percentPositive,
count: outcome.count,
eligibleVoterCount: outcome.eligibleVoterCount,
participationRate: outcome.participationRate,
outcome: outcome.outcome,
},
eventsCreated,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
voteSessionId: input.voteSessionId,
errors: [`Failed to close vote session: ${errorMsg}`],
};
}
}
/**
* Create rating events from vote outcome
* Events are created for the admin being voted on
*/
private async createRatingEvents(session: any, outcome: any): Promise<number> {
let eventsCreated = 0;
// Use RatingEventFactory to create vote outcome events
const voteInput = {
userId: session.adminId, // The admin being voted on
voteSessionId: session.id,
outcome: (outcome.outcome === 'positive' ? 'positive' : 'negative') as 'positive' | 'negative',
voteCount: outcome.count.total,
eligibleVoterCount: outcome.eligibleVoterCount,
percentPositive: outcome.percentPositive,
};
const events = RatingEventFactory.createFromVote(voteInput);
// Save each event to ledger
for (const event of events) {
await this.ratingEventRepository.save(event);
eventsCreated++;
}
// Recompute snapshot for the admin
if (eventsCreated > 0) {
const allEvents = await this.ratingEventRepository.getAllByUserId(session.adminId);
const snapshot = RatingSnapshotCalculator.calculate(session.adminId, allEvents);
await this.userRatingRepository.save(snapshot);
}
return eventsCreated;
}
private validateInput(input: CloseAdminVoteSessionInput): string[] {
const errors: string[] = [];
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
errors.push('voteSessionId is required');
}
if (!input.adminId || input.adminId.trim().length === 0) {
errors.push('adminId is required');
}
return errors;
}
}

View File

@@ -0,0 +1,144 @@
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
import { OpenAdminVoteSessionInput, OpenAdminVoteSessionOutput } from '../dtos/AdminVoteSessionDto';
/**
* Use Case: OpenAdminVoteSessionUseCase
*
* Opens a new admin vote session for a league.
* Follows CQRS Light: command side operation.
*
* Per plans section 7.1.1
*/
export class OpenAdminVoteSessionUseCase {
constructor(
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
) {}
async execute(input: OpenAdminVoteSessionInput): Promise<OpenAdminVoteSessionOutput> {
try {
// Validate input
const errors = this.validateInput(input);
if (errors.length > 0) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors,
};
}
// Check if session already exists
const existing = await this.adminVoteSessionRepository.findById(input.voteSessionId);
if (existing) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Vote session with this ID already exists'],
};
}
// Check for overlapping active sessions for this admin in this league
const activeSessions = await this.adminVoteSessionRepository.findActiveForAdmin(
input.adminId,
input.leagueId
);
const startDate = new Date(input.startDate);
const endDate = new Date(input.endDate);
for (const session of activeSessions) {
// Check for overlap
if (
(startDate >= session.startDate && startDate <= session.endDate) ||
(endDate >= session.startDate && endDate <= session.endDate) ||
(startDate <= session.startDate && endDate >= session.endDate)
) {
return {
success: false,
voteSessionId: input.voteSessionId,
errors: ['Active vote session already exists for this admin in this league with overlapping dates'],
};
}
}
// Create the vote session
const session = AdminVoteSession.create({
voteSessionId: input.voteSessionId,
leagueId: input.leagueId,
adminId: input.adminId,
startDate,
endDate,
eligibleVoters: input.eligibleVoters,
});
// Save to repository
await this.adminVoteSessionRepository.save(session);
return {
success: true,
voteSessionId: input.voteSessionId,
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
voteSessionId: input.voteSessionId,
errors: [`Failed to open vote session: ${errorMsg}`],
};
}
}
private validateInput(input: OpenAdminVoteSessionInput): string[] {
const errors: string[] = [];
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
errors.push('voteSessionId is required');
}
if (!input.leagueId || input.leagueId.trim().length === 0) {
errors.push('leagueId is required');
}
if (!input.adminId || input.adminId.trim().length === 0) {
errors.push('adminId is required');
}
if (!input.startDate) {
errors.push('startDate is required');
}
if (!input.endDate) {
errors.push('endDate is required');
}
if (input.startDate && input.endDate) {
const startDate = new Date(input.startDate);
const endDate = new Date(input.endDate);
if (isNaN(startDate.getTime())) {
errors.push('startDate must be a valid date');
}
if (isNaN(endDate.getTime())) {
errors.push('endDate must be a valid date');
}
if (startDate >= endDate) {
errors.push('startDate must be before endDate');
}
}
if (!input.eligibleVoters || input.eligibleVoters.length === 0) {
errors.push('At least one eligible voter is required');
} else {
// Check for duplicates
const uniqueVoters = new Set(input.eligibleVoters);
if (uniqueVoters.size !== input.eligibleVoters.length) {
errors.push('Duplicate eligible voters are not allowed');
}
}
return errors;
}
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { RecomputeUserRatingSnapshotUseCase } from './RecomputeUserRatingSnapshotUseCase';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { UserRating } from '../../domain/value-objects/UserRating';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
describe('RecomputeUserRatingSnapshotUseCase', () => {
let mockEventRepo: Partial<IRatingEventRepository>;
let mockRatingRepo: Partial<IUserRatingRepository>;
beforeEach(() => {
mockEventRepo = {
getAllByUserId: vi.fn().mockResolvedValue([]),
};
mockRatingRepo = {
save: vi.fn(),
};
});
it('should be constructed with repositories', () => {
const useCase = new RecomputeUserRatingSnapshotUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
expect(useCase).toBeInstanceOf(RecomputeUserRatingSnapshotUseCase);
});
it('should compute snapshot from empty event list', async () => {
const useCase = new RecomputeUserRatingSnapshotUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const result = await useCase.execute({ userId: 'user-1' });
expect(result.snapshot.userId).toBe('user-1');
expect(result.snapshot.driver.value).toBe(50); // Default
expect(mockEventRepo.getAllByUserId).toHaveBeenCalledWith('user-1');
expect(mockRatingRepo.save).toHaveBeenCalled();
});
it('should compute snapshot from events', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
mockEventRepo.getAllByUserId = vi.fn().mockResolvedValue(events);
const useCase = new RecomputeUserRatingSnapshotUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const result = await useCase.execute({ userId: 'user-1' });
expect(result.snapshot.userId).toBe('user-1');
expect(result.snapshot.driver.value).toBeGreaterThan(50); // Should have increased
expect(mockEventRepo.getAllByUserId).toHaveBeenCalledWith('user-1');
expect(mockRatingRepo.save).toHaveBeenCalled();
});
it('should return proper DTO format', async () => {
const useCase = new RecomputeUserRatingSnapshotUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const result = await useCase.execute({ userId: 'user-1' });
// Check DTO structure
expect(result.snapshot).toHaveProperty('userId');
expect(result.snapshot).toHaveProperty('driver');
expect(result.snapshot).toHaveProperty('admin');
expect(result.snapshot).toHaveProperty('steward');
expect(result.snapshot).toHaveProperty('trust');
expect(result.snapshot).toHaveProperty('fairness');
expect(result.snapshot).toHaveProperty('overallReputation');
expect(result.snapshot).toHaveProperty('createdAt');
expect(result.snapshot).toHaveProperty('updatedAt');
// Check dimension structure
expect(result.snapshot.driver).toEqual({
value: expect.any(Number),
confidence: expect.any(Number),
sampleSize: expect.any(Number),
trend: expect.any(String),
lastUpdated: expect.any(String),
});
// Check dates are ISO strings
expect(result.snapshot.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(result.snapshot.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should handle calculatorVersion in DTO', async () => {
// Create a rating with calculatorVersion
const rating = UserRating.create('user-1');
const updated = rating.updateDriverRating(75);
mockRatingRepo.save = vi.fn().mockResolvedValue(updated);
const useCase = new RecomputeUserRatingSnapshotUseCase(
mockEventRepo as IRatingEventRepository,
mockRatingRepo as IUserRatingRepository,
);
const result = await useCase.execute({ userId: 'user-1' });
// Should have calculatorVersion
expect(result.snapshot.calculatorVersion).toBeDefined();
});
});

View File

@@ -0,0 +1,94 @@
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
import { UserRatingDto } from '../dtos/UserRatingDto';
/**
* Input for RecomputeUserRatingSnapshotUseCase
*/
export interface RecomputeUserRatingSnapshotInput {
userId: string;
}
/**
* Output for RecomputeUserRatingSnapshotUseCase
*/
export interface RecomputeUserRatingSnapshotOutput {
snapshot: UserRatingDto;
}
/**
* Use Case: RecomputeUserRatingSnapshotUseCase
*
* Recomputes a user's rating snapshot from all events in the ledger.
* Useful for:
* - Algorithm updates
* - Data corrections
* - Manual recomputation
*/
export class RecomputeUserRatingSnapshotUseCase {
constructor(
private readonly ratingEventRepository: IRatingEventRepository,
private readonly userRatingRepository: IUserRatingRepository,
) {}
async execute(input: RecomputeUserRatingSnapshotInput): Promise<RecomputeUserRatingSnapshotOutput> {
// 1. Load all events for the user
const events = await this.ratingEventRepository.getAllByUserId(input.userId);
// 2. Compute snapshot from events
const snapshot = RatingSnapshotCalculator.calculate(input.userId, events);
// 3. Save snapshot
await this.userRatingRepository.save(snapshot);
// 4. Convert to DTO for output
const dto: UserRatingDto = {
userId: snapshot.userId,
driver: {
value: snapshot.driver.value,
confidence: snapshot.driver.confidence,
sampleSize: snapshot.driver.sampleSize,
trend: snapshot.driver.trend,
lastUpdated: snapshot.driver.lastUpdated.toISOString(),
},
admin: {
value: snapshot.admin.value,
confidence: snapshot.admin.confidence,
sampleSize: snapshot.admin.sampleSize,
trend: snapshot.admin.trend,
lastUpdated: snapshot.admin.lastUpdated.toISOString(),
},
steward: {
value: snapshot.steward.value,
confidence: snapshot.steward.confidence,
sampleSize: snapshot.steward.sampleSize,
trend: snapshot.steward.trend,
lastUpdated: snapshot.steward.lastUpdated.toISOString(),
},
trust: {
value: snapshot.trust.value,
confidence: snapshot.trust.confidence,
sampleSize: snapshot.trust.sampleSize,
trend: snapshot.trust.trend,
lastUpdated: snapshot.trust.lastUpdated.toISOString(),
},
fairness: {
value: snapshot.fairness.value,
confidence: snapshot.fairness.confidence,
sampleSize: snapshot.fairness.sampleSize,
trend: snapshot.fairness.trend,
lastUpdated: snapshot.fairness.lastUpdated.toISOString(),
},
overallReputation: snapshot.overallReputation,
createdAt: snapshot.createdAt.toISOString(),
updatedAt: snapshot.updatedAt.toISOString(),
};
if (snapshot.calculatorVersion !== undefined) {
dto.calculatorVersion = snapshot.calculatorVersion;
}
return { snapshot: dto };
}
}

View File

@@ -0,0 +1,429 @@
import { RecordRaceRatingEventsUseCase } from './RecordRaceRatingEventsUseCase';
import { IRaceResultsProvider, RaceResultsData } from '../ports/IRaceResultsProvider';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
import { UserRating } from '../../domain/value-objects/UserRating';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
// In-memory implementations for integration testing
class InMemoryRaceResultsProvider implements IRaceResultsProvider {
private results: Map<string, RaceResultsData> = new Map();
async getRaceResults(raceId: string): Promise<RaceResultsData | null> {
return this.results.get(raceId) || null;
}
async hasRaceResults(raceId: string): Promise<boolean> {
return this.results.has(raceId);
}
// Helper for tests
setRaceResults(raceId: string, results: RaceResultsData) {
this.results.set(raceId, results);
}
}
class InMemoryRatingEventRepository implements IRatingEventRepository {
private events: Map<string, RatingEvent[]> = new Map();
async save(event: RatingEvent): Promise<RatingEvent> {
const userId = event.userId;
if (!this.events.has(userId)) {
this.events.set(userId, []);
}
this.events.get(userId)!.push(event);
return event;
}
async findByUserId(userId: string): Promise<RatingEvent[]> {
return this.events.get(userId) || [];
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
const allEvents = Array.from(this.events.values()).flat();
return allEvents.filter(e => ids.some(id => id.equals(e.id)));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
return this.events.get(userId) || [];
}
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
const allEvents = await this.findByUserId(userId);
// Apply filters
let filtered = allEvents;
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
}
if (filter.sourceTypes) {
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
}
if (filter.from) {
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
}
if (filter.to) {
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
}
if (filter.reasonCodes) {
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
}
if (filter.visibility) {
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
// Helper for tests
clear() {
this.events.clear();
}
getAllEvents(): RatingEvent[] {
return Array.from(this.events.values()).flat();
}
}
class InMemoryUserRatingRepository implements IUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) || null;
}
async save(userRating: UserRating): Promise<UserRating> {
this.ratings.set(userRating.userId, userRating);
return userRating;
}
// Helper for tests
getAllRatings(): Map<string, UserRating> {
return new Map(this.ratings);
}
clear() {
this.ratings.clear();
}
}
describe('RecordRaceRatingEventsUseCase - Integration', () => {
let useCase: RecordRaceRatingEventsUseCase;
let raceResultsProvider: InMemoryRaceResultsProvider;
let ratingEventRepository: InMemoryRatingEventRepository;
let userRatingRepository: InMemoryUserRatingRepository;
let appendRatingEventsUseCase: AppendRatingEventsUseCase;
beforeEach(() => {
raceResultsProvider = new InMemoryRaceResultsProvider();
ratingEventRepository = new InMemoryRatingEventRepository();
userRatingRepository = new InMemoryUserRatingRepository();
appendRatingEventsUseCase = new AppendRatingEventsUseCase(
ratingEventRepository,
userRatingRepository
);
useCase = new RecordRaceRatingEventsUseCase(
raceResultsProvider,
ratingEventRepository,
userRatingRepository,
appendRatingEventsUseCase
);
});
describe('Full flow: race facts -> events -> persist -> snapshot', () => {
it('should complete full flow for single driver with good performance', async () => {
// Step 1: Setup race facts
const raceFacts: RaceResultsData = {
raceId: 'race-001',
results: [
{
userId: 'driver-001',
startPos: 8,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
raceResultsProvider.setRaceResults('race-001', raceFacts);
// Step 2: Create initial user rating
const initialRating = UserRating.create('driver-001');
await userRatingRepository.save(initialRating);
// Step 3: Execute use case
const result = await useCase.execute({ raceId: 'race-001' });
// Step 4: Verify success
expect(result.success).toBe(true);
expect(result.raceId).toBe('race-001');
expect(result.eventsCreated).toBeGreaterThan(0);
expect(result.driversUpdated).toContain('driver-001');
expect(result.errors).toEqual([]);
// Step 5: Verify events were persisted
const events = await ratingEventRepository.findByUserId('driver-001');
expect(events.length).toBeGreaterThan(0);
// Step 6: Verify snapshot was updated
const updatedRating = await userRatingRepository.findByUserId('driver-001');
expect(updatedRating).toBeDefined();
expect(updatedRating!.driver.value).toBeGreaterThan(initialRating.driver.value);
expect(updatedRating!.driver.sampleSize).toBeGreaterThan(0);
expect(updatedRating!.driver.confidence).toBeGreaterThan(0);
// Step 7: Verify event details
const performanceEvent = events.find(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN');
expect(performanceEvent).toBeDefined();
expect(performanceEvent!.delta.value).toBeGreaterThan(0);
expect(performanceEvent!.source.id).toBe('race-001');
});
it('should handle multiple drivers with mixed results', async () => {
// Setup race with multiple drivers
const raceFacts: RaceResultsData = {
raceId: 'race-002',
results: [
{
userId: 'driver-001',
startPos: 5,
finishPos: 1,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'driver-002',
startPos: 3,
finishPos: 8,
incidents: 3,
status: 'finished',
sof: 2500,
},
{
userId: 'driver-003',
startPos: 5,
finishPos: 5,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
};
raceResultsProvider.setRaceResults('race-002', raceFacts);
// Create initial ratings
await userRatingRepository.save(UserRating.create('driver-001'));
await userRatingRepository.save(UserRating.create('driver-002'));
await userRatingRepository.save(UserRating.create('driver-003'));
// Execute
const result = await useCase.execute({ raceId: 'race-002' });
// Verify
expect(result.success).toBe(true);
expect(result.driversUpdated.length).toBe(3);
expect(result.eventsCreated).toBeGreaterThan(0);
// Check each driver
for (const driverId of ['driver-001', 'driver-002', 'driver-003']) {
const events = await ratingEventRepository.findByUserId(driverId);
expect(events.length).toBeGreaterThan(0);
const rating = await userRatingRepository.findByUserId(driverId);
expect(rating).toBeDefined();
expect(rating!.driver.sampleSize).toBeGreaterThan(0);
}
// driver-001 should have positive delta
const driver1Rating = await userRatingRepository.findByUserId('driver-001');
expect(driver1Rating!.driver.value).toBeGreaterThan(50);
// driver-002 should have negative delta (poor position + incidents)
const driver2Rating = await userRatingRepository.findByUserId('driver-002');
expect(driver2Rating!.driver.value).toBeLessThan(50);
// driver-003 should have negative delta (DNS)
const driver3Rating = await userRatingRepository.findByUserId('driver-003');
expect(driver3Rating!.driver.value).toBeLessThan(50);
});
it('should compute SoF when not provided', async () => {
const raceFacts: RaceResultsData = {
raceId: 'race-003',
results: [
{
userId: 'driver-001',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
// No sof
},
{
userId: 'driver-002',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
// No sof
},
],
};
raceResultsProvider.setRaceResults('race-003', raceFacts);
// Set ratings for SoF calculation
const rating1 = UserRating.create('driver-001').updateDriverRating(60);
const rating2 = UserRating.create('driver-002').updateDriverRating(40);
await userRatingRepository.save(rating1);
await userRatingRepository.save(rating2);
// Execute
const result = await useCase.execute({ raceId: 'race-003' });
// Verify SoF was computed (average of 60 and 40 = 50)
expect(result.success).toBe(true);
expect(result.eventsCreated).toBeGreaterThan(0);
// Events should be created with computed SoF
const events = await ratingEventRepository.getAllByUserId('driver-001');
expect(events.length).toBeGreaterThan(0);
});
it('should handle partial failures gracefully', async () => {
const raceFacts: RaceResultsData = {
raceId: 'race-004',
results: [
{
userId: 'driver-001',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'driver-002',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
raceResultsProvider.setRaceResults('race-004', raceFacts);
// Only create rating for first driver
await userRatingRepository.save(UserRating.create('driver-001'));
// Execute
const result = await useCase.execute({ raceId: 'race-004' });
// Should have partial success
expect(result.raceId).toBe('race-004');
expect(result.driversUpdated).toContain('driver-001');
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
});
it('should maintain event immutability and ordering', async () => {
const raceFacts: RaceResultsData = {
raceId: 'race-005',
results: [
{
userId: 'driver-001',
startPos: 5,
finishPos: 2,
incidents: 1,
status: 'finished',
sof: 2500,
},
],
};
raceResultsProvider.setRaceResults('race-005', raceFacts);
await userRatingRepository.save(UserRating.create('driver-001'));
// Execute multiple times
await useCase.execute({ raceId: 'race-005' });
const result1 = await ratingEventRepository.findByUserId('driver-001');
// Execute again (should add more events)
await useCase.execute({ raceId: 'race-005' });
const result2 = await ratingEventRepository.findByUserId('driver-001');
// Events should accumulate
expect(result2.length).toBeGreaterThan(result1.length);
// All events should be immutable
for (const event of result2) {
expect(event.id).toBeDefined();
expect(event.createdAt).toBeDefined();
expect(event.occurredAt).toBeDefined();
}
});
it('should update snapshot with weighted average and confidence', async () => {
// Multiple races for same driver
const race1: RaceResultsData = {
raceId: 'race-006',
results: [{ userId: 'driver-001', startPos: 10, finishPos: 5, incidents: 0, status: 'finished', sof: 2500 }],
};
const race2: RaceResultsData = {
raceId: 'race-007',
results: [{ userId: 'driver-001', startPos: 5, finishPos: 2, incidents: 0, status: 'finished', sof: 2500 }],
};
raceResultsProvider.setRaceResults('race-006', race1);
raceResultsProvider.setRaceResults('race-007', race2);
await userRatingRepository.save(UserRating.create('driver-001'));
// Execute first race
await useCase.execute({ raceId: 'race-006' });
const rating1 = await userRatingRepository.findByUserId('driver-001');
expect(rating1!.driver.sampleSize).toBe(1);
// Execute second race
await useCase.execute({ raceId: 'race-007' });
const rating2 = await userRatingRepository.findByUserId('driver-001');
expect(rating2!.driver.sampleSize).toBe(2);
expect(rating2!.driver.confidence).toBeGreaterThan(rating1!.driver.confidence);
// Trend should be calculated
expect(rating2!.driver.trend).toBeDefined();
});
});
});

View File

@@ -0,0 +1,359 @@
import { RecordRaceRatingEventsUseCase } from './RecordRaceRatingEventsUseCase';
import { IRaceResultsProvider, RaceResultsData } from '../ports/IRaceResultsProvider';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
import { UserRating } from '../../domain/value-objects/UserRating';
import { RatingEvent } from '../../domain/entities/RatingEvent';
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
// Mock implementations
class MockRaceResultsProvider implements IRaceResultsProvider {
private results: RaceResultsData | null = null;
setResults(results: RaceResultsData | null) {
this.results = results;
}
async getRaceResults(raceId: string): Promise<RaceResultsData | null> {
return this.results;
}
async hasRaceResults(raceId: string): Promise<boolean> {
return this.results !== null;
}
}
class MockRatingEventRepository implements IRatingEventRepository {
private events: RatingEvent[] = [];
async save(event: RatingEvent): Promise<RatingEvent> {
this.events.push(event);
return event;
}
async findByUserId(userId: string): Promise<RatingEvent[]> {
return this.events.filter(e => e.userId === userId);
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
return this.events.filter(e => ids.some(id => id.equals(e.id)));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
return this.events.filter(e => e.userId === userId);
}
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
const allEvents = await this.findByUserId(userId);
// Apply filters
let filtered = allEvents;
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
}
if (filter.sourceTypes) {
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
}
if (filter.from) {
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
}
if (filter.to) {
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
}
if (filter.reasonCodes) {
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
}
if (filter.visibility) {
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
class MockUserRatingRepository implements IUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) || null;
}
async save(userRating: UserRating): Promise<UserRating> {
this.ratings.set(userRating.userId, userRating);
return userRating;
}
// Helper for tests
setRating(userId: string, rating: UserRating) {
this.ratings.set(userId, rating);
}
}
describe('RecordRaceRatingEventsUseCase', () => {
let useCase: RecordRaceRatingEventsUseCase;
let mockRaceResultsProvider: MockRaceResultsProvider;
let mockRatingEventRepository: MockRatingEventRepository;
let mockUserRatingRepository: MockUserRatingRepository;
let appendRatingEventsUseCase: AppendRatingEventsUseCase;
beforeEach(() => {
mockRaceResultsProvider = new MockRaceResultsProvider();
mockRatingEventRepository = new MockRatingEventRepository();
mockUserRatingRepository = new MockUserRatingRepository();
appendRatingEventsUseCase = new AppendRatingEventsUseCase(
mockRatingEventRepository,
mockUserRatingRepository
);
useCase = new RecordRaceRatingEventsUseCase(
mockRaceResultsProvider,
mockRatingEventRepository,
mockUserRatingRepository,
appendRatingEventsUseCase
);
});
describe('execute', () => {
it('should return error when race results not found', async () => {
mockRaceResultsProvider.setResults(null);
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(false);
expect(result.raceId).toBe('race-123');
expect(result.eventsCreated).toBe(0);
expect(result.driversUpdated).toEqual([]);
expect(result.errors).toContain('Race results not found');
});
it('should return error when no results in race', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [],
});
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(false);
expect(result.eventsCreated).toBe(0);
expect(result.errors).toContain('No results found for race');
});
it('should process single driver with good performance', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
});
// Set initial rating for user
const initialRating = UserRating.create('user-123');
mockUserRatingRepository.setRating('user-123', initialRating);
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(true);
expect(result.raceId).toBe('race-123');
expect(result.eventsCreated).toBeGreaterThan(0);
expect(result.driversUpdated).toContain('user-123');
expect(result.errors).toEqual([]);
});
it('should process multiple drivers with mixed results', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 2,
status: 'finished',
sof: 2500,
},
{
userId: 'user-789',
startPos: 5,
finishPos: 5,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
});
// Set initial ratings
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
mockUserRatingRepository.setRating('user-456', UserRating.create('user-456'));
mockUserRatingRepository.setRating('user-789', UserRating.create('user-789'));
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(true);
expect(result.eventsCreated).toBeGreaterThan(0);
expect(result.driversUpdated.length).toBe(3);
expect(result.driversUpdated).toContain('user-123');
expect(result.driversUpdated).toContain('user-456');
expect(result.driversUpdated).toContain('user-789');
expect(result.errors).toEqual([]);
});
it('should compute SoF if not provided', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
// No sof
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
// No sof
},
],
});
// Set ratings for SoF calculation
const rating1 = UserRating.create('user-123');
const rating2 = UserRating.create('user-456');
// Update driver ratings to specific values
mockUserRatingRepository.setRating('user-123', rating1.updateDriverRating(60));
mockUserRatingRepository.setRating('user-456', rating2.updateDriverRating(40));
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(true);
expect(result.eventsCreated).toBeGreaterThan(0);
expect(result.driversUpdated.length).toBe(2);
});
it('should handle errors for individual drivers gracefully', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
});
// Only set rating for first user, second will fail
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
const result = await useCase.execute({ raceId: 'race-123' });
// Should still succeed overall but with errors
expect(result.raceId).toBe('race-123');
expect(result.driversUpdated).toContain('user-123');
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
});
it('should return success with no events when no valid events created', async () => {
// This would require a scenario where factory creates no events
// For now, we'll test with empty results
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [],
});
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(false); // No results
});
it('should handle repository errors', async () => {
mockRaceResultsProvider.setResults({
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
});
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
// Mock repository to throw error
const originalSave = mockRatingEventRepository.save;
mockRatingEventRepository.save = async () => {
throw new Error('Repository error');
};
const result = await useCase.execute({ raceId: 'race-123' });
expect(result.success).toBe(false);
expect(result.errors).toBeDefined();
expect(result.errors!.length).toBeGreaterThan(0);
// Restore
mockRatingEventRepository.save = originalSave;
});
});
});

View File

@@ -0,0 +1,190 @@
import { IRaceResultsProvider } from '../ports/IRaceResultsProvider';
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
import { DrivingRatingCalculator } from '../../domain/services/DrivingRatingCalculator';
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
import { RecordRaceRatingEventsInput, RecordRaceRatingEventsOutput } from '../dtos/RecordRaceRatingEventsDto';
/**
* Use Case: RecordRaceRatingEventsUseCase
*
* Records rating events for a completed race.
* Follows CQRS Light: command side operation.
*
* Flow:
* 1. Load race results from racing context
* 2. Compute SoF if needed (platform-only)
* 3. Factory creates rating events
* 4. Append to ledger via AppendRatingEventsUseCase
* 5. Recompute snapshots
*
* Per plans section 7.1.1 and 10.1
*/
export class RecordRaceRatingEventsUseCase {
constructor(
private readonly raceResultsProvider: IRaceResultsProvider,
private readonly ratingEventRepository: IRatingEventRepository,
private readonly userRatingRepository: IUserRatingRepository,
private readonly appendRatingEventsUseCase: AppendRatingEventsUseCase,
) {}
async execute(input: RecordRaceRatingEventsInput): Promise<RecordRaceRatingEventsOutput> {
const errors: string[] = [];
const driversUpdated: string[] = [];
let totalEventsCreated = 0;
try {
// 1. Load race results
const raceResults = await this.raceResultsProvider.getRaceResults(input.raceId);
if (!raceResults) {
return {
success: false,
raceId: input.raceId,
eventsCreated: 0,
driversUpdated: [],
errors: ['Race results not found'],
};
}
if (raceResults.results.length === 0) {
return {
success: false,
raceId: input.raceId,
eventsCreated: 0,
driversUpdated: [],
errors: ['No results found for race'],
};
}
// 2. Compute SoF if not provided (platform-only approach)
// Use existing user ratings as platform strength indicators
const resultsWithSof = await this.computeSoFIfNeeded(raceResults);
// 3. Create rating events using factory
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({
raceId: input.raceId,
results: resultsWithSof,
});
if (eventsByUser.size === 0) {
return {
success: true,
raceId: input.raceId,
eventsCreated: 0,
driversUpdated: [],
errors: [],
};
}
// 4. Process each driver's events
for (const [userId, events] of eventsByUser) {
try {
// Use AppendRatingEventsUseCase to handle ledger and snapshot
const result = await this.appendRatingEventsUseCase.execute({
userId,
events: events.map(event => ({
userId: event.userId,
dimension: event.dimension.value,
delta: event.delta.value,
weight: event.weight || 1,
sourceType: event.source.type,
sourceId: event.source.id,
reasonCode: event.reason.code,
reasonSummary: event.reason.summary,
reasonDetails: event.reason.details,
occurredAt: event.occurredAt.toISOString(),
})),
});
if (result.snapshotUpdated) {
driversUpdated.push(userId);
totalEventsCreated += result.events.length;
}
} catch (error) {
const errorMsg = `Failed to process events for user ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
}
}
return {
success: errors.length === 0,
raceId: input.raceId,
eventsCreated: totalEventsCreated,
driversUpdated,
errors,
};
} catch (error) {
const errorMsg = `Failed to record race rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
errors.push(errorMsg);
return {
success: false,
raceId: input.raceId,
eventsCreated: 0,
driversUpdated: [],
errors,
};
}
}
/**
* Compute Strength of Field if not provided in results
* Uses platform driving ratings (existing user ratings)
*/
private async computeSoFIfNeeded(raceResults: {
raceId: string;
results: Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number;
}>;
}): Promise<Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof: number;
}>> {
// Check if all results have sof
const hasAllSof = raceResults.results.every(r => r.sof !== undefined);
if (hasAllSof) {
return raceResults.results.map(r => ({ ...r, sof: r.sof! }));
}
// Need to compute SoF - get user ratings for all drivers
const userIds = raceResults.results.map(r => r.userId);
const ratingsMap = new Map<string, number>();
// Get ratings for all drivers
for (const userId of userIds) {
const userRating = await this.userRatingRepository.findByUserId(userId);
if (userRating) {
ratingsMap.set(userId, userRating.driver.value);
} else {
// Default rating for new drivers
ratingsMap.set(userId, 50);
}
}
// Calculate average rating as SoF
const ratings = Array.from(ratingsMap.values());
const sof = ratings.length > 0
? Math.round(ratings.reduce((sum, r) => sum + r, 0) / ratings.length)
: 50;
// Add SoF to each result
return raceResults.results.map(r => ({
...r,
sof: r.sof !== undefined ? r.sof : sof,
}));
}
}

View File

@@ -0,0 +1,265 @@
import { UpsertExternalGameRatingUseCase } from './UpsertExternalGameRatingUseCase';
import { UpsertExternalGameRatingInput } from '../dtos/UpsertExternalGameRatingDto';
// Mock repository for integration test
class MockExternalGameRatingRepository {
private profiles = new Map<string, any>();
private getKey(userId: string, gameKey: string): string {
return `${userId}|${gameKey}`;
}
async findByUserIdAndGameKey(userId: string, gameKey: string): Promise<any | null> {
return this.profiles.get(this.getKey(userId, gameKey)) || null;
}
async findByUserId(userId: string): Promise<any[]> {
return Array.from(this.profiles.values()).filter((p: any) => p.userId.toString() === userId);
}
async findByGameKey(gameKey: string): Promise<any[]> {
return Array.from(this.profiles.values()).filter((p: any) => p.gameKey.toString() === gameKey);
}
async save(profile: any): Promise<any> {
const key = this.getKey(profile.userId.toString(), profile.gameKey.toString());
this.profiles.set(key, profile);
return profile;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
return this.profiles.delete(this.getKey(userId, gameKey));
}
async exists(userId: string, gameKey: string): Promise<boolean> {
return this.profiles.has(this.getKey(userId, gameKey));
}
clear(): void {
this.profiles.clear();
}
}
/**
* Integration test for UpsertExternalGameRatingUseCase
* Tests the full flow from use case to repository
*/
describe('UpsertExternalGameRatingUseCase - Integration', () => {
let useCase: UpsertExternalGameRatingUseCase;
let repository: MockExternalGameRatingRepository;
beforeEach(() => {
repository = new MockExternalGameRatingRepository();
useCase = new UpsertExternalGameRatingUseCase(repository as any);
});
describe('Full upsert flow', () => {
it('should create and then update a profile', async () => {
// Step 1: Create new profile
const createInput: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 85.5 },
{ type: 'skill', value: 92.0 },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
verified: false,
},
};
const createResult = await useCase.execute(createInput);
expect(createResult.success).toBe(true);
expect(createResult.action).toBe('created');
expect(createResult.profile.ratingCount).toBe(2);
expect(createResult.profile.verified).toBe(false);
// Verify it was saved
const savedProfile = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(savedProfile).not.toBeNull();
expect(savedProfile?.ratings.size).toBe(2);
// Step 2: Update the profile
const updateInput: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 90.0 },
{ type: 'skill', value: 95.0 },
{ type: 'consistency', value: 88.0 },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-02T00:00:00Z',
verified: true,
},
};
const updateResult = await useCase.execute(updateInput);
expect(updateResult.success).toBe(true);
expect(updateResult.action).toBe('updated');
expect(updateResult.profile.ratingCount).toBe(3);
expect(updateResult.profile.verified).toBe(true);
// Verify the update was persisted
const updatedProfile = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(updatedProfile).not.toBeNull();
expect(updatedProfile?.ratings.size).toBe(3);
expect(updatedProfile?.getRatingByType('safety')?.value).toBe(90.0);
expect(updatedProfile?.getRatingByType('consistency')).toBeDefined();
expect(updatedProfile?.provenance.verified).toBe(true);
});
it('should handle multiple users and games', async () => {
// Create profiles for different users/games
const inputs: UpsertExternalGameRatingInput[] = [
{
userId: 'user-1',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 80.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
},
{
userId: 'user-1',
gameKey: 'assetto',
ratings: [{ type: 'safety', value: 75.0 }],
provenance: { source: 'assetto', lastSyncedAt: '2024-01-01T00:00:00Z' },
},
{
userId: 'user-2',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
},
];
for (const input of inputs) {
const result = await useCase.execute(input);
expect(result.success).toBe(true);
}
// Verify user-1 has 2 profiles
const user1Profiles = await repository.findByUserId('user-1');
expect(user1Profiles).toHaveLength(2);
// Verify iracing has 2 profiles
const iracingProfiles = await repository.findByGameKey('iracing');
expect(iracingProfiles).toHaveLength(2);
// Verify specific profile
const specific = await repository.findByUserIdAndGameKey('user-1', 'assetto');
expect(specific?.getRatingByType('safety')?.value).toBe(75.0);
});
it('should handle concurrent updates to same profile', async () => {
// Initial profile
const input1: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 80.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
};
await useCase.execute(input1);
// Update 1
const input2: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-02T00:00:00Z' },
};
await useCase.execute(input2);
// Update 2 (should overwrite)
const input3: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 90.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-03T00:00:00Z' },
};
await useCase.execute(input3);
// Verify final state
const profile = await repository.findByUserIdAndGameKey('user-1', 'iracing');
expect(profile?.getRatingByType('safety')?.value).toBe(90.0);
expect(profile?.provenance.lastSyncedAt).toEqual(new Date('2024-01-03T00:00:00Z'));
});
it('should handle complex rating updates', async () => {
// Initial with 2 ratings
const input1: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 80.0 },
{ type: 'skill', value: 75.0 },
],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
};
await useCase.execute(input1);
// Update with different set
const input2: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 85.0 }, // Updated
{ type: 'consistency', value: 88.0 }, // New
// skill removed
],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-02T00:00:00Z' },
};
const result = await useCase.execute(input2);
expect(result.profile.ratingCount).toBe(2);
expect(result.profile.ratingTypes).toEqual(['safety', 'consistency']);
expect(result.profile.ratingTypes).not.toContain('skill');
const profile = await repository.findByUserIdAndGameKey('user-1', 'iracing');
expect(profile?.ratings.size).toBe(2);
expect(profile?.getRatingByType('safety')?.value).toBe(85.0);
expect(profile?.getRatingByType('consistency')?.value).toBe(88.0);
expect(profile?.getRatingByType('skill')).toBeUndefined();
});
});
describe('Repository method integration', () => {
it('should work with repository methods directly', async () => {
// Create via use case
const input: UpsertExternalGameRatingInput = {
userId: 'user-1',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 80.0 }],
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
};
await useCase.execute(input);
// Test repository methods
const exists = await repository.exists('user-1', 'iracing');
expect(exists).toBe(true);
const allForUser = await repository.findByUserId('user-1');
expect(allForUser).toHaveLength(1);
const allForGame = await repository.findByGameKey('iracing');
expect(allForGame).toHaveLength(1);
// Delete
const deleted = await repository.delete('user-1', 'iracing');
expect(deleted).toBe(true);
const existsAfterDelete = await repository.exists('user-1', 'iracing');
expect(existsAfterDelete).toBe(false);
});
});
});

View File

@@ -0,0 +1,285 @@
import { UpsertExternalGameRatingUseCase } from './UpsertExternalGameRatingUseCase';
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
import { UserId } from '../../domain/value-objects/UserId';
import { GameKey } from '../../domain/value-objects/GameKey';
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
import { UpsertExternalGameRatingInput } from '../dtos/UpsertExternalGameRatingDto';
describe('UpsertExternalGameRatingUseCase', () => {
let useCase: UpsertExternalGameRatingUseCase;
let mockRepository: IExternalGameRatingRepository;
beforeEach(() => {
mockRepository = {
findByUserIdAndGameKey: jest.fn(),
findByUserId: jest.fn(),
findByGameKey: jest.fn(),
save: jest.fn(),
saveMany: jest.fn(),
delete: jest.fn(),
exists: jest.fn(),
} as any;
useCase = new UpsertExternalGameRatingUseCase(mockRepository);
});
describe('execute', () => {
it('should create a new profile when it does not exist', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 85.5 },
{ type: 'skill', value: 92.0 },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
verified: true,
},
};
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
const result = await useCase.execute(input);
expect(result.success).toBe(true);
expect(result.action).toBe('created');
expect(result.profile.userId).toBe('user-123');
expect(result.profile.gameKey).toBe('iracing');
expect(result.profile.ratingCount).toBe(2);
expect(result.profile.ratingTypes).toEqual(['safety', 'skill']);
expect(result.profile.verified).toBe(true);
expect(mockRepository.findByUserIdAndGameKey).toHaveBeenCalledWith('user-123', 'iracing');
expect(mockRepository.save).toHaveBeenCalledTimes(1);
});
it('should update existing profile', async () => {
const existingProfile = createTestProfile('user-123', 'iracing');
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(existingProfile);
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: 90.0 },
{ type: 'newType', value: 88.0 },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-02T00:00:00Z',
verified: false,
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(true);
expect(result.action).toBe('updated');
expect(result.profile.ratingCount).toBe(2);
expect(result.profile.ratingTypes).toEqual(['safety', 'newType']);
expect(result.profile.verified).toBe(false);
expect(mockRepository.findByUserIdAndGameKey).toHaveBeenCalledWith('user-123', 'iracing');
expect(mockRepository.save).toHaveBeenCalledTimes(1);
});
it('should handle validation errors - missing userId', async () => {
const input: UpsertExternalGameRatingInput = {
userId: '',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('User ID is required');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle validation errors - missing gameKey', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: '',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Game key is required');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle validation errors - empty ratings', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Ratings are required');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle validation errors - invalid rating value', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: 'safety', value: NaN },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Rating value for type safety must be a valid number');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle validation errors - missing provenance source', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: '',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Provenance source is required');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle validation errors - invalid date', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: 'iracing',
lastSyncedAt: 'invalid-date',
},
};
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Provenance lastSyncedAt must be a valid date');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should handle repository errors gracefully', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
(mockRepository.findByUserIdAndGameKey as any).mockRejectedValue(new Error('Database connection failed'));
const result = await useCase.execute(input);
expect(result.success).toBe(false);
expect(result.errors).toContain('Database connection failed');
expect(mockRepository.save).not.toHaveBeenCalled();
});
it('should default verified to false when not provided', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [{ type: 'safety', value: 85.5 }],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
// verified not provided
},
};
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
const result = await useCase.execute(input);
expect(result.success).toBe(true);
expect(result.profile.verified).toBe(false);
});
it('should trim rating types', async () => {
const input: UpsertExternalGameRatingInput = {
userId: 'user-123',
gameKey: 'iracing',
ratings: [
{ type: ' safety ', value: 85.5 },
],
provenance: {
source: 'iracing',
lastSyncedAt: '2024-01-01T00:00:00Z',
},
};
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
const result = await useCase.execute(input);
expect(result.success).toBe(true);
expect(result.profile.ratingTypes).toEqual(['safety']);
});
});
function createTestProfile(userId: string, gameKey: string): ExternalGameRatingProfile {
const user = UserId.fromString(userId);
const game = GameKey.create(gameKey);
const ratings = new Map([
['safety', ExternalRating.create(game, 'safety', 85.5)],
]);
const provenance = ExternalRatingProvenance.create({
source: gameKey,
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
return ExternalGameRatingProfile.create({
userId: user,
gameKey: game,
ratings,
provenance,
});
}
});

View File

@@ -0,0 +1,186 @@
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
import { UserId } from '../../domain/value-objects/UserId';
import { GameKey } from '../../domain/value-objects/GameKey';
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
import { UpsertExternalGameRatingInput, UpsertExternalGameRatingOutput } from '../dtos/UpsertExternalGameRatingDto';
/**
* Use Case: UpsertExternalGameRatingUseCase
*
* Upserts external game rating profile with latest data and provenance.
* Follows CQRS Light: command side operation.
*
* Store/display only, no compute.
*
* Flow:
* 1. Validate input
* 2. Check if profile exists
* 3. Create or update profile with latest ratings
* 4. Save to repository
* 5. Return result
*/
export class UpsertExternalGameRatingUseCase {
constructor(
private readonly externalGameRatingRepository: IExternalGameRatingRepository
) {}
async execute(input: UpsertExternalGameRatingInput): Promise<UpsertExternalGameRatingOutput> {
try {
// 1. Validate input
const validationError = this.validateInput(input);
if (validationError) {
return {
success: false,
profile: {
userId: input.userId,
gameKey: input.gameKey,
ratingCount: 0,
ratingTypes: [],
source: input.provenance.source,
lastSyncedAt: input.provenance.lastSyncedAt,
verified: input.provenance.verified ?? false,
},
action: 'created',
errors: [validationError],
};
}
// 2. Check if profile exists
const existingProfile = await this.externalGameRatingRepository.findByUserIdAndGameKey(
input.userId,
input.gameKey
);
// 3. Create or update profile
let profile: ExternalGameRatingProfile;
let action: 'created' | 'updated';
if (existingProfile) {
// Update existing profile
const ratingsMap = this.createRatingsMap(input.ratings, input.gameKey);
const provenance = ExternalRatingProvenance.create({
source: input.provenance.source,
lastSyncedAt: new Date(input.provenance.lastSyncedAt),
verified: input.provenance.verified ?? false,
});
existingProfile.updateRatings(ratingsMap, provenance);
profile = existingProfile;
action = 'updated';
} else {
// Create new profile
profile = this.createProfile(input);
action = 'created';
}
// 4. Save to repository
const savedProfile = await this.externalGameRatingRepository.save(profile);
// 5. Return result
const summary = savedProfile.toSummary();
return {
success: true,
profile: {
userId: summary.userId,
gameKey: summary.gameKey,
ratingCount: summary.ratingCount,
ratingTypes: summary.ratingTypes,
source: summary.source,
lastSyncedAt: summary.lastSyncedAt.toISOString(),
verified: summary.verified,
},
action,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return {
success: false,
profile: {
userId: input.userId,
gameKey: input.gameKey,
ratingCount: 0,
ratingTypes: [],
source: input.provenance.source,
lastSyncedAt: input.provenance.lastSyncedAt,
verified: input.provenance.verified ?? false,
},
action: 'created',
errors: [errorMessage],
};
}
}
private validateInput(input: UpsertExternalGameRatingInput): string | null {
if (!input.userId || input.userId.trim().length === 0) {
return 'User ID is required';
}
if (!input.gameKey || input.gameKey.trim().length === 0) {
return 'Game key is required';
}
if (!input.ratings || input.ratings.length === 0) {
return 'Ratings are required';
}
for (const rating of input.ratings) {
if (!rating.type || rating.type.trim().length === 0) {
return 'Rating type cannot be empty';
}
if (typeof rating.value !== 'number' || isNaN(rating.value)) {
return `Rating value for type ${rating.type} must be a valid number`;
}
}
if (!input.provenance.source || input.provenance.source.trim().length === 0) {
return 'Provenance source is required';
}
const syncDate = new Date(input.provenance.lastSyncedAt);
if (isNaN(syncDate.getTime())) {
return 'Provenance lastSyncedAt must be a valid date';
}
return null;
}
private createRatingsMap(
ratingsData: Array<{ type: string; value: number }>,
gameKeyString: string
): Map<string, ExternalRating> {
const gameKey = GameKey.create(gameKeyString);
const ratingsMap = new Map<string, ExternalRating>();
for (const ratingData of ratingsData) {
const rating = ExternalRating.create(
gameKey,
ratingData.type.trim(),
ratingData.value
);
ratingsMap.set(ratingData.type.trim(), rating);
}
return ratingsMap;
}
private createProfile(input: UpsertExternalGameRatingInput): ExternalGameRatingProfile {
const userId = UserId.fromString(input.userId);
const gameKey = GameKey.create(input.gameKey);
const ratingsMap = this.createRatingsMap(input.ratings, input.gameKey);
const provenance = ExternalRatingProvenance.create({
source: input.provenance.source,
lastSyncedAt: new Date(input.provenance.lastSyncedAt),
verified: input.provenance.verified ?? false,
});
return ExternalGameRatingProfile.create({
userId,
gameKey,
ratings: ratingsMap,
provenance,
});
}
}

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,
};
}
}

View File

@@ -0,0 +1,34 @@
import type { IDomainError, CommonDomainErrorKind } from '@core/shared/errors';
export abstract class IdentityDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
readonly type = 'domain' as const;
readonly context = 'identity-domain';
abstract readonly kind: CommonDomainErrorKind;
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
export class IdentityDomainValidationError
extends IdentityDomainError
implements IDomainError<'validation'>
{
readonly kind = 'validation' as const;
constructor(message: string) {
super(message);
}
}
export class IdentityDomainInvariantError
extends IdentityDomainError
implements IDomainError<'invariant'>
{
readonly kind = 'invariant' as const;
constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,42 @@
import type { AdminVoteSession } from '../entities/AdminVoteSession';
/**
* Repository Interface: IAdminVoteSessionRepository
*
* Port for persisting and retrieving admin vote sessions.
* Sessions are scoped to leagues and control voting windows.
*/
export interface IAdminVoteSessionRepository {
/**
* Save a vote session
*/
save(session: AdminVoteSession): Promise<AdminVoteSession>;
/**
* Find a vote session by ID
*/
findById(id: string): Promise<AdminVoteSession | null>;
/**
* Find active vote sessions for an admin in a league
* (within voting window and not closed)
*/
findActiveForAdmin(adminId: string, leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find all vote sessions for an admin in a league
*/
findByAdminAndLeague(adminId: string, leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find vote sessions by league
*/
findByLeague(leagueId: string): Promise<AdminVoteSession[]>;
/**
* Find closed vote sessions ready for outcome processing
* (closed but not yet processed into rating events)
*/
findClosedUnprocessed(): Promise<AdminVoteSession[]>;
}

View File

@@ -0,0 +1,368 @@
import { IExternalGameRatingRepository } from './IExternalGameRatingRepository';
import { ExternalGameRatingProfile } from '../entities/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';
/**
* Test suite for IExternalGameRatingRepository interface
* This tests the contract that all implementations must satisfy
*/
describe('IExternalGameRatingRepository', () => {
// Mock implementation for testing
class MockExternalGameRatingRepository implements IExternalGameRatingRepository {
private profiles: Map<string, ExternalGameRatingProfile> = new Map();
private getKey(userId: string, gameKey: string): string {
return `${userId}|${gameKey}`;
}
async findByUserIdAndGameKey(
userId: string,
gameKey: string
): Promise<ExternalGameRatingProfile | null> {
const key = this.getKey(userId, gameKey);
return this.profiles.get(key) || null;
}
async findByUserId(userId: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.userId.toString() === userId
);
}
async findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]> {
return Array.from(this.profiles.values()).filter(
p => p.gameKey.toString() === gameKey
);
}
async save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile> {
const key = this.getKey(profile.userId.toString(), profile.gameKey.toString());
this.profiles.set(key, profile);
return profile;
}
async saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]> {
for (const profile of profiles) {
await this.save(profile);
}
return profiles;
}
async delete(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.delete(key);
}
async exists(userId: string, gameKey: string): Promise<boolean> {
const key = this.getKey(userId, gameKey);
return this.profiles.has(key);
}
async findProfilesPaginated(userId: string, options?: import('./IExternalGameRatingRepository').PaginatedQueryOptions): Promise<import('./IExternalGameRatingRepository').PaginatedResult<ExternalGameRatingProfile>> {
const allProfiles = await this.findByUserId(userId);
// Apply filters
let filtered = allProfiles;
if (options?.filter) {
const filter = options.filter;
if (filter.gameKeys) {
filtered = filtered.filter(p => filter.gameKeys!.includes(p.gameKey.toString()));
}
if (filter.sources) {
filtered = filtered.filter(p => filter.sources!.includes(p.provenance.source));
}
if (filter.verified !== undefined) {
filtered = filtered.filter(p => p.provenance.verified === filter.verified);
}
if (filter.lastSyncedAfter) {
filtered = filtered.filter(p => p.provenance.lastSyncedAt >= filter.lastSyncedAfter!);
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: import('./IExternalGameRatingRepository').PaginatedResult<ExternalGameRatingProfile> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
let repository: IExternalGameRatingRepository;
beforeEach(() => {
repository = new MockExternalGameRatingRepository();
});
describe('findByUserIdAndGameKey', () => {
it('should return null when profile does not exist', async () => {
const result = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(result).toBeNull();
});
it('should return profile when it exists', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const result = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(result).not.toBeNull();
expect(result?.userId.toString()).toBe('user-123');
expect(result?.gameKey.toString()).toBe('iracing');
});
});
describe('findByUserId', () => {
it('should return empty array when no profiles exist for user', async () => {
const results = await repository.findByUserId('user-123');
expect(results).toEqual([]);
});
it('should return all profiles for a user', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
const profile3 = createTestProfile('user-456', 'iracing');
await repository.saveMany([profile1, profile2, profile3]);
const results = await repository.findByUserId('user-123');
expect(results).toHaveLength(2);
expect(results.map(p => p.gameKey.toString()).sort()).toEqual(['assetto', 'iracing']);
});
});
describe('findByGameKey', () => {
it('should return empty array when no profiles exist for game', async () => {
const results = await repository.findByGameKey('iracing');
expect(results).toEqual([]);
});
it('should return all profiles for a game', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-456', 'iracing');
const profile3 = createTestProfile('user-123', 'assetto');
await repository.saveMany([profile1, profile2, profile3]);
const results = await repository.findByGameKey('iracing');
expect(results).toHaveLength(2);
expect(results.map(p => p.userId.toString()).sort()).toEqual(['user-123', 'user-456']);
});
});
describe('save', () => {
it('should save a new profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
const saved = await repository.save(profile);
expect(saved).toBe(profile);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved).toBe(profile);
});
it('should update an existing profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
// Update the profile
const updatedProvenance = ExternalRatingProvenance.create({
source: 'iracing',
lastSyncedAt: new Date('2024-01-02'),
verified: true,
});
profile.updateLastSyncedAt(new Date('2024-01-02'));
profile.markVerified();
await repository.save(profile);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved?.provenance.verified).toBe(true);
expect(retrieved?.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
});
});
describe('saveMany', () => {
it('should save multiple profiles', async () => {
const profiles = [
createTestProfile('user-123', 'iracing'),
createTestProfile('user-456', 'assetto'),
createTestProfile('user-789', 'iracing'),
];
const saved = await repository.saveMany(profiles);
expect(saved).toHaveLength(3);
const iracingProfiles = await repository.findByGameKey('iracing');
expect(iracingProfiles).toHaveLength(2);
});
});
describe('delete', () => {
it('should delete existing profile', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const deleted = await repository.delete('user-123', 'iracing');
expect(deleted).toBe(true);
const retrieved = await repository.findByUserIdAndGameKey('user-123', 'iracing');
expect(retrieved).toBeNull();
});
it('should return false when deleting non-existent profile', async () => {
const deleted = await repository.delete('user-123', 'iracing');
expect(deleted).toBe(false);
});
});
describe('exists', () => {
it('should return true when profile exists', async () => {
const profile = createTestProfile('user-123', 'iracing');
await repository.save(profile);
const exists = await repository.exists('user-123', 'iracing');
expect(exists).toBe(true);
});
it('should return false when profile does not exist', async () => {
const exists = await repository.exists('user-123', 'iracing');
expect(exists).toBe(false);
});
});
describe('findProfilesPaginated', () => {
it('should return paginated results', async () => {
// Create 15 profiles
for (let i = 0; i < 15; i++) {
const profile = createTestProfile('user-123', `game-${i}`);
await repository.save(profile);
}
const result = await repository.findProfilesPaginated('user-123', { limit: 5, offset: 0 });
expect(result.items).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.limit).toBe(5);
expect(result.offset).toBe(0);
expect(result.hasMore).toBe(true);
expect(result.nextOffset).toBe(5);
});
it('should filter by game keys', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
const profile3 = createTestProfile('user-123', 'rfactor');
await repository.saveMany([profile1, profile2, profile3]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { gameKeys: ['iracing', 'rfactor'] }
});
expect(result.items).toHaveLength(2);
expect(result.items.map(p => p.gameKey.toString()).sort()).toEqual(['iracing', 'rfactor']);
});
it('should filter by sources', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
// Manually update provenance for testing
const profile2Provenance = ExternalRatingProvenance.create({
source: 'manual',
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
profile2.updateRatings(profile2.ratings, profile2Provenance);
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { sources: ['iracing'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should filter by verified status', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
profile1.markVerified();
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { verified: true }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should filter by last synced date', async () => {
const profile1 = createTestProfile('user-123', 'iracing');
const profile2 = createTestProfile('user-123', 'assetto');
profile1.updateLastSyncedAt(new Date('2024-01-02'));
profile2.updateLastSyncedAt(new Date('2024-01-01'));
await repository.saveMany([profile1, profile2]);
const result = await repository.findProfilesPaginated('user-123', {
filter: { lastSyncedAfter: new Date('2024-01-01T12:00:00Z') }
});
expect(result.items).toHaveLength(1);
expect(result.items[0].gameKey.toString()).toBe('iracing');
});
it('should return empty result when no profiles match', async () => {
const result = await repository.findProfilesPaginated('non-existent', {
filter: { gameKeys: ['iracing'] }
});
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
// Helper function to create test profiles
function createTestProfile(userId: string, gameKey: string): ExternalGameRatingProfile {
const user = UserId.fromString(userId);
const game = GameKey.create(gameKey);
const ratings = new Map([
['safety', ExternalRating.create(game, 'safety', 85.5)],
['skill', ExternalRating.create(game, 'skill', 92.0)],
]);
const provenance = ExternalRatingProvenance.create({
source: gameKey,
lastSyncedAt: new Date('2024-01-01'),
verified: false,
});
return ExternalGameRatingProfile.create({
userId: user,
gameKey: game,
ratings,
provenance,
});
}
});

View File

@@ -0,0 +1,76 @@
import { ExternalGameRatingProfile } from '../entities/ExternalGameRatingProfile';
/**
* Repository Interface: IExternalGameRatingRepository
*
* Port for persisting and retrieving external game rating profiles.
* Store/display only, no compute.
*/
export interface ExternalGameRatingFilter {
/** Filter by specific game keys */
gameKeys?: string[];
/** Filter by source */
sources?: string[];
/** Filter by verification status */
verified?: boolean;
/** Filter by last synced date */
lastSyncedAfter?: Date;
}
export interface PaginatedQueryOptions {
limit?: number;
offset?: number;
filter?: ExternalGameRatingFilter;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
nextOffset?: number;
}
export interface IExternalGameRatingRepository {
/**
* Find profile by user ID and game key
*/
findByUserIdAndGameKey(userId: string, gameKey: string): Promise<ExternalGameRatingProfile | null>;
/**
* Find all profiles for a user
*/
findByUserId(userId: string): Promise<ExternalGameRatingProfile[]>;
/**
* Find all profiles for a game
*/
findByGameKey(gameKey: string): Promise<ExternalGameRatingProfile[]>;
/**
* Save or update a profile
*/
save(profile: ExternalGameRatingProfile): Promise<ExternalGameRatingProfile>;
/**
* Save multiple profiles
*/
saveMany(profiles: ExternalGameRatingProfile[]): Promise<ExternalGameRatingProfile[]>;
/**
* Delete a profile
*/
delete(userId: string, gameKey: string): Promise<boolean>;
/**
* Check if profile exists
*/
exists(userId: string, gameKey: string): Promise<boolean>;
/**
* Find profiles with pagination and filtering
*/
findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<ExternalGameRatingProfile>>;
}

View File

@@ -0,0 +1,560 @@
/**
* Unit tests for IRatingEventRepository
*/
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { IRatingEventRepository, FindByUserIdOptions, PaginatedQueryOptions, PaginatedResult } from './IRatingEventRepository';
// In-memory test implementation
class InMemoryRatingEventRepository implements IRatingEventRepository {
private events: RatingEvent[] = [];
async save(event: RatingEvent): Promise<RatingEvent> {
const existingIndex = this.events.findIndex(e => e.id.equals(event.id));
if (existingIndex >= 0) {
this.events[existingIndex] = event;
} else {
this.events.push(event);
}
return event;
}
async findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]> {
let filtered = this.events.filter(e => e.userId === userId);
// Sort by occurredAt, then createdAt, then id for deterministic ordering
filtered.sort((a, b) => {
const timeCompare = a.occurredAt.getTime() - b.occurredAt.getTime();
if (timeCompare !== 0) return timeCompare;
const createdCompare = a.createdAt.getTime() - b.createdAt.getTime();
if (createdCompare !== 0) return createdCompare;
return a.id.value.localeCompare(b.id.value);
});
// Apply afterId filter
if (options?.afterId) {
const afterIndex = filtered.findIndex(e => e.id.equals(options.afterId!));
if (afterIndex >= 0) {
filtered = filtered.slice(afterIndex + 1);
}
}
// Apply limit
if (options?.limit) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
return this.events.filter(e => ids.some(id => e.id.equals(id)));
}
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
return this.findByUserId(userId);
}
async findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>> {
const allEvents = await this.findByUserId(userId);
// Apply filters
let filtered = allEvents;
if (options?.filter) {
const filter = options.filter;
if (filter.dimensions) {
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
}
if (filter.sourceTypes) {
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
}
if (filter.from) {
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
}
if (filter.to) {
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
}
if (filter.reasonCodes) {
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
}
if (filter.visibility) {
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
}
}
const total = filtered.length;
const limit = options?.limit ?? 10;
const offset = options?.offset ?? 0;
const items = filtered.slice(offset, offset + limit);
const hasMore = offset + limit < total;
const nextOffset = hasMore ? offset + limit : undefined;
const result: PaginatedResult<RatingEvent> = {
items,
total,
limit,
offset,
hasMore
};
if (nextOffset !== undefined) {
result.nextOffset = nextOffset;
}
return result;
}
}
describe('IRatingEventRepository', () => {
let repository: InMemoryRatingEventRepository;
beforeEach(() => {
repository = new InMemoryRatingEventRepository();
});
describe('save', () => {
it('should save a new event', async () => {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const saved = await repository.save(event);
expect(saved).toEqual(event);
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(1);
expect(found[0]!.id).toEqual(event.id);
});
it('should update existing event', async () => {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test event', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event);
// Rehydrate with same ID (simulating update)
const updated = RatingEvent.rehydrate({
id: event.id,
userId: event.userId,
dimension: event.dimension,
delta: RatingDelta.create(10),
weight: undefined,
occurredAt: event.occurredAt,
createdAt: event.createdAt,
source: event.source,
reason: event.reason,
visibility: event.visibility,
version: event.version,
});
await repository.save(updated);
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(1);
expect(found[0]!.delta.value).toBe(10);
});
});
describe('findByUserId', () => {
it('should return events ordered by occurredAt', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1');
expect(found).toHaveLength(2);
expect(found[0]!.occurredAt).toEqual(new Date('2024-01-01T10:00:00Z'));
expect(found[1]!.occurredAt).toEqual(new Date('2024-01-01T12:00:00Z'));
});
it('should filter by afterId', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1', {
afterId: events[0]!.id,
});
expect(found).toHaveLength(1);
expect(found[0]!.id).toEqual(events[1]!.id);
});
it('should limit results', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(1),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(2),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T12:00:00Z'),
createdAt: new Date('2024-01-01T12:00:00Z'),
source: { type: 'race', id: 'race-3' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const found = await repository.findByUserId('user-1', { limit: 2 });
expect(found).toHaveLength(2);
expect(found[0]!.delta.value).toBe(1);
expect(found[1]!.delta.value).toBe(2);
});
it('should return empty array for non-existent user', async () => {
const found = await repository.findByUserId('non-existent');
expect(found).toEqual([]);
});
});
describe('findByIds', () => {
it('should return events by IDs', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const found = await repository.findByIds([event1.id, event2.id]);
expect(found).toHaveLength(2);
expect(found.map(e => e.id.value)).toContain(event1.id.value);
expect(found.map(e => e.id.value)).toContain(event2.id.value);
});
it('should return empty array for non-existent IDs', async () => {
const nonExistentId = RatingEventId.generate();
const found = await repository.findByIds([nonExistentId]);
expect(found).toEqual([]);
});
});
describe('getAllByUserId', () => {
it('should return all events for user', async () => {
const events = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-2',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
for (const event of events) {
await repository.save(event);
}
const user1Events = await repository.getAllByUserId('user-1');
expect(user1Events).toHaveLength(1);
expect(user1Events[0]!.userId).toBe('user-1');
const user2Events = await repository.getAllByUserId('user-2');
expect(user2Events).toHaveLength(1);
expect(user2Events[0]!.userId).toBe('user-2');
});
});
describe('findEventsPaginated', () => {
it('should return paginated results', async () => {
// Create 15 events
for (let i = 0; i < 15; i++) {
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(i),
occurredAt: new Date(`2024-01-01T${10 + i}:00:00Z`),
createdAt: new Date(`2024-01-01T${10 + i}:00:00Z`),
source: { type: 'race', id: `race-${i}` },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event);
}
const result = await repository.findEventsPaginated('user-1', { limit: 5, offset: 0 });
expect(result.items).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.limit).toBe(5);
expect(result.offset).toBe(0);
expect(result.hasMore).toBe(true);
expect(result.nextOffset).toBe(5);
});
it('should filter by dimensions', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'vote', id: 'vote-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: { dimensions: ['driving'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.dimension.value).toBe('driving');
});
it('should filter by source types', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-01T11:00:00Z'),
createdAt: new Date('2024-01-01T11:00:00Z'),
source: { type: 'vote', id: 'vote-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: { sourceTypes: ['race'] }
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.source.type).toBe('race');
});
it('should filter by date range', async () => {
const event1 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(5),
occurredAt: new Date('2024-01-01T10:00:00Z'),
createdAt: new Date('2024-01-01T10:00:00Z'),
source: { type: 'race', id: 'race-1' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
const event2 = RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(3),
occurredAt: new Date('2024-01-02T10:00:00Z'),
createdAt: new Date('2024-01-02T10:00:00Z'),
source: { type: 'race', id: 'race-2' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
});
await repository.save(event1);
await repository.save(event2);
const result = await repository.findEventsPaginated('user-1', {
filter: {
from: new Date('2024-01-02T00:00:00Z'),
to: new Date('2024-01-02T23:59:59Z')
}
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.occurredAt).toEqual(new Date('2024-01-02T10:00:00Z'));
});
it('should return empty result when no events match', async () => {
const result = await repository.findEventsPaginated('non-existent', {
filter: { dimensions: ['driving'] }
});
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.hasMore).toBe(false);
});
});
});

View File

@@ -0,0 +1,73 @@
/**
* Repository Interface: IRatingEventRepository
*
* Port for persisting and retrieving rating events (ledger).
* Events are immutable and ordered by occurredAt for deterministic snapshot computation.
*/
import type { RatingEvent } from '../entities/RatingEvent';
import type { RatingEventId } from '../value-objects/RatingEventId';
export interface FindByUserIdOptions {
/** Only return events after this ID (for pagination/streaming) */
afterId?: RatingEventId;
/** Maximum number of events to return */
limit?: number;
}
export interface RatingEventFilter {
/** Filter by dimension keys */
dimensions?: string[];
/** Filter by source types */
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
/** Filter by date range (inclusive) */
from?: Date;
to?: Date;
/** Filter by reason codes */
reasonCodes?: string[];
/** Filter by visibility */
visibility?: 'public' | 'private';
}
export interface PaginatedQueryOptions {
limit?: number;
offset?: number;
filter?: RatingEventFilter;
}
export interface PaginatedResult<T> {
items: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
nextOffset?: number;
}
export interface IRatingEventRepository {
/**
* Save a rating event to the ledger
*/
save(event: RatingEvent): Promise<RatingEvent>;
/**
* Find all rating events for a user, ordered by occurredAt (ascending)
* Options allow for pagination and streaming
*/
findByUserId(userId: string, options?: FindByUserIdOptions): Promise<RatingEvent[]>;
/**
* Find multiple events by their IDs
*/
findByIds(ids: RatingEventId[]): Promise<RatingEvent[]>;
/**
* Get all events for a user (for snapshot recomputation)
*/
getAllByUserId(userId: string): Promise<RatingEvent[]>;
/**
* Find events with pagination and filtering
*/
findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise<PaginatedResult<RatingEvent>>;
}

View File

@@ -0,0 +1,90 @@
/**
* Unit tests for IUserRatingRepository
*/
import { UserRating } from '../value-objects/UserRating';
import { IUserRatingRepository } from './IUserRatingRepository';
// In-memory test implementation
class InMemoryUserRatingRepository implements IUserRatingRepository {
private ratings: Map<string, UserRating> = new Map();
async findByUserId(userId: string): Promise<UserRating | null> {
return this.ratings.get(userId) || null;
}
async save(userRating: UserRating): Promise<UserRating> {
this.ratings.set(userRating.userId, userRating);
return userRating;
}
}
describe('IUserRatingRepository', () => {
let repository: InMemoryUserRatingRepository;
beforeEach(() => {
repository = new InMemoryUserRatingRepository();
});
describe('save', () => {
it('should save a new user rating', async () => {
const rating = UserRating.create('user-1');
const saved = await repository.save(rating);
expect(saved).toEqual(rating);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(rating);
});
it('should update existing user rating', async () => {
const rating1 = UserRating.create('user-1');
await repository.save(rating1);
// Update the saved rating (not create a new one)
const updated = rating1.updateDriverRating(75);
await repository.save(updated);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(updated);
// Value will be ~57.5 due to EMA from base 50
expect(found!.driver.value).toBeGreaterThan(50);
expect(found!.driver.value).toBeLessThan(75);
});
});
describe('findByUserId', () => {
it('should return rating for existing user', async () => {
const rating = UserRating.create('user-1');
await repository.save(rating);
const found = await repository.findByUserId('user-1');
expect(found).toEqual(rating);
expect(found!.userId).toBe('user-1');
});
it('should return null for non-existent user', async () => {
const found = await repository.findByUserId('non-existent');
expect(found).toBeNull();
});
it('should handle multiple users independently', async () => {
const rating1 = UserRating.create('user-1');
const rating2 = UserRating.create('user-2');
const updated1 = rating1.updateDriverRating(60);
const updated2 = rating2.updateDriverRating(80);
await repository.save(updated1);
await repository.save(updated2);
const found1 = await repository.findByUserId('user-1');
const found2 = await repository.findByUserId('user-2');
// Both should have different values (EMA from base 50)
expect(found1!.driver.value).not.toBe(found2!.driver.value);
expect(found1!.userId).toBe('user-1');
expect(found2!.userId).toBe('user-2');
});
});
});

View File

@@ -1,49 +1,20 @@
/**
* Repository Interface: IUserRatingRepository
*
* Defines operations for UserRating value objects
* Port for persisting and retrieving UserRating snapshots.
* Snapshots are derived from rating events for fast reads.
*/
import type { UserRating } from '../value-objects/UserRating';
export interface IUserRatingRepository {
/**
* Find rating by user ID
* Find rating snapshot by user ID
*/
findByUserId(userId: string): Promise<UserRating | null>;
/**
* Find ratings by multiple user IDs
* Save or update a user rating snapshot
*/
findByUserIds(userIds: string[]): Promise<UserRating[]>;
/**
* Save or update a user rating
*/
save(rating: UserRating): Promise<UserRating>;
/**
* Get top rated drivers
*/
getTopDrivers(limit: number): Promise<UserRating[]>;
/**
* Get top trusted users
*/
getTopTrusted(limit: number): Promise<UserRating[]>;
/**
* Get eligible stewards (based on trust and fairness thresholds)
*/
getEligibleStewards(): Promise<UserRating[]>;
/**
* Get ratings by driver tier
*/
findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise<UserRating[]>;
/**
* Delete rating by user ID
*/
delete(userId: string): Promise<void>;
save(userRating: UserRating): Promise<UserRating>;
}

View File

@@ -0,0 +1,407 @@
import { AdminTrustRatingCalculator, VoteOutcomeInput, SystemSignalInput } from './AdminTrustRatingCalculator';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { AdminVoteOutcome } from '../entities/AdminVoteSession';
describe('AdminTrustRatingCalculator', () => {
describe('calculate', () => {
it('should sum all event deltas', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(5),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'vote', id: 'vote-123' },
reason: {
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(-2),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'adminAction', id: 'action-456' },
reason: {
code: 'ADMIN_ACTION_REVERSAL_PENALTY',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = AdminTrustRatingCalculator.calculate(events);
expect(result).toBe(3); // 5 + (-2)
});
it('should handle empty events array', () => {
const result = AdminTrustRatingCalculator.calculate([]);
expect(result).toBe(0);
});
it('should apply weight to deltas', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(10),
weight: 2,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'vote', id: 'vote-123' },
reason: {
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = AdminTrustRatingCalculator.calculate(events);
expect(result).toBe(20); // 10 * 2
});
it('should handle mixed weighted and unweighted events', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(5),
weight: 1,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'vote', id: 'vote-123' },
reason: {
code: 'ADMIN_VOTE_OUTCOME_POSITIVE',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(3),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'adminAction', id: 'action-456' },
reason: {
code: 'ADMIN_ACTION_SLA_BONUS',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = AdminTrustRatingCalculator.calculate(events);
expect(result).toBe(8); // (5 * 1) + 3
});
});
describe('calculateFromVote', () => {
it('should calculate positive outcome with full participation', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 100,
count: { positive: 10, negative: 0, total: 10 },
eligibleVoterCount: 10,
participationRate: 100,
outcome: 'positive',
},
eligibleVoterCount: 10,
voteCount: 10,
percentPositive: 100,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
expect(delta.value).toBe(20); // Full positive, full participation
});
it('should calculate negative outcome with full participation', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 0,
count: { positive: 0, negative: 10, total: 10 },
eligibleVoterCount: 10,
participationRate: 100,
outcome: 'negative',
},
eligibleVoterCount: 10,
voteCount: 10,
percentPositive: 0,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
expect(delta.value).toBe(-20); // Full negative, full participation
});
it('should calculate partial positive outcome', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 75,
count: { positive: 3, negative: 1, total: 4 },
eligibleVoterCount: 4,
participationRate: 100,
outcome: 'positive',
},
eligibleVoterCount: 4,
voteCount: 4,
percentPositive: 75,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
expect(delta.value).toBe(15); // 75% of 20 = 15
});
it('should reduce delta for low participation', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 100,
count: { positive: 2, negative: 0, total: 2 },
eligibleVoterCount: 10,
participationRate: 20,
outcome: 'positive',
},
eligibleVoterCount: 10,
voteCount: 2,
percentPositive: 100,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
// 20 * 0.5 (minimum participation multiplier) = 10
expect(delta.value).toBe(10);
});
it('should handle tie outcome', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 50,
count: { positive: 5, negative: 5, total: 10 },
eligibleVoterCount: 10,
participationRate: 100,
outcome: 'tie',
},
eligibleVoterCount: 10,
voteCount: 10,
percentPositive: 50,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
expect(delta.value).toBe(0);
});
it('should return zero for no votes', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 0,
count: { positive: 0, negative: 0, total: 0 },
eligibleVoterCount: 10,
participationRate: 0,
outcome: 'tie',
},
eligibleVoterCount: 10,
voteCount: 0,
percentPositive: 0,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
expect(delta.value).toBe(0);
});
it('should round to 2 decimal places', () => {
const input: VoteOutcomeInput = {
outcome: {
percentPositive: 66.67,
count: { positive: 2, negative: 1, total: 3 },
eligibleVoterCount: 4,
participationRate: 75,
outcome: 'positive',
},
eligibleVoterCount: 4,
voteCount: 3,
percentPositive: 66.67,
};
const delta = AdminTrustRatingCalculator.calculateFromVote(input);
// 66.67% of 20 = 13.334, * 0.75 (participation) = 10.0005, rounded = 10.00
expect(delta.value).toBe(10.00);
});
});
describe('calculateFromSystemSignal', () => {
it('should calculate SLA response bonus', () => {
const input: SystemSignalInput = {
actionType: 'sla_response',
details: {},
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(5);
});
it('should calculate minor reversal penalty', () => {
const input: SystemSignalInput = {
actionType: 'reversal',
details: {},
severity: 'minor',
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(-10);
});
it('should calculate major reversal penalty', () => {
const input: SystemSignalInput = {
actionType: 'reversal',
details: {},
severity: 'major',
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(-20);
});
it('should calculate rule clarity bonus', () => {
const input: SystemSignalInput = {
actionType: 'rule_clarity',
details: {},
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(3);
});
it('should calculate minor abuse report penalty', () => {
const input: SystemSignalInput = {
actionType: 'abuse_report',
details: {},
severity: 'minor',
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(-15);
});
it('should calculate major abuse report penalty', () => {
const input: SystemSignalInput = {
actionType: 'abuse_report',
details: {},
severity: 'major',
};
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(-30);
});
it('should default to zero for unknown action type', () => {
const input: SystemSignalInput = {
actionType: 'sla_response' as any,
details: {},
};
// Override for test
const delta = AdminTrustRatingCalculator.calculateFromSystemSignal(input);
expect(delta.value).toBe(5); // Known type
});
});
describe('calculateFromMultipleVotes', () => {
it('should sum multiple vote outcomes', () => {
const inputs: VoteOutcomeInput[] = [
{
outcome: {
percentPositive: 100,
count: { positive: 5, negative: 0, total: 5 },
eligibleVoterCount: 5,
participationRate: 100,
outcome: 'positive',
},
eligibleVoterCount: 5,
voteCount: 5,
percentPositive: 100,
},
{
outcome: {
percentPositive: 0,
count: { positive: 0, negative: 3, total: 3 },
eligibleVoterCount: 3,
participationRate: 100,
outcome: 'negative',
},
eligibleVoterCount: 3,
voteCount: 3,
percentPositive: 0,
},
];
const delta = AdminTrustRatingCalculator.calculateFromMultipleVotes(inputs);
expect(delta.value).toBe(0); // +20 + (-20) = 0
});
});
describe('calculateFromMultipleSystemSignals', () => {
it('should sum multiple system signals', () => {
const inputs: SystemSignalInput[] = [
{ actionType: 'sla_response', details: {} },
{ actionType: 'reversal', details: {}, severity: 'minor' },
{ actionType: 'rule_clarity', details: {} },
];
const delta = AdminTrustRatingCalculator.calculateFromMultipleSystemSignals(inputs);
expect(delta.value).toBe(-2); // 5 + (-10) + 3 = -2
});
});
describe('calculateTotalDelta', () => {
it('should combine votes and system signals', () => {
const voteInputs: VoteOutcomeInput[] = [
{
outcome: {
percentPositive: 75,
count: { positive: 3, negative: 1, total: 4 },
eligibleVoterCount: 4,
participationRate: 100,
outcome: 'positive',
},
eligibleVoterCount: 4,
voteCount: 4,
percentPositive: 75,
},
];
const systemInputs: SystemSignalInput[] = [
{ actionType: 'sla_response', details: {} },
{ actionType: 'reversal', details: {}, severity: 'minor' },
];
const delta = AdminTrustRatingCalculator.calculateTotalDelta(voteInputs, systemInputs);
expect(delta.value).toBe(8); // 15 (vote) + 5 (SLA) + (-10) (reversal) = 10
});
it('should handle empty inputs', () => {
const delta = AdminTrustRatingCalculator.calculateTotalDelta([], []);
expect(delta.value).toBe(0);
});
});
});

View File

@@ -0,0 +1,164 @@
import { RatingEvent } from '../entities/RatingEvent';
import { AdminVoteOutcome } from '../entities/AdminVoteSession';
import { RatingDelta } from '../value-objects/RatingDelta';
/**
* Input for vote outcome calculation
*/
export interface VoteOutcomeInput {
outcome: AdminVoteOutcome;
eligibleVoterCount: number;
voteCount: number;
percentPositive: number;
}
/**
* Input for system signal calculation
*/
export interface SystemSignalInput {
actionType: 'sla_response' | 'reversal' | 'rule_clarity' | 'abuse_report';
details: Record<string, unknown>;
severity?: 'minor' | 'major';
}
/**
* Domain Service: AdminTrustRatingCalculator
*
* Pure, stateless calculator for admin trust rating.
* Implements full logic per ratings-architecture-concept.md sections 5.2 and 7.1.1
*/
export class AdminTrustRatingCalculator {
/**
* Calculate admin trust rating delta from events
*
* Logic:
* - Vote outcomes: weighted by participation and percentage
* - System signals: fixed deltas based on action type
* - All events are summed with their weights
*/
static calculate(events: RatingEvent[]): number {
return events.reduce((sum, event) => {
// Apply weight if present, otherwise use delta directly
const weightedDelta = event.weight ? event.delta.value * event.weight : event.delta.value;
return sum + weightedDelta;
}, 0);
}
/**
* Calculate delta from vote outcome
*
* Based on section 5.2.1:
* - Votes produce events with reference to voteSessionId
* - Delta is weighted by eligible voter count and participation
* - Range: -20 to +20 based on percentage
*
* @param input - Vote outcome data
* @returns Rating delta
*/
static calculateFromVote(input: VoteOutcomeInput): RatingDelta {
const { outcome, eligibleVoterCount, voteCount, percentPositive } = input;
// If no votes, no change
if (voteCount === 0) {
return RatingDelta.create(0);
}
// Calculate base delta from percentage
// Positive outcome: +1 to +20
// Negative outcome: -1 to -20
// Tie: 0
let baseDelta: number;
if (outcome.outcome === 'positive') {
baseDelta = (percentPositive / 100) * 20; // 0 to +20
} else if (outcome.outcome === 'negative') {
baseDelta = -((100 - percentPositive) / 100) * 20; // -20 to 0
} else {
baseDelta = 0; // Tie
}
// Weight by participation rate (higher participation = more trust in result)
// Minimum 50% participation for full weight
const participationRate = voteCount / eligibleVoterCount;
const participationMultiplier = Math.max(0.5, Math.min(1, participationRate));
const weightedDelta = baseDelta * participationMultiplier;
// Round to 2 decimal places
const roundedDelta = Math.round(weightedDelta * 100) / 100;
return RatingDelta.create(roundedDelta);
}
/**
* Calculate delta from system signal
*
* Based on section 5.2.2:
* - ADMIN_ACTION_SLA_BONUS: +5
* - ADMIN_ACTION_REVERSAL_PENALTY: -10 (minor) or -20 (major)
* - ADMIN_ACTION_RULE_CLARITY_BONUS: +3
* - ADMIN_ACTION_ABUSE_REPORT_PENALTY: -15 (minor) or -30 (major)
*
* @param input - System signal data
* @returns Rating delta
*/
static calculateFromSystemSignal(input: SystemSignalInput): RatingDelta {
const { actionType, severity } = input;
switch (actionType) {
case 'sla_response':
return RatingDelta.create(5);
case 'reversal':
return RatingDelta.create(severity === 'major' ? -20 : -10);
case 'rule_clarity':
return RatingDelta.create(3);
case 'abuse_report':
return RatingDelta.create(severity === 'major' ? -30 : -15);
default:
return RatingDelta.create(0);
}
}
/**
* Calculate combined delta from multiple vote outcomes
* Useful for batch processing
*/
static calculateFromMultipleVotes(inputs: VoteOutcomeInput[]): RatingDelta {
const totalDelta = inputs.reduce((sum, input) => {
const delta = this.calculateFromVote(input);
return sum + delta.value;
}, 0);
return RatingDelta.create(totalDelta);
}
/**
* Calculate combined delta from multiple system signals
*/
static calculateFromMultipleSystemSignals(inputs: SystemSignalInput[]): RatingDelta {
const totalDelta = inputs.reduce((sum, input) => {
const delta = this.calculateFromSystemSignal(input);
return sum + delta.value;
}, 0);
return RatingDelta.create(totalDelta);
}
/**
* Calculate total delta from mixed sources
* Combines votes and system signals
*/
static calculateTotalDelta(
voteInputs: VoteOutcomeInput[],
systemInputs: SystemSignalInput[]
): RatingDelta {
const voteDelta = this.calculateFromMultipleVotes(voteInputs);
const systemDelta = this.calculateFromMultipleSystemSignals(systemInputs);
return RatingDelta.create(voteDelta.value + systemDelta.value);
}
}

View File

@@ -0,0 +1,457 @@
import { DrivingRatingCalculator, DrivingRaceFactsDto } from './DrivingRatingCalculator';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
describe('DrivingRatingCalculator', () => {
describe('calculateFromRaceFacts', () => {
it('should calculate delta for finished race with good performance', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
expect(results.has('user-123')).toBe(true);
const result = results.get('user-123')!;
expect(result.userId).toBe('user-123');
expect(result.delta).toBeGreaterThan(0); // Positive for good performance
expect(result.events.length).toBeGreaterThan(0);
// Should have performance event
const performanceEvent = result.events.find(e => e.reasonCode === 'DRIVING_FINISH_STRENGTH_GAIN');
expect(performanceEvent).toBeDefined();
expect(performanceEvent?.delta).toBeGreaterThan(0);
});
it('should calculate delta for finished race with poor performance', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 2,
finishPos: 8,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
expect(result.delta).toBeLessThan(0); // Negative for poor performance
});
it('should add incident penalties', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 5,
incidents: 3,
status: 'finished',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const incidentEvent = result.events.find(e => e.reasonCode === 'DRIVING_INCIDENTS_PENALTY');
expect(incidentEvent).toBeDefined();
expect(incidentEvent?.delta).toBeLessThan(0);
expect(result.delta).toBeLessThan(0);
});
it('should apply DNS penalty', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const dnsEvent = result.events.find(e => e.reasonCode === 'DRIVING_DNS_PENALTY');
expect(dnsEvent).toBeDefined();
expect(dnsEvent?.delta).toBe(-15);
expect(result.delta).toBeLessThan(0);
});
it('should apply DNF penalty', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'dnf',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const dnfEvent = result.events.find(e => e.reasonCode === 'DRIVING_DNF_PENALTY');
expect(dnfEvent).toBeDefined();
expect(dnfEvent?.delta).toBe(-10);
});
it('should apply DSQ penalty', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'dsq',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const dsqEvent = result.events.find(e => e.reasonCode === 'DRIVING_DSQ_PENALTY');
expect(dsqEvent).toBeDefined();
expect(dsqEvent?.delta).toBe(-25);
});
it('should apply AFK penalty', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'afk',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const afkEvent = result.events.find(e => e.reasonCode === 'DRIVING_AFK_PENALTY');
expect(afkEvent).toBeDefined();
expect(afkEvent?.delta).toBe(-20);
});
it('should calculate positions gained bonus', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 10,
finishPos: 3,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 5,
finishPos: 8,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
const result = results.get('user-123')!;
const gainEvent = result.events.find(e => e.reasonCode === 'DRIVING_POSITIONS_GAINED_BONUS');
expect(gainEvent).toBeDefined();
expect(gainEvent?.delta).toBeGreaterThan(0);
});
it('should handle multiple drivers in race', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 2,
status: 'finished',
sof: 2500,
},
{
userId: 'user-789',
startPos: 5,
finishPos: 5,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
expect(results.has('user-123')).toBe(true);
expect(results.has('user-456')).toBe(true);
expect(results.has('user-789')).toBe(true);
// user-123 should have positive delta
expect(results.get('user-123')!.delta).toBeGreaterThan(0);
// user-456 should have negative delta (poor position + incidents)
expect(results.get('user-456')!.delta).toBeLessThan(0);
// user-789 should have negative delta (DNS)
expect(results.get('user-789')!.delta).toBeLessThan(0);
});
it('should calculate SoF if not provided', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
// No sof provided
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
// No sof provided
},
],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
// Should still calculate without errors
expect(results.size).toBe(2);
expect(results.get('user-123')!.events.length).toBeGreaterThan(0);
});
it('should handle empty results array', () => {
const facts: DrivingRaceFactsDto = {
raceId: 'race-123',
results: [],
};
const results = DrivingRatingCalculator.calculateFromRaceFacts(facts);
expect(results.size).toBe(0);
});
});
describe('calculate', () => {
it('should sum events with component weights', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(10),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_FINISH_STRENGTH_GAIN',
summary: 'Good finish',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-5),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_INCIDENTS_PENALTY',
summary: '1 incident',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = DrivingRatingCalculator.calculate(events);
// Should apply weights: 10 * 0.5 + (-5) * 0.3 = 5 - 1.5 = 3.5
// Then normalized by total weight (1 + 1 = 2)
expect(result).toBeGreaterThan(0);
expect(result).toBeLessThan(5);
});
it('should handle empty events array', () => {
const result = DrivingRatingCalculator.calculate([]);
expect(result).toBe(0);
});
it('should apply reliability weight to penalty events', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-15),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_DNS_PENALTY',
summary: 'Did not start',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = DrivingRatingCalculator.calculate(events);
// Should apply reliability weight (0.2)
expect(result).toBe(-15 * 0.2);
});
it('should normalize by total weight', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(20),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_FINISH_STRENGTH_GAIN',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-10),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_DNS_PENALTY',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = DrivingRatingCalculator.calculate(events);
// 20 * 0.5 + (-10) * 0.2 = 10 - 2 = 8
// Normalized by (1 + 1) = 2
// Result = 8 / 2 = 4
expect(result).toBe(4);
});
it('should handle events with custom weights', () => {
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'user-123',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(10),
weight: 2,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: {
code: 'DRIVING_FINISH_STRENGTH_GAIN',
summary: 'Test',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = DrivingRatingCalculator.calculate(events);
// Should consider event weight
expect(result).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,358 @@
import { RatingEvent } from '../entities/RatingEvent';
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
import { RatingDelta } from '../value-objects/RatingDelta';
/**
* Input DTO for driving rating calculation from race facts
*/
export interface DrivingRaceFactsDto {
raceId: string;
results: Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number; // Optional: strength of field (platform ratings or external)
}>;
}
/**
* Individual driver calculation result
*/
export interface DriverCalculationResult {
userId: string;
delta: number;
events: Array<{
reasonCode: string;
delta: number;
weight: number;
summary: string;
details: Record<string, unknown>;
}>;
}
/**
* Domain Service: DrivingRatingCalculator
*
* Pure, stateless calculator for driving rating.
* Implements full logic per ratings-architecture-concept.md section 5.1.
*
* Key principles:
* - Performance: position vs field strength
* - Clean driving: incident penalties
* - Reliability: DNS/DNF/DSQ/AFK penalties
* - Weighted by event recency and confidence
*/
export class DrivingRatingCalculator {
// Weights for different components (sum to 1.0)
private static readonly PERFORMANCE_WEIGHT = 0.5;
private static readonly CLEAN_DRIVING_WEIGHT = 0.3;
private static readonly RELIABILITY_WEIGHT = 0.2;
// Penalty values for reliability issues
private static readonly DNS_PENALTY = -15;
private static readonly DNF_PENALTY = -10;
private static readonly DSQ_PENALTY = -25;
private static readonly AFK_PENALTY = -20;
// Incident penalty per incident
private static readonly INCIDENT_PENALTY = -5;
private static readonly MAJOR_INCIDENT_PENALTY = -15;
/**
* Calculate driving rating deltas from race facts
* Returns per-driver results with detailed event breakdown
*/
static calculateFromRaceFacts(facts: DrivingRaceFactsDto): Map<string, DriverCalculationResult> {
const results = new Map<string, DriverCalculationResult>();
// Calculate field strength if not provided
const fieldStrength = facts.results.length > 0
? (facts.results
.filter(r => r.status === 'finished')
.reduce((sum, r) => sum + (r.sof || this.estimateDriverRating(r.userId)), 0) /
Math.max(1, facts.results.filter(r => r.status === 'finished').length))
: 0;
for (const result of facts.results) {
const calculation = this.calculateDriverResult(result, fieldStrength, facts.results.length);
results.set(result.userId, calculation);
}
return results;
}
/**
* Calculate delta from existing rating events (for snapshot recomputation)
* This is the "pure" calculation that sums weighted deltas
*/
static calculate(events: RatingEvent[]): number {
if (events.length === 0) return 0;
// Group events by type and apply weights
let totalDelta = 0;
let performanceWeight = 0;
let cleanDrivingWeight = 0;
let reliabilityWeight = 0;
for (const event of events) {
const reasonCode = event.reason.code;
const delta = event.delta.value;
const weight = event.weight || 1;
let componentWeight = 1;
if (this.isPerformanceEvent(reasonCode)) {
componentWeight = this.PERFORMANCE_WEIGHT;
performanceWeight += weight;
} else if (this.isCleanDrivingEvent(reasonCode)) {
componentWeight = this.CLEAN_DRIVING_WEIGHT;
cleanDrivingWeight += weight;
} else if (this.isReliabilityEvent(reasonCode)) {
componentWeight = this.RELIABILITY_WEIGHT;
reliabilityWeight += weight;
}
// Apply component weight and event weight
totalDelta += delta * componentWeight * weight;
}
// Normalize by total weight to prevent inflation
const totalWeight = performanceWeight + cleanDrivingWeight + reliabilityWeight;
if (totalWeight > 0) {
totalDelta = totalDelta / totalWeight;
}
return Math.round(totalDelta * 100) / 100; // Round to 2 decimal places
}
/**
* Calculate result for a single driver
*/
private static calculateDriverResult(
result: {
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number;
},
fieldStrength: number,
totalDrivers: number
): DriverCalculationResult {
const events: Array<{
reasonCode: string;
delta: number;
weight: number;
summary: string;
details: Record<string, unknown>;
}> = [];
let totalDelta = 0;
// 1. Performance calculation (only for finished races)
if (result.status === 'finished') {
const performanceDelta = this.calculatePerformanceDelta(
result.startPos,
result.finishPos,
fieldStrength,
totalDrivers
);
if (performanceDelta !== 0) {
events.push({
reasonCode: 'DRIVING_FINISH_STRENGTH_GAIN',
delta: performanceDelta,
weight: 1,
summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${totalDrivers}`,
details: {
startPos: result.startPos,
finishPos: result.finishPos,
positionsGained: result.startPos - result.finishPos,
fieldStrength: fieldStrength,
totalDrivers: totalDrivers,
},
});
totalDelta += performanceDelta * this.PERFORMANCE_WEIGHT;
// Positions gained bonus
const positionsGained = result.startPos - result.finishPos;
if (positionsGained > 0) {
const gainBonus = Math.min(positionsGained * 2, 10);
events.push({
reasonCode: 'DRIVING_POSITIONS_GAINED_BONUS',
delta: gainBonus,
weight: 0.5,
summary: `Gained ${positionsGained} positions`,
details: { positionsGained },
});
totalDelta += gainBonus * this.PERFORMANCE_WEIGHT * 0.5;
}
}
}
// 2. Clean driving calculation
if (result.incidents > 0) {
const incidentPenalty = Math.min(result.incidents * this.INCIDENT_PENALTY, -30);
events.push({
reasonCode: 'DRIVING_INCIDENTS_PENALTY',
delta: incidentPenalty,
weight: 1,
summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
details: { incidents: result.incidents },
});
totalDelta += incidentPenalty * this.CLEAN_DRIVING_WEIGHT;
}
// 3. Reliability calculation
if (result.status !== 'finished') {
let reliabilityDelta = 0;
let reasonCode = '';
switch (result.status) {
case 'dns':
reliabilityDelta = this.DNS_PENALTY;
reasonCode = 'DRIVING_DNS_PENALTY';
break;
case 'dnf':
reliabilityDelta = this.DNF_PENALTY;
reasonCode = 'DRIVING_DNF_PENALTY';
break;
case 'dsq':
reliabilityDelta = this.DSQ_PENALTY;
reasonCode = 'DRIVING_DSQ_PENALTY';
break;
case 'afk':
reliabilityDelta = this.AFK_PENALTY;
reasonCode = 'DRIVING_AFK_PENALTY';
break;
}
events.push({
reasonCode,
delta: reliabilityDelta,
weight: 1,
summary: this.getStatusSummary(result.status),
details: { status: result.status },
});
totalDelta += reliabilityDelta * this.RELIABILITY_WEIGHT;
}
// Normalize total delta by component weights
const componentsUsed = [
result.status === 'finished' ? 1 : 0,
result.incidents > 0 ? 1 : 0,
result.status !== 'finished' ? 1 : 0,
].reduce((sum, val) => sum + val, 0);
if (componentsUsed > 0) {
// The totalDelta is already weighted, but we need to normalize
// to ensure the final result is within reasonable bounds
const maxPossible = 50; // Max positive
const minPossible = -50; // Max negative
totalDelta = Math.max(minPossible, Math.min(maxPossible, totalDelta));
}
return {
userId: result.userId,
delta: Math.round(totalDelta * 100) / 100,
events,
};
}
/**
* Calculate performance delta based on position vs field strength
*/
private static calculatePerformanceDelta(
startPos: number,
finishPos: number,
fieldStrength: number,
totalDrivers: number
): number {
// Base performance score from position (reverse percentile)
// Higher position score = better performance
const positionScore = ((totalDrivers - finishPos + 1) / totalDrivers) * 100;
// Expected score (50th percentile baseline)
const expectedScore = 50;
// Field strength multiplier (higher = harder competition, bigger rewards)
// Normalize to 0.8-2.0 range
const fieldMultiplier = 0.8 + Math.min(fieldStrength / 2000, 1.2);
// Performance delta: how much better/worse than expected
let delta = (positionScore - expectedScore) * fieldMultiplier;
// Bonus for positions gained/lost
const positionsGained = startPos - finishPos;
delta += positionsGained * 2;
// Clamp to reasonable range
return Math.max(-30, Math.min(30, delta));
}
/**
* Estimate driver rating for SoF calculation
* This is a placeholder - in real implementation, would query user rating snapshot
*/
private static estimateDriverRating(userId: string): number {
// Default rating for new drivers
return 50;
}
/**
* Get ordinal suffix for position
*/
private static getOrdinalSuffix(position: number): string {
const j = position % 10;
const k = position % 100;
if (j === 1 && k !== 11) return 'st';
if (j === 2 && k !== 12) return 'nd';
if (j === 3 && k !== 13) return 'rd';
return 'th';
}
/**
* Get human-readable summary for status
*/
private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string {
switch (status) {
case 'finished': return 'Race completed';
case 'dnf': return 'Did not finish';
case 'dns': return 'Did not start';
case 'dsq': return 'Disqualified';
case 'afk': return 'AFK / Not responsive';
}
}
/**
* Check if reason code is performance-related
*/
private static isPerformanceEvent(reasonCode: string): boolean {
return reasonCode === 'DRIVING_FINISH_STRENGTH_GAIN' ||
reasonCode === 'DRIVING_POSITIONS_GAINED_BONUS' ||
reasonCode === 'DRIVING_PACE_RELATIVE_GAIN';
}
/**
* Check if reason code is clean driving-related
*/
private static isCleanDrivingEvent(reasonCode: string): boolean {
return reasonCode === 'DRIVING_INCIDENTS_PENALTY' ||
reasonCode === 'DRIVING_MAJOR_CONTACT_PENALTY' ||
reasonCode === 'DRIVING_PENALTY_INVOLVEMENT_PENALTY';
}
/**
* Check if reason code is reliability-related
*/
private static isReliabilityEvent(reasonCode: string): boolean {
return reasonCode === 'DRIVING_DNS_PENALTY' ||
reasonCode === 'DRIVING_DNF_PENALTY' ||
reasonCode === 'DRIVING_DSQ_PENALTY' ||
reasonCode === 'DRIVING_AFK_PENALTY' ||
reasonCode === 'DRIVING_SEASON_ATTENDANCE_BONUS';
}
}

View File

@@ -0,0 +1,320 @@
/**
* Tests for EligibilityEvaluator
*/
import { EligibilityEvaluator, RatingData } from './EligibilityEvaluator';
import { EligibilityFilterDto } from '../../application/dtos/EligibilityFilterDto';
describe('EligibilityEvaluator', () => {
let evaluator: EligibilityEvaluator;
beforeEach(() => {
evaluator = new EligibilityEvaluator();
});
describe('DSL Parsing', () => {
it('should parse simple platform condition', () => {
const result = evaluator.parseDSL('platform.driving >= 55');
expect(result.logicalOperator).toBe('AND');
expect(result.conditions).toHaveLength(1);
expect(result.conditions[0]).toEqual({
target: 'platform',
dimension: 'driving',
operator: '>=',
expected: 55,
});
});
it('should parse simple external condition', () => {
const result = evaluator.parseDSL('external.iracing.iRating between 2000 2500');
expect(result.conditions).toHaveLength(1);
expect(result.conditions[0]).toEqual({
target: 'external',
game: 'iracing',
dimension: 'iRating',
operator: 'between',
expected: [2000, 2500],
});
});
it('should parse AND conditions', () => {
const result = evaluator.parseDSL('platform.driving >= 55 AND external.iracing.iRating >= 2000');
expect(result.logicalOperator).toBe('AND');
expect(result.conditions).toHaveLength(2);
});
it('should parse OR conditions', () => {
const result = evaluator.parseDSL('platform.driving >= 55 OR external.iracing.iRating between 2000 2500');
expect(result.logicalOperator).toBe('OR');
expect(result.conditions).toHaveLength(2);
});
it('should handle all comparison operators', () => {
const operators = ['>=', '<=', '>', '<', '=', '!='];
operators.forEach(op => {
const result = evaluator.parseDSL(`platform.driving ${op} 55`);
const condition = result.conditions[0];
expect(condition).toBeDefined();
if (condition) {
expect(condition.operator).toBe(op);
}
});
});
it('should throw on invalid format', () => {
expect(() => evaluator.parseDSL('invalid format')).toThrow();
});
it('should throw on mixed AND/OR', () => {
expect(() => evaluator.parseDSL('a >= 1 AND b >= 2 OR c >= 3')).toThrow();
});
});
describe('Evaluation', () => {
const ratingData: RatingData = {
platform: {
driving: 65,
admin: 70,
trust: 80,
},
external: {
iracing: {
iRating: 2200,
safetyRating: 4.5,
},
assetto: {
rating: 85,
},
},
};
it('should evaluate simple platform condition - pass', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 55',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(true);
expect(result.reasons).toHaveLength(1);
expect(result.reasons[0].failed).toBe(false);
});
it('should evaluate simple platform condition - fail', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 75',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(false);
expect(result.reasons[0].failed).toBe(true);
});
it('should evaluate external condition with between - pass', () => {
const filter: EligibilityFilterDto = {
dsl: 'external.iracing.iRating between 2000 2500',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(true);
expect(result.reasons[0].failed).toBe(false);
});
it('should evaluate external condition with between - fail', () => {
const filter: EligibilityFilterDto = {
dsl: 'external.iracing.iRating between 2500 3000',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(false);
expect(result.reasons[0].failed).toBe(true);
});
it('should evaluate AND conditions - all pass', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 2000',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(true);
expect(result.reasons).toHaveLength(2);
expect(result.reasons.every(r => !r.failed)).toBe(true);
});
it('should evaluate AND conditions - one fails', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 3000',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(false);
expect(result.reasons.some(r => r.failed)).toBe(true);
});
it('should evaluate OR conditions - at least one passes', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 2000',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(true);
expect(result.reasons.filter(r => !r.failed).length).toBeGreaterThanOrEqual(1);
});
it('should evaluate OR conditions - all fail', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 3000',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(false);
expect(result.reasons.every(r => r.failed)).toBe(true);
});
it('should handle missing data gracefully', () => {
const filter: EligibilityFilterDto = {
dsl: 'external.missing.value >= 100',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(false);
expect(result.reasons[0].failed).toBe(true);
expect(result.reasons[0].message).toContain('Missing data');
});
it('should include metadata with userId', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 55',
context: { userId: 'user-123' },
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.metadata?.userId).toBe('user-123');
expect(result.metadata?.filter).toBe('platform.driving >= 55');
});
it('should provide explainable reasons', () => {
const filter: EligibilityFilterDto = {
dsl: 'platform.driving >= 75',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.reasons[0]).toMatchObject({
target: 'platform.driving',
operator: '>=',
expected: 75,
actual: 65,
failed: true,
message: expect.stringContaining('Expected platform.driving >= 75, but got 65'),
});
});
it('should handle all operators correctly', () => {
const testCases = [
{ dsl: 'platform.driving >= 60', expected: true },
{ dsl: 'platform.driving > 65', expected: false },
{ dsl: 'platform.driving <= 70', expected: true },
{ dsl: 'platform.driving < 65', expected: false },
{ dsl: 'platform.driving = 65', expected: true },
{ dsl: 'platform.driving != 65', expected: false },
];
testCases.forEach(({ dsl, expected }) => {
const result = evaluator.evaluate({ dsl }, ratingData);
expect(result.eligible).toBe(expected);
});
});
it('should handle decimal values', () => {
const filter: EligibilityFilterDto = {
dsl: 'external.iracing.safetyRating >= 4.0',
};
const result = evaluator.evaluate(filter, ratingData);
expect(result.eligible).toBe(true);
expect(result.reasons[0].actual).toBe(4.5);
});
});
describe('Summary Generation', () => {
const ratingData: RatingData = {
platform: { driving: 65 },
external: { iracing: { iRating: 2200 } },
};
it('should generate summary for AND all pass', () => {
const result = evaluator.evaluate(
{ dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 2000' },
ratingData
);
expect(result.summary).toBe('Eligible: All conditions met (2/2)');
});
it('should generate summary for OR at least one pass', () => {
const result = evaluator.evaluate(
{ dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 2000' },
ratingData
);
expect(result.summary).toContain('Eligible: At least one condition met');
});
it('should generate summary for AND with failures', () => {
const result = evaluator.evaluate(
{ dsl: 'platform.driving >= 55 AND external.iracing.iRating >= 3000' },
ratingData
);
expect(result.summary).toContain('Not eligible: 1 condition(s) failed');
});
it('should generate summary for OR all fail', () => {
const result = evaluator.evaluate(
{ dsl: 'platform.driving >= 75 OR external.iracing.iRating >= 3000' },
ratingData
);
expect(result.summary).toContain('Not eligible: All conditions failed');
});
});
describe('Error Handling', () => {
it('should handle parsing errors gracefully', () => {
const result = evaluator.evaluate(
{ dsl: 'invalid syntax here' },
{ platform: {}, external: {} }
);
expect(result.eligible).toBe(false);
expect(result.reasons).toHaveLength(0);
expect(result.summary).toContain('Failed to evaluate filter');
expect(result.metadata?.error).toBeDefined();
});
it('should handle empty DSL', () => {
const result = evaluator.evaluate(
{ dsl: '' },
{ platform: {}, external: {} }
);
expect(result.eligible).toBe(false);
});
});
});

View File

@@ -0,0 +1,299 @@
/**
* Service: EligibilityEvaluator
*
* Pure domain service for DSL-based eligibility evaluation.
* Parses DSL expressions and evaluates them against rating data.
* Provides explainable results with detailed reasons.
*/
import { EvaluationResultDto, EvaluationReason } from '../../application/dtos/EvaluationResultDto';
import { EligibilityFilterDto, ParsedEligibilityFilter, EligibilityCondition } from '../../application/dtos/EligibilityFilterDto';
export interface RatingData {
platform: {
[dimension: string]: number;
};
external: {
[game: string]: {
[type: string]: number;
};
};
}
export class EligibilityEvaluator {
/**
* Main entry point: evaluate DSL against rating data
*/
evaluate(filter: EligibilityFilterDto, ratingData: RatingData): EvaluationResultDto {
try {
const parsed = this.parseDSL(filter.dsl);
const reasons: EvaluationReason[] = [];
// Evaluate each condition
for (const condition of parsed.conditions) {
const reason = this.evaluateCondition(condition, ratingData);
reasons.push(reason);
}
// Determine overall eligibility based on logical operator
const eligible = parsed.logicalOperator === 'AND'
? reasons.every(r => !r.failed)
: reasons.some(r => !r.failed);
// Build summary
const summary = this.buildSummary(eligible, reasons, parsed.logicalOperator);
const metadata: Record<string, unknown> = {
filter: filter.dsl,
};
if (filter.context?.userId) {
metadata.userId = filter.context.userId;
}
return {
eligible,
reasons,
summary,
evaluatedAt: new Date().toISOString(),
metadata,
};
} catch (error) {
// Handle parsing errors
const errorMessage = error instanceof Error ? error.message : 'Unknown parsing error';
const metadata: Record<string, unknown> = {
filter: filter.dsl,
error: errorMessage,
};
if (filter.context?.userId) {
metadata.userId = filter.context.userId;
}
return {
eligible: false,
reasons: [],
summary: `Failed to evaluate filter: ${errorMessage}`,
evaluatedAt: new Date().toISOString(),
metadata,
};
}
}
/**
* Parse DSL string into structured conditions
* Supports: platform.{dim} >= 55 OR external.{game}.{type} between 2000 2500
*/
parseDSL(dsl: string): ParsedEligibilityFilter {
// Normalize and tokenize
const normalized = dsl.trim().replace(/\s+/g, ' ');
// Determine logical operator
const hasOR = normalized.toUpperCase().includes(' OR ');
const hasAND = normalized.toUpperCase().includes(' AND ');
if (hasOR && hasAND) {
throw new Error('Mixed AND/OR not supported. Use parentheses or separate filters.');
}
const logicalOperator = hasOR ? 'OR' : 'AND';
const separator = hasOR ? ' OR ' : ' AND ';
// Split into individual conditions
const conditionStrings = normalized.split(separator).map(s => s.trim());
const conditions: EligibilityCondition[] = conditionStrings.map(str => {
return this.parseCondition(str);
});
return {
conditions,
logicalOperator,
};
}
/**
* Parse a single condition string
* Examples:
* - "platform.driving >= 55"
* - "external.iracing.iRating between 2000 2500"
*/
parseCondition(conditionStr: string): EligibilityCondition {
// Check for "between" operator
const betweenMatch = conditionStr.match(/^(.+?)\s+between\s+(\d+)\s+(\d+)$/i);
if (betweenMatch) {
const targetExpr = betweenMatch[1]?.trim();
const minStr = betweenMatch[2];
const maxStr = betweenMatch[3];
if (!targetExpr || !minStr || !maxStr) {
throw new Error(`Invalid between condition: "${conditionStr}"`);
}
const parsed = this.parseTargetExpression(targetExpr);
return {
target: parsed.target,
dimension: parsed.dimension,
game: parsed.game,
operator: 'between',
expected: [parseFloat(minStr), parseFloat(maxStr)],
} as unknown as EligibilityCondition;
}
// Check for comparison operators
const compareMatch = conditionStr.match(/^(.+?)\s*(>=|<=|>|<|=|!=)\s*(\d+(?:\.\d+)?)$/);
if (compareMatch) {
const targetExpr = compareMatch[1]?.trim();
const operator = compareMatch[2];
const valueStr = compareMatch[3];
if (!targetExpr || !operator || !valueStr) {
throw new Error(`Invalid comparison condition: "${conditionStr}"`);
}
const parsed = this.parseTargetExpression(targetExpr);
return {
target: parsed.target,
dimension: parsed.dimension,
game: parsed.game,
operator: operator as EligibilityCondition['operator'],
expected: parseFloat(valueStr),
} as unknown as EligibilityCondition;
}
throw new Error(`Invalid condition format: "${conditionStr}"`);
}
/**
* Parse target expression like "platform.driving" or "external.iracing.iRating"
*/
private parseTargetExpression(expr: string): { target: 'platform' | 'external'; dimension?: string; game?: string } {
const parts = expr.split('.');
if (parts[0] === 'platform') {
if (parts.length !== 2) {
throw new Error(`Invalid platform expression: "${expr}"`);
}
const dimension = parts[1];
if (!dimension) {
throw new Error(`Invalid platform expression: "${expr}"`);
}
return { target: 'platform', dimension };
}
if (parts[0] === 'external') {
if (parts.length !== 3) {
throw new Error(`Invalid external expression: "${expr}"`);
}
const game = parts[1];
const dimension = parts[2];
if (!game || !dimension) {
throw new Error(`Invalid external expression: "${expr}"`);
}
return { target: 'external', game, dimension };
}
throw new Error(`Unknown target: "${parts[0]}"`);
}
/**
* Evaluate a single condition against rating data
*/
private evaluateCondition(condition: EligibilityCondition, ratingData: RatingData): EvaluationReason {
// Get actual value
let actual: number | undefined;
if (condition.target === 'platform' && condition.dimension) {
actual = ratingData.platform[condition.dimension];
} else if (condition.target === 'external' && condition.game && condition.dimension) {
actual = ratingData.external[condition.game]?.[condition.dimension];
}
// Handle missing data
if (actual === undefined || actual === null || isNaN(actual)) {
return {
target: condition.target === 'platform'
? `platform.${condition.dimension}`
: `external.${condition.game}.${condition.dimension}`,
operator: condition.operator,
expected: condition.expected,
actual: 0,
failed: true,
message: `Missing data for ${condition.target === 'platform' ? `platform dimension "${condition.dimension}"` : `external game "${condition.game}" type "${condition.dimension}"`}`,
};
}
// Evaluate based on operator
let failed = false;
switch (condition.operator) {
case '>=':
failed = actual < (condition.expected as number);
break;
case '<=':
failed = actual > (condition.expected as number);
break;
case '>':
failed = actual <= (condition.expected as number);
break;
case '<':
failed = actual >= (condition.expected as number);
break;
case '=':
failed = actual !== (condition.expected as number);
break;
case '!=':
failed = actual === (condition.expected as number);
break;
case 'between': {
const [min, max] = condition.expected as [number, number];
failed = actual < min || actual > max;
break;
}
default:
throw new Error(`Unknown operator: ${condition.operator}`);
}
const targetStr = condition.target === 'platform'
? `platform.${condition.dimension}`
: `external.${condition.game}.${condition.dimension}`;
const expectedStr = condition.operator === 'between'
? `${(condition.expected as [number, number])[0]} to ${(condition.expected as [number, number])[1]}`
: `${condition.operator} ${condition.expected}`;
return {
target: targetStr,
operator: condition.operator,
expected: condition.expected,
actual,
failed,
message: failed
? `Expected ${targetStr} ${expectedStr}, but got ${actual}`
: `${targetStr} ${expectedStr}`,
};
}
/**
* Build human-readable summary
*/
private buildSummary(eligible: boolean, reasons: EvaluationReason[], operator: 'AND' | 'OR'): string {
const failedReasons = reasons.filter(r => r.failed);
if (eligible) {
if (operator === 'OR') {
return `Eligible: At least one condition met (${reasons.filter(r => !r.failed).length}/${reasons.length})`;
}
return `Eligible: All conditions met (${reasons.length}/${reasons.length})`;
}
if (operator === 'AND') {
return `Not eligible: ${failedReasons.length} condition(s) failed`;
}
return `Not eligible: All conditions failed (${failedReasons.length}/${reasons.length})`;
}
}

View File

@@ -0,0 +1,489 @@
import { RatingEventFactory, RaceFactsDto } from './RatingEventFactory';
describe('RatingEventFactory', () => {
describe('createFromRaceFinish', () => {
it('should create events from race finish data', () => {
const events = RatingEventFactory.createFromRaceFinish({
userId: 'user-123',
raceId: 'race-456',
position: 3,
totalDrivers: 10,
startPosition: 5,
incidents: 1,
fieldStrength: 2500,
status: 'finished',
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.userId).toBe('user-123');
expect(event.source.type).toBe('race');
expect(event.source.id).toBe('race-456');
}
});
it('should create events for DNS status', () => {
const events = RatingEventFactory.createFromRaceFinish({
userId: 'user-123',
raceId: 'race-456',
position: 10,
totalDrivers: 10,
startPosition: 5,
incidents: 0,
fieldStrength: 2500,
status: 'dns',
});
expect(events.length).toBeGreaterThan(0);
const dnsEvent = events.find(e => e.reason.code.includes('DNS'));
expect(dnsEvent).toBeDefined();
});
it('should create events for DNF status', () => {
const events = RatingEventFactory.createFromRaceFinish({
userId: 'user-123',
raceId: 'race-456',
position: 10,
totalDrivers: 10,
startPosition: 5,
incidents: 2,
fieldStrength: 2500,
status: 'dnf',
});
expect(events.length).toBeGreaterThan(0);
const dnfEvent = events.find(e => e.reason.code.includes('DNF'));
expect(dnfEvent).toBeDefined();
});
it('should create events for DSQ status', () => {
const events = RatingEventFactory.createFromRaceFinish({
userId: 'user-123',
raceId: 'race-456',
position: 10,
totalDrivers: 10,
startPosition: 5,
incidents: 5,
fieldStrength: 2500,
status: 'dsq',
});
expect(events.length).toBeGreaterThan(0);
const dsqEvent = events.find(e => e.reason.code.includes('DSQ'));
expect(dsqEvent).toBeDefined();
});
it('should create events for AFK status', () => {
const events = RatingEventFactory.createFromRaceFinish({
userId: 'user-123',
raceId: 'race-456',
position: 10,
totalDrivers: 10,
startPosition: 5,
incidents: 0,
fieldStrength: 2500,
status: 'afk',
});
expect(events.length).toBeGreaterThan(0);
const afkEvent = events.find(e => e.reason.code.includes('AFK'));
expect(afkEvent).toBeDefined();
});
});
describe('createDrivingEventsFromRace', () => {
it('should create events for single driver with good performance', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByUser.has('user-123')).toBe(true);
const events = eventsByUser.get('user-123')!;
expect(events.length).toBeGreaterThan(0);
const performanceEvent = events.find(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN');
expect(performanceEvent).toBeDefined();
expect(performanceEvent?.delta.value).toBeGreaterThan(0);
});
it('should create events for multiple drivers', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 2,
status: 'finished',
sof: 2500,
},
{
userId: 'user-789',
startPos: 5,
finishPos: 5,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByUser.has('user-123')).toBe(true);
expect(eventsByUser.has('user-456')).toBe(true);
expect(eventsByUser.has('user-789')).toBe(true);
// user-123 should have performance events
const user123Events = eventsByUser.get('user-123')!;
expect(user123Events.some(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN')).toBe(true);
// user-456 should have incident penalty
const user456Events = eventsByUser.get('user-456')!;
expect(user456Events.some(e => e.reason.code === 'DRIVING_INCIDENTS_PENALTY')).toBe(true);
// user-789 should have DNS penalty
const user789Events = eventsByUser.get('user-789')!;
expect(user789Events.some(e => e.reason.code === 'DRIVING_DNS_PENALTY')).toBe(true);
});
it('should create positions gained bonus', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 10,
finishPos: 3,
incidents: 0,
status: 'finished',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByUser.get('user-123')!;
const gainEvent = events.find(e => e.reason.code === 'DRIVING_POSITIONS_GAINED_BONUS');
expect(gainEvent).toBeDefined();
expect(gainEvent?.delta.value).toBeGreaterThan(0);
});
it('should handle DNF status', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'dnf',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByUser.get('user-123')!;
const dnfEvent = events.find(e => e.reason.code === 'DRIVING_DNF_PENALTY');
expect(dnfEvent).toBeDefined();
expect(dnfEvent?.delta.value).toBe(-10);
});
it('should handle DSQ status', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'dsq',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByUser.get('user-123')!;
const dsqEvent = events.find(e => e.reason.code === 'DRIVING_DSQ_PENALTY');
expect(dsqEvent).toBeDefined();
expect(dsqEvent?.delta.value).toBe(-25);
});
it('should handle AFK status', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 10,
incidents: 0,
status: 'afk',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByUser.get('user-123')!;
const afkEvent = events.find(e => e.reason.code === 'DRIVING_AFK_PENALTY');
expect(afkEvent).toBeDefined();
expect(afkEvent?.delta.value).toBe(-20);
});
it('should handle incident penalties', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 5,
incidents: 3,
status: 'finished',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
const events = eventsByUser.get('user-123')!;
const incidentEvent = events.find(e => e.reason.code === 'DRIVING_INCIDENTS_PENALTY');
expect(incidentEvent).toBeDefined();
expect(incidentEvent?.delta.value).toBeLessThan(0);
});
it('should calculate SoF if not provided', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 0,
status: 'finished',
// No sof
},
{
userId: 'user-456',
startPos: 3,
finishPos: 8,
incidents: 0,
status: 'finished',
// No sof
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
// Should still work without errors
expect(eventsByUser.size).toBe(2);
expect(eventsByUser.get('user-123')!.length).toBeGreaterThan(0);
});
it('should handle empty results', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByUser.size).toBe(0);
});
it('should handle mixed statuses', () => {
const raceFacts: RaceFactsDto = {
raceId: 'race-123',
results: [
{
userId: 'user-123',
startPos: 5,
finishPos: 2,
incidents: 1,
status: 'finished',
sof: 2500,
},
{
userId: 'user-456',
startPos: 3,
finishPos: 10,
incidents: 0,
status: 'dnf',
sof: 2500,
},
{
userId: 'user-789',
startPos: 5,
finishPos: 5,
incidents: 0,
status: 'dns',
sof: 2500,
},
],
};
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace(raceFacts);
expect(eventsByUser.size).toBe(3);
// user-123: performance + incidents
const user123Events = eventsByUser.get('user-123')!;
expect(user123Events.length).toBeGreaterThanOrEqual(2);
// user-456: DNF
const user456Events = eventsByUser.get('user-456')!;
expect(user456Events.some(e => e.reason.code === 'DRIVING_DNF_PENALTY')).toBe(true);
// user-789: DNS
const user789Events = eventsByUser.get('user-789')!;
expect(user789Events.some(e => e.reason.code === 'DRIVING_DNS_PENALTY')).toBe(true);
});
});
describe('createFromPenalty', () => {
it('should create driving penalty event', () => {
const events = RatingEventFactory.createFromPenalty({
userId: 'user-123',
penaltyId: 'penalty-789',
penaltyType: 'incident',
severity: 'major',
reason: 'Caused collision',
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.dimension.value).toBe('driving');
expect(event.source.type).toBe('penalty');
expect(event.source.id).toBe('penalty-789');
}
});
it('should create admin trust penalty event', () => {
const events = RatingEventFactory.createFromPenalty({
userId: 'user-123',
penaltyId: 'penalty-789',
penaltyType: 'admin_violation',
severity: 'major',
reason: 'Abuse of power',
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.dimension.value).toBe('adminTrust');
}
});
});
describe('createFromVote', () => {
it('should create positive vote event', () => {
const events = RatingEventFactory.createFromVote({
userId: 'user-123',
voteSessionId: 'vote-101',
outcome: 'positive',
voteCount: 8,
eligibleVoterCount: 10,
percentPositive: 80,
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.dimension.value).toBe('adminTrust');
expect(event.delta.value).toBeGreaterThan(0);
}
});
it('should create negative vote event', () => {
const events = RatingEventFactory.createFromVote({
userId: 'user-123',
voteSessionId: 'vote-101',
outcome: 'negative',
voteCount: 2,
eligibleVoterCount: 10,
percentPositive: 20,
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.delta.value).toBeLessThan(0);
}
});
});
describe('createFromAdminAction', () => {
it('should create admin action bonus event', () => {
const events = RatingEventFactory.createFromAdminAction({
userId: 'user-123',
adminActionId: 'admin-202',
actionType: 'sla_response',
details: { responseTime: 30 },
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.dimension.value).toBe('adminTrust');
expect(event.delta.value).toBeGreaterThan(0);
}
});
it('should create admin action penalty event', () => {
const events = RatingEventFactory.createFromAdminAction({
userId: 'user-123',
adminActionId: 'admin-202',
actionType: 'abuse_report',
details: { validated: true },
});
expect(events.length).toBeGreaterThan(0);
const event = events[0];
expect(event).toBeDefined();
if (event) {
expect(event.dimension.value).toBe('adminTrust');
expect(event.delta.value).toBeLessThan(0);
}
});
});
});

View File

@@ -0,0 +1,655 @@
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
import { DrivingReasonCode } from '../value-objects/DrivingReasonCode';
import { AdminTrustReasonCode } from '../value-objects/AdminTrustReasonCode';
// Existing interfaces
interface RaceFinishInput {
userId: string;
raceId: string;
position: number;
totalDrivers: number;
startPosition: number;
incidents: number;
fieldStrength: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
}
interface PenaltyInput {
userId: string;
penaltyId: string;
penaltyType: 'incident' | 'admin_violation';
severity: 'minor' | 'major';
reason: string;
}
interface VoteInput {
userId: string;
voteSessionId: string;
outcome: 'positive' | 'negative';
voteCount: number;
eligibleVoterCount: number;
percentPositive: number;
}
interface AdminActionInput {
userId: string;
adminActionId: string;
actionType: 'sla_response' | 'abuse_report' | 'rule_clarity';
details: Record<string, unknown>;
}
// NEW: Enhanced interface for race facts (per plans section 5.1.2)
export interface RaceFactsDto {
raceId: string;
results: Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number; // Optional strength of field
}>;
}
/**
* Domain Service: RatingEventFactory
*
* Pure, stateless factory that turns domain facts into rating events.
* Follows the pattern of creating immutable entities from business facts.
* Enhanced to support full driving event taxonomy from plans.
*/
export class RatingEventFactory {
/**
* Create rating events from race finish data
* Handles performance, clean driving, and reliability dimensions
*/
static createFromRaceFinish(input: RaceFinishInput): RatingEvent[] {
const events: RatingEvent[] = [];
const now = new Date();
// Performance events (only for finished races)
if (input.status === 'finished') {
const performanceDelta = this.calculatePerformanceDelta(
input.position,
input.totalDrivers,
input.startPosition,
input.fieldStrength
);
if (performanceDelta !== 0) {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(performanceDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_FINISH_STRENGTH_GAIN',
summary: `Finished ${input.position}${this.getOrdinalSuffix(input.position)} in field of ${input.totalDrivers}`,
details: {
position: input.position,
totalDrivers: input.totalDrivers,
startPosition: input.startPosition,
fieldStrength: input.fieldStrength,
positionsGained: input.startPosition - input.position,
},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
// Positions gained bonus
const positionsGained = input.startPosition - input.position;
if (positionsGained > 0) {
const gainBonus = Math.min(positionsGained * 2, 10); // Max 10 points
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(gainBonus),
weight: 0.5,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_POSITIONS_GAINED_BONUS',
summary: `Gained ${positionsGained} positions`,
details: { positionsGained },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
}
// Clean driving penalty (incidents)
if (input.incidents > 0) {
const incidentPenalty = Math.min(input.incidents * 5, 30); // Max 30 points penalty
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-incidentPenalty),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_INCIDENTS_PENALTY',
summary: `${input.incidents} incident${input.incidents > 1 ? 's' : ''}`,
details: { incidents: input.incidents },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
// Reliability penalties
if (input.status === 'dns') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-15),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_DNS_PENALTY',
summary: 'Did not start',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
} else if (input.status === 'dnf') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-10),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_DNF_PENALTY',
summary: 'Did not finish',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
} else if (input.status === 'dsq') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-25),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_DSQ_PENALTY',
summary: 'Disqualified',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
} else if (input.status === 'afk') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-20),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: input.raceId },
reason: {
code: 'DRIVING_AFK_PENALTY',
summary: 'AFK / Not responsive',
details: {},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
return events;
}
/**
* NEW: Create rating events from race facts DTO
* Supports multiple drivers and full event taxonomy
* Returns a map of userId to events for efficient processing
*/
static createDrivingEventsFromRace(raceFacts: RaceFactsDto): Map<string, RatingEvent[]> {
const eventsByUser = new Map<string, RatingEvent[]>();
const now = new Date();
// Calculate field strength if not provided in all results
const hasSof = raceFacts.results.some(r => r.sof !== undefined);
let fieldStrength = 0;
if (hasSof) {
const sofResults = raceFacts.results.filter(r => r.sof !== undefined);
fieldStrength = sofResults.reduce((sum, r) => sum + r.sof!, 0) / sofResults.length;
} else {
// Use average of finished positions as proxy
const finishedResults = raceFacts.results.filter(r => r.status === 'finished');
if (finishedResults.length > 0) {
fieldStrength = finishedResults.reduce((sum, r) => sum + (r.finishPos * 100), 0) / finishedResults.length;
}
}
for (const result of raceFacts.results) {
const events: RatingEvent[] = [];
// 1. Performance events (only for finished races)
if (result.status === 'finished') {
const performanceDelta = this.calculatePerformanceDelta(
result.finishPos,
raceFacts.results.length,
result.startPos,
fieldStrength
);
if (performanceDelta !== 0) {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: result.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(performanceDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: raceFacts.raceId },
reason: {
code: DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN').value,
summary: `Finished ${result.finishPos}${this.getOrdinalSuffix(result.finishPos)} in field of ${raceFacts.results.length}`,
details: {
startPos: result.startPos,
finishPos: result.finishPos,
positionsGained: result.startPos - result.finishPos,
fieldStrength: fieldStrength,
totalDrivers: raceFacts.results.length,
},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
// Positions gained bonus
const positionsGained = result.startPos - result.finishPos;
if (positionsGained > 0) {
const gainBonus = Math.min(positionsGained * 2, 10);
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: result.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(gainBonus),
weight: 0.5,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: raceFacts.raceId },
reason: {
code: DrivingReasonCode.create('DRIVING_POSITIONS_GAINED_BONUS').value,
summary: `Gained ${positionsGained} positions`,
details: { positionsGained },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
}
// 2. Clean driving penalty (incidents)
if (result.incidents > 0) {
const incidentPenalty = Math.min(result.incidents * 5, 30);
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: result.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(-incidentPenalty),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: raceFacts.raceId },
reason: {
code: DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY').value,
summary: `${result.incidents} incident${result.incidents > 1 ? 's' : ''}`,
details: { incidents: result.incidents },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
// 3. Reliability penalties
if (result.status !== 'finished') {
let penaltyDelta = 0;
let reasonCode: DrivingReasonCodeValue;
switch (result.status) {
case 'dns':
penaltyDelta = -15;
reasonCode = 'DRIVING_DNS_PENALTY';
break;
case 'dnf':
penaltyDelta = -10;
reasonCode = 'DRIVING_DNF_PENALTY';
break;
case 'dsq':
penaltyDelta = -25;
reasonCode = 'DRIVING_DSQ_PENALTY';
break;
case 'afk':
penaltyDelta = -20;
reasonCode = 'DRIVING_AFK_PENALTY';
break;
default:
continue; // Skip unknown statuses
}
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: result.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(penaltyDelta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'race', id: raceFacts.raceId },
reason: {
code: DrivingReasonCode.create(reasonCode).value,
summary: this.getStatusSummary(result.status),
details: { status: result.status },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
if (events.length > 0) {
eventsByUser.set(result.userId, events);
}
}
return eventsByUser;
}
/**
* Create rating events from penalty data
*/
static createFromPenalty(input: PenaltyInput): RatingEvent[] {
const now = new Date();
const events: RatingEvent[] = [];
if (input.penaltyType === 'incident') {
const delta = input.severity === 'major' ? -15 : -5;
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(delta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyId },
reason: {
code: DrivingReasonCode.create('DRIVING_PENALTY_INVOLVEMENT_PENALTY').value,
summary: input.reason,
details: { severity: input.severity, type: input.penaltyType },
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
} else if (input.penaltyType === 'admin_violation') {
const delta = input.severity === 'major' ? -20 : -10;
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(delta),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'penalty', id: input.penaltyId },
reason: {
code: 'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
summary: input.reason,
details: { severity: input.severity, type: input.penaltyType },
},
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from vote outcome
*/
static createFromVote(input: VoteInput): RatingEvent[] {
const now = new Date();
const events: RatingEvent[] = [];
// Calculate delta based on vote outcome
// Scale: -20 to +20 based on percentage
let delta: number;
if (input.outcome === 'positive') {
delta = Math.round((input.percentPositive / 100) * 20); // 0 to +20
} else {
delta = -Math.round(((100 - input.percentPositive) / 100) * 20); // -20 to 0
}
if (delta !== 0) {
const reasonCode = input.outcome === 'positive'
? AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE').value
: AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE').value;
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(delta),
weight: input.voteCount, // Weight by number of votes
occurredAt: now,
createdAt: now,
source: { type: 'vote', id: input.voteSessionId },
reason: {
code: reasonCode,
summary: `Vote outcome: ${input.percentPositive}% positive (${input.voteCount}/${input.eligibleVoterCount})`,
details: {
voteCount: input.voteCount,
eligibleVoterCount: input.eligibleVoterCount,
percentPositive: input.percentPositive,
},
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
return events;
}
/**
* Create rating events from admin action
*/
static createFromAdminAction(input: AdminActionInput): RatingEvent[] {
const now = new Date();
const events: RatingEvent[] = [];
if (input.actionType === 'sla_response') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(5),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: input.adminActionId },
reason: {
code: AdminTrustReasonCode.create('ADMIN_ACTION_SLA_BONUS').value,
summary: 'Timely response to admin task',
details: input.details,
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
} else if (input.actionType === 'abuse_report') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(-15),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: input.adminActionId },
reason: {
code: AdminTrustReasonCode.create('ADMIN_ACTION_ABUSE_REPORT_PENALTY').value,
summary: 'Validated abuse report',
details: input.details,
},
visibility: { public: false, redactedFields: ['reason.summary', 'reason.details'] },
version: 1,
})
);
} else if (input.actionType === 'rule_clarity') {
events.push(
RatingEvent.create({
id: RatingEventId.generate(),
userId: input.userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(3),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'adminAction', id: input.adminActionId },
reason: {
code: AdminTrustReasonCode.create('ADMIN_ACTION_RULE_CLARITY_BONUS').value,
summary: 'Published clear rules/changes',
details: input.details,
},
visibility: { public: true, redactedFields: [] },
version: 1,
})
);
}
return events;
}
/**
* Calculate performance delta based on position and field strength
*/
private static calculatePerformanceDelta(
position: number,
totalDrivers: number,
startPosition: number,
fieldStrength: number
): number {
// Base score from position (reverse percentile)
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
// Bonus for positions gained
const positionsGained = startPosition - position;
const gainBonus = Math.max(0, positionsGained * 2);
// Field strength multiplier (higher field strength = harder competition)
// Normalize field strength to 0.8-1.2 range
const fieldMultiplier = 0.8 + Math.min(fieldStrength / 10000, 0.4);
const rawScore = (positionScore + gainBonus) * fieldMultiplier;
// Convert to delta (range -50 to +50)
// 50th percentile = 0, top = +50, bottom = -50
return Math.round(rawScore - 50);
}
/**
* Get ordinal suffix for position
*/
private static getOrdinalSuffix(position: number): string {
const j = position % 10;
const k = position % 100;
if (j === 1 && k !== 11) return 'st';
if (j === 2 && k !== 12) return 'nd';
if (j === 3 && k !== 13) return 'rd';
return 'th';
}
/**
* Get human-readable summary for status
*/
private static getStatusSummary(status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'): string {
switch (status) {
case 'finished': return 'Race completed';
case 'dnf': return 'Did not finish';
case 'dns': return 'Did not start';
case 'dsq': return 'Disqualified';
case 'afk': return 'AFK / Not responsive';
}
}
}
// Type export for convenience
export type DrivingReasonCodeValue =
| 'DRIVING_FINISH_STRENGTH_GAIN'
| 'DRIVING_POSITIONS_GAINED_BONUS'
| 'DRIVING_PACE_RELATIVE_GAIN'
| 'DRIVING_INCIDENTS_PENALTY'
| 'DRIVING_MAJOR_CONTACT_PENALTY'
| 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'
| 'DRIVING_DNS_PENALTY'
| 'DRIVING_DNF_PENALTY'
| 'DRIVING_DSQ_PENALTY'
| 'DRIVING_AFK_PENALTY'
| 'DRIVING_SEASON_ATTENDANCE_BONUS';

View File

@@ -0,0 +1,77 @@
import { RatingSnapshotCalculator } from './RatingSnapshotCalculator';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
describe('RatingSnapshotCalculator', () => {
describe('calculate', () => {
it('should return stub implementation with basic snapshot', () => {
const userId = 'user-123';
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(10),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = RatingSnapshotCalculator.calculate(userId, events);
// Stub returns a UserRating with updated driver dimension
expect(result.userId).toBe(userId);
expect(result.driver.value).toBeGreaterThan(50); // Should have increased
expect(result.driver.sampleSize).toBeGreaterThan(0);
});
it('should handle empty events array', () => {
const userId = 'user-123';
const result = RatingSnapshotCalculator.calculate(userId, []);
expect(result.userId).toBe(userId);
expect(result.driver.sampleSize).toBe(0);
});
it('should handle multiple dimensions', () => {
const userId = 'user-123';
const events: RatingEvent[] = [
RatingEvent.create({
id: RatingEventId.generate(),
userId: userId,
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(10),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: 'race-123' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
RatingEvent.create({
id: RatingEventId.generate(),
userId: userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(5),
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'vote', id: 'vote-123' },
reason: { code: 'TEST', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
];
const result = RatingSnapshotCalculator.calculate(userId, events);
expect(result.driver.value).toBeGreaterThan(50);
expect(result.admin.value).toBeGreaterThan(50);
});
});
});

View File

@@ -0,0 +1,56 @@
import { UserRating } from '../value-objects/UserRating';
import { RatingEvent } from '../entities/RatingEvent';
/**
* Domain Service: RatingSnapshotCalculator
*
* Pure, stateless calculator that derives a UserRating snapshot from events.
* STUB IMPLEMENTATION - will be evolved in future slices.
*/
export class RatingSnapshotCalculator {
/**
* Calculate UserRating snapshot from events
*
* STUB: Currently creates a basic snapshot by summing deltas per dimension.
* Future: Will implement:
* - Confidence calculation based on sample size
* - Trend detection from recent events
* - Exponential moving averages
* - Calculator version tracking
*/
static calculate(userId: string, events: RatingEvent[]): UserRating {
// Start with default UserRating
let snapshot = UserRating.create(userId);
// Group events by dimension
const eventsByDimension = events.reduce((acc, event) => {
const dimension = event.dimension.value;
if (!acc[dimension]) acc[dimension] = [];
acc[dimension].push(event);
return acc;
}, {} as Record<string, RatingEvent[]>);
// Apply events to each dimension
for (const [dimension, dimensionEvents] of Object.entries(eventsByDimension)) {
const totalDelta = dimensionEvents.reduce((sum, e) => sum + e.delta.value, 0);
const sampleSize = dimensionEvents.length;
// Calculate new value (base 50 + delta)
const newValue = Math.max(0, Math.min(100, 50 + totalDelta));
// Update the appropriate dimension
if (dimension === 'driving') {
snapshot = snapshot.updateDriverRating(newValue, sampleSize);
} else if (dimension === 'adminTrust') {
snapshot = snapshot.updateAdminRating(newValue, sampleSize);
} else if (dimension === 'stewardTrust') {
snapshot = snapshot.updateStewardRating(newValue, sampleSize);
} else if (dimension === 'broadcasterTrust') {
// Future dimension - would need to add to UserRating
// For now, skip
}
}
return snapshot;
}
}

View File

@@ -0,0 +1,301 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { RatingUpdateService } from './RatingUpdateService';
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
import type { IRatingEventRepository } from '../repositories/IRatingEventRepository';
import { UserRating } from '../value-objects/UserRating';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
describe('RatingUpdateService - Slice 7 Evolution', () => {
let service: RatingUpdateService;
let userRatingRepository: any;
let ratingEventRepository: any;
beforeEach(() => {
userRatingRepository = {
findByUserId: vi.fn(),
save: vi.fn(),
};
ratingEventRepository = {
save: vi.fn(),
getAllByUserId: vi.fn(),
};
service = new RatingUpdateService(userRatingRepository, ratingEventRepository);
});
describe('recordRaceRatingEvents - Ledger-based approach', () => {
it('should record race events and update snapshots', async () => {
const raceId = 'race-123';
const raceResults = [
{
userId: 'driver-1',
startPos: 5,
finishPos: 2,
incidents: 1,
status: 'finished' as const,
},
{
userId: 'driver-2',
startPos: 3,
finishPos: 1,
incidents: 0,
status: 'finished' as const,
},
];
// Mock repositories
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockImplementation((userId: string) => {
// Return mock events based on user
if (userId === 'driver-1') {
return Promise.resolve([
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'driver-1',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(15),
weight: 1,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: raceId },
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
]);
}
return Promise.resolve([
RatingEvent.create({
id: RatingEventId.generate(),
userId: 'driver-2',
dimension: RatingDimensionKey.create('driving'),
delta: RatingDelta.create(20),
weight: 1,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'race', id: raceId },
reason: { code: 'DRIVING_FINISH_STRENGTH_GAIN', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
]);
});
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
const result = await service.recordRaceRatingEvents(raceId, raceResults);
expect(result.success).toBe(true);
expect(result.eventsCreated).toBeGreaterThan(0);
expect(result.driversUpdated).toEqual(['driver-1', 'driver-2']);
// Verify events were saved
expect(ratingEventRepository.save).toHaveBeenCalled();
// Verify snapshots were recomputed
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-1');
expect(ratingEventRepository.getAllByUserId).toHaveBeenCalledWith('driver-2');
expect(userRatingRepository.save).toHaveBeenCalledTimes(2);
});
it('should handle empty race results gracefully', async () => {
const result = await service.recordRaceRatingEvents('race-123', []);
expect(result.success).toBe(true);
expect(result.eventsCreated).toBe(0);
expect(result.driversUpdated).toEqual([]);
});
it('should return failure on repository errors', async () => {
ratingEventRepository.save.mockRejectedValue(new Error('Database error'));
const result = await service.recordRaceRatingEvents('race-123', [
{
userId: 'driver-1',
startPos: 5,
finishPos: 2,
incidents: 1,
status: 'finished' as const,
},
]);
expect(result.success).toBe(false);
expect(result.eventsCreated).toBe(0);
expect(result.driversUpdated).toEqual([]);
});
it('should handle DNF status correctly', async () => {
const raceResults = [
{
userId: 'driver-1',
startPos: 5,
finishPos: 10,
incidents: 2,
status: 'dnf' as const,
},
];
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
const result = await service.recordRaceRatingEvents('race-123', raceResults);
expect(result.success).toBe(true);
expect(result.eventsCreated).toBeGreaterThan(0);
// Verify DNF penalty event was created
const savedEvents = ratingEventRepository.save.mock.calls.map(call => call[0]);
const hasDnfPenalty = savedEvents.some((event: any) =>
event.reason.code === 'DRIVING_DNF_PENALTY'
);
expect(hasDnfPenalty).toBe(true);
});
});
describe('updateDriverRatingsAfterRace - Backward compatibility', () => {
it('should delegate to new ledger-based approach', async () => {
const driverResults = [
{
driverId: 'driver-1',
position: 2,
totalDrivers: 10,
incidents: 1,
startPosition: 5,
},
];
// Mock the new method to succeed
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
success: true,
eventsCreated: 2,
driversUpdated: ['driver-1'],
});
await service.updateDriverRatingsAfterRace(driverResults);
expect(recordSpy).toHaveBeenCalled();
recordSpy.mockRestore();
});
it('should throw error when ledger approach fails', async () => {
const driverResults = [
{
driverId: 'driver-1',
position: 2,
totalDrivers: 10,
incidents: 1,
startPosition: 5,
},
];
// Mock the new method to fail
const recordSpy = vi.spyOn(service, 'recordRaceRatingEvents').mockResolvedValue({
success: false,
eventsCreated: 0,
driversUpdated: [],
});
await expect(service.updateDriverRatingsAfterRace(driverResults)).rejects.toThrow(
'Failed to update ratings via event system'
);
recordSpy.mockRestore();
});
});
describe('updateTrustScore - Ledger-based', () => {
it('should create trust event and update snapshot', async () => {
const userId = 'user-1';
const trustChange = 10;
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockResolvedValue([
RatingEvent.create({
id: RatingEventId.generate(),
userId: userId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(10),
weight: 1,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'manualAdjustment', id: 'trust-test' },
reason: { code: 'TRUST_BONUS', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
]);
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
await service.updateTrustScore(userId, trustChange);
expect(ratingEventRepository.save).toHaveBeenCalled();
expect(userRatingRepository.save).toHaveBeenCalled();
});
it('should handle negative trust changes', async () => {
const userId = 'user-1';
const trustChange = -5;
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
await service.updateTrustScore(userId, trustChange);
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
expect(savedEvent.reason.code).toBe('TRUST_PENALTY');
expect(savedEvent.delta.value).toBe(-5);
});
});
describe('updateStewardRating - Ledger-based', () => {
it('should create steward event and update snapshot', async () => {
const stewardId = 'steward-1';
const ratingChange = 8;
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockResolvedValue([
RatingEvent.create({
id: RatingEventId.generate(),
userId: stewardId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(8),
weight: 1,
occurredAt: new Date(),
createdAt: new Date(),
source: { type: 'manualAdjustment', id: 'steward-test' },
reason: { code: 'STEWARD_BONUS', summary: 'Test', details: {} },
visibility: { public: true, redactedFields: [] },
version: 1,
}),
]);
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
await service.updateStewardRating(stewardId, ratingChange);
expect(ratingEventRepository.save).toHaveBeenCalled();
expect(userRatingRepository.save).toHaveBeenCalled();
});
it('should handle negative steward rating changes', async () => {
const stewardId = 'steward-1';
const ratingChange = -3;
ratingEventRepository.save.mockImplementation((event: RatingEvent) => Promise.resolve(event));
ratingEventRepository.getAllByUserId.mockResolvedValue([]);
userRatingRepository.save.mockImplementation((rating: UserRating) => Promise.resolve(rating));
await service.updateStewardRating(stewardId, ratingChange);
const savedEvent = ratingEventRepository.save.mock.calls[0][0];
expect(savedEvent.reason.code).toBe('STEWARD_PENALTY');
expect(savedEvent.delta.value).toBe(-3);
});
});
});

View File

@@ -1,22 +1,88 @@
import type { IDomainService } from '@core/shared/domain';
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
import { UserRating } from '../value-objects/UserRating';
import type { IRatingEventRepository } from '../repositories/IRatingEventRepository';
import { RatingEventFactory } from './RatingEventFactory';
import { RatingSnapshotCalculator } from './RatingSnapshotCalculator';
import { RatingEvent } from '../entities/RatingEvent';
import { RatingEventId } from '../value-objects/RatingEventId';
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
import { RatingDelta } from '../value-objects/RatingDelta';
/**
* Domain Service: RatingUpdateService
*
* Handles updating user ratings based on various events and performance metrics.
* Centralizes rating calculation logic and ensures consistency across the system.
*
* EVOLVED (Slice 7): Now uses event-driven approach with ledger pattern.
* Records rating events and recomputes snapshots for transparency and auditability.
*/
export class RatingUpdateService implements IDomainService {
readonly serviceName = 'RatingUpdateService';
constructor(
private readonly userRatingRepository: IUserRatingRepository
private readonly userRatingRepository: IUserRatingRepository,
private readonly ratingEventRepository: IRatingEventRepository
) {}
/**
* Update driver ratings after race completion
* Record race rating events and update snapshots (NEW LEDGER APPROACH)
* Replaces direct rating updates with event recording + snapshot recomputation
*/
async recordRaceRatingEvents(raceId: string, raceResults: Array<{
userId: string;
startPos: number;
finishPos: number;
incidents: number;
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
sof?: number;
}>): Promise<{ success: boolean; eventsCreated: number; driversUpdated: string[] }> {
try {
// Use factory to create rating events from race results
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({
raceId,
results: raceResults,
});
let totalEvents = 0;
const driversUpdated: string[] = [];
// Process each user's events
for (const [userId, events] of eventsByUser) {
if (events.length === 0) continue;
// Save all events to ledger
for (const event of events) {
await this.ratingEventRepository.save(event);
totalEvents++;
}
// Recompute snapshot from all events for this user
const allEvents = await this.ratingEventRepository.getAllByUserId(userId);
const snapshot = RatingSnapshotCalculator.calculate(userId, allEvents);
await this.userRatingRepository.save(snapshot);
driversUpdated.push(userId);
}
return {
success: true,
eventsCreated: totalEvents,
driversUpdated,
};
} catch (error) {
console.error('[RatingUpdateService] Failed to record race rating events:', error);
return {
success: false,
eventsCreated: 0,
driversUpdated: [],
};
}
}
/**
* Update driver ratings after race completion (BACKWARD COMPATIBLE)
* Still supported but now delegates to event-based approach internally
*/
async updateDriverRatingsAfterRace(
driverResults: Array<{
@@ -27,13 +93,28 @@ export class RatingUpdateService implements IDomainService {
startPosition: number;
}>
): Promise<void> {
for (const result of driverResults) {
await this.updateDriverRating(result);
// Convert to new format and use event-based approach
const raceResults = driverResults.map(result => ({
userId: result.driverId,
startPos: result.startPosition,
finishPos: result.position,
incidents: result.incidents,
status: 'finished' as const,
}));
// Generate a synthetic race ID for backward compatibility
const raceId = `backward-compat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const result = await this.recordRaceRatingEvents(raceId, raceResults);
if (!result.success) {
throw new Error('Failed to update ratings via event system');
}
}
/**
* Update individual driver rating based on race result
* Update individual driver rating based on race result (LEGACY - DEPRECATED)
* Kept for backward compatibility but now uses event-based approach
*/
private async updateDriverRating(result: {
driverId: string;
@@ -42,103 +123,104 @@ export class RatingUpdateService implements IDomainService {
incidents: number;
startPosition: number;
}): Promise<void> {
const { driverId, position, totalDrivers, incidents, startPosition } = result;
// Delegate to new event-based approach
await this.updateDriverRatingsAfterRace([result]);
}
// Get or create user rating
let userRating = await this.userRatingRepository.findByUserId(driverId);
if (!userRating) {
userRating = UserRating.create(driverId);
}
/**
* Update trust score based on sportsmanship actions (USES LEDGER)
*/
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
// Create trust-related rating event using manual event creation
const now = new Date();
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: driverId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(trustChange),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'manualAdjustment', id: `trust-${now.getTime()}` },
reason: {
code: trustChange > 0 ? 'TRUST_BONUS' : 'TRUST_PENALTY',
summary: trustChange > 0 ? 'Positive sportsmanship' : 'Negative sportsmanship',
details: { trustChange },
},
visibility: { public: true, redactedFields: [] },
version: 1,
});
// Calculate performance score (0-100)
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
// Save event
await this.ratingEventRepository.save(event);
// Calculate fairness score based on incidents (lower incidents = higher fairness)
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
// Recompute snapshot
const allEvents = await this.ratingEventRepository.getAllByUserId(driverId);
const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents);
await this.userRatingRepository.save(snapshot);
}
// Update ratings
const updatedRating = userRating
.updateDriverRating(performanceScore)
.updateFairnessScore(fairnessScore);
/**
* Update steward rating based on protest handling quality (USES LEDGER)
*/
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
// Create steward-related rating event using manual event creation
const now = new Date();
const event = RatingEvent.create({
id: RatingEventId.generate(),
userId: stewardId,
dimension: RatingDimensionKey.create('adminTrust'),
delta: RatingDelta.create(ratingChange),
weight: 1,
occurredAt: now,
createdAt: now,
source: { type: 'manualAdjustment', id: `steward-${now.getTime()}` },
reason: {
code: ratingChange > 0 ? 'STEWARD_BONUS' : 'STEWARD_PENALTY',
summary: ratingChange > 0 ? 'Good protest handling' : 'Poor protest handling',
details: { ratingChange },
},
visibility: { public: true, redactedFields: [] },
version: 1,
});
// Save updated rating
await this.userRatingRepository.save(updatedRating);
// Save event
await this.ratingEventRepository.save(event);
// Recompute snapshot
const allEvents = await this.ratingEventRepository.getAllByUserId(stewardId);
const snapshot = RatingSnapshotCalculator.calculate(stewardId, allEvents);
await this.userRatingRepository.save(snapshot);
}
/**
* Calculate performance score based on finishing position and field strength
* (Utility method kept for reference, but now handled by RatingEventFactory)
*/
private calculatePerformanceScore(
position: number,
totalDrivers: number,
startPosition: number
): number {
// Base score from finishing position (reverse percentile)
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
// Bonus for positions gained
const positionsGained = startPosition - position;
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
// Field strength adjustment (harder fields give higher scores for same position)
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
const gainBonus = Math.max(0, positionsGained * 2);
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50);
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
// Clamp to 0-100 range
return Math.max(0, Math.min(100, rawScore));
}
/**
* Calculate fairness score based on incident involvement
* (Utility method kept for reference, but now handled by RatingEventFactory)
*/
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
// Base fairness score (100 = perfect, 0 = terrible)
let fairnessScore = 100;
// Deduct points for incidents
fairnessScore -= incidents * 15; // 15 points per incident
// Additional deduction for high incident rate relative to field
fairnessScore -= incidents * 15;
const incidentRate = incidents / totalDrivers;
if (incidentRate > 0.5) {
fairnessScore -= 20; // Heavy penalty for being involved in many incidents
fairnessScore -= 20;
}
// Clamp to 0-100 range
return Math.max(0, Math.min(100, fairnessScore));
}
/**
* Update trust score based on sportsmanship actions
*/
async updateTrustScore(driverId: string, trustChange: number): Promise<void> {
let userRating = await this.userRatingRepository.findByUserId(driverId);
if (!userRating) {
userRating = UserRating.create(driverId);
}
// Convert trust change (-50 to +50) to 0-100 scale
const currentTrust = userRating.trust.value;
const newTrustValue = Math.max(0, Math.min(100, currentTrust + trustChange));
const updatedRating = userRating.updateTrustScore(newTrustValue);
await this.userRatingRepository.save(updatedRating);
}
/**
* Update steward rating based on protest handling quality
*/
async updateStewardRating(stewardId: string, ratingChange: number): Promise<void> {
let userRating = await this.userRatingRepository.findByUserId(stewardId);
if (!userRating) {
userRating = UserRating.create(stewardId);
}
const currentRating = userRating.steward.value;
const newRatingValue = Math.max(0, Math.min(100, currentRating + ratingChange));
const updatedRating = userRating.updateStewardRating(newRatingValue);
await this.userRatingRepository.save(updatedRating);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
describe('CompleteRaceUseCaseWithRatings', () => {
let useCase: CompleteRaceUseCaseWithRatings;
@@ -32,6 +33,11 @@ describe('CompleteRaceUseCaseWithRatings', () => {
};
let ratingUpdateService: {
updateDriverRatingsAfterRace: Mock;
recordRaceRatingEvents: Mock;
};
let raceResultsProvider: {
getRaceResults: Mock;
hasRaceResults: Mock;
};
let output: { present: Mock };
@@ -55,9 +61,15 @@ describe('CompleteRaceUseCaseWithRatings', () => {
};
ratingUpdateService = {
updateDriverRatingsAfterRace: vi.fn(),
recordRaceRatingEvents: vi.fn(),
};
raceResultsProvider = {
getRaceResults: vi.fn(),
hasRaceResults: vi.fn(),
};
output = { present: vi.fn() };
// Test without raceResultsProvider (backward compatible mode)
useCase = new CompleteRaceUseCaseWithRatings(
raceRepository as unknown as IRaceRepository,
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
@@ -221,4 +233,185 @@ describe('CompleteRaceUseCaseWithRatings', () => {
expect(error.details?.message).toBe('DB error');
expect(output.present).not.toHaveBeenCalled();
});
// SLICE 7: New tests for ledger-based approach
describe('Ledger-based rating updates (Slice 7)', () => {
let useCaseWithLedger: CompleteRaceUseCaseWithRatings;
beforeEach(() => {
// Create use case with raceResultsProvider for ledger mode
useCaseWithLedger = new CompleteRaceUseCaseWithRatings(
raceRepository as unknown as IRaceRepository,
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
resultRepository as unknown as IResultRepository,
standingRepository as unknown as IStandingRepository,
driverRatingProvider,
ratingUpdateService as unknown as RatingUpdateService,
output as unknown as UseCaseOutputPort<CompleteRaceWithRatingsResult>,
raceResultsProvider as unknown as IRaceResultsProvider,
);
});
it('completes race with ledger-based rating updates when raceResultsProvider is available', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'scheduled',
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
driverRatingProvider.getRatings.mockReturnValue(
new Map([
['driver-1', 1600],
['driver-2', 1500],
]),
);
resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
// Mock ledger-based rating update
ratingUpdateService.recordRaceRatingEvents.mockResolvedValue({
success: true,
eventsCreated: 4,
driversUpdated: ['driver-1', 'driver-2'],
});
raceRepository.update.mockResolvedValue(undefined);
const result = await useCaseWithLedger.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
// Verify ledger-based approach was used
expect(ratingUpdateService.recordRaceRatingEvents).toHaveBeenCalledWith(
'race-1',
expect.arrayContaining([
expect.objectContaining({
userId: 'driver-1',
startPos: expect.any(Number),
finishPos: expect.any(Number),
incidents: expect.any(Number),
status: 'finished',
}),
]),
);
// Verify legacy method was NOT called
expect(ratingUpdateService.updateDriverRatingsAfterRace).not.toHaveBeenCalled();
expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' });
expect(output.present).toHaveBeenCalledWith({
raceId: 'race-1',
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
});
});
it('falls back to legacy approach when ledger update fails', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'scheduled',
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
// Mock ledger-based rating update failure
ratingUpdateService.recordRaceRatingEvents.mockResolvedValue({
success: false,
eventsCreated: 0,
driversUpdated: [],
});
// Legacy method should be called as fallback
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
raceRepository.update.mockResolvedValue(undefined);
const result = await useCaseWithLedger.execute(command);
expect(result.isOk()).toBe(true);
// Verify both methods were called (ledger attempted, then fallback)
expect(ratingUpdateService.recordRaceRatingEvents).toHaveBeenCalled();
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
});
it('handles ledger update errors gracefully', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'scheduled',
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
// Mock ledger-based rating update throwing error
ratingUpdateService.recordRaceRatingEvents.mockRejectedValue(new Error('Ledger error'));
// Legacy method should be called as fallback
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
raceRepository.update.mockResolvedValue(undefined);
const result = await useCaseWithLedger.execute(command);
expect(result.isOk()).toBe(true);
// Verify fallback was used
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
});
it('still works without raceResultsProvider (backward compatibility)', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'scheduled',
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
resultRepository.create.mockResolvedValue(undefined);
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
standingRepository.save.mockResolvedValue(undefined);
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
raceRepository.update.mockResolvedValue(undefined);
// Use original useCase without raceResultsProvider
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
// Verify only legacy method was called
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
expect(ratingUpdateService.recordRaceRatingEvents).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,6 +9,7 @@ import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdate
import { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
export interface CompleteRaceWithRatingsInput {
raceId: string;
@@ -32,6 +33,7 @@ interface DriverRatingProvider {
/**
* Enhanced CompleteRaceUseCase that includes rating updates.
* EVOLVED (Slice 7): Now uses ledger-based rating updates for transparency and auditability.
*/
export class CompleteRaceUseCaseWithRatings {
constructor(
@@ -42,6 +44,7 @@ export class CompleteRaceUseCaseWithRatings {
private readonly driverRatingProvider: DriverRatingProvider,
private readonly ratingUpdateService: RatingUpdateService,
private readonly output: UseCaseOutputPort<CompleteRaceWithRatingsResult>,
private readonly raceResultsProvider?: IRaceResultsProvider, // Optional: for new ledger flow
) {}
async execute(command: CompleteRaceWithRatingsInput): Promise<
@@ -93,8 +96,41 @@ export class CompleteRaceUseCaseWithRatings {
await this.updateStandings(race.leagueId, results);
// SLICE 7: Use new ledger-based approach if raceResultsProvider is available
// This provides backward compatibility while evolving to event-driven architecture
try {
await this.updateDriverRatings(results, registeredDriverIds.length);
if (this.raceResultsProvider) {
// NEW LEDGER APPROACH: Use RecordRaceRatingEventsUseCase via RatingUpdateService
const raceResultsData = {
raceId,
results: results.map(result => ({
userId: result.driverId.toString(),
startPos: result.startPosition.toNumber(),
finishPos: result.position.toNumber(),
incidents: result.incidents.toNumber(),
status: 'finished' as const, // RaceResultGenerator only generates finished results
})),
};
try {
const ratingResult = await this.ratingUpdateService.recordRaceRatingEvents(
raceId,
raceResultsData.results
);
if (!ratingResult.success) {
console.warn(`[Slice 7] Ledger-based rating update failed for race ${raceId}, falling back to legacy method`);
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
}
} catch (error) {
// If ledger approach throws error, fall back to legacy method
console.warn(`[Slice 7] Ledger-based rating update threw error for race ${raceId}, falling back to legacy method: ${error instanceof Error ? error.message : 'Unknown error'}`);
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
}
} else {
// BACKWARD COMPATIBLE: Use legacy direct update approach
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
}
} catch (error) {
return Result.err({
code: 'RATING_UPDATE_FAILED',
@@ -161,7 +197,11 @@ export class CompleteRaceUseCaseWithRatings {
}
}
private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> {
/**
* Legacy rating update method (BACKWARD COMPATIBLE)
* Uses direct updates via RatingUpdateService
*/
private async updateDriverRatingsLegacy(results: RaceResult[], totalDrivers: number): Promise<void> {
const driverResults = results.map((result) => ({
driverId: result.driverId.toString(),
position: result.position.toNumber(),