From 7a853d4e430817a14c572802c8ffdbbad5d641e1 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 29 Dec 2025 22:27:33 +0100 Subject: [PATCH] rating --- .../InMemoryExternalGameRatingRepository.ts | 111 +++ .../ExternalGameRatingProfileOrmEntity.ts | 39 + .../typeorm/entities/RatingEventOrmEntity.ts | 57 ++ .../typeorm/entities/UserRatingOrmEntity.ts | 71 ++ ...ExternalGameRatingProfileOrmMapper.test.ts | 107 +++ .../ExternalGameRatingProfileOrmMapper.ts | 85 +++ .../mappers/RatingEventOrmMapper.test.ts | 145 ++++ .../typeorm/mappers/RatingEventOrmMapper.ts | 57 ++ .../mappers/UserRatingOrmMapper.test.ts | 128 ++++ .../typeorm/mappers/UserRatingOrmMapper.ts | 52 ++ .../TypeOrmExternalGameRatingRepository.ts | 153 ++++ .../TypeOrmRatingEventRepository.test.ts | 95 +++ .../TypeOrmRatingEventRepository.ts | 151 ++++ .../TypeOrmUserRatingRepository.test.ts | 50 ++ .../TypeOrmUserRatingRepository.ts | 34 + .../PostgresIdentityRatingSlice.int.test.ts | 198 +++++ .../application/dtos/AdminVoteSessionDto.ts | 73 ++ .../application/dtos/CreateRatingEventDto.ts | 18 + .../application/dtos/EligibilityFilterDto.ts | 45 ++ .../application/dtos/EvaluationResultDto.ts | 68 ++ .../application/dtos/LedgerEntryDto.ts | 51 ++ .../application/dtos/RatingSummaryDto.ts | 56 ++ .../dtos/RecordRaceRatingEventsDto.ts | 17 + .../dtos/UpsertExternalGameRatingDto.ts | 32 + .../application/dtos/UserRatingDto.ts | 26 + core/identity/application/dtos/index.ts | 13 + .../application/ports/IRaceResultsProvider.ts | 33 + .../GetLeagueEligibilityPreviewQuery.test.ts | 193 +++++ .../GetLeagueEligibilityPreviewQuery.ts | 72 ++ .../queries/GetUserRatingLedgerQuery.ts | 89 +++ .../GetUserRatingsSummaryQuery.test.ts | 170 +++++ .../queries/GetUserRatingsSummaryQuery.ts | 118 +++ core/identity/application/queries/index.ts | 17 + .../AdminVoteSessionUseCases.test.ts | 707 ++++++++++++++++++ .../AppendRatingEventsUseCase.test.ts | 177 +++++ .../use-cases/AppendRatingEventsUseCase.ts | 123 +++ .../use-cases/CastAdminVoteUseCase.ts | 99 +++ .../use-cases/CloseAdminVoteSessionUseCase.ts | 163 ++++ .../use-cases/OpenAdminVoteSessionUseCase.ts | 144 ++++ ...RecomputeUserRatingSnapshotUseCase.test.ts | 129 ++++ .../RecomputeUserRatingSnapshotUseCase.ts | 94 +++ ...aceRatingEventsUseCase.integration.test.ts | 429 +++++++++++ .../RecordRaceRatingEventsUseCase.test.ts | 359 +++++++++ .../RecordRaceRatingEventsUseCase.ts | 190 +++++ ...ernalGameRatingUseCase.integration.test.ts | 265 +++++++ .../UpsertExternalGameRatingUseCase.test.ts | 285 +++++++ .../UpsertExternalGameRatingUseCase.ts | 186 +++++ .../domain/entities/AdminVoteSession.test.ts | 467 ++++++++++++ .../domain/entities/AdminVoteSession.ts | 295 ++++++++ .../ExternalGameRatingProfile.test.ts | 410 ++++++++++ .../entities/ExternalGameRatingProfile.ts | 233 ++++++ .../domain/entities/RatingEvent.test.ts | 174 +++++ core/identity/domain/entities/RatingEvent.ts | 140 ++++ .../domain/errors/IdentityDomainError.ts | 34 + .../IAdminVoteSessionRepository.ts | 42 ++ .../IExternalGameRatingRepository.test.ts | 368 +++++++++ .../IExternalGameRatingRepository.ts | 76 ++ .../IRatingEventRepository.test.ts | 560 ++++++++++++++ .../repositories/IRatingEventRepository.ts | 73 ++ .../IUserRatingRepository.test.ts | 90 +++ .../repositories/IUserRatingRepository.ts | 41 +- .../AdminTrustRatingCalculator.test.ts | 407 ++++++++++ .../services/AdminTrustRatingCalculator.ts | 164 ++++ .../services/DrivingRatingCalculator.test.ts | 457 +++++++++++ .../services/DrivingRatingCalculator.ts | 358 +++++++++ .../services/EligibilityEvaluator.test.ts | 320 ++++++++ .../domain/services/EligibilityEvaluator.ts | 299 ++++++++ .../services/RatingEventFactory.test.ts | 489 ++++++++++++ .../domain/services/RatingEventFactory.ts | 655 ++++++++++++++++ .../services/RatingSnapshotCalculator.test.ts | 77 ++ .../services/RatingSnapshotCalculator.ts | 56 ++ .../services/RatingUpdateService.test.ts | 301 ++++++++ .../domain/services/RatingUpdateService.ts | 230 ++++-- .../AdminTrustReasonCode.test.ts | 169 +++++ .../value-objects/AdminTrustReasonCode.ts | 112 +++ .../value-objects/DrivingReasonCode.test.ts | 207 +++++ .../domain/value-objects/DrivingReasonCode.ts | 133 ++++ .../value-objects/ExternalRating.test.ts | 99 +++ .../domain/value-objects/ExternalRating.ts | 54 ++ .../ExternalRatingProvenance.test.ts | 217 ++++++ .../value-objects/ExternalRatingProvenance.ts | 67 ++ .../domain/value-objects/GameKey.test.ts | 56 ++ core/identity/domain/value-objects/GameKey.ts | 43 ++ .../domain/value-objects/RatingDelta.test.ts | 110 +++ .../domain/value-objects/RatingDelta.ts | 56 ++ .../value-objects/RatingDimensionKey.test.ts | 55 ++ .../value-objects/RatingDimensionKey.ts | 49 ++ .../value-objects/RatingEventId.test.ts | 76 ++ .../domain/value-objects/RatingEventId.ts | 48 ++ .../value-objects/RatingReference.test.ts | 134 ++++ .../domain/value-objects/RatingReference.ts | 64 ++ .../domain/value-objects/RatingValue.test.ts | 75 ++ .../domain/value-objects/RatingValue.ts | 44 ++ .../domain/value-objects/UserRating.ts | 6 + .../CompleteRaceUseCaseWithRatings.test.ts | 193 +++++ .../CompleteRaceUseCaseWithRatings.ts | 44 +- 96 files changed, 14790 insertions(+), 111 deletions(-) create mode 100644 adapters/identity/persistence/inmemory/InMemoryExternalGameRatingRepository.ts create mode 100644 adapters/identity/persistence/typeorm/entities/ExternalGameRatingProfileOrmEntity.ts create mode 100644 adapters/identity/persistence/typeorm/entities/RatingEventOrmEntity.ts create mode 100644 adapters/identity/persistence/typeorm/entities/UserRatingOrmEntity.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.test.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.test.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.test.ts create mode 100644 adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmExternalGameRatingRepository.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.test.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.test.ts create mode 100644 adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.ts create mode 100644 apps/api/src/persistence/postgres/typeorm/PostgresIdentityRatingSlice.int.test.ts create mode 100644 core/identity/application/dtos/AdminVoteSessionDto.ts create mode 100644 core/identity/application/dtos/CreateRatingEventDto.ts create mode 100644 core/identity/application/dtos/EligibilityFilterDto.ts create mode 100644 core/identity/application/dtos/EvaluationResultDto.ts create mode 100644 core/identity/application/dtos/LedgerEntryDto.ts create mode 100644 core/identity/application/dtos/RatingSummaryDto.ts create mode 100644 core/identity/application/dtos/RecordRaceRatingEventsDto.ts create mode 100644 core/identity/application/dtos/UpsertExternalGameRatingDto.ts create mode 100644 core/identity/application/dtos/UserRatingDto.ts create mode 100644 core/identity/application/dtos/index.ts create mode 100644 core/identity/application/ports/IRaceResultsProvider.ts create mode 100644 core/identity/application/queries/GetLeagueEligibilityPreviewQuery.test.ts create mode 100644 core/identity/application/queries/GetLeagueEligibilityPreviewQuery.ts create mode 100644 core/identity/application/queries/GetUserRatingLedgerQuery.ts create mode 100644 core/identity/application/queries/GetUserRatingsSummaryQuery.test.ts create mode 100644 core/identity/application/queries/GetUserRatingsSummaryQuery.ts create mode 100644 core/identity/application/queries/index.ts create mode 100644 core/identity/application/use-cases/AdminVoteSessionUseCases.test.ts create mode 100644 core/identity/application/use-cases/AppendRatingEventsUseCase.test.ts create mode 100644 core/identity/application/use-cases/AppendRatingEventsUseCase.ts create mode 100644 core/identity/application/use-cases/CastAdminVoteUseCase.ts create mode 100644 core/identity/application/use-cases/CloseAdminVoteSessionUseCase.ts create mode 100644 core/identity/application/use-cases/OpenAdminVoteSessionUseCase.ts create mode 100644 core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.test.ts create mode 100644 core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.ts create mode 100644 core/identity/application/use-cases/RecordRaceRatingEventsUseCase.integration.test.ts create mode 100644 core/identity/application/use-cases/RecordRaceRatingEventsUseCase.test.ts create mode 100644 core/identity/application/use-cases/RecordRaceRatingEventsUseCase.ts create mode 100644 core/identity/application/use-cases/UpsertExternalGameRatingUseCase.integration.test.ts create mode 100644 core/identity/application/use-cases/UpsertExternalGameRatingUseCase.test.ts create mode 100644 core/identity/application/use-cases/UpsertExternalGameRatingUseCase.ts create mode 100644 core/identity/domain/entities/AdminVoteSession.test.ts create mode 100644 core/identity/domain/entities/AdminVoteSession.ts create mode 100644 core/identity/domain/entities/ExternalGameRatingProfile.test.ts create mode 100644 core/identity/domain/entities/ExternalGameRatingProfile.ts create mode 100644 core/identity/domain/entities/RatingEvent.test.ts create mode 100644 core/identity/domain/entities/RatingEvent.ts create mode 100644 core/identity/domain/errors/IdentityDomainError.ts create mode 100644 core/identity/domain/repositories/IAdminVoteSessionRepository.ts create mode 100644 core/identity/domain/repositories/IExternalGameRatingRepository.test.ts create mode 100644 core/identity/domain/repositories/IExternalGameRatingRepository.ts create mode 100644 core/identity/domain/repositories/IRatingEventRepository.test.ts create mode 100644 core/identity/domain/repositories/IRatingEventRepository.ts create mode 100644 core/identity/domain/repositories/IUserRatingRepository.test.ts create mode 100644 core/identity/domain/services/AdminTrustRatingCalculator.test.ts create mode 100644 core/identity/domain/services/AdminTrustRatingCalculator.ts create mode 100644 core/identity/domain/services/DrivingRatingCalculator.test.ts create mode 100644 core/identity/domain/services/DrivingRatingCalculator.ts create mode 100644 core/identity/domain/services/EligibilityEvaluator.test.ts create mode 100644 core/identity/domain/services/EligibilityEvaluator.ts create mode 100644 core/identity/domain/services/RatingEventFactory.test.ts create mode 100644 core/identity/domain/services/RatingEventFactory.ts create mode 100644 core/identity/domain/services/RatingSnapshotCalculator.test.ts create mode 100644 core/identity/domain/services/RatingSnapshotCalculator.ts create mode 100644 core/identity/domain/services/RatingUpdateService.test.ts create mode 100644 core/identity/domain/value-objects/AdminTrustReasonCode.test.ts create mode 100644 core/identity/domain/value-objects/AdminTrustReasonCode.ts create mode 100644 core/identity/domain/value-objects/DrivingReasonCode.test.ts create mode 100644 core/identity/domain/value-objects/DrivingReasonCode.ts create mode 100644 core/identity/domain/value-objects/ExternalRating.test.ts create mode 100644 core/identity/domain/value-objects/ExternalRating.ts create mode 100644 core/identity/domain/value-objects/ExternalRatingProvenance.test.ts create mode 100644 core/identity/domain/value-objects/ExternalRatingProvenance.ts create mode 100644 core/identity/domain/value-objects/GameKey.test.ts create mode 100644 core/identity/domain/value-objects/GameKey.ts create mode 100644 core/identity/domain/value-objects/RatingDelta.test.ts create mode 100644 core/identity/domain/value-objects/RatingDelta.ts create mode 100644 core/identity/domain/value-objects/RatingDimensionKey.test.ts create mode 100644 core/identity/domain/value-objects/RatingDimensionKey.ts create mode 100644 core/identity/domain/value-objects/RatingEventId.test.ts create mode 100644 core/identity/domain/value-objects/RatingEventId.ts create mode 100644 core/identity/domain/value-objects/RatingReference.test.ts create mode 100644 core/identity/domain/value-objects/RatingReference.ts create mode 100644 core/identity/domain/value-objects/RatingValue.test.ts create mode 100644 core/identity/domain/value-objects/RatingValue.ts diff --git a/adapters/identity/persistence/inmemory/InMemoryExternalGameRatingRepository.ts b/adapters/identity/persistence/inmemory/InMemoryExternalGameRatingRepository.ts new file mode 100644 index 000000000..6fb6526b1 --- /dev/null +++ b/adapters/identity/persistence/inmemory/InMemoryExternalGameRatingRepository.ts @@ -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 = new Map(); + + private getKey(userId: string, gameKey: string): string { + return `${userId}|${gameKey}`; + } + + async findByUserIdAndGameKey( + userId: string, + gameKey: string + ): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.get(key) || null; + } + + async findByUserId(userId: string): Promise { + return Array.from(this.profiles.values()).filter( + p => p.userId.toString() === userId + ); + } + + async findByGameKey(gameKey: string): Promise { + return Array.from(this.profiles.values()).filter( + p => p.gameKey.toString() === gameKey + ); + } + + async save(profile: ExternalGameRatingProfile): Promise { + const key = this.getKey(profile.userId.toString(), profile.gameKey.toString()); + this.profiles.set(key, profile); + return profile; + } + + async saveMany(profiles: ExternalGameRatingProfile[]): Promise { + for (const profile of profiles) { + await this.save(profile); + } + return profiles; + } + + async delete(userId: string, gameKey: string): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.delete(key); + } + + async exists(userId: string, gameKey: string): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.has(key); + } + + async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise> { + 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 = { + 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()); + } +} diff --git a/adapters/identity/persistence/typeorm/entities/ExternalGameRatingProfileOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/ExternalGameRatingProfileOrmEntity.ts new file mode 100644 index 000000000..11a48663c --- /dev/null +++ b/adapters/identity/persistence/typeorm/entities/ExternalGameRatingProfileOrmEntity.ts @@ -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; +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/entities/RatingEventOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/RatingEventOrmEntity.ts new file mode 100644 index 000000000..5059d0ce2 --- /dev/null +++ b/adapters/identity/persistence/typeorm/entities/RatingEventOrmEntity.ts @@ -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; + }; + + @Column({ type: 'jsonb' }) + visibility!: { + public: boolean; + redactedFields: string[]; + }; + + @Column({ type: 'integer' }) + version!: number; +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/entities/UserRatingOrmEntity.ts b/adapters/identity/persistence/typeorm/entities/UserRatingOrmEntity.ts new file mode 100644 index 000000000..e17d9a8ba --- /dev/null +++ b/adapters/identity/persistence/typeorm/entities/UserRatingOrmEntity.ts @@ -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; +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.test.ts b/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.test.ts new file mode 100644 index 000000000..9473ba543 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.test.ts @@ -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, + }); + } +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.ts new file mode 100644 index 000000000..8826ea104 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/ExternalGameRatingProfileOrmMapper.ts @@ -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; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.test.ts b/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.test.ts new file mode 100644 index 000000000..bd9dd01bb --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.ts new file mode 100644 index 000000000..59ae7b808 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/RatingEventOrmMapper.ts @@ -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; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.test.ts b/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.test.ts new file mode 100644 index 000000000..f02da3088 --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.ts b/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.ts new file mode 100644 index 000000000..be8baa33a --- /dev/null +++ b/adapters/identity/persistence/typeorm/mappers/UserRatingOrmMapper.ts @@ -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; + } +} \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmExternalGameRatingRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmExternalGameRatingRepository.ts new file mode 100644 index 000000000..cf721b473 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmExternalGameRatingRepository.ts @@ -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 + ) {} + + async findByUserIdAndGameKey( + userId: string, + gameKey: string + ): Promise { + const entity = await this.repository.findOne({ + where: { userId, gameKey }, + }); + + if (!entity) { + return null; + } + + return ExternalGameRatingProfileOrmMapper.toDomain(entity); + } + + async findByUserId(userId: string): Promise { + const entities = await this.repository.find({ + where: { userId }, + order: { updatedAt: 'DESC' }, + }); + + return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity)); + } + + async findByGameKey(gameKey: string): Promise { + const entities = await this.repository.find({ + where: { gameKey }, + order: { updatedAt: 'DESC' }, + }); + + return entities.map(entity => ExternalGameRatingProfileOrmMapper.toDomain(entity)); + } + + async save(profile: ExternalGameRatingProfile): Promise { + 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 { + 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 { + const result = await this.repository.delete({ userId, gameKey }); + return (result.affected ?? 0) > 0; + } + + async exists(userId: string, gameKey: string): Promise { + const count = await this.repository.count({ + where: { userId, gameKey }, + }); + return count > 0; + } + + async findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise> { + 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 = { + items, + total, + limit, + offset, + hasMore + }; + + if (nextOffset !== undefined) { + result.nextOffset = nextOffset; + } + + return result; + } +} diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.test.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.test.ts new file mode 100644 index 000000000..88b403ab4 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.test.ts @@ -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([]); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.ts new file mode 100644 index 000000000..5316d2cf4 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmRatingEventRepository.ts @@ -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 { + const repo = this.dataSource.getRepository(RatingEventOrmEntity); + const entity = RatingEventOrmMapper.toOrmEntity(event); + await repo.save(entity); + return event; + } + + async findByUserId(userId: string, options?: FindByUserIdOptions): Promise { + 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 { + 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 { + 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> { + 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 = { + items, + total, + limit, + offset, + hasMore + }; + + if (nextOffset !== undefined) { + result.nextOffset = nextOffset; + } + + return result; + } +} diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.test.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.test.ts new file mode 100644 index 000000000..b8b41bed6 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.ts b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.ts new file mode 100644 index 000000000..9c0d99bc3 --- /dev/null +++ b/adapters/identity/persistence/typeorm/repositories/TypeOrmUserRatingRepository.ts @@ -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 { + 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 { + const repo = this.dataSource.getRepository(UserRatingOrmEntity); + const entity = UserRatingOrmMapper.toOrmEntity(userRating); + await repo.save(entity); + return userRating; + } +} \ No newline at end of file diff --git a/apps/api/src/persistence/postgres/typeorm/PostgresIdentityRatingSlice.int.test.ts b/apps/api/src/persistence/postgres/typeorm/PostgresIdentityRatingSlice.int.test.ts new file mode 100644 index 000000000..4e3a9bf40 --- /dev/null +++ b/apps/api/src/persistence/postgres/typeorm/PostgresIdentityRatingSlice.int.test.ts @@ -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 + }); +}); \ No newline at end of file diff --git a/core/identity/application/dtos/AdminVoteSessionDto.ts b/core/identity/application/dtos/AdminVoteSessionDto.ts new file mode 100644 index 000000000..13d7907b1 --- /dev/null +++ b/core/identity/application/dtos/AdminVoteSessionDto.ts @@ -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[]; +} diff --git a/core/identity/application/dtos/CreateRatingEventDto.ts b/core/identity/application/dtos/CreateRatingEventDto.ts new file mode 100644 index 000000000..30c410d72 --- /dev/null +++ b/core/identity/application/dtos/CreateRatingEventDto.ts @@ -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; + occurredAt?: string; // ISO date string +} \ No newline at end of file diff --git a/core/identity/application/dtos/EligibilityFilterDto.ts b/core/identity/application/dtos/EligibilityFilterDto.ts new file mode 100644 index 000000000..4c291a5d2 --- /dev/null +++ b/core/identity/application/dtos/EligibilityFilterDto.ts @@ -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'; +} diff --git a/core/identity/application/dtos/EvaluationResultDto.ts b/core/identity/application/dtos/EvaluationResultDto.ts new file mode 100644 index 000000000..f119c6609 --- /dev/null +++ b/core/identity/application/dtos/EvaluationResultDto.ts @@ -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; + }; +} diff --git a/core/identity/application/dtos/LedgerEntryDto.ts b/core/identity/application/dtos/LedgerEntryDto.ts new file mode 100644 index 000000000..7f3290130 --- /dev/null +++ b/core/identity/application/dtos/LedgerEntryDto.ts @@ -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; + }; + + 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; + }; +} diff --git a/core/identity/application/dtos/RatingSummaryDto.ts b/core/identity/application/dtos/RatingSummaryDto.ts new file mode 100644 index 000000000..f3e76cf5e --- /dev/null +++ b/core/identity/application/dtos/RatingSummaryDto.ts @@ -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; // 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) +} diff --git a/core/identity/application/dtos/RecordRaceRatingEventsDto.ts b/core/identity/application/dtos/RecordRaceRatingEventsDto.ts new file mode 100644 index 000000000..c827a21a4 --- /dev/null +++ b/core/identity/application/dtos/RecordRaceRatingEventsDto.ts @@ -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[]; +} \ No newline at end of file diff --git a/core/identity/application/dtos/UpsertExternalGameRatingDto.ts b/core/identity/application/dtos/UpsertExternalGameRatingDto.ts new file mode 100644 index 000000000..43fd3049d --- /dev/null +++ b/core/identity/application/dtos/UpsertExternalGameRatingDto.ts @@ -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[]; +} \ No newline at end of file diff --git a/core/identity/application/dtos/UserRatingDto.ts b/core/identity/application/dtos/UserRatingDto.ts new file mode 100644 index 000000000..295b17eff --- /dev/null +++ b/core/identity/application/dtos/UserRatingDto.ts @@ -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 +} \ No newline at end of file diff --git a/core/identity/application/dtos/index.ts b/core/identity/application/dtos/index.ts new file mode 100644 index 000000000..7a56e8733 --- /dev/null +++ b/core/identity/application/dtos/index.ts @@ -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'; diff --git a/core/identity/application/ports/IRaceResultsProvider.ts b/core/identity/application/ports/IRaceResultsProvider.ts new file mode 100644 index 000000000..7e6c02d2e --- /dev/null +++ b/core/identity/application/ports/IRaceResultsProvider.ts @@ -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; + + /** + * Check if race results exist for a race + */ + hasRaceResults(raceId: string): Promise; +} \ No newline at end of file diff --git a/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.test.ts b/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.test.ts new file mode 100644 index 000000000..77726ff9d --- /dev/null +++ b/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.test.ts @@ -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); + }); + }); +}); diff --git a/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.ts b/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.ts new file mode 100644 index 000000000..04e833b4f --- /dev/null +++ b/core/identity/application/queries/GetLeagueEligibilityPreviewQuery.ts @@ -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 { + 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); + } +} diff --git a/core/identity/application/queries/GetUserRatingLedgerQuery.ts b/core/identity/application/queries/GetUserRatingLedgerQuery.ts new file mode 100644 index 000000000..cba63e71b --- /dev/null +++ b/core/identity/application/queries/GetUserRatingLedgerQuery.ts @@ -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 { + 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, + }, + }; + } +} diff --git a/core/identity/application/queries/GetUserRatingsSummaryQuery.test.ts b/core/identity/application/queries/GetUserRatingsSummaryQuery.test.ts new file mode 100644 index 000000000..2e05e115d --- /dev/null +++ b/core/identity/application/queries/GetUserRatingsSummaryQuery.test.ts @@ -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()); + }); + }); +}); diff --git a/core/identity/application/queries/GetUserRatingsSummaryQuery.ts b/core/identity/application/queries/GetUserRatingsSummaryQuery.ts new file mode 100644 index 000000000..8a7336d13 --- /dev/null +++ b/core/identity/application/queries/GetUserRatingsSummaryQuery.ts @@ -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 { + 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; + } +} diff --git a/core/identity/application/queries/index.ts b/core/identity/application/queries/index.ts new file mode 100644 index 000000000..dcb6f9344 --- /dev/null +++ b/core/identity/application/queries/index.ts @@ -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'; diff --git a/core/identity/application/use-cases/AdminVoteSessionUseCases.test.ts b/core/identity/application/use-cases/AdminVoteSessionUseCases.test.ts new file mode 100644 index 000000000..b11ef938c --- /dev/null +++ b/core/identity/application/use-cases/AdminVoteSessionUseCases.test.ts @@ -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 = new Map(); + + async save(session: AdminVoteSession): Promise { + this.sessions.set(session.id, session); + return session; + } + + async findById(id: string): Promise { + return this.sessions.get(id) || null; + } + + async findActiveForAdmin(adminId: string, leagueId: string): Promise { + 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 { + return Array.from(this.sessions.values()).filter( + s => s.adminId === adminId && s.leagueId === leagueId + ); + } + + async findByLeague(leagueId: string): Promise { + return Array.from(this.sessions.values()).filter( + s => s.leagueId === leagueId + ); + } + + async findClosedUnprocessed(): Promise { + return Array.from(this.sessions.values()).filter( + s => s.closed && s.outcome !== undefined + ); + } +} + +class MockRatingEventRepository { + private events: Map = new Map(); + + async save(event: RatingEvent): Promise { + this.events.set(event.id.value, event); + return event; + } + + async findByUserId(userId: string): Promise { + return Array.from(this.events.values()).filter(e => e.userId === userId); + } + + async findByIds(ids: string[]): Promise { + return Array.from(this.events.values()).filter(e => ids.includes(e.id.value)); + } + + async getAllByUserId(userId: string): Promise { + return Array.from(this.events.values()).filter(e => e.userId === userId); + } + + async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise> { + 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 = { + items, + total, + limit, + offset, + hasMore + }; + + if (nextOffset !== undefined) { + result.nextOffset = nextOffset; + } + + return result; + } +} + +class MockUserRatingRepository { + private ratings: Map = new Map(); + + async save(rating: UserRating): Promise { + this.ratings.set(rating.userId, rating); + return rating; + } + + async findByUserId(userId: string): Promise { + return this.ratings.get(userId) || null; + } +} + +// Mock AppendRatingEventsUseCase +class MockAppendRatingEventsUseCase { + constructor( + private ratingEventRepository: any, + private userRatingRepository: any + ) {} + + async execute(input: any): Promise { + 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); + }); + }); +}); diff --git a/core/identity/application/use-cases/AppendRatingEventsUseCase.test.ts b/core/identity/application/use-cases/AppendRatingEventsUseCase.test.ts new file mode 100644 index 000000000..a5c1f7f05 --- /dev/null +++ b/core/identity/application/use-cases/AppendRatingEventsUseCase.test.ts @@ -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; + let mockRatingRepo: Partial; + + 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(); + }); +}); \ No newline at end of file diff --git a/core/identity/application/use-cases/AppendRatingEventsUseCase.ts b/core/identity/application/use-cases/AppendRatingEventsUseCase.ts new file mode 100644 index 000000000..d248280ac --- /dev/null +++ b/core/identity/application/use-cases/AppendRatingEventsUseCase.ts @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/core/identity/application/use-cases/CastAdminVoteUseCase.ts b/core/identity/application/use-cases/CastAdminVoteUseCase.ts new file mode 100644 index 000000000..59f761ee4 --- /dev/null +++ b/core/identity/application/use-cases/CastAdminVoteUseCase.ts @@ -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 { + 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; + } +} diff --git a/core/identity/application/use-cases/CloseAdminVoteSessionUseCase.ts b/core/identity/application/use-cases/CloseAdminVoteSessionUseCase.ts new file mode 100644 index 000000000..4bf3d3324 --- /dev/null +++ b/core/identity/application/use-cases/CloseAdminVoteSessionUseCase.ts @@ -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 { + 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 { + 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; + } +} diff --git a/core/identity/application/use-cases/OpenAdminVoteSessionUseCase.ts b/core/identity/application/use-cases/OpenAdminVoteSessionUseCase.ts new file mode 100644 index 000000000..d2cea97d3 --- /dev/null +++ b/core/identity/application/use-cases/OpenAdminVoteSessionUseCase.ts @@ -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 { + 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; + } +} diff --git a/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.test.ts b/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.test.ts new file mode 100644 index 000000000..c5ebe93e0 --- /dev/null +++ b/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.test.ts @@ -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; + let mockRatingRepo: Partial; + + 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(); + }); +}); \ No newline at end of file diff --git a/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.ts b/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.ts new file mode 100644 index 000000000..ed8430a17 --- /dev/null +++ b/core/identity/application/use-cases/RecomputeUserRatingSnapshotUseCase.ts @@ -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 { + // 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 }; + } +} \ No newline at end of file diff --git a/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.integration.test.ts b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.integration.test.ts new file mode 100644 index 000000000..df912855f --- /dev/null +++ b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.integration.test.ts @@ -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 = new Map(); + + async getRaceResults(raceId: string): Promise { + return this.results.get(raceId) || null; + } + + async hasRaceResults(raceId: string): Promise { + 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 = new Map(); + + async save(event: RatingEvent): Promise { + 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 { + return this.events.get(userId) || []; + } + + async findByIds(ids: RatingEventId[]): Promise { + const allEvents = Array.from(this.events.values()).flat(); + return allEvents.filter(e => ids.some(id => id.equals(e.id))); + } + + async getAllByUserId(userId: string): Promise { + return this.events.get(userId) || []; + } + + async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise> { + 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 = { + 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 = new Map(); + + async findByUserId(userId: string): Promise { + return this.ratings.get(userId) || null; + } + + async save(userRating: UserRating): Promise { + this.ratings.set(userRating.userId, userRating); + return userRating; + } + + // Helper for tests + getAllRatings(): Map { + 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(); + }); + }); +}); diff --git a/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.test.ts b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.test.ts new file mode 100644 index 000000000..86c1988b9 --- /dev/null +++ b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.test.ts @@ -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 { + return this.results; + } + + async hasRaceResults(raceId: string): Promise { + return this.results !== null; + } +} + +class MockRatingEventRepository implements IRatingEventRepository { + private events: RatingEvent[] = []; + + async save(event: RatingEvent): Promise { + this.events.push(event); + return event; + } + + async findByUserId(userId: string): Promise { + return this.events.filter(e => e.userId === userId); + } + + async findByIds(ids: RatingEventId[]): Promise { + return this.events.filter(e => ids.some(id => id.equals(e.id))); + } + + async getAllByUserId(userId: string): Promise { + return this.events.filter(e => e.userId === userId); + } + + async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise> { + 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 = { + items, + total, + limit, + offset, + hasMore + }; + + if (nextOffset !== undefined) { + result.nextOffset = nextOffset; + } + + return result; + } +} + +class MockUserRatingRepository implements IUserRatingRepository { + private ratings: Map = new Map(); + + async findByUserId(userId: string): Promise { + return this.ratings.get(userId) || null; + } + + async save(userRating: UserRating): Promise { + 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; + }); + }); +}); diff --git a/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.ts b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.ts new file mode 100644 index 000000000..4a83d69ae --- /dev/null +++ b/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.ts @@ -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 { + 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> { + // 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(); + + // 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, + })); + } +} \ No newline at end of file diff --git a/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.integration.test.ts b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.integration.test.ts new file mode 100644 index 000000000..97e4141f0 --- /dev/null +++ b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.integration.test.ts @@ -0,0 +1,265 @@ +import { UpsertExternalGameRatingUseCase } from './UpsertExternalGameRatingUseCase'; +import { UpsertExternalGameRatingInput } from '../dtos/UpsertExternalGameRatingDto'; + +// Mock repository for integration test +class MockExternalGameRatingRepository { + private profiles = new Map(); + + private getKey(userId: string, gameKey: string): string { + return `${userId}|${gameKey}`; + } + + async findByUserIdAndGameKey(userId: string, gameKey: string): Promise { + return this.profiles.get(this.getKey(userId, gameKey)) || null; + } + + async findByUserId(userId: string): Promise { + return Array.from(this.profiles.values()).filter((p: any) => p.userId.toString() === userId); + } + + async findByGameKey(gameKey: string): Promise { + return Array.from(this.profiles.values()).filter((p: any) => p.gameKey.toString() === gameKey); + } + + async save(profile: any): Promise { + const key = this.getKey(profile.userId.toString(), profile.gameKey.toString()); + this.profiles.set(key, profile); + return profile; + } + + async delete(userId: string, gameKey: string): Promise { + return this.profiles.delete(this.getKey(userId, gameKey)); + } + + async exists(userId: string, gameKey: string): Promise { + 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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.test.ts b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.test.ts new file mode 100644 index 000000000..24c2a7035 --- /dev/null +++ b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.test.ts @@ -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, + }); + } +}); \ No newline at end of file diff --git a/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.ts b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.ts new file mode 100644 index 000000000..f462ba139 --- /dev/null +++ b/core/identity/application/use-cases/UpsertExternalGameRatingUseCase.ts @@ -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 { + 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 { + const gameKey = GameKey.create(gameKeyString); + const ratingsMap = new Map(); + + 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, + }); + } +} \ No newline at end of file diff --git a/core/identity/domain/entities/AdminVoteSession.test.ts b/core/identity/domain/entities/AdminVoteSession.test.ts new file mode 100644 index 000000000..1a43a2317 --- /dev/null +++ b/core/identity/domain/entities/AdminVoteSession.test.ts @@ -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); + }); + }); +}); diff --git a/core/identity/domain/entities/AdminVoteSession.ts b/core/identity/domain/entities/AdminVoteSession.ts new file mode 100644 index 000000000..fc19ebced --- /dev/null +++ b/core/identity/domain/entities/AdminVoteSession.ts @@ -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 { + 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(); + 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): boolean { + return this.id === other.id; + } + + toJSON(): Record { + 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(), + }; + } +} diff --git a/core/identity/domain/entities/ExternalGameRatingProfile.test.ts b/core/identity/domain/entities/ExternalGameRatingProfile.test.ts new file mode 100644 index 000000000..e02439c36 --- /dev/null +++ b/core/identity/domain/entities/ExternalGameRatingProfile.test.ts @@ -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; + 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(); + 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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/entities/ExternalGameRatingProfile.ts b/core/identity/domain/entities/ExternalGameRatingProfile.ts new file mode 100644 index 000000000..e436b3bb5 --- /dev/null +++ b/core/identity/domain/entities/ExternalGameRatingProfile.ts @@ -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; // type -> rating + provenance: ExternalRatingProvenance; +} + +export class ExternalGameRatingProfile extends Entity { + private readonly _userId: UserId; + private readonly _gameKey: GameKey; + private _ratings: Map; + 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(); + 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 { + return new Map(this._ratings); + } + + get provenance(): ExternalRatingProvenance { + return this._provenance; + } + + /** + * Update ratings and provenance with latest data + */ + updateRatings( + newRatings: Map, + 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; + } +} \ No newline at end of file diff --git a/core/identity/domain/entities/RatingEvent.test.ts b/core/identity/domain/entities/RatingEvent.test.ts new file mode 100644 index 000000000..eb362dda9 --- /dev/null +++ b/core/identity/domain/entities/RatingEvent.test.ts @@ -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' }); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/entities/RatingEvent.ts b/core/identity/domain/entities/RatingEvent.ts new file mode 100644 index 000000000..205ee43e2 --- /dev/null +++ b/core/identity/domain/entities/RatingEvent.ts @@ -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; +} + +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 { + 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): boolean { + return this.id.equals(other.id); + } + + toJSON(): Record { + 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, + }; + } +} \ No newline at end of file diff --git a/core/identity/domain/errors/IdentityDomainError.ts b/core/identity/domain/errors/IdentityDomainError.ts new file mode 100644 index 000000000..9a2be1b6b --- /dev/null +++ b/core/identity/domain/errors/IdentityDomainError.ts @@ -0,0 +1,34 @@ +import type { IDomainError, CommonDomainErrorKind } from '@core/shared/errors'; + +export abstract class IdentityDomainError extends Error implements IDomainError { + 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); + } +} \ No newline at end of file diff --git a/core/identity/domain/repositories/IAdminVoteSessionRepository.ts b/core/identity/domain/repositories/IAdminVoteSessionRepository.ts new file mode 100644 index 000000000..98f9f68e7 --- /dev/null +++ b/core/identity/domain/repositories/IAdminVoteSessionRepository.ts @@ -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; + + /** + * Find a vote session by ID + */ + findById(id: string): Promise; + + /** + * Find active vote sessions for an admin in a league + * (within voting window and not closed) + */ + findActiveForAdmin(adminId: string, leagueId: string): Promise; + + /** + * Find all vote sessions for an admin in a league + */ + findByAdminAndLeague(adminId: string, leagueId: string): Promise; + + /** + * Find vote sessions by league + */ + findByLeague(leagueId: string): Promise; + + /** + * Find closed vote sessions ready for outcome processing + * (closed but not yet processed into rating events) + */ + findClosedUnprocessed(): Promise; +} diff --git a/core/identity/domain/repositories/IExternalGameRatingRepository.test.ts b/core/identity/domain/repositories/IExternalGameRatingRepository.test.ts new file mode 100644 index 000000000..9803161af --- /dev/null +++ b/core/identity/domain/repositories/IExternalGameRatingRepository.test.ts @@ -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 = new Map(); + + private getKey(userId: string, gameKey: string): string { + return `${userId}|${gameKey}`; + } + + async findByUserIdAndGameKey( + userId: string, + gameKey: string + ): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.get(key) || null; + } + + async findByUserId(userId: string): Promise { + return Array.from(this.profiles.values()).filter( + p => p.userId.toString() === userId + ); + } + + async findByGameKey(gameKey: string): Promise { + return Array.from(this.profiles.values()).filter( + p => p.gameKey.toString() === gameKey + ); + } + + async save(profile: ExternalGameRatingProfile): Promise { + const key = this.getKey(profile.userId.toString(), profile.gameKey.toString()); + this.profiles.set(key, profile); + return profile; + } + + async saveMany(profiles: ExternalGameRatingProfile[]): Promise { + for (const profile of profiles) { + await this.save(profile); + } + return profiles; + } + + async delete(userId: string, gameKey: string): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.delete(key); + } + + async exists(userId: string, gameKey: string): Promise { + const key = this.getKey(userId, gameKey); + return this.profiles.has(key); + } + + async findProfilesPaginated(userId: string, options?: import('./IExternalGameRatingRepository').PaginatedQueryOptions): Promise> { + 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 = { + 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, + }); + } +}); diff --git a/core/identity/domain/repositories/IExternalGameRatingRepository.ts b/core/identity/domain/repositories/IExternalGameRatingRepository.ts new file mode 100644 index 000000000..587d826e9 --- /dev/null +++ b/core/identity/domain/repositories/IExternalGameRatingRepository.ts @@ -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 { + 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; + + /** + * Find all profiles for a user + */ + findByUserId(userId: string): Promise; + + /** + * Find all profiles for a game + */ + findByGameKey(gameKey: string): Promise; + + /** + * Save or update a profile + */ + save(profile: ExternalGameRatingProfile): Promise; + + /** + * Save multiple profiles + */ + saveMany(profiles: ExternalGameRatingProfile[]): Promise; + + /** + * Delete a profile + */ + delete(userId: string, gameKey: string): Promise; + + /** + * Check if profile exists + */ + exists(userId: string, gameKey: string): Promise; + + /** + * Find profiles with pagination and filtering + */ + findProfilesPaginated(userId: string, options?: PaginatedQueryOptions): Promise>; +} diff --git a/core/identity/domain/repositories/IRatingEventRepository.test.ts b/core/identity/domain/repositories/IRatingEventRepository.test.ts new file mode 100644 index 000000000..96cb95553 --- /dev/null +++ b/core/identity/domain/repositories/IRatingEventRepository.test.ts @@ -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 { + 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 { + 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 { + return this.events.filter(e => ids.some(id => e.id.equals(id))); + } + + async getAllByUserId(userId: string): Promise { + return this.findByUserId(userId); + } + + async findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise> { + 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 = { + 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); + }); + }); +}); diff --git a/core/identity/domain/repositories/IRatingEventRepository.ts b/core/identity/domain/repositories/IRatingEventRepository.ts new file mode 100644 index 000000000..9e2f822ad --- /dev/null +++ b/core/identity/domain/repositories/IRatingEventRepository.ts @@ -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 { + 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; + + /** + * Find all rating events for a user, ordered by occurredAt (ascending) + * Options allow for pagination and streaming + */ + findByUserId(userId: string, options?: FindByUserIdOptions): Promise; + + /** + * Find multiple events by their IDs + */ + findByIds(ids: RatingEventId[]): Promise; + + /** + * Get all events for a user (for snapshot recomputation) + */ + getAllByUserId(userId: string): Promise; + + /** + * Find events with pagination and filtering + */ + findEventsPaginated(userId: string, options?: PaginatedQueryOptions): Promise>; +} diff --git a/core/identity/domain/repositories/IUserRatingRepository.test.ts b/core/identity/domain/repositories/IUserRatingRepository.test.ts new file mode 100644 index 000000000..afbe7c266 --- /dev/null +++ b/core/identity/domain/repositories/IUserRatingRepository.test.ts @@ -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 = new Map(); + + async findByUserId(userId: string): Promise { + return this.ratings.get(userId) || null; + } + + async save(userRating: UserRating): Promise { + 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'); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/repositories/IUserRatingRepository.ts b/core/identity/domain/repositories/IUserRatingRepository.ts index 8ffe1ffec..d6b6770f1 100644 --- a/core/identity/domain/repositories/IUserRatingRepository.ts +++ b/core/identity/domain/repositories/IUserRatingRepository.ts @@ -1,49 +1,20 @@ /** * Repository Interface: IUserRatingRepository * - * Defines operations for UserRating value objects + * Port for persisting and retrieving UserRating snapshots. + * Snapshots are derived from rating events for fast reads. */ import type { UserRating } from '../value-objects/UserRating'; export interface IUserRatingRepository { /** - * Find rating by user ID + * Find rating snapshot by user ID */ findByUserId(userId: string): Promise; - + /** - * Find ratings by multiple user IDs + * Save or update a user rating snapshot */ - findByUserIds(userIds: string[]): Promise; - - /** - * Save or update a user rating - */ - save(rating: UserRating): Promise; - - /** - * Get top rated drivers - */ - getTopDrivers(limit: number): Promise; - - /** - * Get top trusted users - */ - getTopTrusted(limit: number): Promise; - - /** - * Get eligible stewards (based on trust and fairness thresholds) - */ - getEligibleStewards(): Promise; - - /** - * Get ratings by driver tier - */ - findByDriverTier(tier: 'rookie' | 'amateur' | 'semi-pro' | 'pro' | 'elite'): Promise; - - /** - * Delete rating by user ID - */ - delete(userId: string): Promise; + save(userRating: UserRating): Promise; } \ No newline at end of file diff --git a/core/identity/domain/services/AdminTrustRatingCalculator.test.ts b/core/identity/domain/services/AdminTrustRatingCalculator.test.ts new file mode 100644 index 000000000..608186f48 --- /dev/null +++ b/core/identity/domain/services/AdminTrustRatingCalculator.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/services/AdminTrustRatingCalculator.ts b/core/identity/domain/services/AdminTrustRatingCalculator.ts new file mode 100644 index 000000000..7bc969337 --- /dev/null +++ b/core/identity/domain/services/AdminTrustRatingCalculator.ts @@ -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; + 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); + } +} \ No newline at end of file diff --git a/core/identity/domain/services/DrivingRatingCalculator.test.ts b/core/identity/domain/services/DrivingRatingCalculator.test.ts new file mode 100644 index 000000000..f68199750 --- /dev/null +++ b/core/identity/domain/services/DrivingRatingCalculator.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/services/DrivingRatingCalculator.ts b/core/identity/domain/services/DrivingRatingCalculator.ts new file mode 100644 index 000000000..4d2d192c9 --- /dev/null +++ b/core/identity/domain/services/DrivingRatingCalculator.ts @@ -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; + }>; +} + +/** + * 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 { + const results = new Map(); + + // 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; + }> = []; + + 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'; + } +} \ No newline at end of file diff --git a/core/identity/domain/services/EligibilityEvaluator.test.ts b/core/identity/domain/services/EligibilityEvaluator.test.ts new file mode 100644 index 000000000..b40379d41 --- /dev/null +++ b/core/identity/domain/services/EligibilityEvaluator.test.ts @@ -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); + }); + }); +}); diff --git a/core/identity/domain/services/EligibilityEvaluator.ts b/core/identity/domain/services/EligibilityEvaluator.ts new file mode 100644 index 000000000..6674e8c00 --- /dev/null +++ b/core/identity/domain/services/EligibilityEvaluator.ts @@ -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 = { + 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 = { + 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})`; + } +} diff --git a/core/identity/domain/services/RatingEventFactory.test.ts b/core/identity/domain/services/RatingEventFactory.test.ts new file mode 100644 index 000000000..bdd264857 --- /dev/null +++ b/core/identity/domain/services/RatingEventFactory.test.ts @@ -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); + } + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/services/RatingEventFactory.ts b/core/identity/domain/services/RatingEventFactory.ts new file mode 100644 index 000000000..b1cca72da --- /dev/null +++ b/core/identity/domain/services/RatingEventFactory.ts @@ -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; +} + +// 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 { + const eventsByUser = new Map(); + 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'; \ No newline at end of file diff --git a/core/identity/domain/services/RatingSnapshotCalculator.test.ts b/core/identity/domain/services/RatingSnapshotCalculator.test.ts new file mode 100644 index 000000000..62285ecb5 --- /dev/null +++ b/core/identity/domain/services/RatingSnapshotCalculator.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/services/RatingSnapshotCalculator.ts b/core/identity/domain/services/RatingSnapshotCalculator.ts new file mode 100644 index 000000000..dc76f391e --- /dev/null +++ b/core/identity/domain/services/RatingSnapshotCalculator.ts @@ -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); + + // 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; + } +} \ No newline at end of file diff --git a/core/identity/domain/services/RatingUpdateService.test.ts b/core/identity/domain/services/RatingUpdateService.test.ts new file mode 100644 index 000000000..252dc72f8 --- /dev/null +++ b/core/identity/domain/services/RatingUpdateService.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/services/RatingUpdateService.ts b/core/identity/domain/services/RatingUpdateService.ts index 4d9183eb7..228b6628d 100644 --- a/core/identity/domain/services/RatingUpdateService.ts +++ b/core/identity/domain/services/RatingUpdateService.ts @@ -1,22 +1,88 @@ import type { IDomainService } from '@core/shared/domain'; import type { IUserRatingRepository } from '../repositories/IUserRatingRepository'; -import { UserRating } from '../value-objects/UserRating'; +import type { IRatingEventRepository } from '../repositories/IRatingEventRepository'; +import { RatingEventFactory } from './RatingEventFactory'; +import { RatingSnapshotCalculator } from './RatingSnapshotCalculator'; +import { RatingEvent } from '../entities/RatingEvent'; +import { RatingEventId } from '../value-objects/RatingEventId'; +import { RatingDimensionKey } from '../value-objects/RatingDimensionKey'; +import { RatingDelta } from '../value-objects/RatingDelta'; /** * Domain Service: RatingUpdateService * * Handles updating user ratings based on various events and performance metrics. * Centralizes rating calculation logic and ensures consistency across the system. + * + * EVOLVED (Slice 7): Now uses event-driven approach with ledger pattern. + * Records rating events and recomputes snapshots for transparency and auditability. */ export class RatingUpdateService implements IDomainService { readonly serviceName = 'RatingUpdateService'; constructor( - private readonly userRatingRepository: IUserRatingRepository + private readonly userRatingRepository: IUserRatingRepository, + private readonly ratingEventRepository: IRatingEventRepository ) {} /** - * Update driver ratings after race completion + * Record race rating events and update snapshots (NEW LEDGER APPROACH) + * Replaces direct rating updates with event recording + snapshot recomputation + */ + async recordRaceRatingEvents(raceId: string, raceResults: Array<{ + userId: string; + startPos: number; + finishPos: number; + incidents: number; + status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk'; + sof?: number; + }>): Promise<{ success: boolean; eventsCreated: number; driversUpdated: string[] }> { + try { + // Use factory to create rating events from race results + const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({ + raceId, + results: raceResults, + }); + + let totalEvents = 0; + const driversUpdated: string[] = []; + + // Process each user's events + for (const [userId, events] of eventsByUser) { + if (events.length === 0) continue; + + // Save all events to ledger + for (const event of events) { + await this.ratingEventRepository.save(event); + totalEvents++; + } + + // Recompute snapshot from all events for this user + const allEvents = await this.ratingEventRepository.getAllByUserId(userId); + const snapshot = RatingSnapshotCalculator.calculate(userId, allEvents); + await this.userRatingRepository.save(snapshot); + + driversUpdated.push(userId); + } + + return { + success: true, + eventsCreated: totalEvents, + driversUpdated, + }; + } catch (error) { + console.error('[RatingUpdateService] Failed to record race rating events:', error); + return { + success: false, + eventsCreated: 0, + driversUpdated: [], + }; + } + } + + /** + * Update driver ratings after race completion (BACKWARD COMPATIBLE) + * Still supported but now delegates to event-based approach internally */ async updateDriverRatingsAfterRace( driverResults: Array<{ @@ -27,13 +93,28 @@ export class RatingUpdateService implements IDomainService { startPosition: number; }> ): Promise { - for (const result of driverResults) { - await this.updateDriverRating(result); + // Convert to new format and use event-based approach + const raceResults = driverResults.map(result => ({ + userId: result.driverId, + startPos: result.startPosition, + finishPos: result.position, + incidents: result.incidents, + status: 'finished' as const, + })); + + // Generate a synthetic race ID for backward compatibility + const raceId = `backward-compat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + const result = await this.recordRaceRatingEvents(raceId, raceResults); + + if (!result.success) { + throw new Error('Failed to update ratings via event system'); } } /** - * Update individual driver rating based on race result + * Update individual driver rating based on race result (LEGACY - DEPRECATED) + * Kept for backward compatibility but now uses event-based approach */ private async updateDriverRating(result: { driverId: string; @@ -42,103 +123,104 @@ export class RatingUpdateService implements IDomainService { incidents: number; startPosition: number; }): Promise { - const { driverId, position, totalDrivers, incidents, startPosition } = result; + // Delegate to new event-based approach + await this.updateDriverRatingsAfterRace([result]); + } - // Get or create user rating - let userRating = await this.userRatingRepository.findByUserId(driverId); - if (!userRating) { - userRating = UserRating.create(driverId); - } + /** + * Update trust score based on sportsmanship actions (USES LEDGER) + */ + async updateTrustScore(driverId: string, trustChange: number): Promise { + // Create trust-related rating event using manual event creation + const now = new Date(); + const event = RatingEvent.create({ + id: RatingEventId.generate(), + userId: driverId, + dimension: RatingDimensionKey.create('adminTrust'), + delta: RatingDelta.create(trustChange), + weight: 1, + occurredAt: now, + createdAt: now, + source: { type: 'manualAdjustment', id: `trust-${now.getTime()}` }, + reason: { + code: trustChange > 0 ? 'TRUST_BONUS' : 'TRUST_PENALTY', + summary: trustChange > 0 ? 'Positive sportsmanship' : 'Negative sportsmanship', + details: { trustChange }, + }, + visibility: { public: true, redactedFields: [] }, + version: 1, + }); - // Calculate performance score (0-100) - const performanceScore = this.calculatePerformanceScore(position, totalDrivers, startPosition); + // Save event + await this.ratingEventRepository.save(event); - // Calculate fairness score based on incidents (lower incidents = higher fairness) - const fairnessScore = this.calculateFairnessScore(incidents, totalDrivers); + // Recompute snapshot + const allEvents = await this.ratingEventRepository.getAllByUserId(driverId); + const snapshot = RatingSnapshotCalculator.calculate(driverId, allEvents); + await this.userRatingRepository.save(snapshot); + } - // Update ratings - const updatedRating = userRating - .updateDriverRating(performanceScore) - .updateFairnessScore(fairnessScore); + /** + * Update steward rating based on protest handling quality (USES LEDGER) + */ + async updateStewardRating(stewardId: string, ratingChange: number): Promise { + // Create steward-related rating event using manual event creation + const now = new Date(); + const event = RatingEvent.create({ + id: RatingEventId.generate(), + userId: stewardId, + dimension: RatingDimensionKey.create('adminTrust'), + delta: RatingDelta.create(ratingChange), + weight: 1, + occurredAt: now, + createdAt: now, + source: { type: 'manualAdjustment', id: `steward-${now.getTime()}` }, + reason: { + code: ratingChange > 0 ? 'STEWARD_BONUS' : 'STEWARD_PENALTY', + summary: ratingChange > 0 ? 'Good protest handling' : 'Poor protest handling', + details: { ratingChange }, + }, + visibility: { public: true, redactedFields: [] }, + version: 1, + }); - // Save updated rating - await this.userRatingRepository.save(updatedRating); + // Save event + await this.ratingEventRepository.save(event); + + // Recompute snapshot + const allEvents = await this.ratingEventRepository.getAllByUserId(stewardId); + const snapshot = RatingSnapshotCalculator.calculate(stewardId, allEvents); + await this.userRatingRepository.save(snapshot); } /** * Calculate performance score based on finishing position and field strength + * (Utility method kept for reference, but now handled by RatingEventFactory) */ private calculatePerformanceScore( position: number, totalDrivers: number, startPosition: number ): number { - // Base score from finishing position (reverse percentile) const positionScore = ((totalDrivers - position + 1) / totalDrivers) * 100; - - // Bonus for positions gained const positionsGained = startPosition - position; - const gainBonus = Math.max(0, positionsGained * 2); // 2 points per position gained - - // Field strength adjustment (harder fields give higher scores for same position) - const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); // Max 1.0 for 30+ drivers - + const gainBonus = Math.max(0, positionsGained * 2); + const fieldStrengthMultiplier = 0.8 + (totalDrivers / 50); const rawScore = (positionScore + gainBonus) * fieldStrengthMultiplier; - - // Clamp to 0-100 range return Math.max(0, Math.min(100, rawScore)); } /** * Calculate fairness score based on incident involvement + * (Utility method kept for reference, but now handled by RatingEventFactory) */ private calculateFairnessScore(incidents: number, totalDrivers: number): number { - // Base fairness score (100 = perfect, 0 = terrible) let fairnessScore = 100; - - // Deduct points for incidents - fairnessScore -= incidents * 15; // 15 points per incident - - // Additional deduction for high incident rate relative to field + fairnessScore -= incidents * 15; const incidentRate = incidents / totalDrivers; if (incidentRate > 0.5) { - fairnessScore -= 20; // Heavy penalty for being involved in many incidents + fairnessScore -= 20; } - - // Clamp to 0-100 range return Math.max(0, Math.min(100, fairnessScore)); } - - /** - * Update trust score based on sportsmanship actions - */ - async updateTrustScore(driverId: string, trustChange: number): Promise { - 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 { - 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); - } } \ No newline at end of file diff --git a/core/identity/domain/value-objects/AdminTrustReasonCode.test.ts b/core/identity/domain/value-objects/AdminTrustReasonCode.test.ts new file mode 100644 index 000000000..da0aef32a --- /dev/null +++ b/core/identity/domain/value-objects/AdminTrustReasonCode.test.ts @@ -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); + }); + }); + }); + }); +}); diff --git a/core/identity/domain/value-objects/AdminTrustReasonCode.ts b/core/identity/domain/value-objects/AdminTrustReasonCode.ts new file mode 100644 index 000000000..3dd864a9f --- /dev/null +++ b/core/identity/domain/value-objects/AdminTrustReasonCode.ts @@ -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 { + 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): 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'; + } +} diff --git a/core/identity/domain/value-objects/DrivingReasonCode.test.ts b/core/identity/domain/value-objects/DrivingReasonCode.test.ts new file mode 100644 index 000000000..a0973dba2 --- /dev/null +++ b/core/identity/domain/value-objects/DrivingReasonCode.test.ts @@ -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); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/DrivingReasonCode.ts b/core/identity/domain/value-objects/DrivingReasonCode.ts new file mode 100644 index 000000000..ed7f29141 --- /dev/null +++ b/core/identity/domain/value-objects/DrivingReasonCode.ts @@ -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 { + 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): 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'); + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/ExternalRating.test.ts b/core/identity/domain/value-objects/ExternalRating.test.ts new file mode 100644 index 000000000..45e2064fd --- /dev/null +++ b/core/identity/domain/value-objects/ExternalRating.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/ExternalRating.ts b/core/identity/domain/value-objects/ExternalRating.ts new file mode 100644 index 000000000..6e2821721 --- /dev/null +++ b/core/identity/domain/value-objects/ExternalRating.ts @@ -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 { + 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): 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}`; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/ExternalRatingProvenance.test.ts b/core/identity/domain/value-objects/ExternalRatingProvenance.test.ts new file mode 100644 index 000000000..63582db06 --- /dev/null +++ b/core/identity/domain/value-objects/ExternalRatingProvenance.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/ExternalRatingProvenance.ts b/core/identity/domain/value-objects/ExternalRatingProvenance.ts new file mode 100644 index 000000000..ab408af2d --- /dev/null +++ b/core/identity/domain/value-objects/ExternalRatingProvenance.ts @@ -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 { + 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): 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); + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/GameKey.test.ts b/core/identity/domain/value-objects/GameKey.test.ts new file mode 100644 index 000000000..ccf40bd44 --- /dev/null +++ b/core/identity/domain/value-objects/GameKey.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/GameKey.ts b/core/identity/domain/value-objects/GameKey.ts new file mode 100644 index 000000000..a7ec6fa5e --- /dev/null +++ b/core/identity/domain/value-objects/GameKey.ts @@ -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 { + 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): boolean { + return this.value === other.props.value; + } + + toString(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingDelta.test.ts b/core/identity/domain/value-objects/RatingDelta.test.ts new file mode 100644 index 000000000..39c54c06c --- /dev/null +++ b/core/identity/domain/value-objects/RatingDelta.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingDelta.ts b/core/identity/domain/value-objects/RatingDelta.ts new file mode 100644 index 000000000..367dfedd9 --- /dev/null +++ b/core/identity/domain/value-objects/RatingDelta.ts @@ -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 { + 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): 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; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingDimensionKey.test.ts b/core/identity/domain/value-objects/RatingDimensionKey.test.ts new file mode 100644 index 000000000..548974dba --- /dev/null +++ b/core/identity/domain/value-objects/RatingDimensionKey.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingDimensionKey.ts b/core/identity/domain/value-objects/RatingDimensionKey.ts new file mode 100644 index 000000000..ebe3115b5 --- /dev/null +++ b/core/identity/domain/value-objects/RatingDimensionKey.ts @@ -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 { + 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): boolean { + return this.value === other.props.value; + } + + toString(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingEventId.test.ts b/core/identity/domain/value-objects/RatingEventId.test.ts new file mode 100644 index 000000000..8a6b9cc51 --- /dev/null +++ b/core/identity/domain/value-objects/RatingEventId.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingEventId.ts b/core/identity/domain/value-objects/RatingEventId.ts new file mode 100644 index 000000000..ef7de6951 --- /dev/null +++ b/core/identity/domain/value-objects/RatingEventId.ts @@ -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 { + 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): boolean { + return this.value === other.props.value; + } + + toString(): string { + return this.value; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingReference.test.ts b/core/identity/domain/value-objects/RatingReference.test.ts new file mode 100644 index 000000000..fe6965307 --- /dev/null +++ b/core/identity/domain/value-objects/RatingReference.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingReference.ts b/core/identity/domain/value-objects/RatingReference.ts new file mode 100644 index 000000000..4bccbd5a4 --- /dev/null +++ b/core/identity/domain/value-objects/RatingReference.ts @@ -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 { + 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): 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'; + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingValue.test.ts b/core/identity/domain/value-objects/RatingValue.test.ts new file mode 100644 index 000000000..069f8ee47 --- /dev/null +++ b/core/identity/domain/value-objects/RatingValue.test.ts @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/core/identity/domain/value-objects/RatingValue.ts b/core/identity/domain/value-objects/RatingValue.ts new file mode 100644 index 000000000..c488988dd --- /dev/null +++ b/core/identity/domain/value-objects/RatingValue.ts @@ -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 { + 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): boolean { + return this.value === other.props.value; + } + + toNumber(): number { + return this.value; + } + + toString(): string { + return this.value.toString(); + } +} \ No newline at end of file diff --git a/core/identity/domain/value-objects/UserRating.ts b/core/identity/domain/value-objects/UserRating.ts index ef337949c..e6e244568 100644 --- a/core/identity/domain/value-objects/UserRating.ts +++ b/core/identity/domain/value-objects/UserRating.ts @@ -27,6 +27,7 @@ export interface UserRatingProps { trust: RatingDimension; fairness: RatingDimension; overallReputation: number; + calculatorVersion?: string; createdAt: Date; updatedAt: Date; } @@ -82,6 +83,10 @@ export class UserRating implements IValueObject { return this.props.updatedAt; } + get calculatorVersion(): string | undefined { + return this.props.calculatorVersion; + } + static create(userId: string): UserRating { if (!userId || userId.trim().length === 0) { throw new Error('UserRating userId is required'); @@ -96,6 +101,7 @@ export class UserRating implements IValueObject { trust: { ...DEFAULT_DIMENSION, lastUpdated: now }, fairness: { ...DEFAULT_DIMENSION, lastUpdated: now }, overallReputation: 50, + calculatorVersion: '1.0', createdAt: now, updatedAt: now, }); diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts index a5f7fb62c..8ac1fa8c1 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts @@ -10,6 +10,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit import type { IStandingRepository } from '../../domain/repositories/IStandingRepository'; import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider'; describe('CompleteRaceUseCaseWithRatings', () => { let useCase: CompleteRaceUseCaseWithRatings; @@ -32,6 +33,11 @@ describe('CompleteRaceUseCaseWithRatings', () => { }; let ratingUpdateService: { updateDriverRatingsAfterRace: Mock; + recordRaceRatingEvents: Mock; + }; + let raceResultsProvider: { + getRaceResults: Mock; + hasRaceResults: Mock; }; let output: { present: Mock }; @@ -55,9 +61,15 @@ describe('CompleteRaceUseCaseWithRatings', () => { }; ratingUpdateService = { updateDriverRatingsAfterRace: vi.fn(), + recordRaceRatingEvents: vi.fn(), + }; + raceResultsProvider = { + getRaceResults: vi.fn(), + hasRaceResults: vi.fn(), }; output = { present: vi.fn() }; + // Test without raceResultsProvider (backward compatible mode) useCase = new CompleteRaceUseCaseWithRatings( raceRepository as unknown as IRaceRepository, raceRegistrationRepository as unknown as IRaceRegistrationRepository, @@ -221,4 +233,185 @@ describe('CompleteRaceUseCaseWithRatings', () => { expect(error.details?.message).toBe('DB error'); expect(output.present).not.toHaveBeenCalled(); }); + + // SLICE 7: New tests for ledger-based approach + describe('Ledger-based rating updates (Slice 7)', () => { + let useCaseWithLedger: CompleteRaceUseCaseWithRatings; + + beforeEach(() => { + // Create use case with raceResultsProvider for ledger mode + useCaseWithLedger = new CompleteRaceUseCaseWithRatings( + raceRepository as unknown as IRaceRepository, + raceRegistrationRepository as unknown as IRaceRegistrationRepository, + resultRepository as unknown as IResultRepository, + standingRepository as unknown as IStandingRepository, + driverRatingProvider, + ratingUpdateService as unknown as RatingUpdateService, + output as unknown as UseCaseOutputPort, + 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(); + }); + }); }); diff --git a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts index 88b9a58b2..66cbc0b87 100644 --- a/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts +++ b/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.ts @@ -9,6 +9,7 @@ import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdate import { Result } from '@core/shared/application/Result'; import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode'; import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort'; +import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider'; export interface CompleteRaceWithRatingsInput { raceId: string; @@ -32,6 +33,7 @@ interface DriverRatingProvider { /** * Enhanced CompleteRaceUseCase that includes rating updates. + * EVOLVED (Slice 7): Now uses ledger-based rating updates for transparency and auditability. */ export class CompleteRaceUseCaseWithRatings { constructor( @@ -42,6 +44,7 @@ export class CompleteRaceUseCaseWithRatings { private readonly driverRatingProvider: DriverRatingProvider, private readonly ratingUpdateService: RatingUpdateService, private readonly output: UseCaseOutputPort, + private readonly raceResultsProvider?: IRaceResultsProvider, // Optional: for new ledger flow ) {} async execute(command: CompleteRaceWithRatingsInput): Promise< @@ -93,8 +96,41 @@ export class CompleteRaceUseCaseWithRatings { await this.updateStandings(race.leagueId, results); + // SLICE 7: Use new ledger-based approach if raceResultsProvider is available + // This provides backward compatibility while evolving to event-driven architecture try { - await this.updateDriverRatings(results, registeredDriverIds.length); + if (this.raceResultsProvider) { + // NEW LEDGER APPROACH: Use RecordRaceRatingEventsUseCase via RatingUpdateService + const raceResultsData = { + raceId, + results: results.map(result => ({ + userId: result.driverId.toString(), + startPos: result.startPosition.toNumber(), + finishPos: result.position.toNumber(), + incidents: result.incidents.toNumber(), + status: 'finished' as const, // RaceResultGenerator only generates finished results + })), + }; + + try { + const ratingResult = await this.ratingUpdateService.recordRaceRatingEvents( + raceId, + raceResultsData.results + ); + + if (!ratingResult.success) { + console.warn(`[Slice 7] Ledger-based rating update failed for race ${raceId}, falling back to legacy method`); + await this.updateDriverRatingsLegacy(results, registeredDriverIds.length); + } + } catch (error) { + // If ledger approach throws error, fall back to legacy method + console.warn(`[Slice 7] Ledger-based rating update threw error for race ${raceId}, falling back to legacy method: ${error instanceof Error ? error.message : 'Unknown error'}`); + await this.updateDriverRatingsLegacy(results, registeredDriverIds.length); + } + } else { + // BACKWARD COMPATIBLE: Use legacy direct update approach + await this.updateDriverRatingsLegacy(results, registeredDriverIds.length); + } } catch (error) { return Result.err({ code: 'RATING_UPDATE_FAILED', @@ -161,7 +197,11 @@ export class CompleteRaceUseCaseWithRatings { } } - private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise { + /** + * Legacy rating update method (BACKWARD COMPATIBLE) + * Uses direct updates via RatingUpdateService + */ + private async updateDriverRatingsLegacy(results: RaceResult[], totalDrivers: number): Promise { const driverResults = results.map((result) => ({ driverId: result.driverId.toString(), position: result.position.toNumber(),