rating
This commit is contained in:
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
73
core/identity/application/dtos/AdminVoteSessionDto.ts
Normal file
73
core/identity/application/dtos/AdminVoteSessionDto.ts
Normal 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[];
|
||||||
|
}
|
||||||
18
core/identity/application/dtos/CreateRatingEventDto.ts
Normal file
18
core/identity/application/dtos/CreateRatingEventDto.ts
Normal 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
|
||||||
|
}
|
||||||
45
core/identity/application/dtos/EligibilityFilterDto.ts
Normal file
45
core/identity/application/dtos/EligibilityFilterDto.ts
Normal 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';
|
||||||
|
}
|
||||||
68
core/identity/application/dtos/EvaluationResultDto.ts
Normal file
68
core/identity/application/dtos/EvaluationResultDto.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
51
core/identity/application/dtos/LedgerEntryDto.ts
Normal file
51
core/identity/application/dtos/LedgerEntryDto.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
56
core/identity/application/dtos/RatingSummaryDto.ts
Normal file
56
core/identity/application/dtos/RatingSummaryDto.ts
Normal 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)
|
||||||
|
}
|
||||||
17
core/identity/application/dtos/RecordRaceRatingEventsDto.ts
Normal file
17
core/identity/application/dtos/RecordRaceRatingEventsDto.ts
Normal 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[];
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
26
core/identity/application/dtos/UserRatingDto.ts
Normal file
26
core/identity/application/dtos/UserRatingDto.ts
Normal 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
|
||||||
|
}
|
||||||
13
core/identity/application/dtos/index.ts
Normal file
13
core/identity/application/dtos/index.ts
Normal 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';
|
||||||
33
core/identity/application/ports/IRaceResultsProvider.ts
Normal file
33
core/identity/application/ports/IRaceResultsProvider.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
118
core/identity/application/queries/GetUserRatingsSummaryQuery.ts
Normal file
118
core/identity/application/queries/GetUserRatingsSummaryQuery.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
core/identity/application/queries/index.ts
Normal file
17
core/identity/application/queries/index.ts
Normal 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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
123
core/identity/application/use-cases/AppendRatingEventsUseCase.ts
Normal file
123
core/identity/application/use-cases/AppendRatingEventsUseCase.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
core/identity/application/use-cases/CastAdminVoteUseCase.ts
Normal file
99
core/identity/application/use-cases/CastAdminVoteUseCase.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
467
core/identity/domain/entities/AdminVoteSession.test.ts
Normal file
467
core/identity/domain/entities/AdminVoteSession.test.ts
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import { AdminVoteSession } from './AdminVoteSession';
|
||||||
|
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('AdminVoteSession', () => {
|
||||||
|
const now = new Date('2025-01-01T00:00:00Z');
|
||||||
|
const tomorrow = new Date('2025-01-02T00:00:00Z');
|
||||||
|
const dayAfter = new Date('2025-01-03T00:00:00Z');
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a valid vote session', () => {
|
||||||
|
const session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2', 'user-3'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.id).toBe('vote-123');
|
||||||
|
expect(session.leagueId).toBe('league-456');
|
||||||
|
expect(session.adminId).toBe('admin-789');
|
||||||
|
expect(session.startDate).toEqual(now);
|
||||||
|
expect(session.endDate).toEqual(tomorrow);
|
||||||
|
expect(session.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']);
|
||||||
|
expect(session.votes).toEqual([]);
|
||||||
|
expect(session.closed).toBe(false);
|
||||||
|
expect(session.outcome).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for missing voteSessionId', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: '',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
})).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for missing leagueId', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: '',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
})).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for missing adminId', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: '',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
})).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid date range', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: tomorrow,
|
||||||
|
endDate: now, // End before start
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
})).toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for empty eligible voters', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: [],
|
||||||
|
})).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for duplicate eligible voters', () => {
|
||||||
|
expect(() => AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2', 'user-1'],
|
||||||
|
})).toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept optional votes and outcome', () => {
|
||||||
|
const session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2'],
|
||||||
|
votes: [
|
||||||
|
{ voterId: 'user-1', positive: true, votedAt: now },
|
||||||
|
],
|
||||||
|
closed: true,
|
||||||
|
outcome: {
|
||||||
|
percentPositive: 100,
|
||||||
|
count: { positive: 1, negative: 0, total: 1 },
|
||||||
|
eligibleVoterCount: 2,
|
||||||
|
participationRate: 50,
|
||||||
|
outcome: 'positive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.votes.length).toBe(1);
|
||||||
|
expect(session.closed).toBe(true);
|
||||||
|
expect(session.outcome).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rehydrate', () => {
|
||||||
|
it('should rehydrate from persisted data', () => {
|
||||||
|
const session = AdminVoteSession.rehydrate({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2'],
|
||||||
|
votes: [{ voterId: 'user-1', positive: true, votedAt: now }],
|
||||||
|
closed: true,
|
||||||
|
outcome: {
|
||||||
|
percentPositive: 100,
|
||||||
|
count: { positive: 1, negative: 0, total: 1 },
|
||||||
|
eligibleVoterCount: 2,
|
||||||
|
participationRate: 50,
|
||||||
|
outcome: 'positive',
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: tomorrow,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.id).toBe('vote-123');
|
||||||
|
expect(session.closed).toBe(true);
|
||||||
|
expect(session.votes.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('castVote', () => {
|
||||||
|
let session: AdminVoteSession;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2', 'user-3'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow eligible voter to cast positive vote', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
|
||||||
|
expect(session.votes.length).toBe(1);
|
||||||
|
const vote = session.votes[0];
|
||||||
|
expect(vote).toBeDefined();
|
||||||
|
expect(vote!.voterId).toBe('user-1');
|
||||||
|
expect(vote!.positive).toBe(true);
|
||||||
|
expect(vote!.votedAt).toEqual(now);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow eligible voter to cast negative vote', () => {
|
||||||
|
session.castVote('user-1', false, now);
|
||||||
|
|
||||||
|
expect(session.votes.length).toBe(1);
|
||||||
|
const vote = session.votes[0];
|
||||||
|
expect(vote).toBeDefined();
|
||||||
|
expect(vote!.positive).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if voter is not eligible', () => {
|
||||||
|
expect(() => session.castVote('user-999', true, now))
|
||||||
|
.toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if voter already voted', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
|
||||||
|
expect(() => session.castVote('user-1', false, now))
|
||||||
|
.toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if session is closed', () => {
|
||||||
|
session.close();
|
||||||
|
|
||||||
|
expect(() => session.castVote('user-1', true, now))
|
||||||
|
.toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if vote is outside voting window', () => {
|
||||||
|
const beforeStart = new Date('2024-12-31T23:59:59Z');
|
||||||
|
const afterEnd = new Date('2025-01-02T00:00:01Z');
|
||||||
|
|
||||||
|
expect(() => session.castVote('user-1', true, beforeStart))
|
||||||
|
.toThrow(IdentityDomainInvariantError);
|
||||||
|
expect(() => session.castVote('user-1', true, afterEnd))
|
||||||
|
.toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update updatedAt timestamp', () => {
|
||||||
|
const originalUpdatedAt = session.updatedAt;
|
||||||
|
const voteTime = new Date(now.getTime() + 1000);
|
||||||
|
|
||||||
|
session.castVote('user-1', true, voteTime);
|
||||||
|
|
||||||
|
expect(session.updatedAt).not.toEqual(originalUpdatedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('close', () => {
|
||||||
|
let session: AdminVoteSession;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close session and calculate positive outcome', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
session.castVote('user-2', true, now);
|
||||||
|
session.castVote('user-3', false, now);
|
||||||
|
|
||||||
|
const outcome = session.close();
|
||||||
|
|
||||||
|
expect(session.closed).toBe(true);
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
percentPositive: 66.67,
|
||||||
|
count: { positive: 2, negative: 1, total: 3 },
|
||||||
|
eligibleVoterCount: 4,
|
||||||
|
participationRate: 75,
|
||||||
|
outcome: 'positive',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate negative outcome', () => {
|
||||||
|
session.castVote('user-1', false, now);
|
||||||
|
session.castVote('user-2', false, now);
|
||||||
|
session.castVote('user-3', true, now);
|
||||||
|
|
||||||
|
const outcome = session.close();
|
||||||
|
|
||||||
|
expect(outcome.outcome).toBe('negative');
|
||||||
|
expect(outcome.percentPositive).toBe(33.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate tie outcome', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
session.castVote('user-2', false, now);
|
||||||
|
|
||||||
|
const outcome = session.close();
|
||||||
|
|
||||||
|
expect(outcome.outcome).toBe('tie');
|
||||||
|
expect(outcome.percentPositive).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no votes', () => {
|
||||||
|
const outcome = session.close();
|
||||||
|
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
percentPositive: 0,
|
||||||
|
count: { positive: 0, negative: 0, total: 0 },
|
||||||
|
eligibleVoterCount: 4,
|
||||||
|
participationRate: 0,
|
||||||
|
outcome: 'tie',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if already closed', () => {
|
||||||
|
session.close();
|
||||||
|
|
||||||
|
expect(() => session.close()).toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if closed outside voting window', () => {
|
||||||
|
const pastSession = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: new Date('2024-01-01'),
|
||||||
|
endDate: new Date('2024-01-02'),
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => pastSession.close()).toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should round percentPositive to 2 decimal places', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
session.castVote('user-2', true, now);
|
||||||
|
session.castVote('user-3', true, now);
|
||||||
|
session.castVote('user-4', false, now);
|
||||||
|
|
||||||
|
const outcome = session.close();
|
||||||
|
|
||||||
|
expect(outcome.percentPositive).toBe(75.00);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('helper methods', () => {
|
||||||
|
let session: AdminVoteSession;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasVoted', () => {
|
||||||
|
it('should return true if voter has voted', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
expect(session.hasVoted('user-1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if voter has not voted', () => {
|
||||||
|
expect(session.hasVoted('user-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVote', () => {
|
||||||
|
it('should return vote if exists', () => {
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
const vote = session.getVote('user-1');
|
||||||
|
|
||||||
|
expect(vote).toBeDefined();
|
||||||
|
expect(vote?.voterId).toBe('user-1');
|
||||||
|
expect(vote?.positive).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined if vote does not exist', () => {
|
||||||
|
expect(session.getVote('user-1')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVoteCount', () => {
|
||||||
|
it('should return correct count', () => {
|
||||||
|
expect(session.getVoteCount()).toBe(0);
|
||||||
|
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
expect(session.getVoteCount()).toBe(1);
|
||||||
|
|
||||||
|
session.castVote('user-2', false, now);
|
||||||
|
expect(session.getVoteCount()).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isVotingWindowOpen', () => {
|
||||||
|
it('should return true during voting window', () => {
|
||||||
|
expect(session.isVotingWindowOpen(now)).toBe(true);
|
||||||
|
|
||||||
|
const midPoint = new Date((now.getTime() + tomorrow.getTime()) / 2);
|
||||||
|
expect(session.isVotingWindowOpen(midPoint)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false before voting window', () => {
|
||||||
|
const before = new Date('2024-12-31T23:59:59Z');
|
||||||
|
expect(session.isVotingWindowOpen(before)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false after voting window', () => {
|
||||||
|
const after = new Date('2025-01-02T00:00:01Z');
|
||||||
|
expect(session.isVotingWindowOpen(after)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false if session is closed', () => {
|
||||||
|
session.close();
|
||||||
|
expect(session.isVotingWindowOpen(now)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toJSON', () => {
|
||||||
|
it('should serialize to JSON correctly', () => {
|
||||||
|
const session = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1', 'user-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
session.castVote('user-1', true, now);
|
||||||
|
session.close();
|
||||||
|
|
||||||
|
const json = session.toJSON();
|
||||||
|
|
||||||
|
expect(json.voteSessionId).toBe('vote-123');
|
||||||
|
expect(json.leagueId).toBe('league-456');
|
||||||
|
expect(json.adminId).toBe('admin-789');
|
||||||
|
expect(json.closed).toBe(true);
|
||||||
|
expect(json.votes).toHaveLength(1);
|
||||||
|
expect(json.outcome).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same ID', () => {
|
||||||
|
const session1 = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const session2 = AdminVoteSession.rehydrate({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-789', // Different
|
||||||
|
adminId: 'admin-999', // Different
|
||||||
|
startDate: tomorrow, // Different
|
||||||
|
endDate: dayAfter, // Different
|
||||||
|
eligibleVoters: ['user-999'], // Different
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session1.equals(session2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different IDs', () => {
|
||||||
|
const session1 = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-123',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const session2 = AdminVoteSession.create({
|
||||||
|
voteSessionId: 'vote-456',
|
||||||
|
leagueId: 'league-456',
|
||||||
|
adminId: 'admin-789',
|
||||||
|
startDate: now,
|
||||||
|
endDate: tomorrow,
|
||||||
|
eligibleVoters: ['user-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session1.equals(session2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
295
core/identity/domain/entities/AdminVoteSession.ts
Normal file
295
core/identity/domain/entities/AdminVoteSession.ts
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import type { IEntity } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface AdminVote {
|
||||||
|
voterId: string;
|
||||||
|
positive: boolean;
|
||||||
|
votedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminVoteOutcome {
|
||||||
|
percentPositive: number;
|
||||||
|
count: {
|
||||||
|
positive: number;
|
||||||
|
negative: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
eligibleVoterCount: number;
|
||||||
|
participationRate: number;
|
||||||
|
outcome: 'positive' | 'negative' | 'tie';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminVoteSessionProps {
|
||||||
|
voteSessionId: string;
|
||||||
|
leagueId: string;
|
||||||
|
adminId: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
eligibleVoters: string[]; // User IDs
|
||||||
|
votes?: AdminVote[];
|
||||||
|
closed?: boolean;
|
||||||
|
outcome?: AdminVoteOutcome;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AdminVoteSession Entity
|
||||||
|
*
|
||||||
|
* Aggregate root for admin vote sessions scoped to a league.
|
||||||
|
* Controls who can vote, deduplication, time windows, and closure.
|
||||||
|
* Emits outcome events that convert to rating ledger events.
|
||||||
|
*
|
||||||
|
* Based on ratings-architecture-concept.md sections 5.2.1 and 7.1.1
|
||||||
|
*/
|
||||||
|
export class AdminVoteSession implements IEntity<string> {
|
||||||
|
readonly id: string;
|
||||||
|
readonly leagueId: string;
|
||||||
|
readonly adminId: string;
|
||||||
|
readonly startDate: Date;
|
||||||
|
readonly endDate: Date;
|
||||||
|
readonly eligibleVoters: string[];
|
||||||
|
private _votes: AdminVote[];
|
||||||
|
private _closed: boolean;
|
||||||
|
private _outcome: AdminVoteOutcome | undefined;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
private _updatedAt: Date;
|
||||||
|
|
||||||
|
private constructor(props: AdminVoteSessionProps) {
|
||||||
|
this.id = props.voteSessionId;
|
||||||
|
this.leagueId = props.leagueId;
|
||||||
|
this.adminId = props.adminId;
|
||||||
|
this.startDate = props.startDate;
|
||||||
|
this.endDate = props.endDate;
|
||||||
|
this.eligibleVoters = props.eligibleVoters;
|
||||||
|
this._votes = props.votes || [];
|
||||||
|
this._closed = props.closed || false;
|
||||||
|
this._outcome = props.outcome;
|
||||||
|
this.createdAt = props.createdAt || new Date();
|
||||||
|
this._updatedAt = props.updatedAt || new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(props: AdminVoteSessionProps): AdminVoteSession {
|
||||||
|
// Validate required fields
|
||||||
|
if (!props.voteSessionId || props.voteSessionId.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('voteSessionId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.leagueId || props.leagueId.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('leagueId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.adminId || props.adminId.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('adminId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.startDate || !props.endDate) {
|
||||||
|
throw new IdentityDomainValidationError('startDate and endDate are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.startDate >= props.endDate) {
|
||||||
|
throw new IdentityDomainInvariantError('startDate must be before endDate');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.eligibleVoters || props.eligibleVoters.length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('At least one eligible voter is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate no duplicate eligible voters
|
||||||
|
const uniqueVoters = new Set(props.eligibleVoters);
|
||||||
|
if (uniqueVoters.size !== props.eligibleVoters.length) {
|
||||||
|
throw new IdentityDomainInvariantError('Duplicate eligible voters are not allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate votes if provided
|
||||||
|
if (props.votes) {
|
||||||
|
const voterIds = new Set<string>();
|
||||||
|
for (const vote of props.votes) {
|
||||||
|
if (!vote.voterId || vote.voterId.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('Vote voterId is required');
|
||||||
|
}
|
||||||
|
if (!props.eligibleVoters.includes(vote.voterId)) {
|
||||||
|
throw new IdentityDomainInvariantError(`Voter ${vote.voterId} is not eligible`);
|
||||||
|
}
|
||||||
|
if (voterIds.has(vote.voterId)) {
|
||||||
|
throw new IdentityDomainInvariantError(`Duplicate vote from voter ${vote.voterId}`);
|
||||||
|
}
|
||||||
|
if (!vote.votedAt) {
|
||||||
|
throw new IdentityDomainValidationError('Vote timestamp is required');
|
||||||
|
}
|
||||||
|
voterIds.add(vote.voterId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate outcome if provided
|
||||||
|
if (props.outcome) {
|
||||||
|
if (props.outcome.percentPositive < 0 || props.outcome.percentPositive > 100) {
|
||||||
|
throw new IdentityDomainValidationError('percentPositive must be between 0 and 100');
|
||||||
|
}
|
||||||
|
if (props.outcome.eligibleVoterCount !== props.eligibleVoters.length) {
|
||||||
|
throw new IdentityDomainInvariantError('eligibleVoterCount must match eligibleVoters length');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdminVoteSession(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rehydrate(props: AdminVoteSessionProps): AdminVoteSession {
|
||||||
|
// Rehydration assumes data is already validated (from persistence)
|
||||||
|
return new AdminVoteSession(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
get votes(): AdminVote[] {
|
||||||
|
return [...this._votes];
|
||||||
|
}
|
||||||
|
|
||||||
|
get closed(): boolean {
|
||||||
|
return this._closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
get outcome(): AdminVoteOutcome | undefined {
|
||||||
|
return this._outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
get updatedAt(): Date {
|
||||||
|
return this._updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast a vote in this session
|
||||||
|
* @param voterId - The user ID of the voter
|
||||||
|
* @param positive - Whether the vote is positive (true) or negative (false)
|
||||||
|
* @param votedAt - When the vote was cast (optional, defaults to now)
|
||||||
|
* @throws Error if session is closed, voter is not eligible, or already voted
|
||||||
|
*/
|
||||||
|
castVote(voterId: string, positive: boolean, votedAt: Date = new Date()): void {
|
||||||
|
if (this._closed) {
|
||||||
|
throw new IdentityDomainInvariantError('Cannot cast vote: session is closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.eligibleVoters.includes(voterId)) {
|
||||||
|
throw new IdentityDomainInvariantError(`Voter ${voterId} is not eligible for this session`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._votes.some(v => v.voterId === voterId)) {
|
||||||
|
throw new IdentityDomainInvariantError(`Voter ${voterId} has already voted`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (votedAt < this.startDate || votedAt > this.endDate) {
|
||||||
|
throw new IdentityDomainInvariantError('Vote timestamp is outside the voting window');
|
||||||
|
}
|
||||||
|
|
||||||
|
this._votes.push({
|
||||||
|
voterId,
|
||||||
|
positive,
|
||||||
|
votedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this._updatedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the vote session and calculate outcome
|
||||||
|
* @throws Error if session is already closed
|
||||||
|
* @returns The calculated outcome
|
||||||
|
*/
|
||||||
|
close(): AdminVoteOutcome {
|
||||||
|
if (this._closed) {
|
||||||
|
throw new IdentityDomainInvariantError('Session is already closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (now < this.startDate || now > this.endDate) {
|
||||||
|
throw new IdentityDomainInvariantError('Cannot close session outside the voting window');
|
||||||
|
}
|
||||||
|
|
||||||
|
const positiveVotes = this._votes.filter(v => v.positive).length;
|
||||||
|
const negativeVotes = this._votes.filter(v => !v.positive).length;
|
||||||
|
const totalVotes = this._votes.length;
|
||||||
|
const eligibleVoterCount = this.eligibleVoters.length;
|
||||||
|
|
||||||
|
const percentPositive = totalVotes > 0 ? (positiveVotes / totalVotes) * 100 : 0;
|
||||||
|
const participationRate = (totalVotes / eligibleVoterCount) * 100;
|
||||||
|
|
||||||
|
let outcome: 'positive' | 'negative' | 'tie';
|
||||||
|
if (totalVotes === 0) {
|
||||||
|
outcome = 'tie';
|
||||||
|
} else if (positiveVotes > negativeVotes) {
|
||||||
|
outcome = 'positive';
|
||||||
|
} else if (negativeVotes > positiveVotes) {
|
||||||
|
outcome = 'negative';
|
||||||
|
} else {
|
||||||
|
outcome = 'tie';
|
||||||
|
}
|
||||||
|
|
||||||
|
this._outcome = {
|
||||||
|
percentPositive: Math.round(percentPositive * 100) / 100, // Round to 2 decimal places
|
||||||
|
count: {
|
||||||
|
positive: positiveVotes,
|
||||||
|
negative: negativeVotes,
|
||||||
|
total: totalVotes,
|
||||||
|
},
|
||||||
|
eligibleVoterCount,
|
||||||
|
participationRate: Math.round(participationRate * 100) / 100,
|
||||||
|
outcome,
|
||||||
|
};
|
||||||
|
|
||||||
|
this._closed = true;
|
||||||
|
this._updatedAt = now;
|
||||||
|
|
||||||
|
return this._outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a voter has already voted
|
||||||
|
*/
|
||||||
|
hasVoted(voterId: string): boolean {
|
||||||
|
return this._votes.some(v => v.voterId === voterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get vote by voter ID
|
||||||
|
*/
|
||||||
|
getVote(voterId: string): AdminVote | undefined {
|
||||||
|
return this._votes.find(v => v.voterId === voterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of votes cast
|
||||||
|
*/
|
||||||
|
getVoteCount(): number {
|
||||||
|
return this._votes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if session is within voting window
|
||||||
|
*/
|
||||||
|
isVotingWindowOpen(now: Date = new Date()): boolean {
|
||||||
|
return now >= this.startDate && now <= this.endDate && !this._closed;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IEntity<string>): boolean {
|
||||||
|
return this.id === other.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
voteSessionId: this.id,
|
||||||
|
leagueId: this.leagueId,
|
||||||
|
adminId: this.adminId,
|
||||||
|
startDate: this.startDate.toISOString(),
|
||||||
|
endDate: this.endDate.toISOString(),
|
||||||
|
eligibleVoters: this.eligibleVoters,
|
||||||
|
votes: this._votes.map(v => ({
|
||||||
|
voterId: v.voterId,
|
||||||
|
positive: v.positive,
|
||||||
|
votedAt: v.votedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
closed: this._closed,
|
||||||
|
outcome: this._outcome,
|
||||||
|
createdAt: this.createdAt.toISOString(),
|
||||||
|
updatedAt: this._updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
410
core/identity/domain/entities/ExternalGameRatingProfile.test.ts
Normal file
410
core/identity/domain/entities/ExternalGameRatingProfile.test.ts
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { ExternalGameRatingProfile } from './ExternalGameRatingProfile';
|
||||||
|
import { UserId } from '../value-objects/UserId';
|
||||||
|
import { GameKey } from '../value-objects/GameKey';
|
||||||
|
import { ExternalRating } from '../value-objects/ExternalRating';
|
||||||
|
import { ExternalRatingProvenance } from '../value-objects/ExternalRatingProvenance';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('ExternalGameRatingProfile', () => {
|
||||||
|
let userId: UserId;
|
||||||
|
let gameKey: GameKey;
|
||||||
|
let ratings: Map<string, ExternalRating>;
|
||||||
|
let provenance: ExternalRatingProvenance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
userId = UserId.fromString('user-123');
|
||||||
|
gameKey = GameKey.create('iracing');
|
||||||
|
ratings = new Map([
|
||||||
|
['safety', ExternalRating.create(gameKey, 'safety', 85.5)],
|
||||||
|
['skill', ExternalRating.create(gameKey, 'skill', 92.0)],
|
||||||
|
]);
|
||||||
|
provenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-01'),
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a valid profile', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.userId).toBe(userId);
|
||||||
|
expect(profile.gameKey).toBe(gameKey);
|
||||||
|
expect(profile.ratings.size).toBe(2);
|
||||||
|
expect(profile.provenance).toBe(provenance);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow empty ratings map', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings: new Map(),
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.hasRatings()).toBe(false);
|
||||||
|
expect(profile.ratings.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if rating gameKey does not match profile gameKey', () => {
|
||||||
|
const wrongGameKey = GameKey.create('assetto');
|
||||||
|
const wrongRatings = new Map([
|
||||||
|
['safety', ExternalRating.create(wrongGameKey, 'safety', 85.5)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings: wrongRatings,
|
||||||
|
provenance,
|
||||||
|
})
|
||||||
|
).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore', () => {
|
||||||
|
it('should restore profile from stored data', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.restore({
|
||||||
|
userId: 'user-123',
|
||||||
|
gameKey: 'iracing',
|
||||||
|
ratings: [
|
||||||
|
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
|
||||||
|
{ type: 'skill', gameKey: 'iracing', value: 92.0 },
|
||||||
|
],
|
||||||
|
provenance: {
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-01'),
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.userId.toString()).toBe('user-123');
|
||||||
|
expect(profile.gameKey.toString()).toBe('iracing');
|
||||||
|
expect(profile.ratings.size).toBe(2);
|
||||||
|
expect(profile.provenance.source).toBe('iracing');
|
||||||
|
expect(profile.provenance.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle missing verified flag in provenance', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.restore({
|
||||||
|
userId: 'user-123',
|
||||||
|
gameKey: 'iracing',
|
||||||
|
ratings: [
|
||||||
|
{ type: 'safety', gameKey: 'iracing', value: 85.5 },
|
||||||
|
],
|
||||||
|
provenance: {
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-01'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.provenance.verified).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRatingByType', () => {
|
||||||
|
it('should return rating for existing type', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rating = profile.getRatingByType('safety');
|
||||||
|
expect(rating).toBeDefined();
|
||||||
|
expect(rating?.type).toBe('safety');
|
||||||
|
expect(rating?.value).toBe(85.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-existing type', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rating = profile.getRatingByType('nonexistent');
|
||||||
|
expect(rating).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRatingTypes', () => {
|
||||||
|
it('should return all rating types', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const types = profile.getRatingTypes();
|
||||||
|
expect(types).toHaveLength(2);
|
||||||
|
expect(types).toContain('safety');
|
||||||
|
expect(types).toContain('skill');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasRatings', () => {
|
||||||
|
it('should return true when has ratings', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.hasRatings()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no ratings', () => {
|
||||||
|
const emptyRatings = new Map<string, ExternalRating>();
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings: emptyRatings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile.hasRatings()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateRatings', () => {
|
||||||
|
it('should update ratings and provenance', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRatings = new Map([
|
||||||
|
['safety', ExternalRating.create(gameKey, 'safety', 90.0)],
|
||||||
|
['newType', ExternalRating.create(gameKey, 'newType', 88.0)],
|
||||||
|
]);
|
||||||
|
const newProvenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-02'),
|
||||||
|
verified: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
profile.updateRatings(newRatings, newProvenance);
|
||||||
|
|
||||||
|
expect(profile.ratings.size).toBe(2);
|
||||||
|
expect(profile.getRatingByType('safety')?.value).toBe(90.0);
|
||||||
|
expect(profile.getRatingByType('newType')?.value).toBe(88.0);
|
||||||
|
expect(profile.provenance.lastSyncedAt).toEqual(new Date('2024-01-02'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when updating with mismatched gameKey', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrongGameKey = GameKey.create('assetto');
|
||||||
|
const wrongRatings = new Map([
|
||||||
|
['safety', ExternalRating.create(wrongGameKey, 'safety', 90.0)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
profile.updateRatings(
|
||||||
|
wrongRatings,
|
||||||
|
ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markVerified', () => {
|
||||||
|
it('should mark provenance as verified', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance: ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
verified: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
profile.markVerified();
|
||||||
|
|
||||||
|
expect(profile.provenance.verified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLastSyncedAt', () => {
|
||||||
|
it('should update last synced timestamp', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDate = new Date('2024-01-03');
|
||||||
|
profile.updateLastSyncedAt(newDate);
|
||||||
|
|
||||||
|
expect(profile.provenance.lastSyncedAt).toEqual(newDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toSummary', () => {
|
||||||
|
it('should return summary object', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary = profile.toSummary();
|
||||||
|
|
||||||
|
expect(summary.userId).toBe('user-123');
|
||||||
|
expect(summary.gameKey).toBe('iracing');
|
||||||
|
expect(summary.ratingCount).toBe(2);
|
||||||
|
expect(summary.ratingTypes).toEqual(['safety', 'skill']);
|
||||||
|
expect(summary.source).toBe('iracing');
|
||||||
|
expect(summary.verified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toJSON', () => {
|
||||||
|
it('should serialize to JSON format', () => {
|
||||||
|
const profile = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = profile.toJSON();
|
||||||
|
|
||||||
|
expect(json.userId).toBe('user-123');
|
||||||
|
expect(json.gameKey).toBe('iracing');
|
||||||
|
expect(json.ratings).toHaveLength(2);
|
||||||
|
expect(json.provenance.source).toBe('iracing');
|
||||||
|
expect(json.provenance.verified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for identical profiles', () => {
|
||||||
|
const profile1 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile2 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile1.equals(profile2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different userId', () => {
|
||||||
|
const profile1 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile2 = ExternalGameRatingProfile.create({
|
||||||
|
userId: UserId.fromString('user-456'),
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile1.equals(profile2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different gameKey', () => {
|
||||||
|
const profile1 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const differentGameKey = GameKey.create('assetto');
|
||||||
|
const differentRatings = new Map([
|
||||||
|
['safety', ExternalRating.create(differentGameKey, 'safety', 85.5)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const profile2 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey: differentGameKey,
|
||||||
|
ratings: differentRatings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile1.equals(profile2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different ratings', () => {
|
||||||
|
const profile1 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const differentRatings = new Map([
|
||||||
|
['safety', ExternalRating.create(gameKey, 'safety', 99.0)],
|
||||||
|
]);
|
||||||
|
const profile2 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings: differentRatings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile1.equals(profile2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different provenance', () => {
|
||||||
|
const profile1 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
const differentProvenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'different',
|
||||||
|
lastSyncedAt: new Date(),
|
||||||
|
});
|
||||||
|
const profile2 = ExternalGameRatingProfile.create({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings,
|
||||||
|
provenance: differentProvenance,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile1.equals(profile2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
233
core/identity/domain/entities/ExternalGameRatingProfile.ts
Normal file
233
core/identity/domain/entities/ExternalGameRatingProfile.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { Entity } from '@core/shared/domain';
|
||||||
|
import { UserId } from '../value-objects/UserId';
|
||||||
|
import { GameKey } from '../value-objects/GameKey';
|
||||||
|
import { ExternalRating } from '../value-objects/ExternalRating';
|
||||||
|
import { ExternalRatingProvenance } from '../value-objects/ExternalRatingProvenance';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface ExternalGameRatingProfileProps {
|
||||||
|
userId: UserId;
|
||||||
|
gameKey: GameKey;
|
||||||
|
ratings: Map<string, ExternalRating>; // type -> rating
|
||||||
|
provenance: ExternalRatingProvenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalGameRatingProfile extends Entity<UserId> {
|
||||||
|
private readonly _userId: UserId;
|
||||||
|
private readonly _gameKey: GameKey;
|
||||||
|
private _ratings: Map<string, ExternalRating>;
|
||||||
|
private _provenance: ExternalRatingProvenance;
|
||||||
|
|
||||||
|
private constructor(props: ExternalGameRatingProfileProps) {
|
||||||
|
super(props.userId);
|
||||||
|
this._userId = props.userId;
|
||||||
|
this._gameKey = props.gameKey;
|
||||||
|
this._ratings = props.ratings;
|
||||||
|
this._provenance = props.provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(props: ExternalGameRatingProfileProps): ExternalGameRatingProfile {
|
||||||
|
if (!props.userId || !props.gameKey || !props.ratings || !props.provenance) {
|
||||||
|
throw new IdentityDomainValidationError('All properties are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Empty ratings map is allowed for initial creation
|
||||||
|
// The entity can be created with no ratings and updated later
|
||||||
|
|
||||||
|
// Validate that all ratings match the gameKey
|
||||||
|
for (const [type, rating] of props.ratings.entries()) {
|
||||||
|
if (!rating.gameKey.equals(props.gameKey)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Rating type ${type} has mismatched gameKey`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExternalGameRatingProfile(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
static restore(props: {
|
||||||
|
userId: string;
|
||||||
|
gameKey: string;
|
||||||
|
ratings: Array<{ type: string; gameKey: string; value: number }>;
|
||||||
|
provenance: {
|
||||||
|
source: string;
|
||||||
|
lastSyncedAt: Date;
|
||||||
|
verified?: boolean;
|
||||||
|
};
|
||||||
|
}): ExternalGameRatingProfile {
|
||||||
|
const userId = UserId.fromString(props.userId);
|
||||||
|
const gameKey = GameKey.create(props.gameKey);
|
||||||
|
|
||||||
|
const ratingsMap = new Map<string, ExternalRating>();
|
||||||
|
for (const ratingData of props.ratings) {
|
||||||
|
const ratingGameKey = GameKey.create(ratingData.gameKey);
|
||||||
|
const rating = ExternalRating.create(ratingGameKey, ratingData.type, ratingData.value);
|
||||||
|
ratingsMap.set(ratingData.type, rating);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provenance = ExternalRatingProvenance.restore(props.provenance);
|
||||||
|
|
||||||
|
return new ExternalGameRatingProfile({
|
||||||
|
userId,
|
||||||
|
gameKey,
|
||||||
|
ratings: ratingsMap,
|
||||||
|
provenance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
get userId(): UserId {
|
||||||
|
return this._userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
get gameKey(): GameKey {
|
||||||
|
return this._gameKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
get ratings(): ReadonlyMap<string, ExternalRating> {
|
||||||
|
return new Map(this._ratings);
|
||||||
|
}
|
||||||
|
|
||||||
|
get provenance(): ExternalRatingProvenance {
|
||||||
|
return this._provenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update ratings and provenance with latest data
|
||||||
|
*/
|
||||||
|
updateRatings(
|
||||||
|
newRatings: Map<string, ExternalRating>,
|
||||||
|
newProvenance: ExternalRatingProvenance
|
||||||
|
): void {
|
||||||
|
// Validate all new ratings match the gameKey
|
||||||
|
for (const [type, rating] of newRatings.entries()) {
|
||||||
|
if (!rating.gameKey.equals(this._gameKey)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Rating type ${type} has mismatched gameKey`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ratings = newRatings;
|
||||||
|
this._provenance = newProvenance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific rating by type
|
||||||
|
*/
|
||||||
|
getRatingByType(type: string): ExternalRating | undefined {
|
||||||
|
return this._ratings.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all rating types
|
||||||
|
*/
|
||||||
|
getRatingTypes(): string[] {
|
||||||
|
return Array.from(this._ratings.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if profile has any ratings
|
||||||
|
*/
|
||||||
|
hasRatings(): boolean {
|
||||||
|
return this._ratings.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark provenance as verified
|
||||||
|
*/
|
||||||
|
markVerified(): void {
|
||||||
|
this._provenance = this._provenance.markVerified();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update last synced timestamp
|
||||||
|
*/
|
||||||
|
updateLastSyncedAt(date: Date): void {
|
||||||
|
this._provenance = this._provenance.updateLastSyncedAt(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get summary for display
|
||||||
|
*/
|
||||||
|
toSummary(): {
|
||||||
|
userId: string;
|
||||||
|
gameKey: string;
|
||||||
|
ratingCount: number;
|
||||||
|
ratingTypes: string[];
|
||||||
|
source: string;
|
||||||
|
lastSyncedAt: Date;
|
||||||
|
verified: boolean;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
gameKey: this._gameKey.toString(),
|
||||||
|
ratingCount: this._ratings.size,
|
||||||
|
ratingTypes: this.getRatingTypes(),
|
||||||
|
source: this._provenance.source,
|
||||||
|
lastSyncedAt: this._provenance.lastSyncedAt,
|
||||||
|
verified: this._provenance.verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize for storage
|
||||||
|
*/
|
||||||
|
toJSON(): {
|
||||||
|
userId: string;
|
||||||
|
gameKey: string;
|
||||||
|
ratings: Array<{ type: string; gameKey: string; value: number }>;
|
||||||
|
provenance: {
|
||||||
|
source: string;
|
||||||
|
lastSyncedAt: Date;
|
||||||
|
verified: boolean;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
userId: this._userId.toString(),
|
||||||
|
gameKey: this._gameKey.toString(),
|
||||||
|
ratings: Array.from(this._ratings.entries()).map(([type, rating]) => ({
|
||||||
|
type,
|
||||||
|
gameKey: rating.gameKey.toString(),
|
||||||
|
value: rating.value,
|
||||||
|
})),
|
||||||
|
provenance: {
|
||||||
|
source: this._provenance.source,
|
||||||
|
lastSyncedAt: this._provenance.lastSyncedAt,
|
||||||
|
verified: this._provenance.verified,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: ExternalGameRatingProfile): boolean {
|
||||||
|
if (!(other instanceof ExternalGameRatingProfile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._userId.equals(other._userId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._gameKey.equals(other._gameKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._provenance.equals(other._provenance)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare ratings maps
|
||||||
|
if (this._ratings.size !== other._ratings.size) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [type, rating] of this._ratings.entries()) {
|
||||||
|
const otherRating = other._ratings.get(type);
|
||||||
|
if (!otherRating || !rating.equals(otherRating)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
174
core/identity/domain/entities/RatingEvent.test.ts
Normal file
174
core/identity/domain/entities/RatingEvent.test.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { RatingEvent } from './RatingEvent';
|
||||||
|
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||||
|
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||||
|
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||||
|
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingEvent', () => {
|
||||||
|
const validProps = {
|
||||||
|
id: RatingEventId.create('123e4567-e89b-12d3-a456-426614174000'),
|
||||||
|
userId: 'user-123',
|
||||||
|
dimension: RatingDimensionKey.create('driving'),
|
||||||
|
delta: RatingDelta.create(10),
|
||||||
|
occurredAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
source: {
|
||||||
|
type: 'race' as const,
|
||||||
|
id: 'race-456',
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
code: 'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
summary: 'Finished 3rd in strong field',
|
||||||
|
details: { position: 3, fieldStrength: 2500 },
|
||||||
|
},
|
||||||
|
visibility: {
|
||||||
|
public: true,
|
||||||
|
redactedFields: [] as string[],
|
||||||
|
},
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a valid rating event', () => {
|
||||||
|
const event = RatingEvent.create(validProps);
|
||||||
|
|
||||||
|
expect(event.id.value).toBe(validProps.id.value);
|
||||||
|
expect(event.userId).toBe(validProps.userId);
|
||||||
|
expect(event.dimension.value).toBe('driving');
|
||||||
|
expect(event.delta.value).toBe(10);
|
||||||
|
expect(event.occurredAt).toEqual(validProps.occurredAt);
|
||||||
|
expect(event.source.type).toBe('race');
|
||||||
|
expect(event.reason.code).toBe('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
expect(event.visibility.public).toBe(true);
|
||||||
|
expect(event.version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create event with optional weight', () => {
|
||||||
|
const props = { ...validProps, weight: 2 };
|
||||||
|
const event = RatingEvent.create(props);
|
||||||
|
|
||||||
|
expect(event.weight).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create event with non-public visibility', () => {
|
||||||
|
const props = {
|
||||||
|
...validProps,
|
||||||
|
visibility: { public: false, redactedFields: ['reason.summary'] },
|
||||||
|
};
|
||||||
|
const event = RatingEvent.create(props);
|
||||||
|
|
||||||
|
expect(event.visibility.public).toBe(false);
|
||||||
|
expect(event.visibility.redactedFields).toEqual(['reason.summary']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing userId', () => {
|
||||||
|
const props = { ...validProps, userId: '' };
|
||||||
|
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing dimension', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { dimension: _dimension, ...rest } = validProps;
|
||||||
|
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing delta', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { delta: _delta, ...rest } = validProps;
|
||||||
|
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing source', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { source: _source, ...rest } = validProps;
|
||||||
|
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing reason', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { reason: _reason, ...rest } = validProps;
|
||||||
|
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for missing visibility', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { visibility: _visibility, ...rest } = validProps;
|
||||||
|
expect(() => RatingEvent.create(rest as typeof validProps)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid version', () => {
|
||||||
|
const props = { ...validProps, version: 0 };
|
||||||
|
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for future occurredAt', () => {
|
||||||
|
const futureDate = new Date(Date.now() + 86400000);
|
||||||
|
const props = { ...validProps, occurredAt: futureDate };
|
||||||
|
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for future createdAt', () => {
|
||||||
|
const futureDate = new Date(Date.now() + 86400000);
|
||||||
|
const props = { ...validProps, createdAt: futureDate };
|
||||||
|
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for occurredAt after createdAt', () => {
|
||||||
|
const props = {
|
||||||
|
...validProps,
|
||||||
|
occurredAt: new Date('2024-01-02T00:00:00Z'),
|
||||||
|
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
};
|
||||||
|
expect(() => RatingEvent.create(props)).toThrow(IdentityDomainInvariantError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rehydrate', () => {
|
||||||
|
it('should rehydrate event from stored data', () => {
|
||||||
|
const event = RatingEvent.rehydrate(validProps);
|
||||||
|
|
||||||
|
expect(event.id.value).toBe(validProps.id.value);
|
||||||
|
expect(event.userId).toBe(validProps.userId);
|
||||||
|
expect(event.dimension.value).toBe('driving');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rehydrate with optional weight', () => {
|
||||||
|
const props = { ...validProps, weight: 2 };
|
||||||
|
const event = RatingEvent.rehydrate(props);
|
||||||
|
|
||||||
|
expect(event.weight).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same ID', () => {
|
||||||
|
const event1 = RatingEvent.create(validProps);
|
||||||
|
const event2 = RatingEvent.rehydrate(validProps);
|
||||||
|
|
||||||
|
expect(event1.equals(event2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different IDs', () => {
|
||||||
|
const event1 = RatingEvent.create(validProps);
|
||||||
|
const event2 = RatingEvent.create({
|
||||||
|
...validProps,
|
||||||
|
id: RatingEventId.create('123e4567-e89b-12d3-a456-426614174001'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(event1.equals(event2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toJSON', () => {
|
||||||
|
it('should return plain object representation', () => {
|
||||||
|
const event = RatingEvent.create(validProps);
|
||||||
|
const json = event.toJSON();
|
||||||
|
|
||||||
|
expect(json.id).toBe(validProps.id.value);
|
||||||
|
expect(json.userId).toBe(validProps.userId);
|
||||||
|
expect(json.dimension).toBe('driving');
|
||||||
|
expect(json.delta).toBe(10);
|
||||||
|
expect(json.source).toEqual({ type: 'race', id: 'race-456' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
140
core/identity/domain/entities/RatingEvent.ts
Normal file
140
core/identity/domain/entities/RatingEvent.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { IEntity } from '@core/shared/domain';
|
||||||
|
import { RatingEventId } from '../value-objects/RatingEventId';
|
||||||
|
import { RatingDimensionKey } from '../value-objects/RatingDimensionKey';
|
||||||
|
import { RatingDelta } from '../value-objects/RatingDelta';
|
||||||
|
import { IdentityDomainValidationError, IdentityDomainInvariantError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface RatingEventSource {
|
||||||
|
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingEventReason {
|
||||||
|
code: string;
|
||||||
|
summary: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingEventVisibility {
|
||||||
|
public: boolean;
|
||||||
|
redactedFields: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RatingEventProps {
|
||||||
|
id: RatingEventId;
|
||||||
|
userId: string;
|
||||||
|
dimension: RatingDimensionKey;
|
||||||
|
delta: RatingDelta;
|
||||||
|
weight?: number;
|
||||||
|
occurredAt: Date;
|
||||||
|
createdAt: Date;
|
||||||
|
source: RatingEventSource;
|
||||||
|
reason: RatingEventReason;
|
||||||
|
visibility: RatingEventVisibility;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingEvent implements IEntity<RatingEventId> {
|
||||||
|
readonly id: RatingEventId;
|
||||||
|
readonly userId: string;
|
||||||
|
readonly dimension: RatingDimensionKey;
|
||||||
|
readonly delta: RatingDelta;
|
||||||
|
readonly weight: number | undefined;
|
||||||
|
readonly occurredAt: Date;
|
||||||
|
readonly createdAt: Date;
|
||||||
|
readonly source: RatingEventSource;
|
||||||
|
readonly reason: RatingEventReason;
|
||||||
|
readonly visibility: RatingEventVisibility;
|
||||||
|
readonly version: number;
|
||||||
|
|
||||||
|
private constructor(props: RatingEventProps) {
|
||||||
|
this.id = props.id;
|
||||||
|
this.userId = props.userId;
|
||||||
|
this.dimension = props.dimension;
|
||||||
|
this.delta = props.delta;
|
||||||
|
this.weight = props.weight;
|
||||||
|
this.occurredAt = props.occurredAt;
|
||||||
|
this.createdAt = props.createdAt;
|
||||||
|
this.source = props.source;
|
||||||
|
this.reason = props.reason;
|
||||||
|
this.visibility = props.visibility;
|
||||||
|
this.version = props.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(props: RatingEventProps): RatingEvent {
|
||||||
|
// Validate required fields
|
||||||
|
if (!props.userId || props.userId.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('userId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.dimension) {
|
||||||
|
throw new IdentityDomainValidationError('dimension is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.delta) {
|
||||||
|
throw new IdentityDomainValidationError('delta is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.source) {
|
||||||
|
throw new IdentityDomainValidationError('source is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.reason) {
|
||||||
|
throw new IdentityDomainValidationError('reason is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.visibility) {
|
||||||
|
throw new IdentityDomainValidationError('visibility is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.version || props.version < 1) {
|
||||||
|
throw new IdentityDomainValidationError('version must be a positive integer');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dates
|
||||||
|
const now = new Date();
|
||||||
|
if (props.occurredAt > now) {
|
||||||
|
throw new IdentityDomainValidationError('occurredAt cannot be in the future');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.createdAt > now) {
|
||||||
|
throw new IdentityDomainValidationError('createdAt cannot be in the future');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.occurredAt > props.createdAt) {
|
||||||
|
throw new IdentityDomainInvariantError('occurredAt must be before or equal to createdAt');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate weight if provided
|
||||||
|
if (props.weight !== undefined && (props.weight <= 0 || !Number.isFinite(props.weight))) {
|
||||||
|
throw new IdentityDomainValidationError('weight must be a positive number');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RatingEvent(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
static rehydrate(props: RatingEventProps): RatingEvent {
|
||||||
|
// Rehydration assumes data is already validated (from persistence)
|
||||||
|
return new RatingEvent(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IEntity<RatingEventId>): boolean {
|
||||||
|
return this.id.equals(other.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: this.id.value,
|
||||||
|
userId: this.userId,
|
||||||
|
dimension: this.dimension.value,
|
||||||
|
delta: this.delta.value,
|
||||||
|
weight: this.weight,
|
||||||
|
occurredAt: this.occurredAt.toISOString(),
|
||||||
|
createdAt: this.createdAt.toISOString(),
|
||||||
|
source: this.source,
|
||||||
|
reason: this.reason,
|
||||||
|
visibility: this.visibility,
|
||||||
|
version: this.version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
34
core/identity/domain/errors/IdentityDomainError.ts
Normal file
34
core/identity/domain/errors/IdentityDomainError.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[]>;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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>>;
|
||||||
|
}
|
||||||
560
core/identity/domain/repositories/IRatingEventRepository.test.ts
Normal file
560
core/identity/domain/repositories/IRatingEventRepository.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
core/identity/domain/repositories/IRatingEventRepository.ts
Normal file
73
core/identity/domain/repositories/IRatingEventRepository.ts
Normal 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>>;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,49 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Repository Interface: IUserRatingRepository
|
* 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';
|
import type { UserRating } from '../value-objects/UserRating';
|
||||||
|
|
||||||
export interface IUserRatingRepository {
|
export interface IUserRatingRepository {
|
||||||
/**
|
/**
|
||||||
* Find rating by user ID
|
* Find rating snapshot by user ID
|
||||||
*/
|
*/
|
||||||
findByUserId(userId: string): Promise<UserRating | null>;
|
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(userRating: UserRating): 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>;
|
|
||||||
}
|
}
|
||||||
407
core/identity/domain/services/AdminTrustRatingCalculator.test.ts
Normal file
407
core/identity/domain/services/AdminTrustRatingCalculator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
164
core/identity/domain/services/AdminTrustRatingCalculator.ts
Normal file
164
core/identity/domain/services/AdminTrustRatingCalculator.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
457
core/identity/domain/services/DrivingRatingCalculator.test.ts
Normal file
457
core/identity/domain/services/DrivingRatingCalculator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal file
358
core/identity/domain/services/DrivingRatingCalculator.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
320
core/identity/domain/services/EligibilityEvaluator.test.ts
Normal file
320
core/identity/domain/services/EligibilityEvaluator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal file
299
core/identity/domain/services/EligibilityEvaluator.ts
Normal 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})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
489
core/identity/domain/services/RatingEventFactory.test.ts
Normal file
489
core/identity/domain/services/RatingEventFactory.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
655
core/identity/domain/services/RatingEventFactory.ts
Normal file
655
core/identity/domain/services/RatingEventFactory.ts
Normal 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';
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
core/identity/domain/services/RatingSnapshotCalculator.ts
Normal file
56
core/identity/domain/services/RatingSnapshotCalculator.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
301
core/identity/domain/services/RatingUpdateService.test.ts
Normal file
301
core/identity/domain/services/RatingUpdateService.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,88 @@
|
|||||||
import type { IDomainService } from '@core/shared/domain';
|
import type { IDomainService } from '@core/shared/domain';
|
||||||
import type { IUserRatingRepository } from '../repositories/IUserRatingRepository';
|
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
|
* Domain Service: RatingUpdateService
|
||||||
*
|
*
|
||||||
* Handles updating user ratings based on various events and performance metrics.
|
* Handles updating user ratings based on various events and performance metrics.
|
||||||
* Centralizes rating calculation logic and ensures consistency across the system.
|
* 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 {
|
export class RatingUpdateService implements IDomainService {
|
||||||
readonly serviceName = 'RatingUpdateService';
|
readonly serviceName = 'RatingUpdateService';
|
||||||
|
|
||||||
constructor(
|
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(
|
async updateDriverRatingsAfterRace(
|
||||||
driverResults: Array<{
|
driverResults: Array<{
|
||||||
@@ -27,13 +93,28 @@ export class RatingUpdateService implements IDomainService {
|
|||||||
startPosition: number;
|
startPosition: number;
|
||||||
}>
|
}>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
for (const result of driverResults) {
|
// Convert to new format and use event-based approach
|
||||||
await this.updateDriverRating(result);
|
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: {
|
private async updateDriverRating(result: {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
@@ -42,103 +123,104 @@ export class RatingUpdateService implements IDomainService {
|
|||||||
incidents: number;
|
incidents: number;
|
||||||
startPosition: number;
|
startPosition: number;
|
||||||
}): Promise<void> {
|
}): 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);
|
* Update trust score based on sportsmanship actions (USES LEDGER)
|
||||||
if (!userRating) {
|
*/
|
||||||
userRating = UserRating.create(driverId);
|
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)
|
// Save event
|
||||||
const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition);
|
await this.ratingEventRepository.save(event);
|
||||||
|
|
||||||
// Calculate fairness score based on incidents (lower incidents = higher fairness)
|
// Recompute snapshot
|
||||||
const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers);
|
const allEvents = await this.ratingEventRepository.getAllByUserId(driverId);
|
||||||
|
const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents);
|
||||||
|
await this.userRatingRepository.save(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
// Update ratings
|
/**
|
||||||
const updatedRating = userRating
|
* Update steward rating based on protest handling quality (USES LEDGER)
|
||||||
.updateDriverRating(performanceScore)
|
*/
|
||||||
.updateFairnessScore(fairnessScore);
|
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
|
// Save event
|
||||||
await this.userRatingRepository.save(updatedRating);
|
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
|
* Calculate performance score based on finishing position and field strength
|
||||||
|
* (Utility method kept for reference, but now handled by RatingEventFactory)
|
||||||
*/
|
*/
|
||||||
private calculatePerformanceScore(
|
private calculatePerformanceScore(
|
||||||
position: number,
|
position: number,
|
||||||
totalDrivers: number,
|
totalDrivers: number,
|
||||||
startPosition: number
|
startPosition: number
|
||||||
): number {
|
): number {
|
||||||
// Base score from finishing position (reverse percentile)
|
|
||||||
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100;
|
||||||
|
|
||||||
// Bonus for positions gained
|
|
||||||
const positionsGained = startPosition - position;
|
const positionsGained = startPosition - position;
|
||||||
const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained
|
const gainBonus = Math.max(0, positionsGained * 2);
|
||||||
|
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50);
|
||||||
// Field strength adjustment (harder fields give higher scores for same position)
|
|
||||||
const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers
|
|
||||||
|
|
||||||
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
|
const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier;
|
||||||
|
|
||||||
// Clamp to 0-100 range
|
|
||||||
return Math.max(0, Math.min(100, rawScore));
|
return Math.max(0, Math.min(100, rawScore));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate fairness score based on incident involvement
|
* Calculate fairness score based on incident involvement
|
||||||
|
* (Utility method kept for reference, but now handled by RatingEventFactory)
|
||||||
*/
|
*/
|
||||||
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
|
private calculateFairnessScore(incidents: number, totalDrivers: number): number {
|
||||||
// Base fairness score (100 = perfect, 0 = terrible)
|
|
||||||
let fairnessScore = 100;
|
let fairnessScore = 100;
|
||||||
|
fairnessScore -= incidents * 15;
|
||||||
// Deduct points for incidents
|
|
||||||
fairnessScore -= incidents * 15; // 15 points per incident
|
|
||||||
|
|
||||||
// Additional deduction for high incident rate relative to field
|
|
||||||
const incidentRate = incidents / totalDrivers;
|
const incidentRate = incidents / totalDrivers;
|
||||||
if (incidentRate > 0.5) {
|
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));
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
169
core/identity/domain/value-objects/AdminTrustReasonCode.test.ts
Normal file
169
core/identity/domain/value-objects/AdminTrustReasonCode.test.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { AdminTrustReasonCode } from './AdminTrustReasonCode';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('AdminTrustReasonCode', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid reason codes', () => {
|
||||||
|
const validCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
'ADMIN_ACTION_RULE_CLARITY_BONUS',
|
||||||
|
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||||
|
];
|
||||||
|
|
||||||
|
validCodes.forEach(code => {
|
||||||
|
const reasonCode = AdminTrustReasonCode.create(code);
|
||||||
|
expect(reasonCode.value).toBe(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for empty string', () => {
|
||||||
|
expect(() => AdminTrustReasonCode.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => AdminTrustReasonCode.create(' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid reason code', () => {
|
||||||
|
expect(() => AdminTrustReasonCode.create('INVALID_CODE')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => AdminTrustReasonCode.create('admin_vote')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from valid codes', () => {
|
||||||
|
const reasonCode = AdminTrustReasonCode.create(' ADMIN_VOTE_OUTCOME_POSITIVE ');
|
||||||
|
expect(reasonCode.value).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromValue', () => {
|
||||||
|
it('should create from value without validation', () => {
|
||||||
|
const reasonCode = AdminTrustReasonCode.fromValue('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
expect(reasonCode.value).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for equal codes', () => {
|
||||||
|
const code1 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
const code2 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
expect(code1.equals(code2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different codes', () => {
|
||||||
|
const code1 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
const code2 = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_NEGATIVE');
|
||||||
|
expect(code1.equals(code2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return the string value', () => {
|
||||||
|
const code = AdminTrustReasonCode.create('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
expect(code.toString()).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('category methods', () => {
|
||||||
|
describe('isVoteOutcome', () => {
|
||||||
|
it('should return true for vote outcome codes', () => {
|
||||||
|
const voteCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
];
|
||||||
|
voteCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isVoteOutcome()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-vote outcome codes', () => {
|
||||||
|
const nonVoteCodes = [
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
];
|
||||||
|
nonVoteCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isVoteOutcome()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSystemSignal', () => {
|
||||||
|
it('should return true for system signal codes', () => {
|
||||||
|
const systemCodes = [
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
'ADMIN_ACTION_RULE_CLARITY_BONUS',
|
||||||
|
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||||
|
];
|
||||||
|
systemCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isSystemSignal()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-system signal codes', () => {
|
||||||
|
const nonSystemCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
];
|
||||||
|
nonSystemCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isSystemSignal()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositive', () => {
|
||||||
|
it('should return true for positive impact codes', () => {
|
||||||
|
const positiveCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_RULE_CLARITY_BONUS',
|
||||||
|
];
|
||||||
|
positiveCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPositive()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-positive codes', () => {
|
||||||
|
const nonPositiveCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||||
|
];
|
||||||
|
nonPositiveCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPositive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNegative', () => {
|
||||||
|
it('should return true for negative impact codes', () => {
|
||||||
|
const negativeCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||||
|
];
|
||||||
|
negativeCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isNegative()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-negative codes', () => {
|
||||||
|
const nonNegativeCodes = [
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_RULE_CLARITY_BONUS',
|
||||||
|
];
|
||||||
|
nonNegativeCodes.forEach(codeStr => {
|
||||||
|
const code = AdminTrustReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isNegative()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
112
core/identity/domain/value-objects/AdminTrustReasonCode.ts
Normal file
112
core/identity/domain/value-objects/AdminTrustReasonCode.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Trust Reason Code Value Object
|
||||||
|
*
|
||||||
|
* Stable machine codes for admin trust rating events to support:
|
||||||
|
* - Filtering and analytics
|
||||||
|
* - i18n translations
|
||||||
|
* - Consistent UI explanations
|
||||||
|
*
|
||||||
|
* Based on ratings-architecture-concept.md sections 5.2.1 and 5.2.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AdminTrustReasonCodeValue =
|
||||||
|
// Vote outcomes
|
||||||
|
| 'ADMIN_VOTE_OUTCOME_POSITIVE'
|
||||||
|
| 'ADMIN_VOTE_OUTCOME_NEGATIVE'
|
||||||
|
// System signals
|
||||||
|
| 'ADMIN_ACTION_SLA_BONUS'
|
||||||
|
| 'ADMIN_ACTION_REVERSAL_PENALTY'
|
||||||
|
| 'ADMIN_ACTION_RULE_CLARITY_BONUS'
|
||||||
|
| 'ADMIN_ACTION_ABUSE_REPORT_PENALTY';
|
||||||
|
|
||||||
|
export interface AdminTrustReasonCodeProps {
|
||||||
|
value: AdminTrustReasonCodeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_REASON_CODES: AdminTrustReasonCodeValue[] = [
|
||||||
|
// Vote outcomes
|
||||||
|
'ADMIN_VOTE_OUTCOME_POSITIVE',
|
||||||
|
'ADMIN_VOTE_OUTCOME_NEGATIVE',
|
||||||
|
// System signals
|
||||||
|
'ADMIN_ACTION_SLA_BONUS',
|
||||||
|
'ADMIN_ACTION_REVERSAL_PENALTY',
|
||||||
|
'ADMIN_ACTION_RULE_CLARITY_BONUS',
|
||||||
|
'ADMIN_ACTION_ABUSE_REPORT_PENALTY',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class AdminTrustReasonCode implements IValueObject<AdminTrustReasonCodeProps> {
|
||||||
|
readonly value: AdminTrustReasonCodeValue;
|
||||||
|
|
||||||
|
private constructor(value: AdminTrustReasonCodeValue) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): AdminTrustReasonCode {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('AdminTrustReasonCode cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim() as AdminTrustReasonCodeValue;
|
||||||
|
|
||||||
|
if (!VALID_REASON_CODES.includes(trimmed)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid admin trust reason code: ${value}. Valid options: ${VALID_REASON_CODES.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdminTrustReasonCode(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromValue(value: AdminTrustReasonCodeValue): AdminTrustReasonCode {
|
||||||
|
return new AdminTrustReasonCode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): AdminTrustReasonCodeProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<AdminTrustReasonCodeProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a vote-related reason code
|
||||||
|
*/
|
||||||
|
isVoteOutcome(): boolean {
|
||||||
|
return this.value === 'ADMIN_VOTE_OUTCOME_POSITIVE' ||
|
||||||
|
this.value === 'ADMIN_VOTE_OUTCOME_NEGATIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a system signal reason code
|
||||||
|
*/
|
||||||
|
isSystemSignal(): boolean {
|
||||||
|
return this.value === 'ADMIN_ACTION_SLA_BONUS' ||
|
||||||
|
this.value === 'ADMIN_ACTION_REVERSAL_PENALTY' ||
|
||||||
|
this.value === 'ADMIN_ACTION_RULE_CLARITY_BONUS' ||
|
||||||
|
this.value === 'ADMIN_ACTION_ABUSE_REPORT_PENALTY';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a positive impact (bonus)
|
||||||
|
*/
|
||||||
|
isPositive(): boolean {
|
||||||
|
return this.value.endsWith('_BONUS') ||
|
||||||
|
this.value === 'ADMIN_VOTE_OUTCOME_POSITIVE';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a negative impact (penalty)
|
||||||
|
*/
|
||||||
|
isNegative(): boolean {
|
||||||
|
return this.value.endsWith('_PENALTY') ||
|
||||||
|
this.value === 'ADMIN_VOTE_OUTCOME_NEGATIVE';
|
||||||
|
}
|
||||||
|
}
|
||||||
207
core/identity/domain/value-objects/DrivingReasonCode.test.ts
Normal file
207
core/identity/domain/value-objects/DrivingReasonCode.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { DrivingReasonCode } from './DrivingReasonCode';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('DrivingReasonCode', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid reason codes', () => {
|
||||||
|
const validCodes = [
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_POSITIONS_GAINED_BONUS',
|
||||||
|
'DRIVING_PACE_RELATIVE_GAIN',
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_MAJOR_CONTACT_PENALTY',
|
||||||
|
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_DNF_PENALTY',
|
||||||
|
'DRIVING_DSQ_PENALTY',
|
||||||
|
'DRIVING_AFK_PENALTY',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
|
||||||
|
validCodes.forEach(code => {
|
||||||
|
const reasonCode = DrivingReasonCode.create(code);
|
||||||
|
expect(reasonCode.value).toBe(code);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for empty string', () => {
|
||||||
|
expect(() => DrivingReasonCode.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => DrivingReasonCode.create(' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid reason code', () => {
|
||||||
|
expect(() => DrivingReasonCode.create('INVALID_CODE')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => DrivingReasonCode.create('driving_finish')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from valid codes', () => {
|
||||||
|
const reasonCode = DrivingReasonCode.create(' DRIVING_FINISH_STRENGTH_GAIN ');
|
||||||
|
expect(reasonCode.value).toBe('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fromValue', () => {
|
||||||
|
it('should create from value without validation', () => {
|
||||||
|
const reasonCode = DrivingReasonCode.fromValue('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
expect(reasonCode.value).toBe('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for equal codes', () => {
|
||||||
|
const code1 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
const code2 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
expect(code1.equals(code2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different codes', () => {
|
||||||
|
const code1 = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
const code2 = DrivingReasonCode.create('DRIVING_INCIDENTS_PENALTY');
|
||||||
|
expect(code1.equals(code2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return the string value', () => {
|
||||||
|
const code = DrivingReasonCode.create('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
expect(code.toString()).toBe('DRIVING_FINISH_STRENGTH_GAIN');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('category methods', () => {
|
||||||
|
describe('isPerformance', () => {
|
||||||
|
it('should return true for performance codes', () => {
|
||||||
|
const performanceCodes = [
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_POSITIONS_GAINED_BONUS',
|
||||||
|
'DRIVING_PACE_RELATIVE_GAIN',
|
||||||
|
];
|
||||||
|
performanceCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPerformance()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-performance codes', () => {
|
||||||
|
const nonPerformanceCodes = [
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
nonPerformanceCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPerformance()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isCleanDriving', () => {
|
||||||
|
it('should return true for clean driving codes', () => {
|
||||||
|
const cleanDrivingCodes = [
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_MAJOR_CONTACT_PENALTY',
|
||||||
|
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
|
||||||
|
];
|
||||||
|
cleanDrivingCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isCleanDriving()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-clean driving codes', () => {
|
||||||
|
const nonCleanDrivingCodes = [
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
nonCleanDrivingCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isCleanDriving()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isReliability', () => {
|
||||||
|
it('should return true for reliability codes', () => {
|
||||||
|
const reliabilityCodes = [
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_DNF_PENALTY',
|
||||||
|
'DRIVING_DSQ_PENALTY',
|
||||||
|
'DRIVING_AFK_PENALTY',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
reliabilityCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isReliability()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-reliability codes', () => {
|
||||||
|
const nonReliabilityCodes = [
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
];
|
||||||
|
nonReliabilityCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isReliability()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPenalty', () => {
|
||||||
|
it('should return true for penalty codes', () => {
|
||||||
|
const penaltyCodes = [
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_MAJOR_CONTACT_PENALTY',
|
||||||
|
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_DNF_PENALTY',
|
||||||
|
'DRIVING_DSQ_PENALTY',
|
||||||
|
'DRIVING_AFK_PENALTY',
|
||||||
|
];
|
||||||
|
penaltyCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPenalty()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-penalty codes', () => {
|
||||||
|
const nonPenaltyCodes = [
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_POSITIONS_GAINED_BONUS',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
nonPenaltyCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isPenalty()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isBonus', () => {
|
||||||
|
it('should return true for bonus codes', () => {
|
||||||
|
const bonusCodes = [
|
||||||
|
'DRIVING_POSITIONS_GAINED_BONUS',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_PACE_RELATIVE_GAIN',
|
||||||
|
];
|
||||||
|
bonusCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isBonus()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-bonus codes', () => {
|
||||||
|
const nonBonusCodes = [
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
];
|
||||||
|
nonBonusCodes.forEach(codeStr => {
|
||||||
|
const code = DrivingReasonCode.fromValue(codeStr as any);
|
||||||
|
expect(code.isBonus()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
133
core/identity/domain/value-objects/DrivingReasonCode.ts
Normal file
133
core/identity/domain/value-objects/DrivingReasonCode.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driving Reason Code Value Object
|
||||||
|
*
|
||||||
|
* Stable machine codes for driving rating events to support:
|
||||||
|
* - Filtering and analytics
|
||||||
|
* - i18n translations
|
||||||
|
* - Consistent UI explanations
|
||||||
|
*
|
||||||
|
* Based on ratings-architecture-concept.md section 5.1.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DrivingReasonCodeValue =
|
||||||
|
// Performance
|
||||||
|
| 'DRIVING_FINISH_STRENGTH_GAIN'
|
||||||
|
| 'DRIVING_POSITIONS_GAINED_BONUS'
|
||||||
|
| 'DRIVING_PACE_RELATIVE_GAIN'
|
||||||
|
// Clean driving
|
||||||
|
| 'DRIVING_INCIDENTS_PENALTY'
|
||||||
|
| 'DRIVING_MAJOR_CONTACT_PENALTY'
|
||||||
|
| 'DRIVING_PENALTY_INVOLVEMENT_PENALTY'
|
||||||
|
// Reliability
|
||||||
|
| 'DRIVING_DNS_PENALTY'
|
||||||
|
| 'DRIVING_DNF_PENALTY'
|
||||||
|
| 'DRIVING_DSQ_PENALTY'
|
||||||
|
| 'DRIVING_AFK_PENALTY'
|
||||||
|
| 'DRIVING_SEASON_ATTENDANCE_BONUS';
|
||||||
|
|
||||||
|
export interface DrivingReasonCodeProps {
|
||||||
|
value: DrivingReasonCodeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_REASON_CODES: DrivingReasonCodeValue[] = [
|
||||||
|
// Performance
|
||||||
|
'DRIVING_FINISH_STRENGTH_GAIN',
|
||||||
|
'DRIVING_POSITIONS_GAINED_BONUS',
|
||||||
|
'DRIVING_PACE_RELATIVE_GAIN',
|
||||||
|
// Clean driving
|
||||||
|
'DRIVING_INCIDENTS_PENALTY',
|
||||||
|
'DRIVING_MAJOR_CONTACT_PENALTY',
|
||||||
|
'DRIVING_PENALTY_INVOLVEMENT_PENALTY',
|
||||||
|
// Reliability
|
||||||
|
'DRIVING_DNS_PENALTY',
|
||||||
|
'DRIVING_DNF_PENALTY',
|
||||||
|
'DRIVING_DSQ_PENALTY',
|
||||||
|
'DRIVING_AFK_PENALTY',
|
||||||
|
'DRIVING_SEASON_ATTENDANCE_BONUS',
|
||||||
|
];
|
||||||
|
|
||||||
|
export class DrivingReasonCode implements IValueObject<DrivingReasonCodeProps> {
|
||||||
|
readonly value: DrivingReasonCodeValue;
|
||||||
|
|
||||||
|
private constructor(value: DrivingReasonCodeValue) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): DrivingReasonCode {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('DrivingReasonCode cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim() as DrivingReasonCodeValue;
|
||||||
|
|
||||||
|
if (!VALID_REASON_CODES.includes(trimmed)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid driving reason code: ${value}. Valid options: ${VALID_REASON_CODES.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DrivingReasonCode(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromValue(value: DrivingReasonCodeValue): DrivingReasonCode {
|
||||||
|
return new DrivingReasonCode(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): DrivingReasonCodeProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<DrivingReasonCodeProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a performance-related reason code
|
||||||
|
*/
|
||||||
|
isPerformance(): boolean {
|
||||||
|
return this.value === 'DRIVING_FINISH_STRENGTH_GAIN' ||
|
||||||
|
this.value === 'DRIVING_POSITIONS_GAINED_BONUS' ||
|
||||||
|
this.value === 'DRIVING_PACE_RELATIVE_GAIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a clean driving-related reason code
|
||||||
|
*/
|
||||||
|
isCleanDriving(): boolean {
|
||||||
|
return this.value === 'DRIVING_INCIDENTS_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_MAJOR_CONTACT_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_PENALTY_INVOLVEMENT_PENALTY';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a reliability-related reason code
|
||||||
|
*/
|
||||||
|
isReliability(): boolean {
|
||||||
|
return this.value === 'DRIVING_DNS_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_DNF_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_DSQ_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_AFK_PENALTY' ||
|
||||||
|
this.value === 'DRIVING_SEASON_ATTENDANCE_BONUS';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a penalty (negative impact)
|
||||||
|
*/
|
||||||
|
isPenalty(): boolean {
|
||||||
|
return this.value.endsWith('_PENALTY');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is a bonus (positive impact)
|
||||||
|
*/
|
||||||
|
isBonus(): boolean {
|
||||||
|
return this.value.endsWith('_BONUS') || this.value.endsWith('_GAIN');
|
||||||
|
}
|
||||||
|
}
|
||||||
99
core/identity/domain/value-objects/ExternalRating.test.ts
Normal file
99
core/identity/domain/value-objects/ExternalRating.test.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { ExternalRating } from './ExternalRating';
|
||||||
|
import { GameKey } from './GameKey';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('ExternalRating', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid external rating', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
|
||||||
|
|
||||||
|
expect(rating.gameKey.value).toBe('iracing');
|
||||||
|
expect(rating.type).toBe('iRating');
|
||||||
|
expect(rating.value).toBe(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create rating with safety rating', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating = ExternalRating.create(gameKey, 'safetyRating', 2.5);
|
||||||
|
|
||||||
|
expect(rating.type).toBe('safetyRating');
|
||||||
|
expect(rating.value).toBe(2.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty type', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
expect(() => ExternalRating.create(gameKey, '', 2500)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => ExternalRating.create(gameKey, ' ', 2500)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for non-numeric value', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
expect(() => ExternalRating.create(gameKey, 'iRating', '2500' as unknown as number)).toThrow();
|
||||||
|
expect(() => ExternalRating.create(gameKey, 'iRating', null as unknown as number)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from type', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating = ExternalRating.create(gameKey, ' iRating ', 2500);
|
||||||
|
expect(rating.type).toBe('iRating');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same gameKey, type, and value', () => {
|
||||||
|
const gameKey1 = GameKey.create('iracing');
|
||||||
|
const gameKey2 = GameKey.create('iracing');
|
||||||
|
const rating1 = ExternalRating.create(gameKey1, 'iRating', 2500);
|
||||||
|
const rating2 = ExternalRating.create(gameKey2, 'iRating', 2500);
|
||||||
|
|
||||||
|
expect(rating1.equals(rating2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different gameKeys', () => {
|
||||||
|
const gameKey1 = GameKey.create('iracing');
|
||||||
|
const gameKey2 = GameKey.create('acc');
|
||||||
|
const rating1 = ExternalRating.create(gameKey1, 'iRating', 2500);
|
||||||
|
const rating2 = ExternalRating.create(gameKey2, 'iRating', 2500);
|
||||||
|
|
||||||
|
expect(rating1.equals(rating2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different types', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating1 = ExternalRating.create(gameKey, 'iRating', 2500);
|
||||||
|
const rating2 = ExternalRating.create(gameKey, 'safetyRating', 2500);
|
||||||
|
|
||||||
|
expect(rating1.equals(rating2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating1 = ExternalRating.create(gameKey, 'iRating', 2500);
|
||||||
|
const rating2 = ExternalRating.create(gameKey, 'iRating', 2600);
|
||||||
|
|
||||||
|
expect(rating1.equals(rating2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
|
||||||
|
const props = rating.props;
|
||||||
|
|
||||||
|
expect(props.gameKey.value).toBe('iracing');
|
||||||
|
expect(props.type).toBe('iRating');
|
||||||
|
expect(props.value).toBe(2500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const gameKey = GameKey.create('iracing');
|
||||||
|
const rating = ExternalRating.create(gameKey, 'iRating', 2500);
|
||||||
|
|
||||||
|
expect(rating.toString()).toBe('iracing:iRating=2500');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
54
core/identity/domain/value-objects/ExternalRating.ts
Normal file
54
core/identity/domain/value-objects/ExternalRating.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
import { GameKey } from './GameKey';
|
||||||
|
|
||||||
|
export interface ExternalRatingProps {
|
||||||
|
gameKey: GameKey;
|
||||||
|
type: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalRating implements IValueObject<ExternalRatingProps> {
|
||||||
|
readonly gameKey: GameKey;
|
||||||
|
readonly type: string;
|
||||||
|
readonly value: number;
|
||||||
|
|
||||||
|
private constructor(gameKey: GameKey, type: string, value: number) {
|
||||||
|
this.gameKey = gameKey;
|
||||||
|
this.type = type;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(gameKey: GameKey, type: string, value: number): ExternalRating {
|
||||||
|
if (!type || type.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('External rating type cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'number' || isNaN(value)) {
|
||||||
|
throw new IdentityDomainValidationError('External rating value must be a valid number');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedType = type.trim();
|
||||||
|
return new ExternalRating(gameKey, trimmedType, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): ExternalRatingProps {
|
||||||
|
return {
|
||||||
|
gameKey: this.gameKey,
|
||||||
|
type: this.type,
|
||||||
|
value: this.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<ExternalRatingProps>): boolean {
|
||||||
|
return (
|
||||||
|
this.gameKey.equals(other.props.gameKey) &&
|
||||||
|
this.type === other.props.type &&
|
||||||
|
this.value === other.props.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this.gameKey.toString()}:${this.type}=${this.value}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { ExternalRatingProvenance } from './ExternalRatingProvenance';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('ExternalRatingProvenance', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a valid provenance with default verified=false', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.source).toBe('iracing');
|
||||||
|
expect(provenance.lastSyncedAt).toBe(now);
|
||||||
|
expect(provenance.verified).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a valid provenance with verified=true', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim source string', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.create({
|
||||||
|
source: ' iracing ',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.source).toBe('iracing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for empty source', () => {
|
||||||
|
const now = new Date();
|
||||||
|
expect(() =>
|
||||||
|
ExternalRatingProvenance.create({
|
||||||
|
source: '',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
})
|
||||||
|
).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() =>
|
||||||
|
ExternalRatingProvenance.create({
|
||||||
|
source: ' ',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
})
|
||||||
|
).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for invalid date', () => {
|
||||||
|
expect(() =>
|
||||||
|
ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('invalid'),
|
||||||
|
})
|
||||||
|
).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('restore', () => {
|
||||||
|
it('should restore provenance from stored props', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.restore({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.source).toBe('iracing');
|
||||||
|
expect(provenance.lastSyncedAt).toBe(now);
|
||||||
|
expect(provenance.verified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default verified to false when not provided', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.restore({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.verified).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for identical provenance', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const p1 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
const p2 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1.equals(p2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different source', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const p1 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
});
|
||||||
|
const p2 = ExternalRatingProvenance.create({
|
||||||
|
source: 'simracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1.equals(p2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different lastSyncedAt', () => {
|
||||||
|
const p1 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-01'),
|
||||||
|
});
|
||||||
|
const p2 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: new Date('2024-01-02'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1.equals(p2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different verified', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const p1 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
const p2 = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(p1.equals(p2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const now = new Date('2024-01-01T00:00:00Z');
|
||||||
|
const provenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provenance.toString()).toBe('iracing:2024-01-01T00:00:00.000Z:verified');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markVerified', () => {
|
||||||
|
it('should return new provenance with verified=true', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const original = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const verified = original.markVerified();
|
||||||
|
|
||||||
|
expect(verified.verified).toBe(true);
|
||||||
|
expect(verified.source).toBe(original.source);
|
||||||
|
expect(verified.lastSyncedAt).toBe(original.lastSyncedAt);
|
||||||
|
expect(original.verified).toBe(false); // Original unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateLastSyncedAt', () => {
|
||||||
|
it('should return new provenance with updated date', () => {
|
||||||
|
const now = new Date('2024-01-01');
|
||||||
|
const newDate = new Date('2024-01-02');
|
||||||
|
const original = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = original.updateLastSyncedAt(newDate);
|
||||||
|
|
||||||
|
expect(updated.lastSyncedAt).toBe(newDate);
|
||||||
|
expect(updated.source).toBe(original.source);
|
||||||
|
expect(updated.verified).toBe(original.verified);
|
||||||
|
expect(original.lastSyncedAt).toBe(now); // Original unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should return correct props object', () => {
|
||||||
|
const now = new Date();
|
||||||
|
const provenance = ExternalRatingProvenance.create({
|
||||||
|
source: 'iracing',
|
||||||
|
lastSyncedAt: now,
|
||||||
|
verified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = provenance.props;
|
||||||
|
|
||||||
|
expect(props.source).toBe('iracing');
|
||||||
|
expect(props.lastSyncedAt).toBe(now);
|
||||||
|
expect(props.verified).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface ExternalRatingProvenanceProps {
|
||||||
|
source: string;
|
||||||
|
lastSyncedAt: Date;
|
||||||
|
verified?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExternalRatingProvenance implements IValueObject<ExternalRatingProvenanceProps> {
|
||||||
|
readonly source: string;
|
||||||
|
readonly lastSyncedAt: Date;
|
||||||
|
readonly verified: boolean;
|
||||||
|
|
||||||
|
private constructor(source: string, lastSyncedAt: Date, verified: boolean) {
|
||||||
|
this.source = source;
|
||||||
|
this.lastSyncedAt = lastSyncedAt;
|
||||||
|
this.verified = verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(props: ExternalRatingProvenanceProps): ExternalRatingProvenance {
|
||||||
|
if (!props.source || props.source.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('Provenance source cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.lastSyncedAt || isNaN(props.lastSyncedAt.getTime())) {
|
||||||
|
throw new IdentityDomainValidationError('Provenance lastSyncedAt must be a valid date');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSource = props.source.trim();
|
||||||
|
const verified = props.verified ?? false;
|
||||||
|
|
||||||
|
return new ExternalRatingProvenance(trimmedSource, props.lastSyncedAt, verified);
|
||||||
|
}
|
||||||
|
|
||||||
|
static restore(props: ExternalRatingProvenanceProps): ExternalRatingProvenance {
|
||||||
|
return new ExternalRatingProvenance(props.source, props.lastSyncedAt, props.verified ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): ExternalRatingProvenanceProps {
|
||||||
|
return {
|
||||||
|
source: this.source,
|
||||||
|
lastSyncedAt: this.lastSyncedAt,
|
||||||
|
verified: this.verified,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<ExternalRatingProvenanceProps>): boolean {
|
||||||
|
return (
|
||||||
|
this.source === other.props.source &&
|
||||||
|
this.lastSyncedAt.getTime() === other.props.lastSyncedAt.getTime() &&
|
||||||
|
this.verified === other.props.verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this.source}:${this.lastSyncedAt.toISOString()}:${this.verified ? 'verified' : 'unverified'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markVerified(): ExternalRatingProvenance {
|
||||||
|
return new ExternalRatingProvenance(this.source, this.lastSyncedAt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastSyncedAt(date: Date): ExternalRatingProvenance {
|
||||||
|
return new ExternalRatingProvenance(this.source, date, this.verified);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
core/identity/domain/value-objects/GameKey.test.ts
Normal file
56
core/identity/domain/value-objects/GameKey.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { GameKey } from './GameKey';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('GameKey', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid game keys', () => {
|
||||||
|
expect(GameKey.create('iracing').value).toBe('iracing');
|
||||||
|
expect(GameKey.create('acc').value).toBe('acc');
|
||||||
|
expect(GameKey.create('f1').value).toBe('f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid game key', () => {
|
||||||
|
expect(() => GameKey.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => GameKey.create(' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => GameKey.create('invalid game')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
const key = GameKey.create(' iracing ');
|
||||||
|
expect(key.value).toBe('iracing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept lowercase', () => {
|
||||||
|
const key = GameKey.create('iracing');
|
||||||
|
expect(key.value).toBe('iracing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same value', () => {
|
||||||
|
const key1 = GameKey.create('iracing');
|
||||||
|
const key2 = GameKey.create('iracing');
|
||||||
|
expect(key1.equals(key2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const key1 = GameKey.create('iracing');
|
||||||
|
const key2 = GameKey.create('acc');
|
||||||
|
expect(key1.equals(key2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const key = GameKey.create('iracing');
|
||||||
|
expect(key.props.value).toBe('iracing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const key = GameKey.create('iracing');
|
||||||
|
expect(key.toString()).toBe('iracing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
core/identity/domain/value-objects/GameKey.ts
Normal file
43
core/identity/domain/value-objects/GameKey.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface GameKeyProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GameKey implements IValueObject<GameKeyProps> {
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
private constructor(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): GameKey {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('GameKey cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
// Game keys should be lowercase alphanumeric with optional underscores/hyphens
|
||||||
|
const gameKeyRegex = /^[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
if (!gameKeyRegex.test(trimmed)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid game key: ${value}. Must be lowercase alphanumeric with optional underscores/hyphens`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GameKey(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): GameKeyProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<GameKeyProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
core/identity/domain/value-objects/RatingDelta.test.ts
Normal file
110
core/identity/domain/value-objects/RatingDelta.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { RatingDelta } from './RatingDelta';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingDelta', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid delta values', () => {
|
||||||
|
expect(RatingDelta.create(0).value).toBe(0);
|
||||||
|
expect(RatingDelta.create(10).value).toBe(10);
|
||||||
|
expect(RatingDelta.create(-10).value).toBe(-10);
|
||||||
|
expect(RatingDelta.create(100).value).toBe(100);
|
||||||
|
expect(RatingDelta.create(-100).value).toBe(-100);
|
||||||
|
expect(RatingDelta.create(50.5).value).toBe(50.5);
|
||||||
|
expect(RatingDelta.create(-50.5).value).toBe(-50.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for values outside range', () => {
|
||||||
|
expect(() => RatingDelta.create(100.1)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingDelta.create(-100.1)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingDelta.create(101)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingDelta.create(-101)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept zero', () => {
|
||||||
|
const delta = RatingDelta.create(0);
|
||||||
|
expect(delta.value).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for non-numeric values', () => {
|
||||||
|
expect(() => RatingDelta.create('50' as unknown as number)).toThrow();
|
||||||
|
expect(() => RatingDelta.create(null as unknown as number)).toThrow();
|
||||||
|
expect(() => RatingDelta.create(undefined as unknown as number)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same value', () => {
|
||||||
|
const delta1 = RatingDelta.create(10);
|
||||||
|
const delta2 = RatingDelta.create(10);
|
||||||
|
expect(delta1.equals(delta2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const delta1 = RatingDelta.create(10);
|
||||||
|
const delta2 = RatingDelta.create(-10);
|
||||||
|
expect(delta1.equals(delta2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal comparisons', () => {
|
||||||
|
const delta1 = RatingDelta.create(50.5);
|
||||||
|
const delta2 = RatingDelta.create(50.5);
|
||||||
|
expect(delta1.equals(delta2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const delta = RatingDelta.create(10);
|
||||||
|
expect(delta.props.value).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toNumber', () => {
|
||||||
|
it('should return numeric value', () => {
|
||||||
|
const delta = RatingDelta.create(50.5);
|
||||||
|
expect(delta.toNumber()).toBe(50.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const delta = RatingDelta.create(50.5);
|
||||||
|
expect(delta.toString()).toBe('50.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPositive', () => {
|
||||||
|
it('should return true for positive deltas', () => {
|
||||||
|
expect(RatingDelta.create(1).isPositive()).toBe(true);
|
||||||
|
expect(RatingDelta.create(100).isPositive()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for zero and negative deltas', () => {
|
||||||
|
expect(RatingDelta.create(0).isPositive()).toBe(false);
|
||||||
|
expect(RatingDelta.create(-1).isPositive()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNegative', () => {
|
||||||
|
it('should return true for negative deltas', () => {
|
||||||
|
expect(RatingDelta.create(-1).isNegative()).toBe(true);
|
||||||
|
expect(RatingDelta.create(-100).isNegative()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for zero and positive deltas', () => {
|
||||||
|
expect(RatingDelta.create(0).isNegative()).toBe(false);
|
||||||
|
expect(RatingDelta.create(1).isNegative()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isZero', () => {
|
||||||
|
it('should return true for zero delta', () => {
|
||||||
|
expect(RatingDelta.create(0).isZero()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-zero deltas', () => {
|
||||||
|
expect(RatingDelta.create(1).isZero()).toBe(false);
|
||||||
|
expect(RatingDelta.create(-1).isZero()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
core/identity/domain/value-objects/RatingDelta.ts
Normal file
56
core/identity/domain/value-objects/RatingDelta.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface RatingDeltaProps {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingDelta implements IValueObject<RatingDeltaProps> {
|
||||||
|
readonly value: number;
|
||||||
|
|
||||||
|
private constructor(value: number) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: number): RatingDelta {
|
||||||
|
if (typeof value !== 'number' || isNaN(value)) {
|
||||||
|
throw new IdentityDomainValidationError('Rating delta must be a valid number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < -100 || value > 100) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Rating delta must be between -100 and 100, got: ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RatingDelta(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): RatingDeltaProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RatingDeltaProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toNumber(): number {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
isPositive(): boolean {
|
||||||
|
return this.value > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNegative(): boolean {
|
||||||
|
return this.value < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
isZero(): boolean {
|
||||||
|
return this.value === 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { RatingDimensionKey } from './RatingDimensionKey';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingDimensionKey', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid dimension keys', () => {
|
||||||
|
expect(RatingDimensionKey.create('driving').value).toBe('driving');
|
||||||
|
expect(RatingDimensionKey.create('adminTrust').value).toBe('adminTrust');
|
||||||
|
expect(RatingDimensionKey.create('stewardTrust').value).toBe('stewardTrust');
|
||||||
|
expect(RatingDimensionKey.create('broadcasterTrust').value).toBe('broadcasterTrust');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid dimension key', () => {
|
||||||
|
expect(() => RatingDimensionKey.create('invalid')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingDimensionKey.create('driving ')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingDimensionKey.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty string', () => {
|
||||||
|
expect(() => RatingDimensionKey.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for whitespace', () => {
|
||||||
|
expect(() => RatingDimensionKey.create(' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same value', () => {
|
||||||
|
const key1 = RatingDimensionKey.create('driving');
|
||||||
|
const key2 = RatingDimensionKey.create('driving');
|
||||||
|
expect(key1.equals(key2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const key1 = RatingDimensionKey.create('driving');
|
||||||
|
const key2 = RatingDimensionKey.create('adminTrust');
|
||||||
|
expect(key1.equals(key2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const key = RatingDimensionKey.create('driving');
|
||||||
|
expect(key.props.value).toBe('driving');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const key = RatingDimensionKey.create('driving');
|
||||||
|
expect(key.toString()).toBe('driving');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
49
core/identity/domain/value-objects/RatingDimensionKey.ts
Normal file
49
core/identity/domain/value-objects/RatingDimensionKey.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface RatingDimensionKeyProps {
|
||||||
|
value: 'driving' | 'adminTrust' | 'stewardTrust' | 'broadcasterTrust';
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_DIMENSIONS = ['driving', 'adminTrust', 'stewardTrust', 'broadcasterTrust'] as const;
|
||||||
|
|
||||||
|
export class RatingDimensionKey implements IValueObject<RatingDimensionKeyProps> {
|
||||||
|
readonly value: RatingDimensionKeyProps['value'];
|
||||||
|
|
||||||
|
private constructor(value: RatingDimensionKeyProps['value']) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): RatingDimensionKey {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('Rating dimension key cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict validation: no leading/trailing whitespace allowed
|
||||||
|
if (value !== value.trim()) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Rating dimension key cannot have leading or trailing whitespace: "${value}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!VALID_DIMENSIONS.includes(value as RatingDimensionKeyProps['value'])) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid rating dimension key: ${value}. Valid options: ${VALID_DIMENSIONS.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RatingDimensionKey(value as RatingDimensionKeyProps['value']);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): RatingDimensionKeyProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RatingDimensionKeyProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
core/identity/domain/value-objects/RatingEventId.test.ts
Normal file
76
core/identity/domain/value-objects/RatingEventId.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { RatingEventId } from './RatingEventId';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingEventId', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid UUID v4', () => {
|
||||||
|
const validUuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||||
|
const id = RatingEventId.create(validUuid);
|
||||||
|
expect(id.value).toBe(validUuid);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid UUID', () => {
|
||||||
|
expect(() => RatingEventId.create('not-a-uuid')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingEventId.create('123e4567-e89b-12d3-a456')).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingEventId.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty string', () => {
|
||||||
|
expect(() => RatingEventId.create('')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for whitespace', () => {
|
||||||
|
expect(() => RatingEventId.create(' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept UUID with uppercase', () => {
|
||||||
|
const uuid = '123E4567-E89B-12D3-A456-426614174000';
|
||||||
|
const id = RatingEventId.create(uuid);
|
||||||
|
expect(id.value).toBe(uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generate', () => {
|
||||||
|
it('should generate a valid UUID', () => {
|
||||||
|
const id = RatingEventId.generate();
|
||||||
|
expect(id.value).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique IDs', () => {
|
||||||
|
const id1 = RatingEventId.generate();
|
||||||
|
const id2 = RatingEventId.generate();
|
||||||
|
expect(id1.equals(id2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same UUID', () => {
|
||||||
|
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||||
|
const id1 = RatingEventId.create(uuid);
|
||||||
|
const id2 = RatingEventId.create(uuid);
|
||||||
|
expect(id1.equals(id2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different UUIDs', () => {
|
||||||
|
const id1 = RatingEventId.create('123e4567-e89b-12d3-a456-426614174000');
|
||||||
|
const id2 = RatingEventId.create('123e4567-e89b-12d3-a456-426614174001');
|
||||||
|
expect(id1.equals(id2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||||
|
const id = RatingEventId.create(uuid);
|
||||||
|
expect(id.props.value).toBe(uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const uuid = '123e4567-e89b-12d3-a456-426614174000';
|
||||||
|
const id = RatingEventId.create(uuid);
|
||||||
|
expect(id.toString()).toBe(uuid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
48
core/identity/domain/value-objects/RatingEventId.ts
Normal file
48
core/identity/domain/value-objects/RatingEventId.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
|
export interface RatingEventIdProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingEventId implements IValueObject<RatingEventIdProps> {
|
||||||
|
readonly value: string;
|
||||||
|
|
||||||
|
private constructor(value: string) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: string): RatingEventId {
|
||||||
|
if (!value || value.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('RatingEventId cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
// Basic UUID v4 validation
|
||||||
|
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
if (!uuidRegex.test(trimmed)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid UUID format: ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RatingEventId(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
static generate(): RatingEventId {
|
||||||
|
return new RatingEventId(uuidv4());
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): RatingEventIdProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RatingEventIdProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
134
core/identity/domain/value-objects/RatingReference.test.ts
Normal file
134
core/identity/domain/value-objects/RatingReference.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { RatingReference } from './RatingReference';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingReference', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid reference for race', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.type).toBe('race');
|
||||||
|
expect(ref.id).toBe('race-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create valid reference for penalty', () => {
|
||||||
|
const ref = RatingReference.create('penalty', 'penalty-456');
|
||||||
|
expect(ref.type).toBe('penalty');
|
||||||
|
expect(ref.id).toBe('penalty-456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create valid reference for vote', () => {
|
||||||
|
const ref = RatingReference.create('vote', 'vote-789');
|
||||||
|
expect(ref.type).toBe('vote');
|
||||||
|
expect(ref.id).toBe('vote-789');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create valid reference for adminAction', () => {
|
||||||
|
const ref = RatingReference.create('adminAction', 'admin-101');
|
||||||
|
expect(ref.type).toBe('adminAction');
|
||||||
|
expect(ref.id).toBe('admin-101');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for invalid type', () => {
|
||||||
|
expect(() => RatingReference.create('invalid' as 'race', 'id-123')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty type', () => {
|
||||||
|
expect(() => RatingReference.create('' as 'race', 'id-123')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for empty id', () => {
|
||||||
|
expect(() => RatingReference.create('race', '')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for whitespace id', () => {
|
||||||
|
expect(() => RatingReference.create('race', ' ')).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace from id', () => {
|
||||||
|
const ref = RatingReference.create('race', ' race-123 ');
|
||||||
|
expect(ref.id).toBe('race-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same type and id', () => {
|
||||||
|
const ref1 = RatingReference.create('race', 'race-123');
|
||||||
|
const ref2 = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref1.equals(ref2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different types', () => {
|
||||||
|
const ref1 = RatingReference.create('race', 'race-123');
|
||||||
|
const ref2 = RatingReference.create('penalty', 'race-123');
|
||||||
|
expect(ref1.equals(ref2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different ids', () => {
|
||||||
|
const ref1 = RatingReference.create('race', 'race-123');
|
||||||
|
const ref2 = RatingReference.create('race', 'race-456');
|
||||||
|
expect(ref1.equals(ref2)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.props.type).toBe('race');
|
||||||
|
expect(ref.props.id).toBe('race-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.toString()).toBe('race:race-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isRace', () => {
|
||||||
|
it('should return true for race type', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.isRace()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other types', () => {
|
||||||
|
const ref = RatingReference.create('penalty', 'penalty-123');
|
||||||
|
expect(ref.isRace()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPenalty', () => {
|
||||||
|
it('should return true for penalty type', () => {
|
||||||
|
const ref = RatingReference.create('penalty', 'penalty-123');
|
||||||
|
expect(ref.isPenalty()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other types', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.isPenalty()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isVote', () => {
|
||||||
|
it('should return true for vote type', () => {
|
||||||
|
const ref = RatingReference.create('vote', 'vote-123');
|
||||||
|
expect(ref.isVote()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other types', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.isVote()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isAdminAction', () => {
|
||||||
|
it('should return true for adminAction type', () => {
|
||||||
|
const ref = RatingReference.create('adminAction', 'admin-123');
|
||||||
|
expect(ref.isAdminAction()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for other types', () => {
|
||||||
|
const ref = RatingReference.create('race', 'race-123');
|
||||||
|
expect(ref.isAdminAction()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
64
core/identity/domain/value-objects/RatingReference.ts
Normal file
64
core/identity/domain/value-objects/RatingReference.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export type RatingReferenceType = 'race' | 'penalty' | 'vote' | 'adminAction';
|
||||||
|
|
||||||
|
export interface RatingReferenceProps {
|
||||||
|
type: RatingReferenceType;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_TYPES: RatingReferenceType[] = ['race', 'penalty', 'vote', 'adminAction'];
|
||||||
|
|
||||||
|
export class RatingReference implements IValueObject<RatingReferenceProps> {
|
||||||
|
readonly type: RatingReferenceType;
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
|
private constructor(type: RatingReferenceType, id: string) {
|
||||||
|
this.type = type;
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(type: RatingReferenceType, id: string): RatingReference {
|
||||||
|
if (!type || !VALID_TYPES.includes(type)) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Invalid rating reference type: ${type}. Valid types: ${VALID_TYPES.join(', ')}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id || id.trim().length === 0) {
|
||||||
|
throw new IdentityDomainValidationError('Rating reference ID cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedId = id.trim();
|
||||||
|
return new RatingReference(type, trimmedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): RatingReferenceProps {
|
||||||
|
return { type: this.type, id: this.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RatingReferenceProps>): boolean {
|
||||||
|
return this.type === other.props.type && this.id === other.props.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return `${this.type}:${this.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRace(): boolean {
|
||||||
|
return this.type === 'race';
|
||||||
|
}
|
||||||
|
|
||||||
|
isPenalty(): boolean {
|
||||||
|
return this.type === 'penalty';
|
||||||
|
}
|
||||||
|
|
||||||
|
isVote(): boolean {
|
||||||
|
return this.type === 'vote';
|
||||||
|
}
|
||||||
|
|
||||||
|
isAdminAction(): boolean {
|
||||||
|
return this.type === 'adminAction';
|
||||||
|
}
|
||||||
|
}
|
||||||
75
core/identity/domain/value-objects/RatingValue.test.ts
Normal file
75
core/identity/domain/value-objects/RatingValue.test.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { RatingValue } from './RatingValue';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
describe('RatingValue', () => {
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create valid rating values', () => {
|
||||||
|
expect(RatingValue.create(0).value).toBe(0);
|
||||||
|
expect(RatingValue.create(50).value).toBe(50);
|
||||||
|
expect(RatingValue.create(100).value).toBe(100);
|
||||||
|
expect(RatingValue.create(75.5).value).toBe(75.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for values below 0', () => {
|
||||||
|
expect(() => RatingValue.create(-1)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingValue.create(-0.1)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for values above 100', () => {
|
||||||
|
expect(() => RatingValue.create(100.1)).toThrow(IdentityDomainValidationError);
|
||||||
|
expect(() => RatingValue.create(101)).toThrow(IdentityDomainValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept decimal values', () => {
|
||||||
|
const value = RatingValue.create(75.5);
|
||||||
|
expect(value.value).toBe(75.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw for non-numeric values', () => {
|
||||||
|
expect(() => RatingValue.create('50' as unknown as number)).toThrow();
|
||||||
|
expect(() => RatingValue.create(null as unknown as number)).toThrow();
|
||||||
|
expect(() => RatingValue.create(undefined as unknown as number)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('equals', () => {
|
||||||
|
it('should return true for same value', () => {
|
||||||
|
const val1 = RatingValue.create(50);
|
||||||
|
const val2 = RatingValue.create(50);
|
||||||
|
expect(val1.equals(val2)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for different values', () => {
|
||||||
|
const val1 = RatingValue.create(50);
|
||||||
|
const val2 = RatingValue.create(60);
|
||||||
|
expect(val1.equals(val2)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle decimal comparisons', () => {
|
||||||
|
const val1 = RatingValue.create(75.5);
|
||||||
|
const val2 = RatingValue.create(75.5);
|
||||||
|
expect(val1.equals(val2)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('props', () => {
|
||||||
|
it('should expose props correctly', () => {
|
||||||
|
const value = RatingValue.create(50);
|
||||||
|
expect(value.props.value).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toNumber', () => {
|
||||||
|
it('should return numeric value', () => {
|
||||||
|
const value = RatingValue.create(75.5);
|
||||||
|
expect(value.toNumber()).toBe(75.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toString', () => {
|
||||||
|
it('should return string representation', () => {
|
||||||
|
const value = RatingValue.create(75.5);
|
||||||
|
expect(value.toString()).toBe('75.5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
core/identity/domain/value-objects/RatingValue.ts
Normal file
44
core/identity/domain/value-objects/RatingValue.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { IValueObject } from '@core/shared/domain';
|
||||||
|
import { IdentityDomainValidationError } from '../errors/IdentityDomainError';
|
||||||
|
|
||||||
|
export interface RatingValueProps {
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RatingValue implements IValueObject<RatingValueProps> {
|
||||||
|
readonly value: number;
|
||||||
|
|
||||||
|
private constructor(value: number) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static create(value: number): RatingValue {
|
||||||
|
if (typeof value !== 'number' || isNaN(value)) {
|
||||||
|
throw new IdentityDomainValidationError('Rating value must be a valid number');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value < 0 || value > 100) {
|
||||||
|
throw new IdentityDomainValidationError(
|
||||||
|
`Rating value must be between 0 and 100, got: ${value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RatingValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
get props(): RatingValueProps {
|
||||||
|
return { value: this.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: IValueObject<RatingValueProps>): boolean {
|
||||||
|
return this.value === other.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toNumber(): number {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ export interface UserRatingProps {
|
|||||||
trust: RatingDimension;
|
trust: RatingDimension;
|
||||||
fairness: RatingDimension;
|
fairness: RatingDimension;
|
||||||
overallReputation: number;
|
overallReputation: number;
|
||||||
|
calculatorVersion?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -82,6 +83,10 @@ export class UserRating implements IValueObject<UserRatingProps> {
|
|||||||
return this.props.updatedAt;
|
return this.props.updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get calculatorVersion(): string | undefined {
|
||||||
|
return this.props.calculatorVersion;
|
||||||
|
}
|
||||||
|
|
||||||
static create(userId: string): UserRating {
|
static create(userId: string): UserRating {
|
||||||
if (!userId || userId.trim().length === 0) {
|
if (!userId || userId.trim().length === 0) {
|
||||||
throw new Error('UserRating userId is required');
|
throw new Error('UserRating userId is required');
|
||||||
@@ -96,6 +101,7 @@ export class UserRating implements IValueObject<UserRatingProps> {
|
|||||||
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
trust: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||||
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
fairness: { ...DEFAULT_DIMENSION, lastUpdated: now },
|
||||||
overallReputation: 50,
|
overallReputation: 50,
|
||||||
|
calculatorVersion: '1.0',
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
|
|||||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||||
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
|
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
|
||||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
|
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
|
||||||
|
|
||||||
describe('CompleteRaceUseCaseWithRatings', () => {
|
describe('CompleteRaceUseCaseWithRatings', () => {
|
||||||
let useCase: CompleteRaceUseCaseWithRatings;
|
let useCase: CompleteRaceUseCaseWithRatings;
|
||||||
@@ -32,6 +33,11 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
|||||||
};
|
};
|
||||||
let ratingUpdateService: {
|
let ratingUpdateService: {
|
||||||
updateDriverRatingsAfterRace: Mock;
|
updateDriverRatingsAfterRace: Mock;
|
||||||
|
recordRaceRatingEvents: Mock;
|
||||||
|
};
|
||||||
|
let raceResultsProvider: {
|
||||||
|
getRaceResults: Mock;
|
||||||
|
hasRaceResults: Mock;
|
||||||
};
|
};
|
||||||
let output: { present: Mock };
|
let output: { present: Mock };
|
||||||
|
|
||||||
@@ -55,9 +61,15 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
|||||||
};
|
};
|
||||||
ratingUpdateService = {
|
ratingUpdateService = {
|
||||||
updateDriverRatingsAfterRace: vi.fn(),
|
updateDriverRatingsAfterRace: vi.fn(),
|
||||||
|
recordRaceRatingEvents: vi.fn(),
|
||||||
|
};
|
||||||
|
raceResultsProvider = {
|
||||||
|
getRaceResults: vi.fn(),
|
||||||
|
hasRaceResults: vi.fn(),
|
||||||
};
|
};
|
||||||
output = { present: vi.fn() };
|
output = { present: vi.fn() };
|
||||||
|
|
||||||
|
// Test without raceResultsProvider (backward compatible mode)
|
||||||
useCase = new CompleteRaceUseCaseWithRatings(
|
useCase = new CompleteRaceUseCaseWithRatings(
|
||||||
raceRepository as unknown as IRaceRepository,
|
raceRepository as unknown as IRaceRepository,
|
||||||
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
|
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
|
||||||
@@ -221,4 +233,185 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
|||||||
expect(error.details?.message).toBe('DB error');
|
expect(error.details?.message).toBe('DB error');
|
||||||
expect(output.present).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdate
|
|||||||
import { Result } from '@core/shared/application/Result';
|
import { Result } from '@core/shared/application/Result';
|
||||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||||
|
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
|
||||||
|
|
||||||
export interface CompleteRaceWithRatingsInput {
|
export interface CompleteRaceWithRatingsInput {
|
||||||
raceId: string;
|
raceId: string;
|
||||||
@@ -32,6 +33,7 @@ interface DriverRatingProvider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced CompleteRaceUseCase that includes rating updates.
|
* Enhanced CompleteRaceUseCase that includes rating updates.
|
||||||
|
* EVOLVED (Slice 7): Now uses ledger-based rating updates for transparency and auditability.
|
||||||
*/
|
*/
|
||||||
export class CompleteRaceUseCaseWithRatings {
|
export class CompleteRaceUseCaseWithRatings {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -42,6 +44,7 @@ export class CompleteRaceUseCaseWithRatings {
|
|||||||
private readonly driverRatingProvider: DriverRatingProvider,
|
private readonly driverRatingProvider: DriverRatingProvider,
|
||||||
private readonly ratingUpdateService: RatingUpdateService,
|
private readonly ratingUpdateService: RatingUpdateService,
|
||||||
private readonly output: UseCaseOutputPort<CompleteRaceWithRatingsResult>,
|
private readonly output: UseCaseOutputPort<CompleteRaceWithRatingsResult>,
|
||||||
|
private readonly raceResultsProvider?: IRaceResultsProvider, // Optional: for new ledger flow
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CompleteRaceWithRatingsInput): Promise<
|
async execute(command: CompleteRaceWithRatingsInput): Promise<
|
||||||
@@ -93,8 +96,41 @@ export class CompleteRaceUseCaseWithRatings {
|
|||||||
|
|
||||||
await this.updateStandings(race.leagueId, results);
|
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 {
|
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) {
|
} catch (error) {
|
||||||
return Result.err({
|
return Result.err({
|
||||||
code: 'RATING_UPDATE_FAILED',
|
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) => ({
|
const driverResults = results.map((result) => ({
|
||||||
driverId: result.driverId.toString(),
|
driverId: result.driverId.toString(),
|
||||||
position: result.position.toNumber(),
|
position: result.position.toNumber(),
|
||||||
|
|||||||
Reference in New Issue
Block a user