rating
This commit is contained in:
73
core/identity/application/dtos/AdminVoteSessionDto.ts
Normal file
73
core/identity/application/dtos/AdminVoteSessionDto.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* DTOs for Admin Vote Session Use Cases
|
||||
*/
|
||||
|
||||
/**
|
||||
* Input for OpenAdminVoteSessionUseCase
|
||||
*/
|
||||
export interface OpenAdminVoteSessionInput {
|
||||
voteSessionId: string;
|
||||
leagueId: string;
|
||||
adminId: string;
|
||||
startDate: string; // ISO date string
|
||||
endDate: string; // ISO date string
|
||||
eligibleVoters: string[]; // User IDs
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for OpenAdminVoteSessionUseCase
|
||||
*/
|
||||
export interface OpenAdminVoteSessionOutput {
|
||||
success: boolean;
|
||||
voteSessionId: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for CastAdminVoteUseCase
|
||||
*/
|
||||
export interface CastAdminVoteInput {
|
||||
voteSessionId: string;
|
||||
voterId: string;
|
||||
positive: boolean; // true = positive vote, false = negative vote
|
||||
votedAt?: string; // ISO date string (optional, defaults to now)
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for CastAdminVoteUseCase
|
||||
*/
|
||||
export interface CastAdminVoteOutput {
|
||||
success: boolean;
|
||||
voteSessionId: string;
|
||||
voterId: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for CloseAdminVoteSessionUseCase
|
||||
*/
|
||||
export interface CloseAdminVoteSessionInput {
|
||||
voteSessionId: string;
|
||||
adminId: string; // For validation
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for CloseAdminVoteSessionUseCase
|
||||
*/
|
||||
export interface CloseAdminVoteSessionOutput {
|
||||
success: boolean;
|
||||
voteSessionId: string;
|
||||
outcome?: {
|
||||
percentPositive: number;
|
||||
count: {
|
||||
positive: number;
|
||||
negative: number;
|
||||
total: number;
|
||||
};
|
||||
eligibleVoterCount: number;
|
||||
participationRate: number;
|
||||
outcome: 'positive' | 'negative' | 'tie';
|
||||
};
|
||||
eventsCreated?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
18
core/identity/application/dtos/CreateRatingEventDto.ts
Normal file
18
core/identity/application/dtos/CreateRatingEventDto.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* DTO: CreateRatingEventDto
|
||||
*
|
||||
* Input for creating a rating event from external sources
|
||||
*/
|
||||
|
||||
export interface CreateRatingEventDto {
|
||||
userId: string;
|
||||
dimension: string;
|
||||
delta: number;
|
||||
weight?: number;
|
||||
sourceType: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
sourceId: string;
|
||||
reasonCode: string;
|
||||
reasonSummary: string;
|
||||
reasonDetails?: Record<string, unknown>;
|
||||
occurredAt?: string; // ISO date string
|
||||
}
|
||||
45
core/identity/application/dtos/EligibilityFilterDto.ts
Normal file
45
core/identity/application/dtos/EligibilityFilterDto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* DTO: EligibilityFilterDto
|
||||
*
|
||||
* DSL-based eligibility filter for league/competition rules.
|
||||
* Example: "platform.driving >= 55 OR external.iracing.iRating between 2000 2500"
|
||||
*/
|
||||
|
||||
export interface EligibilityFilterDto {
|
||||
/**
|
||||
* DSL expression for eligibility rules
|
||||
* Supports:
|
||||
* - Comparisons: >=, <=, >, <, =, !=, between
|
||||
* - Logical: AND, OR, NOT
|
||||
* - Dimensions: platform.{dimension}, external.{game}.{type}
|
||||
*
|
||||
* Examples:
|
||||
* - "platform.driving >= 55"
|
||||
* - "external.iracing.iRating between 2000 2500"
|
||||
* - "platform.driving >= 55 AND external.iracing.iRating >= 2000"
|
||||
* - "platform.driving >= 55 OR external.iracing.iRating between 2000 2500"
|
||||
*/
|
||||
dsl: string;
|
||||
|
||||
/**
|
||||
* Optional context for evaluation
|
||||
*/
|
||||
context?: {
|
||||
userId?: string;
|
||||
leagueId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface EligibilityCondition {
|
||||
target: 'platform' | 'external';
|
||||
dimension?: string; // e.g., 'driving', 'admin', 'iRating'
|
||||
game?: string; // e.g., 'iracing'
|
||||
operator: string; // '>=', '<=', '>', '<', '=', '!=', 'between'
|
||||
expected: number | [number, number];
|
||||
}
|
||||
|
||||
export interface ParsedEligibilityFilter {
|
||||
conditions: EligibilityCondition[];
|
||||
logicalOperator: 'AND' | 'OR';
|
||||
}
|
||||
68
core/identity/application/dtos/EvaluationResultDto.ts
Normal file
68
core/identity/application/dtos/EvaluationResultDto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* DTO: EvaluationResultDto
|
||||
*
|
||||
* Result of DSL eligibility evaluation with explainable reasons.
|
||||
*/
|
||||
|
||||
export interface EvaluationReason {
|
||||
/**
|
||||
* What was evaluated
|
||||
*/
|
||||
target: string; // e.g., 'platform.driving', 'external.iracing.iRating'
|
||||
|
||||
/**
|
||||
* Operator used
|
||||
*/
|
||||
operator: string; // e.g., '>=', 'between'
|
||||
|
||||
/**
|
||||
* Expected threshold/range
|
||||
*/
|
||||
expected: number | [number, number];
|
||||
|
||||
/**
|
||||
* Actual value found
|
||||
*/
|
||||
actual: number;
|
||||
|
||||
/**
|
||||
* Whether this condition failed
|
||||
*/
|
||||
failed: boolean;
|
||||
|
||||
/**
|
||||
* Human-readable explanation
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface EvaluationResultDto {
|
||||
/**
|
||||
* Overall eligibility status
|
||||
*/
|
||||
eligible: boolean;
|
||||
|
||||
/**
|
||||
* Individual condition results
|
||||
*/
|
||||
reasons: EvaluationReason[];
|
||||
|
||||
/**
|
||||
* Summary message
|
||||
*/
|
||||
summary: string;
|
||||
|
||||
/**
|
||||
* Timestamp of evaluation
|
||||
*/
|
||||
evaluatedAt: string; // ISO date string
|
||||
|
||||
/**
|
||||
* Optional metadata
|
||||
*/
|
||||
metadata?: {
|
||||
userId?: string;
|
||||
filter?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
51
core/identity/application/dtos/LedgerEntryDto.ts
Normal file
51
core/identity/application/dtos/LedgerEntryDto.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* DTO: LedgerEntryDto
|
||||
*
|
||||
* Simplified rating event for ledger display/query.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface LedgerEntryDto {
|
||||
id: string;
|
||||
userId: string;
|
||||
dimension: string; // 'driving', 'admin', 'steward', 'trust', 'fairness'
|
||||
delta: number; // positive or negative change
|
||||
weight?: number;
|
||||
occurredAt: string; // ISO date string
|
||||
createdAt: string; // ISO date string
|
||||
|
||||
source: {
|
||||
type: 'race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment';
|
||||
id: string;
|
||||
};
|
||||
|
||||
reason: {
|
||||
code: string;
|
||||
summary: string;
|
||||
details: Record<string, unknown>;
|
||||
};
|
||||
|
||||
visibility: {
|
||||
public: boolean;
|
||||
redactedFields: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LedgerFilter {
|
||||
dimensions?: string[]; // Filter by dimension keys
|
||||
sourceTypes?: ('race' | 'penalty' | 'vote' | 'adminAction' | 'manualAdjustment')[];
|
||||
from?: string; // ISO date string
|
||||
to?: string; // ISO date string
|
||||
reasonCodes?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedLedgerResult {
|
||||
entries: LedgerEntryDto[];
|
||||
pagination: {
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
nextOffset?: number | null;
|
||||
};
|
||||
}
|
||||
56
core/identity/application/dtos/RatingSummaryDto.ts
Normal file
56
core/identity/application/dtos/RatingSummaryDto.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* DTO: RatingSummaryDto
|
||||
*
|
||||
* Comprehensive rating summary with platform and external game ratings.
|
||||
* Pragmatic read model - direct repo DTOs, no domain logic.
|
||||
*/
|
||||
|
||||
export interface PlatformRatingDimension {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface ExternalGameRating {
|
||||
gameKey: string;
|
||||
type: string;
|
||||
value: number;
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface ExternalGameRatings {
|
||||
gameKey: string;
|
||||
ratings: Map<string, number>; // type -> value
|
||||
source: string;
|
||||
lastSyncedAt: string; // ISO date string
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface RatingSummaryDto {
|
||||
userId: string;
|
||||
|
||||
// Platform ratings (from internal calculations)
|
||||
platform: {
|
||||
driving: PlatformRatingDimension;
|
||||
admin: PlatformRatingDimension;
|
||||
steward: PlatformRatingDimension;
|
||||
trust: PlatformRatingDimension;
|
||||
fairness: PlatformRatingDimension;
|
||||
overallReputation: number;
|
||||
};
|
||||
|
||||
// External game ratings (from third-party sources)
|
||||
external: {
|
||||
// gameKey -> { type -> value }
|
||||
[gameKey: string]: {
|
||||
[type: string]: number;
|
||||
};
|
||||
};
|
||||
|
||||
// Timestamps
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
lastRatingEventAt?: string; // ISO date string (optional)
|
||||
}
|
||||
17
core/identity/application/dtos/RecordRaceRatingEventsDto.ts
Normal file
17
core/identity/application/dtos/RecordRaceRatingEventsDto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* DTO: RecordRaceRatingEventsDto
|
||||
*
|
||||
* Input for RecordRaceRatingEventsUseCase
|
||||
*/
|
||||
|
||||
export interface RecordRaceRatingEventsInput {
|
||||
raceId: string;
|
||||
}
|
||||
|
||||
export interface RecordRaceRatingEventsOutput {
|
||||
success: boolean;
|
||||
raceId: string;
|
||||
eventsCreated: number;
|
||||
driversUpdated: string[];
|
||||
errors: string[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* DTOs for UpsertExternalGameRatingUseCase
|
||||
*/
|
||||
|
||||
export interface UpsertExternalGameRatingInput {
|
||||
userId: string;
|
||||
gameKey: string;
|
||||
ratings: Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
}>;
|
||||
provenance: {
|
||||
source: string;
|
||||
lastSyncedAt: string; // ISO 8601
|
||||
verified?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpsertExternalGameRatingOutput {
|
||||
success: boolean;
|
||||
profile: {
|
||||
userId: string;
|
||||
gameKey: string;
|
||||
ratingCount: number;
|
||||
ratingTypes: string[];
|
||||
source: string;
|
||||
lastSyncedAt: string;
|
||||
verified: boolean;
|
||||
};
|
||||
action: 'created' | 'updated';
|
||||
errors?: string[];
|
||||
}
|
||||
26
core/identity/application/dtos/UserRatingDto.ts
Normal file
26
core/identity/application/dtos/UserRatingDto.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* DTO: UserRatingDto
|
||||
*
|
||||
* Output for user rating snapshot
|
||||
*/
|
||||
|
||||
export interface RatingDimensionDto {
|
||||
value: number;
|
||||
confidence: number;
|
||||
sampleSize: number;
|
||||
trend: 'rising' | 'stable' | 'falling';
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface UserRatingDto {
|
||||
userId: string;
|
||||
driver: RatingDimensionDto;
|
||||
admin: RatingDimensionDto;
|
||||
steward: RatingDimensionDto;
|
||||
trust: RatingDimensionDto;
|
||||
fairness: RatingDimensionDto;
|
||||
overallReputation: number;
|
||||
calculatorVersion?: string;
|
||||
createdAt: string; // ISO date string
|
||||
updatedAt: string; // ISO date string
|
||||
}
|
||||
13
core/identity/application/dtos/index.ts
Normal file
13
core/identity/application/dtos/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* DTOs Index
|
||||
*
|
||||
* Export all DTO types
|
||||
*/
|
||||
|
||||
export type { RatingSummaryDto, PlatformRatingDimension, ExternalGameRating, ExternalGameRatings } from './RatingSummaryDto';
|
||||
export type { LedgerEntryDto, LedgerFilter, PaginatedLedgerResult } from './LedgerEntryDto';
|
||||
export type { EligibilityFilterDto, EligibilityCondition, ParsedEligibilityFilter } from './EligibilityFilterDto';
|
||||
export type { EvaluationResultDto, EvaluationReason } from './EvaluationResultDto';
|
||||
|
||||
// Existing DTOs
|
||||
export type { UserRatingDto, RatingDimensionDto } from './UserRatingDto';
|
||||
33
core/identity/application/ports/IRaceResultsProvider.ts
Normal file
33
core/identity/application/ports/IRaceResultsProvider.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Port: IRaceResultsProvider
|
||||
*
|
||||
* Provider interface for race results data needed for rating calculations.
|
||||
* This is an application layer port that bridges racing context to identity context.
|
||||
*/
|
||||
|
||||
export interface RaceResultData {
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number; // Optional strength of field
|
||||
}
|
||||
|
||||
export interface RaceResultsData {
|
||||
raceId: string;
|
||||
results: RaceResultData[];
|
||||
}
|
||||
|
||||
export interface IRaceResultsProvider {
|
||||
/**
|
||||
* Get race results by race ID
|
||||
* Returns null if race not found or no results
|
||||
*/
|
||||
getRaceResults(raceId: string): Promise<RaceResultsData | null>;
|
||||
|
||||
/**
|
||||
* Check if race results exist for a race
|
||||
*/
|
||||
hasRaceResults(raceId: string): Promise<boolean>;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Tests for GetLeagueEligibilityPreviewQuery
|
||||
*/
|
||||
|
||||
import { GetLeagueEligibilityPreviewQuery, GetLeagueEligibilityPreviewQueryHandler } from './GetLeagueEligibilityPreviewQuery';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
|
||||
import { GameKey } from '../../domain/value-objects/GameKey';
|
||||
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
|
||||
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
|
||||
|
||||
describe('GetLeagueEligibilityPreviewQuery', () => {
|
||||
let mockUserRatingRepo: any;
|
||||
let mockExternalRatingRepo: any;
|
||||
let handler: GetLeagueEligibilityPreviewQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRatingRepo = {
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
mockExternalRatingRepo = {
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
handler = new GetLeagueEligibilityPreviewQueryHandler(
|
||||
mockUserRatingRepo,
|
||||
mockExternalRatingRepo
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should evaluate simple platform eligibility - eligible', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'platform.driving >= 55';
|
||||
|
||||
const userRating = UserRating.create(userId);
|
||||
// Update driving to 65
|
||||
const updatedRating = userRating.updateDriverRating(65);
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(updatedRating);
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toHaveLength(1);
|
||||
expect(result.reasons[0].target).toBe('platform.driving');
|
||||
expect(result.reasons[0].failed).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate simple platform eligibility - not eligible', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'platform.driving >= 75';
|
||||
|
||||
const userRating = UserRating.create(userId);
|
||||
// Driving is 50 by default
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons[0].failed).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate external rating eligibility', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'external.iracing.iRating between 2000 2500';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
|
||||
|
||||
const gameKey = GameKey.create('iracing');
|
||||
const profile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey,
|
||||
ratings: new Map([
|
||||
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
|
||||
});
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons[0].target).toBe('external.iracing.iRating');
|
||||
});
|
||||
|
||||
it('should evaluate complex AND conditions', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'platform.driving >= 55 AND external.iracing.iRating >= 2000';
|
||||
|
||||
const userRating = UserRating.create(userId);
|
||||
const updatedRating = userRating.updateDriverRating(65);
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(updatedRating);
|
||||
|
||||
const gameKey = GameKey.create('iracing');
|
||||
const profile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey,
|
||||
ratings: new Map([
|
||||
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
|
||||
});
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toHaveLength(2);
|
||||
expect(result.reasons.every(r => !r.failed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should evaluate OR conditions with at least one passing', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'platform.driving >= 75 OR external.iracing.iRating >= 2000';
|
||||
|
||||
const userRating = UserRating.create(userId);
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
|
||||
|
||||
const gameKey = GameKey.create('iracing');
|
||||
const profile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey,
|
||||
ratings: new Map([
|
||||
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create({ source: 'iRacing API', lastSyncedAt: new Date() }),
|
||||
});
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons.filter(r => !r.failed)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should include context in metadata', async () => {
|
||||
const userId = 'user-123';
|
||||
const leagueId = 'league-456';
|
||||
const rules = 'platform.driving >= 55';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetLeagueEligibilityPreviewQuery = {
|
||||
userId,
|
||||
leagueId,
|
||||
eligibilityRules: rules,
|
||||
};
|
||||
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.metadata?.userId).toBe(userId);
|
||||
expect(result.metadata?.filter).toBe(rules);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Query: GetLeagueEligibilityPreviewQuery
|
||||
*
|
||||
* Preview eligibility for a league based on DSL rules.
|
||||
* Uses EligibilityEvaluator to provide explainable results.
|
||||
*/
|
||||
|
||||
import { EvaluationResultDto } from '../dtos/EvaluationResultDto';
|
||||
import { EligibilityFilterDto } from '../dtos/EligibilityFilterDto';
|
||||
import { EligibilityEvaluator, RatingData } from '../../domain/services/EligibilityEvaluator';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
|
||||
|
||||
export interface GetLeagueEligibilityPreviewQuery {
|
||||
userId: string;
|
||||
leagueId: string;
|
||||
eligibilityRules: string; // DSL expression
|
||||
}
|
||||
|
||||
export class GetLeagueEligibilityPreviewQueryHandler {
|
||||
private readonly evaluator: EligibilityEvaluator;
|
||||
|
||||
constructor(
|
||||
private readonly userRatingRepo: IUserRatingRepository,
|
||||
private readonly externalRatingRepo: IExternalGameRatingRepository
|
||||
) {
|
||||
this.evaluator = new EligibilityEvaluator();
|
||||
}
|
||||
|
||||
async execute(query: GetLeagueEligibilityPreviewQuery): Promise<EvaluationResultDto> {
|
||||
const { userId, leagueId, eligibilityRules } = query;
|
||||
|
||||
// Fetch user's rating data
|
||||
const userRating = await this.userRatingRepo.findByUserId(userId);
|
||||
const externalProfiles = await this.externalRatingRepo.findByUserId(userId);
|
||||
|
||||
// Build rating data for evaluation
|
||||
const ratingData: RatingData = {
|
||||
platform: {
|
||||
driving: userRating?.driver.value || 0,
|
||||
admin: userRating?.admin.value || 0,
|
||||
steward: userRating?.steward.value || 0,
|
||||
trust: userRating?.trust.value || 0,
|
||||
fairness: userRating?.fairness.value || 0,
|
||||
},
|
||||
external: {},
|
||||
};
|
||||
|
||||
// Add external ratings
|
||||
for (const profile of externalProfiles) {
|
||||
const gameKey = profile.gameKey.toString();
|
||||
ratingData.external[gameKey] = {};
|
||||
|
||||
// Convert Map to array and iterate
|
||||
const ratingsArray = Array.from(profile.ratings.entries());
|
||||
for (const [type, rating] of ratingsArray) {
|
||||
ratingData.external[gameKey][type] = rating.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate eligibility
|
||||
const filter: EligibilityFilterDto = {
|
||||
dsl: eligibilityRules,
|
||||
context: {
|
||||
userId,
|
||||
leagueId,
|
||||
},
|
||||
};
|
||||
|
||||
return this.evaluator.evaluate(filter, ratingData);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Query: GetUserRatingLedgerQuery
|
||||
*
|
||||
* Paginated/filtered query for user rating events (ledger).
|
||||
*/
|
||||
|
||||
import { LedgerEntryDto, LedgerFilter, PaginatedLedgerResult } from '../dtos/LedgerEntryDto';
|
||||
import { IRatingEventRepository, PaginatedQueryOptions, RatingEventFilter } from '../../domain/repositories/IRatingEventRepository';
|
||||
|
||||
export interface GetUserRatingLedgerQuery {
|
||||
userId: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
filter?: LedgerFilter;
|
||||
}
|
||||
|
||||
export class GetUserRatingLedgerQueryHandler {
|
||||
constructor(
|
||||
private readonly ratingEventRepo: IRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetUserRatingLedgerQuery): Promise<PaginatedLedgerResult> {
|
||||
const { userId, limit = 20, offset = 0, filter } = query;
|
||||
|
||||
// Build repo options
|
||||
const repoOptions: PaginatedQueryOptions = {
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
|
||||
// Add filter if provided
|
||||
if (filter) {
|
||||
const ratingEventFilter: RatingEventFilter = {};
|
||||
|
||||
if (filter.dimensions) {
|
||||
ratingEventFilter.dimensions = filter.dimensions;
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
ratingEventFilter.sourceTypes = filter.sourceTypes;
|
||||
}
|
||||
if (filter.from) {
|
||||
ratingEventFilter.from = new Date(filter.from);
|
||||
}
|
||||
if (filter.to) {
|
||||
ratingEventFilter.to = new Date(filter.to);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
ratingEventFilter.reasonCodes = filter.reasonCodes;
|
||||
}
|
||||
|
||||
repoOptions.filter = ratingEventFilter;
|
||||
}
|
||||
|
||||
// Query repository
|
||||
const result = await this.ratingEventRepo.findEventsPaginated(userId, repoOptions);
|
||||
|
||||
// Convert domain entities to DTOs
|
||||
const entries: LedgerEntryDto[] = result.items.map(event => {
|
||||
const dto: LedgerEntryDto = {
|
||||
id: event.id.value,
|
||||
userId: event.userId,
|
||||
dimension: event.dimension.value,
|
||||
delta: event.delta.value,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
source: event.source,
|
||||
reason: event.reason,
|
||||
visibility: event.visibility,
|
||||
};
|
||||
if (event.weight !== undefined) {
|
||||
dto.weight = event.weight;
|
||||
}
|
||||
return dto;
|
||||
});
|
||||
|
||||
const nextOffset = result.nextOffset !== undefined ? result.nextOffset : null;
|
||||
|
||||
return {
|
||||
entries,
|
||||
pagination: {
|
||||
total: result.total,
|
||||
limit: result.limit,
|
||||
offset: result.offset,
|
||||
hasMore: result.hasMore,
|
||||
nextOffset,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Tests for GetUserRatingsSummaryQuery
|
||||
*/
|
||||
|
||||
import { GetUserRatingsSummaryQuery, GetUserRatingsSummaryQueryHandler } from './GetUserRatingsSummaryQuery';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
|
||||
import { GameKey } from '../../domain/value-objects/GameKey';
|
||||
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
|
||||
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
|
||||
describe('GetUserRatingsSummaryQuery', () => {
|
||||
let mockUserRatingRepo: any;
|
||||
let mockExternalRatingRepo: any;
|
||||
let mockRatingEventRepo: any;
|
||||
let handler: GetUserRatingsSummaryQueryHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUserRatingRepo = {
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
mockExternalRatingRepo = {
|
||||
findByUserId: jest.fn(),
|
||||
};
|
||||
mockRatingEventRepo = {
|
||||
getAllByUserId: jest.fn(),
|
||||
};
|
||||
|
||||
handler = new GetUserRatingsSummaryQueryHandler(
|
||||
mockUserRatingRepo,
|
||||
mockExternalRatingRepo,
|
||||
mockRatingEventRepo
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return summary with platform and external ratings', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
// Mock user rating
|
||||
const userRating = UserRating.create(userId);
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(userRating);
|
||||
|
||||
// Mock external ratings
|
||||
const gameKey = GameKey.create('iracing');
|
||||
const profile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey,
|
||||
ratings: new Map([
|
||||
['iRating', ExternalRating.create(gameKey, 'iRating', 2200)],
|
||||
['safetyRating', ExternalRating.create(gameKey, 'safetyRating', 4.5)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create('iRacing API', new Date()),
|
||||
});
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([profile]);
|
||||
|
||||
// Mock rating events
|
||||
const event = RatingEvent.create({
|
||||
id: RatingEventId.create(),
|
||||
userId,
|
||||
dimension: RatingDimensionKey.create('driver'),
|
||||
delta: RatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01'),
|
||||
createdAt: new Date('2024-01-01'),
|
||||
source: { type: 'race', id: 'race-123' },
|
||||
reason: { code: 'RACE_FINISH', summary: 'Good race', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
});
|
||||
mockRatingEventRepo.getAllByUserId.mockResolvedValue([event]);
|
||||
|
||||
const query: GetUserRatingsSummaryQuery = { userId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.platform.driving.value).toBe(50); // Default
|
||||
expect(result.platform.overallReputation).toBe(50);
|
||||
expect(result.external.iracing.iRating).toBe(2200);
|
||||
expect(result.external.iracing.safetyRating).toBe(4.5);
|
||||
expect(result.lastRatingEventAt).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should handle missing user rating gracefully', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetUserRatingsSummaryQuery = { userId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.userId).toBe(userId);
|
||||
expect(result.platform.driving.value).toBe(0);
|
||||
expect(result.platform.overallReputation).toBe(0);
|
||||
expect(result.external).toEqual({});
|
||||
expect(result.lastRatingEventAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple external game profiles', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(UserRating.create(userId));
|
||||
|
||||
// Multiple game profiles
|
||||
const iracingProfile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey: GameKey.create('iracing'),
|
||||
ratings: new Map([
|
||||
['iRating', ExternalRating.create(GameKey.create('iracing'), 'iRating', 2200)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create('iRacing API', new Date()),
|
||||
});
|
||||
|
||||
const assettoProfile = ExternalGameRatingProfile.create({
|
||||
userId: { toString: () => userId } as any,
|
||||
gameKey: GameKey.create('assetto'),
|
||||
ratings: new Map([
|
||||
['rating', ExternalRating.create(GameKey.create('assetto'), 'rating', 85)],
|
||||
]),
|
||||
provenance: ExternalRatingProvenance.create('Assetto API', new Date()),
|
||||
});
|
||||
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([iracingProfile, assettoProfile]);
|
||||
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetUserRatingsSummaryQuery = { userId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.external.iracing.iRating).toBe(2200);
|
||||
expect(result.external.assetto.rating).toBe(85);
|
||||
});
|
||||
|
||||
it('should handle empty external ratings', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(UserRating.create(userId));
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
|
||||
|
||||
const query: GetUserRatingsSummaryQuery = { userId };
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.external).toEqual({});
|
||||
});
|
||||
|
||||
it('should use current date for timestamps when no user rating exists', async () => {
|
||||
const userId = 'user-123';
|
||||
|
||||
mockUserRatingRepo.findByUserId.mockResolvedValue(null);
|
||||
mockExternalRatingRepo.findByUserId.mockResolvedValue([]);
|
||||
mockRatingEventRepo.getAllByUserId.mockResolvedValue([]);
|
||||
|
||||
const beforeQuery = new Date();
|
||||
const query: GetUserRatingsSummaryQuery = { userId };
|
||||
const result = await handler.execute(query);
|
||||
const afterQuery = new Date();
|
||||
|
||||
// Should have valid ISO date strings
|
||||
expect(new Date(result.createdAt).getTime()).toBeGreaterThanOrEqual(beforeQuery.getTime());
|
||||
expect(new Date(result.createdAt).getTime()).toBeLessThanOrEqual(afterQuery.getTime());
|
||||
expect(new Date(result.updatedAt).getTime()).toBeGreaterThanOrEqual(beforeQuery.getTime());
|
||||
expect(new Date(result.updatedAt).getTime()).toBeLessThanOrEqual(afterQuery.getTime());
|
||||
});
|
||||
});
|
||||
});
|
||||
118
core/identity/application/queries/GetUserRatingsSummaryQuery.ts
Normal file
118
core/identity/application/queries/GetUserRatingsSummaryQuery.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Query: GetUserRatingsSummaryQuery
|
||||
*
|
||||
* Fast read query for user rating summary.
|
||||
* Combines platform snapshots and external game ratings.
|
||||
*/
|
||||
|
||||
import { RatingSummaryDto } from '../dtos/RatingSummaryDto';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
|
||||
export interface GetUserRatingsSummaryQuery {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export class GetUserRatingsSummaryQueryHandler {
|
||||
constructor(
|
||||
private readonly userRatingRepo: IUserRatingRepository,
|
||||
private readonly externalRatingRepo: IExternalGameRatingRepository,
|
||||
private readonly ratingEventRepo: IRatingEventRepository
|
||||
) {}
|
||||
|
||||
async execute(query: GetUserRatingsSummaryQuery): Promise<RatingSummaryDto> {
|
||||
const { userId } = query;
|
||||
|
||||
// Fetch platform rating snapshot
|
||||
const userRating = await this.userRatingRepo.findByUserId(userId);
|
||||
|
||||
// Fetch all external game ratings
|
||||
const externalProfiles = await this.externalRatingRepo.findByUserId(userId);
|
||||
|
||||
// Get last event timestamp if available
|
||||
let lastRatingEventAt: string | undefined;
|
||||
if (userRating) {
|
||||
// Get all events to find the most recent one
|
||||
const events = await this.ratingEventRepo.getAllByUserId(userId);
|
||||
if (events.length > 0) {
|
||||
const lastEvent = events[events.length - 1];
|
||||
if (lastEvent) {
|
||||
lastRatingEventAt = lastEvent.occurredAt.toISOString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build platform rating dimensions
|
||||
const platform = {
|
||||
driving: {
|
||||
value: userRating?.driver.value || 0,
|
||||
confidence: userRating?.driver.confidence || 0,
|
||||
sampleSize: userRating?.driver.sampleSize || 0,
|
||||
trend: userRating?.driver.trend || 'stable',
|
||||
lastUpdated: userRating?.driver.lastUpdated?.toISOString() || new Date(0).toISOString(),
|
||||
},
|
||||
admin: {
|
||||
value: userRating?.admin.value || 0,
|
||||
confidence: userRating?.admin.confidence || 0,
|
||||
sampleSize: userRating?.admin.sampleSize || 0,
|
||||
trend: userRating?.admin.trend || 'stable',
|
||||
lastUpdated: userRating?.admin.lastUpdated?.toISOString() || new Date(0).toISOString(),
|
||||
},
|
||||
steward: {
|
||||
value: userRating?.steward.value || 0,
|
||||
confidence: userRating?.steward.confidence || 0,
|
||||
sampleSize: userRating?.steward.sampleSize || 0,
|
||||
trend: userRating?.steward.trend || 'stable',
|
||||
lastUpdated: userRating?.steward.lastUpdated?.toISOString() || new Date(0).toISOString(),
|
||||
},
|
||||
trust: {
|
||||
value: userRating?.trust.value || 0,
|
||||
confidence: userRating?.trust.confidence || 0,
|
||||
sampleSize: userRating?.trust.sampleSize || 0,
|
||||
trend: userRating?.trust.trend || 'stable',
|
||||
lastUpdated: userRating?.trust.lastUpdated?.toISOString() || new Date(0).toISOString(),
|
||||
},
|
||||
fairness: {
|
||||
value: userRating?.fairness.value || 0,
|
||||
confidence: userRating?.fairness.confidence || 0,
|
||||
sampleSize: userRating?.fairness.sampleSize || 0,
|
||||
trend: userRating?.fairness.trend || 'stable',
|
||||
lastUpdated: userRating?.fairness.lastUpdated?.toISOString() || new Date(0).toISOString(),
|
||||
},
|
||||
overallReputation: userRating?.overallReputation || 0,
|
||||
};
|
||||
|
||||
// Build external ratings map
|
||||
const external: { [gameKey: string]: { [type: string]: number } } = {};
|
||||
|
||||
for (const profile of externalProfiles) {
|
||||
const gameKey = profile.gameKey.toString();
|
||||
external[gameKey] = {};
|
||||
|
||||
// Convert Map to array and iterate
|
||||
const ratingsArray = Array.from(profile.ratings.entries());
|
||||
for (const [type, rating] of ratingsArray) {
|
||||
external[gameKey][type] = rating.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Get timestamps
|
||||
const createdAt = userRating?.createdAt?.toISOString() || new Date().toISOString();
|
||||
const updatedAt = userRating?.updatedAt?.toISOString() || new Date().toISOString();
|
||||
|
||||
const result: RatingSummaryDto = {
|
||||
userId,
|
||||
platform,
|
||||
external,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
if (lastRatingEventAt) {
|
||||
result.lastRatingEventAt = lastRatingEventAt;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
17
core/identity/application/queries/index.ts
Normal file
17
core/identity/application/queries/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Queries Index
|
||||
*
|
||||
* Export all query handlers and related types
|
||||
*/
|
||||
|
||||
// GetUserRatingsSummaryQuery
|
||||
export type { GetUserRatingsSummaryQuery } from './GetUserRatingsSummaryQuery';
|
||||
export { GetUserRatingsSummaryQueryHandler } from './GetUserRatingsSummaryQuery';
|
||||
|
||||
// GetUserRatingLedgerQuery
|
||||
export type { GetUserRatingLedgerQuery } from './GetUserRatingLedgerQuery';
|
||||
export { GetUserRatingLedgerQueryHandler } from './GetUserRatingLedgerQuery';
|
||||
|
||||
// GetLeagueEligibilityPreviewQuery
|
||||
export type { GetLeagueEligibilityPreviewQuery } from './GetLeagueEligibilityPreviewQuery';
|
||||
export { GetLeagueEligibilityPreviewQueryHandler } from './GetLeagueEligibilityPreviewQuery';
|
||||
@@ -0,0 +1,707 @@
|
||||
import { OpenAdminVoteSessionUseCase } from './OpenAdminVoteSessionUseCase';
|
||||
import { CastAdminVoteUseCase } from './CastAdminVoteUseCase';
|
||||
import { CloseAdminVoteSessionUseCase } from './CloseAdminVoteSessionUseCase';
|
||||
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
|
||||
// Mock Repository
|
||||
class MockAdminVoteSessionRepository {
|
||||
private sessions: Map<string, AdminVoteSession> = new Map();
|
||||
|
||||
async save(session: AdminVoteSession): Promise<AdminVoteSession> {
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<AdminVoteSession | null> {
|
||||
return this.sessions.get(id) || null;
|
||||
}
|
||||
|
||||
async findActiveForAdmin(adminId: string, leagueId: string): Promise<AdminVoteSession[]> {
|
||||
const now = new Date();
|
||||
return Array.from(this.sessions.values()).filter(
|
||||
s => s.adminId === adminId &&
|
||||
s.leagueId === leagueId &&
|
||||
s.isVotingWindowOpen(now) &&
|
||||
!s.closed
|
||||
);
|
||||
}
|
||||
|
||||
async findByAdminAndLeague(adminId: string, leagueId: string): Promise<AdminVoteSession[]> {
|
||||
return Array.from(this.sessions.values()).filter(
|
||||
s => s.adminId === adminId && s.leagueId === leagueId
|
||||
);
|
||||
}
|
||||
|
||||
async findByLeague(leagueId: string): Promise<AdminVoteSession[]> {
|
||||
return Array.from(this.sessions.values()).filter(
|
||||
s => s.leagueId === leagueId
|
||||
);
|
||||
}
|
||||
|
||||
async findClosedUnprocessed(): Promise<AdminVoteSession[]> {
|
||||
return Array.from(this.sessions.values()).filter(
|
||||
s => s.closed && s.outcome !== undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MockRatingEventRepository {
|
||||
private events: Map<string, RatingEvent> = new Map();
|
||||
|
||||
async save(event: RatingEvent): Promise<RatingEvent> {
|
||||
this.events.set(event.id.value, event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return Array.from(this.events.values()).filter(e => e.userId === userId);
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<RatingEvent[]> {
|
||||
return Array.from(this.events.values()).filter(e => ids.includes(e.id.value));
|
||||
}
|
||||
|
||||
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return Array.from(this.events.values()).filter(e => e.userId === userId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
|
||||
const allEvents = await this.findByUserId(userId);
|
||||
|
||||
// Apply filters
|
||||
let filtered = allEvents;
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
if (filter.dimensions) {
|
||||
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
|
||||
}
|
||||
if (filter.from) {
|
||||
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
|
||||
}
|
||||
if (filter.to) {
|
||||
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
|
||||
}
|
||||
if (filter.visibility) {
|
||||
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
|
||||
}
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class MockUserRatingRepository {
|
||||
private ratings: Map<string, UserRating> = new Map();
|
||||
|
||||
async save(rating: UserRating): Promise<UserRating> {
|
||||
this.ratings.set(rating.userId, rating);
|
||||
return rating;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<UserRating | null> {
|
||||
return this.ratings.get(userId) || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Mock AppendRatingEventsUseCase
|
||||
class MockAppendRatingEventsUseCase {
|
||||
constructor(
|
||||
private ratingEventRepository: any,
|
||||
private userRatingRepository: any
|
||||
) {}
|
||||
|
||||
async execute(input: any): Promise<any> {
|
||||
const events: RatingEvent[] = [];
|
||||
|
||||
// Create events from input
|
||||
for (const eventDto of input.events || []) {
|
||||
const event = RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: eventDto.userId,
|
||||
dimension: RatingDimensionKey.create(eventDto.dimension),
|
||||
delta: RatingDelta.create(eventDto.delta),
|
||||
weight: eventDto.weight,
|
||||
occurredAt: new Date(eventDto.occurredAt),
|
||||
createdAt: new Date(),
|
||||
source: { type: eventDto.sourceType, id: eventDto.sourceId },
|
||||
reason: {
|
||||
code: eventDto.reasonCode,
|
||||
summary: eventDto.reasonSummary,
|
||||
details: eventDto.reasonDetails || {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
});
|
||||
events.push(event);
|
||||
await this.ratingEventRepository.save(event);
|
||||
}
|
||||
|
||||
// Recompute snapshot
|
||||
if (events.length > 0) {
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(input.userId);
|
||||
// Simplified snapshot calculation
|
||||
const totalDelta = allEvents.reduce((sum, e) => sum + e.delta.value, 0);
|
||||
const snapshot = UserRating.create({
|
||||
userId: input.userId,
|
||||
driver: { value: Math.max(0, Math.min(100, 50 + totalDelta)) },
|
||||
adminTrust: { value: 50 },
|
||||
stewardTrust: { value: 50 },
|
||||
broadcasterTrust: { value: 50 },
|
||||
lastUpdated: new Date(),
|
||||
});
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
return {
|
||||
events: events.map(e => e.id.value),
|
||||
snapshotUpdated: events.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('Admin Vote Session Use Cases', () => {
|
||||
let mockSessionRepo: MockAdminVoteSessionRepository;
|
||||
let mockEventRepo: MockRatingEventRepository;
|
||||
let mockRatingRepo: MockUserRatingRepository;
|
||||
let mockAppendUseCase: MockAppendRatingEventsUseCase;
|
||||
|
||||
let openUseCase: OpenAdminVoteSessionUseCase;
|
||||
let castUseCase: CastAdminVoteUseCase;
|
||||
let closeUseCase: CloseAdminVoteSessionUseCase;
|
||||
|
||||
const now = new Date('2025-01-01T00:00:00Z');
|
||||
const tomorrow = new Date('2025-01-02T00:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionRepo = new MockAdminVoteSessionRepository();
|
||||
mockEventRepo = new MockRatingEventRepository();
|
||||
mockRatingRepo = new MockUserRatingRepository();
|
||||
mockAppendUseCase = new MockAppendRatingEventsUseCase(mockEventRepo, mockRatingRepo);
|
||||
|
||||
openUseCase = new OpenAdminVoteSessionUseCase(mockSessionRepo);
|
||||
castUseCase = new CastAdminVoteUseCase(mockSessionRepo);
|
||||
closeUseCase = new CloseAdminVoteSessionUseCase(
|
||||
mockSessionRepo,
|
||||
mockEventRepo,
|
||||
mockRatingRepo,
|
||||
mockAppendUseCase
|
||||
);
|
||||
|
||||
// Mock Date.now to return our test time
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now.getTime());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OpenAdminVoteSessionUseCase', () => {
|
||||
it('should successfully open a vote session', async () => {
|
||||
const input = {
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1', 'user-2', 'user-3'],
|
||||
};
|
||||
|
||||
const result = await openUseCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.voteSessionId).toBe('vote-123');
|
||||
|
||||
// Verify session was saved
|
||||
const saved = await mockSessionRepo.findById('vote-123');
|
||||
expect(saved).toBeDefined();
|
||||
expect(saved!.adminId).toBe('admin-789');
|
||||
expect(saved!.eligibleVoters).toEqual(['user-1', 'user-2', 'user-3']);
|
||||
});
|
||||
|
||||
it('should reject duplicate session IDs', async () => {
|
||||
const input = {
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
};
|
||||
|
||||
await openUseCase.execute(input);
|
||||
const result = await openUseCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Vote session with this ID already exists');
|
||||
});
|
||||
|
||||
it('should reject overlapping sessions', async () => {
|
||||
// Create first session
|
||||
await openUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
});
|
||||
|
||||
// Try to create overlapping session
|
||||
const result = await openUseCase.execute({
|
||||
voteSessionId: 'vote-456',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: new Date('2025-01-01T12:00:00Z').toISOString(),
|
||||
endDate: new Date('2025-01-02T12:00:00Z').toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Active vote session already exists for this admin in this league with overlapping dates');
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const result = await openUseCase.execute({
|
||||
voteSessionId: '',
|
||||
leagueId: '',
|
||||
adminId: '',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
eligibleVoters: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should validate date order', async () => {
|
||||
const result = await openUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: tomorrow.toISOString(),
|
||||
endDate: now.toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('startDate must be before endDate');
|
||||
});
|
||||
|
||||
it('should validate duplicate eligible voters', async () => {
|
||||
const result = await openUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1', 'user-2', 'user-1'],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Duplicate eligible voters are not allowed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CastAdminVoteUseCase', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a session for voting tests
|
||||
await openUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1', 'user-2', 'user-3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow eligible voter to cast positive vote', async () => {
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.voterId).toBe('user-1');
|
||||
|
||||
const session = await mockSessionRepo.findById('vote-123');
|
||||
expect(session!.votes.length).toBe(1);
|
||||
expect(session!.votes[0].voterId).toBe('user-1');
|
||||
expect(session!.votes[0].positive).toBe(true);
|
||||
});
|
||||
|
||||
it('should allow eligible voter to cast negative vote', async () => {
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
const session = await mockSessionRepo.findById('vote-123');
|
||||
expect(session!.votes[0].positive).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject non-eligible voter', async () => {
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-999',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Voter user-999 is not eligible for this session');
|
||||
});
|
||||
|
||||
it('should prevent duplicate votes', async () => {
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Voter user-1 has already voted');
|
||||
});
|
||||
|
||||
it('should reject votes after session closes', async () => {
|
||||
// Close the session first
|
||||
await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-2',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Session is closed');
|
||||
});
|
||||
|
||||
it('should reject votes outside voting window', async () => {
|
||||
// Create session in future
|
||||
const futureStart = new Date('2025-02-01T00:00:00Z');
|
||||
const futureEnd = new Date('2025-02-02T00:00:00Z');
|
||||
|
||||
await openUseCase.execute({
|
||||
voteSessionId: 'vote-future',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: futureStart.toISOString(),
|
||||
endDate: futureEnd.toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
});
|
||||
|
||||
const result = await castUseCase.execute({
|
||||
voteSessionId: 'vote-future',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Vote session is not open for voting');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CloseAdminVoteSessionUseCase', () => {
|
||||
beforeEach(async () => {
|
||||
// Create a session for closing tests
|
||||
await openUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should close session and create positive outcome events', async () => {
|
||||
// Cast votes
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-2',
|
||||
positive: true,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-3',
|
||||
positive: false,
|
||||
});
|
||||
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome).toBeDefined();
|
||||
expect(result.outcome!.outcome).toBe('positive');
|
||||
expect(result.outcome!.percentPositive).toBe(66.67);
|
||||
expect(result.outcome!.count.total).toBe(3);
|
||||
expect(result.eventsCreated).toBe(1);
|
||||
|
||||
// Verify session is closed
|
||||
const session = await mockSessionRepo.findById('vote-123');
|
||||
expect(session!.closed).toBe(true);
|
||||
expect(session!.outcome).toBeDefined();
|
||||
|
||||
// Verify rating events were created
|
||||
const events = await mockEventRepo.findByUserId('admin-789');
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].dimension.value).toBe('adminTrust');
|
||||
expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||
});
|
||||
|
||||
it('should create negative outcome events', async () => {
|
||||
// Cast mostly negative votes
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: false,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-2',
|
||||
positive: false,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-3',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome!.outcome).toBe('negative');
|
||||
expect(result.outcome!.percentPositive).toBe(33.33);
|
||||
expect(result.eventsCreated).toBe(1);
|
||||
|
||||
const events = await mockEventRepo.findByUserId('admin-789');
|
||||
expect(events[0].reason.code).toBe('ADMIN_VOTE_OUTCOME_NEGATIVE');
|
||||
expect(events[0].delta.value).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('should handle tie outcome', async () => {
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-2',
|
||||
positive: false,
|
||||
});
|
||||
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome!.outcome).toBe('tie');
|
||||
expect(result.eventsCreated).toBe(0); // No events for tie
|
||||
});
|
||||
|
||||
it('should handle no votes', async () => {
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outcome!.outcome).toBe('tie');
|
||||
expect(result.outcome!.participationRate).toBe(0);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('should reject closing already closed session', async () => {
|
||||
await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Vote session is already closed');
|
||||
});
|
||||
|
||||
it('should reject non-owner trying to close', async () => {
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'wrong-admin',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Admin does not own this vote session');
|
||||
});
|
||||
|
||||
it('should reject closing outside voting window', async () => {
|
||||
// Create session in future
|
||||
const futureStart = new Date('2025-02-01T00:00:00Z');
|
||||
const futureEnd = new Date('2025-02-02T00:00:00Z');
|
||||
|
||||
await openUseCase.execute({
|
||||
voteSessionId: 'vote-future',
|
||||
leagueId: 'league-456',
|
||||
adminId: 'admin-789',
|
||||
startDate: futureStart.toISOString(),
|
||||
endDate: futureEnd.toISOString(),
|
||||
eligibleVoters: ['user-1'],
|
||||
});
|
||||
|
||||
const result = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-future',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Cannot close session outside the voting window');
|
||||
});
|
||||
|
||||
it('should update admin rating snapshot', async () => {
|
||||
// Cast votes
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-1',
|
||||
positive: true,
|
||||
});
|
||||
await castUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
voterId: 'user-2',
|
||||
positive: true,
|
||||
});
|
||||
|
||||
await closeUseCase.execute({
|
||||
voteSessionId: 'vote-123',
|
||||
adminId: 'admin-789',
|
||||
});
|
||||
|
||||
// Check snapshot was updated
|
||||
const snapshot = await mockRatingRepo.findByUserId('admin-789');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot!.adminTrust.value).toBeGreaterThan(50); // Should have increased
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: Full vote flow', () => {
|
||||
it('should complete full flow: open -> cast votes -> close -> events', async () => {
|
||||
// 1. Open session
|
||||
const openResult = await openUseCase.execute({
|
||||
voteSessionId: 'vote-full',
|
||||
leagueId: 'league-full',
|
||||
adminId: 'admin-full',
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrow.toISOString(),
|
||||
eligibleVoters: ['user-1', 'user-2', 'user-3', 'user-4', 'user-5'],
|
||||
});
|
||||
expect(openResult.success).toBe(true);
|
||||
|
||||
// 2. Cast votes
|
||||
const votes = [
|
||||
{ voterId: 'user-1', positive: true },
|
||||
{ voterId: 'user-2', positive: true },
|
||||
{ voterId: 'user-3', positive: true },
|
||||
{ voterId: 'user-4', positive: false },
|
||||
];
|
||||
|
||||
for (const vote of votes) {
|
||||
const castResult = await castUseCase.execute({
|
||||
voteSessionId: 'vote-full',
|
||||
voterId: vote.voterId,
|
||||
positive: vote.positive,
|
||||
});
|
||||
expect(castResult.success).toBe(true);
|
||||
}
|
||||
|
||||
// 3. Close session
|
||||
const closeResult = await closeUseCase.execute({
|
||||
voteSessionId: 'vote-full',
|
||||
adminId: 'admin-full',
|
||||
});
|
||||
|
||||
expect(closeResult.success).toBe(true);
|
||||
expect(closeResult.outcome).toEqual({
|
||||
percentPositive: 75,
|
||||
count: { positive: 3, negative: 1, total: 4 },
|
||||
eligibleVoterCount: 5,
|
||||
participationRate: 80,
|
||||
outcome: 'positive',
|
||||
});
|
||||
expect(closeResult.eventsCreated).toBe(1);
|
||||
|
||||
// 4. Verify events in ledger
|
||||
const events = await mockEventRepo.findByUserId('admin-full');
|
||||
expect(events.length).toBe(1);
|
||||
|
||||
const event = events[0];
|
||||
expect(event.userId).toBe('admin-full');
|
||||
expect(event.dimension.value).toBe('adminTrust');
|
||||
expect(event.source.type).toBe('vote');
|
||||
expect(event.source.id).toBe('vote-full');
|
||||
expect(event.reason.code).toBe('ADMIN_VOTE_OUTCOME_POSITIVE');
|
||||
expect(event.reason.summary).toContain('75% positive');
|
||||
expect(event.weight).toBe(4); // vote count
|
||||
|
||||
// 5. Verify snapshot
|
||||
const snapshot = await mockRatingRepo.findByUserId('admin-full');
|
||||
expect(snapshot).toBeDefined();
|
||||
expect(snapshot!.adminTrust.value).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { AppendRatingEventsUseCase, AppendRatingEventsInput } from './AppendRatingEventsUseCase';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
|
||||
describe('AppendRatingEventsUseCase', () => {
|
||||
let mockEventRepo: Partial<IRatingEventRepository>;
|
||||
let mockRatingRepo: Partial<IUserRatingRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = {
|
||||
save: vi.fn(),
|
||||
getAllByUserId: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockRatingRepo = {
|
||||
save: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should be constructed with repositories', () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
expect(useCase).toBeInstanceOf(AppendRatingEventsUseCase);
|
||||
});
|
||||
|
||||
it('should handle empty input (no events)', async () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.snapshotUpdated).toBe(false);
|
||||
expect(mockEventRepo.save).not.toHaveBeenCalled();
|
||||
expect(mockRatingRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create and save events from direct input', async () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
events: [
|
||||
{
|
||||
userId: 'user-1',
|
||||
dimension: 'driving',
|
||||
delta: 5,
|
||||
sourceType: 'race',
|
||||
sourceId: 'race-1',
|
||||
reasonCode: 'TEST',
|
||||
reasonSummary: 'Test event',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events).toHaveLength(1);
|
||||
expect(result.snapshotUpdated).toBe(true);
|
||||
expect(mockEventRepo.save).toHaveBeenCalledTimes(1);
|
||||
expect(mockRatingRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should create events from race results using factory', async () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
raceId: 'race-123',
|
||||
raceResults: [
|
||||
{
|
||||
position: 3,
|
||||
totalDrivers: 10,
|
||||
startPosition: 5,
|
||||
incidents: 1,
|
||||
fieldStrength: 1500,
|
||||
status: 'finished',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.snapshotUpdated).toBe(true);
|
||||
expect(mockEventRepo.save).toHaveBeenCalled();
|
||||
expect(mockRatingRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle multiple race results', async () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
raceId: 'race-123',
|
||||
raceResults: [
|
||||
{ position: 3, totalDrivers: 10, startPosition: 5, incidents: 1, fieldStrength: 1500, status: 'finished' },
|
||||
{ position: 1, totalDrivers: 10, startPosition: 2, incidents: 0, fieldStrength: 1500, status: 'finished' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events.length).toBeGreaterThan(2); // Multiple events per race
|
||||
expect(result.snapshotUpdated).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle DNF status', async () => {
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
raceId: 'race-123',
|
||||
raceResults: [
|
||||
{ position: 5, totalDrivers: 10, startPosition: 3, incidents: 2, fieldStrength: 1500, status: 'dnf' },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.snapshotUpdated).toBe(true);
|
||||
expect(mockEventRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update snapshot if no events were saved', async () => {
|
||||
// Mock save to throw or do nothing
|
||||
const saveMock = vi.fn().mockResolvedValue({});
|
||||
const getAllMock = vi.fn().mockResolvedValue([]);
|
||||
|
||||
mockEventRepo = {
|
||||
save: saveMock,
|
||||
getAllByUserId: getAllMock,
|
||||
};
|
||||
|
||||
const useCase = new AppendRatingEventsUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const input: AppendRatingEventsInput = {
|
||||
userId: 'user-1',
|
||||
events: [], // Empty
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.events).toEqual([]);
|
||||
expect(result.snapshotUpdated).toBe(false);
|
||||
expect(mockRatingRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
123
core/identity/application/use-cases/AppendRatingEventsUseCase.ts
Normal file
123
core/identity/application/use-cases/AppendRatingEventsUseCase.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
|
||||
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
import { CreateRatingEventDto } from '../dtos/CreateRatingEventDto';
|
||||
|
||||
/**
|
||||
* Input for AppendRatingEventsUseCase
|
||||
*/
|
||||
export interface AppendRatingEventsInput {
|
||||
userId: string;
|
||||
events?: CreateRatingEventDto[]; // Optional: direct event creation
|
||||
// Alternative: raceId, penaltyId, etc. for factory-based creation
|
||||
raceId?: string;
|
||||
raceResults?: Array<{
|
||||
position: number;
|
||||
totalDrivers: number;
|
||||
startPosition: number;
|
||||
incidents: number;
|
||||
fieldStrength: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for AppendRatingEventsUseCase
|
||||
*/
|
||||
export interface AppendRatingEventsOutput {
|
||||
events: string[]; // Event IDs
|
||||
snapshotUpdated: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: AppendRatingEventsUseCase
|
||||
*
|
||||
* Appends rating events to the ledger and recomputes the snapshot.
|
||||
* Follows CQRS Light: command side operation.
|
||||
*/
|
||||
export class AppendRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: IRatingEventRepository,
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: AppendRatingEventsInput): Promise<AppendRatingEventsOutput> {
|
||||
const eventsToSave = [];
|
||||
|
||||
// 1. Create events from direct input
|
||||
if (input.events) {
|
||||
for (const eventDto of input.events) {
|
||||
const event = this.createEventFromDto(eventDto);
|
||||
eventsToSave.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create events from race results (using factory)
|
||||
if (input.raceId && input.raceResults) {
|
||||
for (const result of input.raceResults) {
|
||||
const raceEvents = RatingEventFactory.createFromRaceFinish({
|
||||
userId: input.userId,
|
||||
raceId: input.raceId,
|
||||
position: result.position,
|
||||
totalDrivers: result.totalDrivers,
|
||||
startPosition: result.startPosition,
|
||||
incidents: result.incidents,
|
||||
fieldStrength: result.fieldStrength,
|
||||
status: result.status,
|
||||
});
|
||||
eventsToSave.push(...raceEvents);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Save all events to ledger
|
||||
const savedEventIds: string[] = [];
|
||||
for (const event of eventsToSave) {
|
||||
await this.ratingEventRepository.save(event);
|
||||
savedEventIds.push(event.id.value);
|
||||
}
|
||||
|
||||
// 4. Recompute snapshot if events were saved
|
||||
let snapshotUpdated = false;
|
||||
if (savedEventIds.length > 0) {
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(input.userId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(input.userId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
snapshotUpdated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
events: savedEventIds,
|
||||
snapshotUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
private createEventFromDto(dto: CreateRatingEventDto) {
|
||||
const props: any = {
|
||||
id: RatingEventId.generate(),
|
||||
userId: dto.userId,
|
||||
dimension: RatingDimensionKey.create(dto.dimension),
|
||||
delta: RatingDelta.create(dto.delta),
|
||||
occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(),
|
||||
createdAt: new Date(),
|
||||
source: { type: dto.sourceType, id: dto.sourceId },
|
||||
reason: {
|
||||
code: dto.reasonCode,
|
||||
summary: dto.reasonSummary,
|
||||
details: dto.reasonDetails || {},
|
||||
},
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
if (dto.weight !== undefined) {
|
||||
props.weight = dto.weight;
|
||||
}
|
||||
|
||||
return RatingEvent.create(props);
|
||||
}
|
||||
}
|
||||
99
core/identity/application/use-cases/CastAdminVoteUseCase.ts
Normal file
99
core/identity/application/use-cases/CastAdminVoteUseCase.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
|
||||
import { CastAdminVoteInput, CastAdminVoteOutput } from '../dtos/AdminVoteSessionDto';
|
||||
|
||||
/**
|
||||
* Use Case: CastAdminVoteUseCase
|
||||
*
|
||||
* Casts a vote in an active admin vote session.
|
||||
* Follows CQRS Light: command side operation.
|
||||
*
|
||||
* Per plans section 7.1.1
|
||||
*/
|
||||
export class CastAdminVoteUseCase {
|
||||
constructor(
|
||||
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: CastAdminVoteInput): Promise<CastAdminVoteOutput> {
|
||||
try {
|
||||
// Validate input
|
||||
const errors = this.validateInput(input);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
voterId: input.voterId,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Load the vote session
|
||||
const session = await this.adminVoteSessionRepository.findById(input.voteSessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
voterId: input.voterId,
|
||||
errors: ['Vote session not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if session is open
|
||||
const voteTime = input.votedAt ? new Date(input.votedAt) : new Date();
|
||||
if (!session.isVotingWindowOpen(voteTime)) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
voterId: input.voterId,
|
||||
errors: ['Vote session is not open for voting'],
|
||||
};
|
||||
}
|
||||
|
||||
// Cast the vote
|
||||
session.castVote(input.voterId, input.positive, voteTime);
|
||||
|
||||
// Save updated session
|
||||
await this.adminVoteSessionRepository.save(session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
voteSessionId: input.voteSessionId,
|
||||
voterId: input.voterId,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
voterId: input.voterId,
|
||||
errors: [`Failed to cast vote: ${errorMsg}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(input: CastAdminVoteInput): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
|
||||
errors.push('voteSessionId is required');
|
||||
}
|
||||
|
||||
if (!input.voterId || input.voterId.trim().length === 0) {
|
||||
errors.push('voterId is required');
|
||||
}
|
||||
|
||||
if (typeof input.positive !== 'boolean') {
|
||||
errors.push('positive must be a boolean value');
|
||||
}
|
||||
|
||||
if (input.votedAt) {
|
||||
const votedAt = new Date(input.votedAt);
|
||||
if (isNaN(votedAt.getTime())) {
|
||||
errors.push('votedAt must be a valid date if provided');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { AdminTrustRatingCalculator } from '../../domain/services/AdminTrustRatingCalculator';
|
||||
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
|
||||
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
|
||||
import { CloseAdminVoteSessionInput, CloseAdminVoteSessionOutput } from '../dtos/AdminVoteSessionDto';
|
||||
|
||||
/**
|
||||
* Use Case: CloseAdminVoteSessionUseCase
|
||||
*
|
||||
* Closes an admin vote session and generates rating events.
|
||||
* This is the key use case that triggers events on close per plans section 7.1.1.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load and validate session
|
||||
* 2. Close session and calculate outcome
|
||||
* 3. Create rating events from outcome
|
||||
* 4. Append events to ledger for each affected admin
|
||||
* 5. Recompute snapshots
|
||||
*
|
||||
* Per plans section 7.1.1 and 10.2
|
||||
*/
|
||||
export class CloseAdminVoteSessionUseCase {
|
||||
constructor(
|
||||
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
|
||||
private readonly ratingEventRepository: IRatingEventRepository,
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
private readonly appendRatingEventsUseCase: any, // Will be typed properly in integration
|
||||
) {}
|
||||
|
||||
async execute(input: CloseAdminVoteSessionInput): Promise<CloseAdminVoteSessionOutput> {
|
||||
try {
|
||||
// Validate input
|
||||
const errors = this.validateInput(input);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Load the vote session
|
||||
const session = await this.adminVoteSessionRepository.findById(input.voteSessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Vote session not found'],
|
||||
};
|
||||
}
|
||||
|
||||
// Validate admin ownership
|
||||
if (session.adminId !== input.adminId) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Admin does not own this vote session'],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if already closed
|
||||
if (session.closed) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Vote session is already closed'],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if within voting window
|
||||
const now = new Date();
|
||||
if (now < session.startDate || now > session.endDate) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Cannot close session outside the voting window'],
|
||||
};
|
||||
}
|
||||
|
||||
// Close session and calculate outcome
|
||||
const outcome = session.close();
|
||||
|
||||
// Save closed session
|
||||
await this.adminVoteSessionRepository.save(session);
|
||||
|
||||
// Create rating events from outcome
|
||||
// Per plans: events are created for the admin being voted on
|
||||
const eventsCreated = await this.createRatingEvents(session, outcome);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
voteSessionId: input.voteSessionId,
|
||||
outcome: {
|
||||
percentPositive: outcome.percentPositive,
|
||||
count: outcome.count,
|
||||
eligibleVoterCount: outcome.eligibleVoterCount,
|
||||
participationRate: outcome.participationRate,
|
||||
outcome: outcome.outcome,
|
||||
},
|
||||
eventsCreated,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: [`Failed to close vote session: ${errorMsg}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rating events from vote outcome
|
||||
* Events are created for the admin being voted on
|
||||
*/
|
||||
private async createRatingEvents(session: any, outcome: any): Promise<number> {
|
||||
let eventsCreated = 0;
|
||||
|
||||
// Use RatingEventFactory to create vote outcome events
|
||||
const voteInput = {
|
||||
userId: session.adminId, // The admin being voted on
|
||||
voteSessionId: session.id,
|
||||
outcome: (outcome.outcome === 'positive' ? 'positive' : 'negative') as 'positive' | 'negative',
|
||||
voteCount: outcome.count.total,
|
||||
eligibleVoterCount: outcome.eligibleVoterCount,
|
||||
percentPositive: outcome.percentPositive,
|
||||
};
|
||||
|
||||
const events = RatingEventFactory.createFromVote(voteInput);
|
||||
|
||||
// Save each event to ledger
|
||||
for (const event of events) {
|
||||
await this.ratingEventRepository.save(event);
|
||||
eventsCreated++;
|
||||
}
|
||||
|
||||
// Recompute snapshot for the admin
|
||||
if (eventsCreated > 0) {
|
||||
const allEvents = await this.ratingEventRepository.getAllByUserId(session.adminId);
|
||||
const snapshot = RatingSnapshotCalculator.calculate(session.adminId, allEvents);
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
}
|
||||
|
||||
return eventsCreated;
|
||||
}
|
||||
|
||||
private validateInput(input: CloseAdminVoteSessionInput): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
|
||||
errors.push('voteSessionId is required');
|
||||
}
|
||||
|
||||
if (!input.adminId || input.adminId.trim().length === 0) {
|
||||
errors.push('adminId is required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { IAdminVoteSessionRepository } from '../../domain/repositories/IAdminVoteSessionRepository';
|
||||
import { AdminVoteSession } from '../../domain/entities/AdminVoteSession';
|
||||
import { OpenAdminVoteSessionInput, OpenAdminVoteSessionOutput } from '../dtos/AdminVoteSessionDto';
|
||||
|
||||
/**
|
||||
* Use Case: OpenAdminVoteSessionUseCase
|
||||
*
|
||||
* Opens a new admin vote session for a league.
|
||||
* Follows CQRS Light: command side operation.
|
||||
*
|
||||
* Per plans section 7.1.1
|
||||
*/
|
||||
export class OpenAdminVoteSessionUseCase {
|
||||
constructor(
|
||||
private readonly adminVoteSessionRepository: IAdminVoteSessionRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: OpenAdminVoteSessionInput): Promise<OpenAdminVoteSessionOutput> {
|
||||
try {
|
||||
// Validate input
|
||||
const errors = this.validateInput(input);
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if session already exists
|
||||
const existing = await this.adminVoteSessionRepository.findById(input.voteSessionId);
|
||||
if (existing) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Vote session with this ID already exists'],
|
||||
};
|
||||
}
|
||||
|
||||
// Check for overlapping active sessions for this admin in this league
|
||||
const activeSessions = await this.adminVoteSessionRepository.findActiveForAdmin(
|
||||
input.adminId,
|
||||
input.leagueId
|
||||
);
|
||||
|
||||
const startDate = new Date(input.startDate);
|
||||
const endDate = new Date(input.endDate);
|
||||
|
||||
for (const session of activeSessions) {
|
||||
// Check for overlap
|
||||
if (
|
||||
(startDate >= session.startDate && startDate <= session.endDate) ||
|
||||
(endDate >= session.startDate && endDate <= session.endDate) ||
|
||||
(startDate <= session.startDate && endDate >= session.endDate)
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: ['Active vote session already exists for this admin in this league with overlapping dates'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create the vote session
|
||||
const session = AdminVoteSession.create({
|
||||
voteSessionId: input.voteSessionId,
|
||||
leagueId: input.leagueId,
|
||||
adminId: input.adminId,
|
||||
startDate,
|
||||
endDate,
|
||||
eligibleVoters: input.eligibleVoters,
|
||||
});
|
||||
|
||||
// Save to repository
|
||||
await this.adminVoteSessionRepository.save(session);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
voteSessionId: input.voteSessionId,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
voteSessionId: input.voteSessionId,
|
||||
errors: [`Failed to open vote session: ${errorMsg}`],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(input: OpenAdminVoteSessionInput): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!input.voteSessionId || input.voteSessionId.trim().length === 0) {
|
||||
errors.push('voteSessionId is required');
|
||||
}
|
||||
|
||||
if (!input.leagueId || input.leagueId.trim().length === 0) {
|
||||
errors.push('leagueId is required');
|
||||
}
|
||||
|
||||
if (!input.adminId || input.adminId.trim().length === 0) {
|
||||
errors.push('adminId is required');
|
||||
}
|
||||
|
||||
if (!input.startDate) {
|
||||
errors.push('startDate is required');
|
||||
}
|
||||
|
||||
if (!input.endDate) {
|
||||
errors.push('endDate is required');
|
||||
}
|
||||
|
||||
if (input.startDate && input.endDate) {
|
||||
const startDate = new Date(input.startDate);
|
||||
const endDate = new Date(input.endDate);
|
||||
|
||||
if (isNaN(startDate.getTime())) {
|
||||
errors.push('startDate must be a valid date');
|
||||
}
|
||||
|
||||
if (isNaN(endDate.getTime())) {
|
||||
errors.push('endDate must be a valid date');
|
||||
}
|
||||
|
||||
if (startDate >= endDate) {
|
||||
errors.push('startDate must be before endDate');
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.eligibleVoters || input.eligibleVoters.length === 0) {
|
||||
errors.push('At least one eligible voter is required');
|
||||
} else {
|
||||
// Check for duplicates
|
||||
const uniqueVoters = new Set(input.eligibleVoters);
|
||||
if (uniqueVoters.size !== input.eligibleVoters.length) {
|
||||
errors.push('Duplicate eligible voters are not allowed');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { RecomputeUserRatingSnapshotUseCase } from './RecomputeUserRatingSnapshotUseCase';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
|
||||
describe('RecomputeUserRatingSnapshotUseCase', () => {
|
||||
let mockEventRepo: Partial<IRatingEventRepository>;
|
||||
let mockRatingRepo: Partial<IUserRatingRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEventRepo = {
|
||||
getAllByUserId: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
mockRatingRepo = {
|
||||
save: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should be constructed with repositories', () => {
|
||||
const useCase = new RecomputeUserRatingSnapshotUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
expect(useCase).toBeInstanceOf(RecomputeUserRatingSnapshotUseCase);
|
||||
});
|
||||
|
||||
it('should compute snapshot from empty event list', async () => {
|
||||
const useCase = new RecomputeUserRatingSnapshotUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
expect(result.snapshot.userId).toBe('user-1');
|
||||
expect(result.snapshot.driver.value).toBe(50); // Default
|
||||
expect(mockEventRepo.getAllByUserId).toHaveBeenCalledWith('user-1');
|
||||
expect(mockRatingRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compute snapshot from events', async () => {
|
||||
const events = [
|
||||
RatingEvent.create({
|
||||
id: RatingEventId.generate(),
|
||||
userId: 'user-1',
|
||||
dimension: RatingDimensionKey.create('driving'),
|
||||
delta: RatingDelta.create(5),
|
||||
occurredAt: new Date('2024-01-01T10:00:00Z'),
|
||||
createdAt: new Date('2024-01-01T10:00:00Z'),
|
||||
source: { type: 'race', id: 'race-1' },
|
||||
reason: { code: 'TEST', summary: 'Test', details: {} },
|
||||
visibility: { public: true, redactedFields: [] },
|
||||
version: 1,
|
||||
}),
|
||||
];
|
||||
|
||||
mockEventRepo.getAllByUserId = vi.fn().mockResolvedValue(events);
|
||||
|
||||
const useCase = new RecomputeUserRatingSnapshotUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
expect(result.snapshot.userId).toBe('user-1');
|
||||
expect(result.snapshot.driver.value).toBeGreaterThan(50); // Should have increased
|
||||
expect(mockEventRepo.getAllByUserId).toHaveBeenCalledWith('user-1');
|
||||
expect(mockRatingRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return proper DTO format', async () => {
|
||||
const useCase = new RecomputeUserRatingSnapshotUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
// Check DTO structure
|
||||
expect(result.snapshot).toHaveProperty('userId');
|
||||
expect(result.snapshot).toHaveProperty('driver');
|
||||
expect(result.snapshot).toHaveProperty('admin');
|
||||
expect(result.snapshot).toHaveProperty('steward');
|
||||
expect(result.snapshot).toHaveProperty('trust');
|
||||
expect(result.snapshot).toHaveProperty('fairness');
|
||||
expect(result.snapshot).toHaveProperty('overallReputation');
|
||||
expect(result.snapshot).toHaveProperty('createdAt');
|
||||
expect(result.snapshot).toHaveProperty('updatedAt');
|
||||
|
||||
// Check dimension structure
|
||||
expect(result.snapshot.driver).toEqual({
|
||||
value: expect.any(Number),
|
||||
confidence: expect.any(Number),
|
||||
sampleSize: expect.any(Number),
|
||||
trend: expect.any(String),
|
||||
lastUpdated: expect.any(String),
|
||||
});
|
||||
|
||||
// Check dates are ISO strings
|
||||
expect(result.snapshot.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(result.snapshot.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
it('should handle calculatorVersion in DTO', async () => {
|
||||
// Create a rating with calculatorVersion
|
||||
const rating = UserRating.create('user-1');
|
||||
const updated = rating.updateDriverRating(75);
|
||||
|
||||
mockRatingRepo.save = vi.fn().mockResolvedValue(updated);
|
||||
|
||||
const useCase = new RecomputeUserRatingSnapshotUseCase(
|
||||
mockEventRepo as IRatingEventRepository,
|
||||
mockRatingRepo as IUserRatingRepository,
|
||||
);
|
||||
|
||||
const result = await useCase.execute({ userId: 'user-1' });
|
||||
|
||||
// Should have calculatorVersion
|
||||
expect(result.snapshot.calculatorVersion).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
|
||||
import { UserRatingDto } from '../dtos/UserRatingDto';
|
||||
|
||||
/**
|
||||
* Input for RecomputeUserRatingSnapshotUseCase
|
||||
*/
|
||||
export interface RecomputeUserRatingSnapshotInput {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output for RecomputeUserRatingSnapshotUseCase
|
||||
*/
|
||||
export interface RecomputeUserRatingSnapshotOutput {
|
||||
snapshot: UserRatingDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: RecomputeUserRatingSnapshotUseCase
|
||||
*
|
||||
* Recomputes a user's rating snapshot from all events in the ledger.
|
||||
* Useful for:
|
||||
* - Algorithm updates
|
||||
* - Data corrections
|
||||
* - Manual recomputation
|
||||
*/
|
||||
export class RecomputeUserRatingSnapshotUseCase {
|
||||
constructor(
|
||||
private readonly ratingEventRepository: IRatingEventRepository,
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
) {}
|
||||
|
||||
async execute(input: RecomputeUserRatingSnapshotInput): Promise<RecomputeUserRatingSnapshotOutput> {
|
||||
// 1. Load all events for the user
|
||||
const events = await this.ratingEventRepository.getAllByUserId(input.userId);
|
||||
|
||||
// 2. Compute snapshot from events
|
||||
const snapshot = RatingSnapshotCalculator.calculate(input.userId, events);
|
||||
|
||||
// 3. Save snapshot
|
||||
await this.userRatingRepository.save(snapshot);
|
||||
|
||||
// 4. Convert to DTO for output
|
||||
const dto: UserRatingDto = {
|
||||
userId: snapshot.userId,
|
||||
driver: {
|
||||
value: snapshot.driver.value,
|
||||
confidence: snapshot.driver.confidence,
|
||||
sampleSize: snapshot.driver.sampleSize,
|
||||
trend: snapshot.driver.trend,
|
||||
lastUpdated: snapshot.driver.lastUpdated.toISOString(),
|
||||
},
|
||||
admin: {
|
||||
value: snapshot.admin.value,
|
||||
confidence: snapshot.admin.confidence,
|
||||
sampleSize: snapshot.admin.sampleSize,
|
||||
trend: snapshot.admin.trend,
|
||||
lastUpdated: snapshot.admin.lastUpdated.toISOString(),
|
||||
},
|
||||
steward: {
|
||||
value: snapshot.steward.value,
|
||||
confidence: snapshot.steward.confidence,
|
||||
sampleSize: snapshot.steward.sampleSize,
|
||||
trend: snapshot.steward.trend,
|
||||
lastUpdated: snapshot.steward.lastUpdated.toISOString(),
|
||||
},
|
||||
trust: {
|
||||
value: snapshot.trust.value,
|
||||
confidence: snapshot.trust.confidence,
|
||||
sampleSize: snapshot.trust.sampleSize,
|
||||
trend: snapshot.trust.trend,
|
||||
lastUpdated: snapshot.trust.lastUpdated.toISOString(),
|
||||
},
|
||||
fairness: {
|
||||
value: snapshot.fairness.value,
|
||||
confidence: snapshot.fairness.confidence,
|
||||
sampleSize: snapshot.fairness.sampleSize,
|
||||
trend: snapshot.fairness.trend,
|
||||
lastUpdated: snapshot.fairness.lastUpdated.toISOString(),
|
||||
},
|
||||
overallReputation: snapshot.overallReputation,
|
||||
createdAt: snapshot.createdAt.toISOString(),
|
||||
updatedAt: snapshot.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
if (snapshot.calculatorVersion !== undefined) {
|
||||
dto.calculatorVersion = snapshot.calculatorVersion;
|
||||
}
|
||||
|
||||
return { snapshot: dto };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
import { RecordRaceRatingEventsUseCase } from './RecordRaceRatingEventsUseCase';
|
||||
import { IRaceResultsProvider, RaceResultsData } from '../ports/IRaceResultsProvider';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
|
||||
// In-memory implementations for integration testing
|
||||
class InMemoryRaceResultsProvider implements IRaceResultsProvider {
|
||||
private results: Map<string, RaceResultsData> = new Map();
|
||||
|
||||
async getRaceResults(raceId: string): Promise<RaceResultsData | null> {
|
||||
return this.results.get(raceId) || null;
|
||||
}
|
||||
|
||||
async hasRaceResults(raceId: string): Promise<boolean> {
|
||||
return this.results.has(raceId);
|
||||
}
|
||||
|
||||
// Helper for tests
|
||||
setRaceResults(raceId: string, results: RaceResultsData) {
|
||||
this.results.set(raceId, results);
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryRatingEventRepository implements IRatingEventRepository {
|
||||
private events: Map<string, RatingEvent[]> = new Map();
|
||||
|
||||
async save(event: RatingEvent): Promise<RatingEvent> {
|
||||
const userId = event.userId;
|
||||
if (!this.events.has(userId)) {
|
||||
this.events.set(userId, []);
|
||||
}
|
||||
this.events.get(userId)!.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return this.events.get(userId) || [];
|
||||
}
|
||||
|
||||
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
|
||||
const allEvents = Array.from(this.events.values()).flat();
|
||||
return allEvents.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return this.events.get(userId) || [];
|
||||
}
|
||||
|
||||
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
|
||||
const allEvents = await this.findByUserId(userId);
|
||||
|
||||
// Apply filters
|
||||
let filtered = allEvents;
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
if (filter.dimensions) {
|
||||
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
|
||||
}
|
||||
if (filter.from) {
|
||||
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
|
||||
}
|
||||
if (filter.to) {
|
||||
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
|
||||
}
|
||||
if (filter.visibility) {
|
||||
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
|
||||
}
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper for tests
|
||||
clear() {
|
||||
this.events.clear();
|
||||
}
|
||||
|
||||
getAllEvents(): RatingEvent[] {
|
||||
return Array.from(this.events.values()).flat();
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryUserRatingRepository implements IUserRatingRepository {
|
||||
private ratings: Map<string, UserRating> = new Map();
|
||||
|
||||
async findByUserId(userId: string): Promise<UserRating | null> {
|
||||
return this.ratings.get(userId) || null;
|
||||
}
|
||||
|
||||
async save(userRating: UserRating): Promise<UserRating> {
|
||||
this.ratings.set(userRating.userId, userRating);
|
||||
return userRating;
|
||||
}
|
||||
|
||||
// Helper for tests
|
||||
getAllRatings(): Map<string, UserRating> {
|
||||
return new Map(this.ratings);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.ratings.clear();
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecordRaceRatingEventsUseCase - Integration', () => {
|
||||
let useCase: RecordRaceRatingEventsUseCase;
|
||||
let raceResultsProvider: InMemoryRaceResultsProvider;
|
||||
let ratingEventRepository: InMemoryRatingEventRepository;
|
||||
let userRatingRepository: InMemoryUserRatingRepository;
|
||||
let appendRatingEventsUseCase: AppendRatingEventsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
raceResultsProvider = new InMemoryRaceResultsProvider();
|
||||
ratingEventRepository = new InMemoryRatingEventRepository();
|
||||
userRatingRepository = new InMemoryUserRatingRepository();
|
||||
appendRatingEventsUseCase = new AppendRatingEventsUseCase(
|
||||
ratingEventRepository,
|
||||
userRatingRepository
|
||||
);
|
||||
useCase = new RecordRaceRatingEventsUseCase(
|
||||
raceResultsProvider,
|
||||
ratingEventRepository,
|
||||
userRatingRepository,
|
||||
appendRatingEventsUseCase
|
||||
);
|
||||
});
|
||||
|
||||
describe('Full flow: race facts -> events -> persist -> snapshot', () => {
|
||||
it('should complete full flow for single driver with good performance', async () => {
|
||||
// Step 1: Setup race facts
|
||||
const raceFacts: RaceResultsData = {
|
||||
raceId: 'race-001',
|
||||
results: [
|
||||
{
|
||||
userId: 'driver-001',
|
||||
startPos: 8,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-001', raceFacts);
|
||||
|
||||
// Step 2: Create initial user rating
|
||||
const initialRating = UserRating.create('driver-001');
|
||||
await userRatingRepository.save(initialRating);
|
||||
|
||||
// Step 3: Execute use case
|
||||
const result = await useCase.execute({ raceId: 'race-001' });
|
||||
|
||||
// Step 4: Verify success
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.raceId).toBe('race-001');
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.driversUpdated).toContain('driver-001');
|
||||
expect(result.errors).toEqual([]);
|
||||
|
||||
// Step 5: Verify events were persisted
|
||||
const events = await ratingEventRepository.findByUserId('driver-001');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
// Step 6: Verify snapshot was updated
|
||||
const updatedRating = await userRatingRepository.findByUserId('driver-001');
|
||||
expect(updatedRating).toBeDefined();
|
||||
expect(updatedRating!.driver.value).toBeGreaterThan(initialRating.driver.value);
|
||||
expect(updatedRating!.driver.sampleSize).toBeGreaterThan(0);
|
||||
expect(updatedRating!.driver.confidence).toBeGreaterThan(0);
|
||||
|
||||
// Step 7: Verify event details
|
||||
const performanceEvent = events.find(e => e.reason.code === 'DRIVING_FINISH_STRENGTH_GAIN');
|
||||
expect(performanceEvent).toBeDefined();
|
||||
expect(performanceEvent!.delta.value).toBeGreaterThan(0);
|
||||
expect(performanceEvent!.source.id).toBe('race-001');
|
||||
});
|
||||
|
||||
it('should handle multiple drivers with mixed results', async () => {
|
||||
// Setup race with multiple drivers
|
||||
const raceFacts: RaceResultsData = {
|
||||
raceId: 'race-002',
|
||||
results: [
|
||||
{
|
||||
userId: 'driver-001',
|
||||
startPos: 5,
|
||||
finishPos: 1,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'driver-002',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 3,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'driver-003',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-002', raceFacts);
|
||||
|
||||
// Create initial ratings
|
||||
await userRatingRepository.save(UserRating.create('driver-001'));
|
||||
await userRatingRepository.save(UserRating.create('driver-002'));
|
||||
await userRatingRepository.save(UserRating.create('driver-003'));
|
||||
|
||||
// Execute
|
||||
const result = await useCase.execute({ raceId: 'race-002' });
|
||||
|
||||
// Verify
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.driversUpdated.length).toBe(3);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
|
||||
// Check each driver
|
||||
for (const driverId of ['driver-001', 'driver-002', 'driver-003']) {
|
||||
const events = await ratingEventRepository.findByUserId(driverId);
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
const rating = await userRatingRepository.findByUserId(driverId);
|
||||
expect(rating).toBeDefined();
|
||||
expect(rating!.driver.sampleSize).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// driver-001 should have positive delta
|
||||
const driver1Rating = await userRatingRepository.findByUserId('driver-001');
|
||||
expect(driver1Rating!.driver.value).toBeGreaterThan(50);
|
||||
|
||||
// driver-002 should have negative delta (poor position + incidents)
|
||||
const driver2Rating = await userRatingRepository.findByUserId('driver-002');
|
||||
expect(driver2Rating!.driver.value).toBeLessThan(50);
|
||||
|
||||
// driver-003 should have negative delta (DNS)
|
||||
const driver3Rating = await userRatingRepository.findByUserId('driver-003');
|
||||
expect(driver3Rating!.driver.value).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('should compute SoF when not provided', async () => {
|
||||
const raceFacts: RaceResultsData = {
|
||||
raceId: 'race-003',
|
||||
results: [
|
||||
{
|
||||
userId: 'driver-001',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
{
|
||||
userId: 'driver-002',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-003', raceFacts);
|
||||
|
||||
// Set ratings for SoF calculation
|
||||
const rating1 = UserRating.create('driver-001').updateDriverRating(60);
|
||||
const rating2 = UserRating.create('driver-002').updateDriverRating(40);
|
||||
await userRatingRepository.save(rating1);
|
||||
await userRatingRepository.save(rating2);
|
||||
|
||||
// Execute
|
||||
const result = await useCase.execute({ raceId: 'race-003' });
|
||||
|
||||
// Verify SoF was computed (average of 60 and 40 = 50)
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
|
||||
// Events should be created with computed SoF
|
||||
const events = await ratingEventRepository.getAllByUserId('driver-001');
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle partial failures gracefully', async () => {
|
||||
const raceFacts: RaceResultsData = {
|
||||
raceId: 'race-004',
|
||||
results: [
|
||||
{
|
||||
userId: 'driver-001',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'driver-002',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-004', raceFacts);
|
||||
|
||||
// Only create rating for first driver
|
||||
await userRatingRepository.save(UserRating.create('driver-001'));
|
||||
|
||||
// Execute
|
||||
const result = await useCase.execute({ raceId: 'race-004' });
|
||||
|
||||
// Should have partial success
|
||||
expect(result.raceId).toBe('race-004');
|
||||
expect(result.driversUpdated).toContain('driver-001');
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should maintain event immutability and ordering', async () => {
|
||||
const raceFacts: RaceResultsData = {
|
||||
raceId: 'race-005',
|
||||
results: [
|
||||
{
|
||||
userId: 'driver-001',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 1,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-005', raceFacts);
|
||||
await userRatingRepository.save(UserRating.create('driver-001'));
|
||||
|
||||
// Execute multiple times
|
||||
await useCase.execute({ raceId: 'race-005' });
|
||||
const result1 = await ratingEventRepository.findByUserId('driver-001');
|
||||
|
||||
// Execute again (should add more events)
|
||||
await useCase.execute({ raceId: 'race-005' });
|
||||
const result2 = await ratingEventRepository.findByUserId('driver-001');
|
||||
|
||||
// Events should accumulate
|
||||
expect(result2.length).toBeGreaterThan(result1.length);
|
||||
|
||||
// All events should be immutable
|
||||
for (const event of result2) {
|
||||
expect(event.id).toBeDefined();
|
||||
expect(event.createdAt).toBeDefined();
|
||||
expect(event.occurredAt).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should update snapshot with weighted average and confidence', async () => {
|
||||
// Multiple races for same driver
|
||||
const race1: RaceResultsData = {
|
||||
raceId: 'race-006',
|
||||
results: [{ userId: 'driver-001', startPos: 10, finishPos: 5, incidents: 0, status: 'finished', sof: 2500 }],
|
||||
};
|
||||
const race2: RaceResultsData = {
|
||||
raceId: 'race-007',
|
||||
results: [{ userId: 'driver-001', startPos: 5, finishPos: 2, incidents: 0, status: 'finished', sof: 2500 }],
|
||||
};
|
||||
|
||||
raceResultsProvider.setRaceResults('race-006', race1);
|
||||
raceResultsProvider.setRaceResults('race-007', race2);
|
||||
|
||||
await userRatingRepository.save(UserRating.create('driver-001'));
|
||||
|
||||
// Execute first race
|
||||
await useCase.execute({ raceId: 'race-006' });
|
||||
const rating1 = await userRatingRepository.findByUserId('driver-001');
|
||||
expect(rating1!.driver.sampleSize).toBe(1);
|
||||
|
||||
// Execute second race
|
||||
await useCase.execute({ raceId: 'race-007' });
|
||||
const rating2 = await userRatingRepository.findByUserId('driver-001');
|
||||
expect(rating2!.driver.sampleSize).toBe(2);
|
||||
expect(rating2!.driver.confidence).toBeGreaterThan(rating1!.driver.confidence);
|
||||
|
||||
// Trend should be calculated
|
||||
expect(rating2!.driver.trend).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { RecordRaceRatingEventsUseCase } from './RecordRaceRatingEventsUseCase';
|
||||
import { IRaceResultsProvider, RaceResultsData } from '../ports/IRaceResultsProvider';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
|
||||
import { UserRating } from '../../domain/value-objects/UserRating';
|
||||
import { RatingEvent } from '../../domain/entities/RatingEvent';
|
||||
import { RatingEventId } from '../../domain/value-objects/RatingEventId';
|
||||
import { RatingDimensionKey } from '../../domain/value-objects/RatingDimensionKey';
|
||||
import { RatingDelta } from '../../domain/value-objects/RatingDelta';
|
||||
|
||||
// Mock implementations
|
||||
class MockRaceResultsProvider implements IRaceResultsProvider {
|
||||
private results: RaceResultsData | null = null;
|
||||
|
||||
setResults(results: RaceResultsData | null) {
|
||||
this.results = results;
|
||||
}
|
||||
|
||||
async getRaceResults(raceId: string): Promise<RaceResultsData | null> {
|
||||
return this.results;
|
||||
}
|
||||
|
||||
async hasRaceResults(raceId: string): Promise<boolean> {
|
||||
return this.results !== null;
|
||||
}
|
||||
}
|
||||
|
||||
class MockRatingEventRepository implements IRatingEventRepository {
|
||||
private events: RatingEvent[] = [];
|
||||
|
||||
async save(event: RatingEvent): Promise<RatingEvent> {
|
||||
this.events.push(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return this.events.filter(e => e.userId === userId);
|
||||
}
|
||||
|
||||
async findByIds(ids: RatingEventId[]): Promise<RatingEvent[]> {
|
||||
return this.events.filter(e => ids.some(id => id.equals(e.id)));
|
||||
}
|
||||
|
||||
async getAllByUserId(userId: string): Promise<RatingEvent[]> {
|
||||
return this.events.filter(e => e.userId === userId);
|
||||
}
|
||||
|
||||
async findEventsPaginated(userId: string, options?: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent>> {
|
||||
const allEvents = await this.findByUserId(userId);
|
||||
|
||||
// Apply filters
|
||||
let filtered = allEvents;
|
||||
if (options?.filter) {
|
||||
const filter = options.filter;
|
||||
if (filter.dimensions) {
|
||||
filtered = filtered.filter(e => filter.dimensions!.includes(e.dimension.value));
|
||||
}
|
||||
if (filter.sourceTypes) {
|
||||
filtered = filtered.filter(e => filter.sourceTypes!.includes(e.source.type));
|
||||
}
|
||||
if (filter.from) {
|
||||
filtered = filtered.filter(e => e.occurredAt >= filter.from!);
|
||||
}
|
||||
if (filter.to) {
|
||||
filtered = filtered.filter(e => e.occurredAt <= filter.to!);
|
||||
}
|
||||
if (filter.reasonCodes) {
|
||||
filtered = filtered.filter(e => filter.reasonCodes!.includes(e.reason.code));
|
||||
}
|
||||
if (filter.visibility) {
|
||||
filtered = filtered.filter(e => e.visibility.public === (filter.visibility === 'public'));
|
||||
}
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const limit = options?.limit ?? 10;
|
||||
const offset = options?.offset ?? 0;
|
||||
const items = filtered.slice(offset, offset + limit);
|
||||
const hasMore = offset + limit < total;
|
||||
const nextOffset = hasMore ? offset + limit : undefined;
|
||||
|
||||
const result: import('@core/identity/domain/repositories/IRatingEventRepository').PaginatedResult<RatingEvent> = {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
};
|
||||
|
||||
if (nextOffset !== undefined) {
|
||||
result.nextOffset = nextOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
class MockUserRatingRepository implements IUserRatingRepository {
|
||||
private ratings: Map<string, UserRating> = new Map();
|
||||
|
||||
async findByUserId(userId: string): Promise<UserRating | null> {
|
||||
return this.ratings.get(userId) || null;
|
||||
}
|
||||
|
||||
async save(userRating: UserRating): Promise<UserRating> {
|
||||
this.ratings.set(userRating.userId, userRating);
|
||||
return userRating;
|
||||
}
|
||||
|
||||
// Helper for tests
|
||||
setRating(userId: string, rating: UserRating) {
|
||||
this.ratings.set(userId, rating);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecordRaceRatingEventsUseCase', () => {
|
||||
let useCase: RecordRaceRatingEventsUseCase;
|
||||
let mockRaceResultsProvider: MockRaceResultsProvider;
|
||||
let mockRatingEventRepository: MockRatingEventRepository;
|
||||
let mockUserRatingRepository: MockUserRatingRepository;
|
||||
let appendRatingEventsUseCase: AppendRatingEventsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRaceResultsProvider = new MockRaceResultsProvider();
|
||||
mockRatingEventRepository = new MockRatingEventRepository();
|
||||
mockUserRatingRepository = new MockUserRatingRepository();
|
||||
appendRatingEventsUseCase = new AppendRatingEventsUseCase(
|
||||
mockRatingEventRepository,
|
||||
mockUserRatingRepository
|
||||
);
|
||||
useCase = new RecordRaceRatingEventsUseCase(
|
||||
mockRaceResultsProvider,
|
||||
mockRatingEventRepository,
|
||||
mockUserRatingRepository,
|
||||
appendRatingEventsUseCase
|
||||
);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should return error when race results not found', async () => {
|
||||
mockRaceResultsProvider.setResults(null);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.raceId).toBe('race-123');
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.driversUpdated).toEqual([]);
|
||||
expect(result.errors).toContain('Race results not found');
|
||||
});
|
||||
|
||||
it('should return error when no results in race', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.eventsCreated).toBe(0);
|
||||
expect(result.errors).toContain('No results found for race');
|
||||
});
|
||||
|
||||
it('should process single driver with good performance', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Set initial rating for user
|
||||
const initialRating = UserRating.create('user-123');
|
||||
mockUserRatingRepository.setRating('user-123', initialRating);
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.raceId).toBe('race-123');
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.driversUpdated).toContain('user-123');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should process multiple drivers with mixed results', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 2,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-789',
|
||||
startPos: 5,
|
||||
finishPos: 5,
|
||||
incidents: 0,
|
||||
status: 'dns',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Set initial ratings
|
||||
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
|
||||
mockUserRatingRepository.setRating('user-456', UserRating.create('user-456'));
|
||||
mockUserRatingRepository.setRating('user-789', UserRating.create('user-789'));
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.driversUpdated.length).toBe(3);
|
||||
expect(result.driversUpdated).toContain('user-123');
|
||||
expect(result.driversUpdated).toContain('user-456');
|
||||
expect(result.driversUpdated).toContain('user-789');
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should compute SoF if not provided', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
// No sof
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Set ratings for SoF calculation
|
||||
const rating1 = UserRating.create('user-123');
|
||||
const rating2 = UserRating.create('user-456');
|
||||
// Update driver ratings to specific values
|
||||
mockUserRatingRepository.setRating('user-123', rating1.updateDriverRating(60));
|
||||
mockUserRatingRepository.setRating('user-456', rating2.updateDriverRating(40));
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.eventsCreated).toBeGreaterThan(0);
|
||||
expect(result.driversUpdated.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle errors for individual drivers gracefully', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
{
|
||||
userId: 'user-456',
|
||||
startPos: 3,
|
||||
finishPos: 8,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Only set rating for first user, second will fail
|
||||
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
// Should still succeed overall but with errors
|
||||
expect(result.raceId).toBe('race-123');
|
||||
expect(result.driversUpdated).toContain('user-123');
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should return success with no events when no valid events created', async () => {
|
||||
// This would require a scenario where factory creates no events
|
||||
// For now, we'll test with empty results
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [],
|
||||
});
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false); // No results
|
||||
});
|
||||
|
||||
it('should handle repository errors', async () => {
|
||||
mockRaceResultsProvider.setResults({
|
||||
raceId: 'race-123',
|
||||
results: [
|
||||
{
|
||||
userId: 'user-123',
|
||||
startPos: 5,
|
||||
finishPos: 2,
|
||||
incidents: 0,
|
||||
status: 'finished',
|
||||
sof: 2500,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
|
||||
|
||||
// Mock repository to throw error
|
||||
const originalSave = mockRatingEventRepository.save;
|
||||
mockRatingEventRepository.save = async () => {
|
||||
throw new Error('Repository error');
|
||||
};
|
||||
|
||||
const result = await useCase.execute({ raceId: 'race-123' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
|
||||
// Restore
|
||||
mockRatingEventRepository.save = originalSave;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { IRaceResultsProvider } from '../ports/IRaceResultsProvider';
|
||||
import { IRatingEventRepository } from '../../domain/repositories/IRatingEventRepository';
|
||||
import { IUserRatingRepository } from '../../domain/repositories/IUserRatingRepository';
|
||||
import { RatingEventFactory } from '../../domain/services/RatingEventFactory';
|
||||
import { DrivingRatingCalculator } from '../../domain/services/DrivingRatingCalculator';
|
||||
import { RatingSnapshotCalculator } from '../../domain/services/RatingSnapshotCalculator';
|
||||
import { AppendRatingEventsUseCase } from './AppendRatingEventsUseCase';
|
||||
import { RecordRaceRatingEventsInput, RecordRaceRatingEventsOutput } from '../dtos/RecordRaceRatingEventsDto';
|
||||
|
||||
/**
|
||||
* Use Case: RecordRaceRatingEventsUseCase
|
||||
*
|
||||
* Records rating events for a completed race.
|
||||
* Follows CQRS Light: command side operation.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Load race results from racing context
|
||||
* 2. Compute SoF if needed (platform-only)
|
||||
* 3. Factory creates rating events
|
||||
* 4. Append to ledger via AppendRatingEventsUseCase
|
||||
* 5. Recompute snapshots
|
||||
*
|
||||
* Per plans section 7.1.1 and 10.1
|
||||
*/
|
||||
export class RecordRaceRatingEventsUseCase {
|
||||
constructor(
|
||||
private readonly raceResultsProvider: IRaceResultsProvider,
|
||||
private readonly ratingEventRepository: IRatingEventRepository,
|
||||
private readonly userRatingRepository: IUserRatingRepository,
|
||||
private readonly appendRatingEventsUseCase: AppendRatingEventsUseCase,
|
||||
) {}
|
||||
|
||||
async execute(input: RecordRaceRatingEventsInput): Promise<RecordRaceRatingEventsOutput> {
|
||||
const errors: string[] = [];
|
||||
const driversUpdated: string[] = [];
|
||||
let totalEventsCreated = 0;
|
||||
|
||||
try {
|
||||
// 1. Load race results
|
||||
const raceResults = await this.raceResultsProvider.getRaceResults(input.raceId);
|
||||
|
||||
if (!raceResults) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
errors: ['Race results not found'],
|
||||
};
|
||||
}
|
||||
|
||||
if (raceResults.results.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
errors: ['No results found for race'],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Compute SoF if not provided (platform-only approach)
|
||||
// Use existing user ratings as platform strength indicators
|
||||
const resultsWithSof = await this.computeSoFIfNeeded(raceResults);
|
||||
|
||||
// 3. Create rating events using factory
|
||||
const eventsByUser = RatingEventFactory.createDrivingEventsFromRace({
|
||||
raceId: input.raceId,
|
||||
results: resultsWithSof,
|
||||
});
|
||||
|
||||
if (eventsByUser.size === 0) {
|
||||
return {
|
||||
success: true,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
errors: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Process each driver's events
|
||||
for (const [userId, events] of eventsByUser) {
|
||||
try {
|
||||
// Use AppendRatingEventsUseCase to handle ledger and snapshot
|
||||
const result = await this.appendRatingEventsUseCase.execute({
|
||||
userId,
|
||||
events: events.map(event => ({
|
||||
userId: event.userId,
|
||||
dimension: event.dimension.value,
|
||||
delta: event.delta.value,
|
||||
weight: event.weight || 1,
|
||||
sourceType: event.source.type,
|
||||
sourceId: event.source.id,
|
||||
reasonCode: event.reason.code,
|
||||
reasonSummary: event.reason.summary,
|
||||
reasonDetails: event.reason.details,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
|
||||
if (result.snapshotUpdated) {
|
||||
driversUpdated.push(userId);
|
||||
totalEventsCreated += result.events.length;
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to process events for user ${userId}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: errors.length === 0,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: totalEventsCreated,
|
||||
driversUpdated,
|
||||
errors,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMsg = `Failed to record race rating events: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errors.push(errorMsg);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
raceId: input.raceId,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute Strength of Field if not provided in results
|
||||
* Uses platform driving ratings (existing user ratings)
|
||||
*/
|
||||
private async computeSoFIfNeeded(raceResults: {
|
||||
raceId: string;
|
||||
results: Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof?: number;
|
||||
}>;
|
||||
}): Promise<Array<{
|
||||
userId: string;
|
||||
startPos: number;
|
||||
finishPos: number;
|
||||
incidents: number;
|
||||
status: 'finished' | 'dnf' | 'dns' | 'dsq' | 'afk';
|
||||
sof: number;
|
||||
}>> {
|
||||
// Check if all results have sof
|
||||
const hasAllSof = raceResults.results.every(r => r.sof !== undefined);
|
||||
|
||||
if (hasAllSof) {
|
||||
return raceResults.results.map(r => ({ ...r, sof: r.sof! }));
|
||||
}
|
||||
|
||||
// Need to compute SoF - get user ratings for all drivers
|
||||
const userIds = raceResults.results.map(r => r.userId);
|
||||
const ratingsMap = new Map<string, number>();
|
||||
|
||||
// Get ratings for all drivers
|
||||
for (const userId of userIds) {
|
||||
const userRating = await this.userRatingRepository.findByUserId(userId);
|
||||
if (userRating) {
|
||||
ratingsMap.set(userId, userRating.driver.value);
|
||||
} else {
|
||||
// Default rating for new drivers
|
||||
ratingsMap.set(userId, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average rating as SoF
|
||||
const ratings = Array.from(ratingsMap.values());
|
||||
const sof = ratings.length > 0
|
||||
? Math.round(ratings.reduce((sum, r) => sum + r, 0) / ratings.length)
|
||||
: 50;
|
||||
|
||||
// Add SoF to each result
|
||||
return raceResults.results.map(r => ({
|
||||
...r,
|
||||
sof: r.sof !== undefined ? r.sof : sof,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { UpsertExternalGameRatingUseCase } from './UpsertExternalGameRatingUseCase';
|
||||
import { UpsertExternalGameRatingInput } from '../dtos/UpsertExternalGameRatingDto';
|
||||
|
||||
// Mock repository for integration test
|
||||
class MockExternalGameRatingRepository {
|
||||
private profiles = new Map<string, any>();
|
||||
|
||||
private getKey(userId: string, gameKey: string): string {
|
||||
return `${userId}|${gameKey}`;
|
||||
}
|
||||
|
||||
async findByUserIdAndGameKey(userId: string, gameKey: string): Promise<any | null> {
|
||||
return this.profiles.get(this.getKey(userId, gameKey)) || null;
|
||||
}
|
||||
|
||||
async findByUserId(userId: string): Promise<any[]> {
|
||||
return Array.from(this.profiles.values()).filter((p: any) => p.userId.toString() === userId);
|
||||
}
|
||||
|
||||
async findByGameKey(gameKey: string): Promise<any[]> {
|
||||
return Array.from(this.profiles.values()).filter((p: any) => p.gameKey.toString() === gameKey);
|
||||
}
|
||||
|
||||
async save(profile: any): Promise<any> {
|
||||
const key = this.getKey(profile.userId.toString(), profile.gameKey.toString());
|
||||
this.profiles.set(key, profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
async delete(userId: string, gameKey: string): Promise<boolean> {
|
||||
return this.profiles.delete(this.getKey(userId, gameKey));
|
||||
}
|
||||
|
||||
async exists(userId: string, gameKey: string): Promise<boolean> {
|
||||
return this.profiles.has(this.getKey(userId, gameKey));
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.profiles.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration test for UpsertExternalGameRatingUseCase
|
||||
* Tests the full flow from use case to repository
|
||||
*/
|
||||
describe('UpsertExternalGameRatingUseCase - Integration', () => {
|
||||
let useCase: UpsertExternalGameRatingUseCase;
|
||||
let repository: MockExternalGameRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = new MockExternalGameRatingRepository();
|
||||
useCase = new UpsertExternalGameRatingUseCase(repository as any);
|
||||
});
|
||||
|
||||
describe('Full upsert flow', () => {
|
||||
it('should create and then update a profile', async () => {
|
||||
// Step 1: Create new profile
|
||||
const createInput: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 85.5 },
|
||||
{ type: 'skill', value: 92.0 },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
verified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const createResult = await useCase.execute(createInput);
|
||||
|
||||
expect(createResult.success).toBe(true);
|
||||
expect(createResult.action).toBe('created');
|
||||
expect(createResult.profile.ratingCount).toBe(2);
|
||||
expect(createResult.profile.verified).toBe(false);
|
||||
|
||||
// Verify it was saved
|
||||
const savedProfile = await repository.findByUserIdAndGameKey('user-123', 'iracing');
|
||||
expect(savedProfile).not.toBeNull();
|
||||
expect(savedProfile?.ratings.size).toBe(2);
|
||||
|
||||
// Step 2: Update the profile
|
||||
const updateInput: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 90.0 },
|
||||
{ type: 'skill', value: 95.0 },
|
||||
{ type: 'consistency', value: 88.0 },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-02T00:00:00Z',
|
||||
verified: true,
|
||||
},
|
||||
};
|
||||
|
||||
const updateResult = await useCase.execute(updateInput);
|
||||
|
||||
expect(updateResult.success).toBe(true);
|
||||
expect(updateResult.action).toBe('updated');
|
||||
expect(updateResult.profile.ratingCount).toBe(3);
|
||||
expect(updateResult.profile.verified).toBe(true);
|
||||
|
||||
// Verify the update was persisted
|
||||
const updatedProfile = await repository.findByUserIdAndGameKey('user-123', 'iracing');
|
||||
expect(updatedProfile).not.toBeNull();
|
||||
expect(updatedProfile?.ratings.size).toBe(3);
|
||||
expect(updatedProfile?.getRatingByType('safety')?.value).toBe(90.0);
|
||||
expect(updatedProfile?.getRatingByType('consistency')).toBeDefined();
|
||||
expect(updatedProfile?.provenance.verified).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple users and games', async () => {
|
||||
// Create profiles for different users/games
|
||||
const inputs: UpsertExternalGameRatingInput[] = [
|
||||
{
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 80.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
userId: 'user-1',
|
||||
gameKey: 'assetto',
|
||||
ratings: [{ type: 'safety', value: 75.0 }],
|
||||
provenance: { source: 'assetto', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
},
|
||||
{
|
||||
userId: 'user-2',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
},
|
||||
];
|
||||
|
||||
for (const input of inputs) {
|
||||
const result = await useCase.execute(input);
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
|
||||
// Verify user-1 has 2 profiles
|
||||
const user1Profiles = await repository.findByUserId('user-1');
|
||||
expect(user1Profiles).toHaveLength(2);
|
||||
|
||||
// Verify iracing has 2 profiles
|
||||
const iracingProfiles = await repository.findByGameKey('iracing');
|
||||
expect(iracingProfiles).toHaveLength(2);
|
||||
|
||||
// Verify specific profile
|
||||
const specific = await repository.findByUserIdAndGameKey('user-1', 'assetto');
|
||||
expect(specific?.getRatingByType('safety')?.value).toBe(75.0);
|
||||
});
|
||||
|
||||
it('should handle concurrent updates to same profile', async () => {
|
||||
// Initial profile
|
||||
const input1: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 80.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
};
|
||||
|
||||
await useCase.execute(input1);
|
||||
|
||||
// Update 1
|
||||
const input2: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-02T00:00:00Z' },
|
||||
};
|
||||
|
||||
await useCase.execute(input2);
|
||||
|
||||
// Update 2 (should overwrite)
|
||||
const input3: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 90.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-03T00:00:00Z' },
|
||||
};
|
||||
|
||||
await useCase.execute(input3);
|
||||
|
||||
// Verify final state
|
||||
const profile = await repository.findByUserIdAndGameKey('user-1', 'iracing');
|
||||
expect(profile?.getRatingByType('safety')?.value).toBe(90.0);
|
||||
expect(profile?.provenance.lastSyncedAt).toEqual(new Date('2024-01-03T00:00:00Z'));
|
||||
});
|
||||
|
||||
it('should handle complex rating updates', async () => {
|
||||
// Initial with 2 ratings
|
||||
const input1: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 80.0 },
|
||||
{ type: 'skill', value: 75.0 },
|
||||
],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
};
|
||||
|
||||
await useCase.execute(input1);
|
||||
|
||||
// Update with different set
|
||||
const input2: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 85.0 }, // Updated
|
||||
{ type: 'consistency', value: 88.0 }, // New
|
||||
// skill removed
|
||||
],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-02T00:00:00Z' },
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input2);
|
||||
|
||||
expect(result.profile.ratingCount).toBe(2);
|
||||
expect(result.profile.ratingTypes).toEqual(['safety', 'consistency']);
|
||||
expect(result.profile.ratingTypes).not.toContain('skill');
|
||||
|
||||
const profile = await repository.findByUserIdAndGameKey('user-1', 'iracing');
|
||||
expect(profile?.ratings.size).toBe(2);
|
||||
expect(profile?.getRatingByType('safety')?.value).toBe(85.0);
|
||||
expect(profile?.getRatingByType('consistency')?.value).toBe(88.0);
|
||||
expect(profile?.getRatingByType('skill')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository method integration', () => {
|
||||
it('should work with repository methods directly', async () => {
|
||||
// Create via use case
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-1',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 80.0 }],
|
||||
provenance: { source: 'iracing', lastSyncedAt: '2024-01-01T00:00:00Z' },
|
||||
};
|
||||
|
||||
await useCase.execute(input);
|
||||
|
||||
// Test repository methods
|
||||
const exists = await repository.exists('user-1', 'iracing');
|
||||
expect(exists).toBe(true);
|
||||
|
||||
const allForUser = await repository.findByUserId('user-1');
|
||||
expect(allForUser).toHaveLength(1);
|
||||
|
||||
const allForGame = await repository.findByGameKey('iracing');
|
||||
expect(allForGame).toHaveLength(1);
|
||||
|
||||
// Delete
|
||||
const deleted = await repository.delete('user-1', 'iracing');
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const existsAfterDelete = await repository.exists('user-1', 'iracing');
|
||||
expect(existsAfterDelete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { UpsertExternalGameRatingUseCase } from './UpsertExternalGameRatingUseCase';
|
||||
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
|
||||
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { GameKey } from '../../domain/value-objects/GameKey';
|
||||
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
|
||||
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
|
||||
import { UpsertExternalGameRatingInput } from '../dtos/UpsertExternalGameRatingDto';
|
||||
|
||||
describe('UpsertExternalGameRatingUseCase', () => {
|
||||
let useCase: UpsertExternalGameRatingUseCase;
|
||||
let mockRepository: IExternalGameRatingRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
findByUserIdAndGameKey: jest.fn(),
|
||||
findByUserId: jest.fn(),
|
||||
findByGameKey: jest.fn(),
|
||||
save: jest.fn(),
|
||||
saveMany: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
} as any;
|
||||
|
||||
useCase = new UpsertExternalGameRatingUseCase(mockRepository);
|
||||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should create a new profile when it does not exist', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 85.5 },
|
||||
{ type: 'skill', value: 92.0 },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
verified: true,
|
||||
},
|
||||
};
|
||||
|
||||
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
|
||||
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.action).toBe('created');
|
||||
expect(result.profile.userId).toBe('user-123');
|
||||
expect(result.profile.gameKey).toBe('iracing');
|
||||
expect(result.profile.ratingCount).toBe(2);
|
||||
expect(result.profile.ratingTypes).toEqual(['safety', 'skill']);
|
||||
expect(result.profile.verified).toBe(true);
|
||||
|
||||
expect(mockRepository.findByUserIdAndGameKey).toHaveBeenCalledWith('user-123', 'iracing');
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should update existing profile', async () => {
|
||||
const existingProfile = createTestProfile('user-123', 'iracing');
|
||||
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(existingProfile);
|
||||
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
|
||||
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: 90.0 },
|
||||
{ type: 'newType', value: 88.0 },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-02T00:00:00Z',
|
||||
verified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.action).toBe('updated');
|
||||
expect(result.profile.ratingCount).toBe(2);
|
||||
expect(result.profile.ratingTypes).toEqual(['safety', 'newType']);
|
||||
expect(result.profile.verified).toBe(false);
|
||||
|
||||
expect(mockRepository.findByUserIdAndGameKey).toHaveBeenCalledWith('user-123', 'iracing');
|
||||
expect(mockRepository.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle validation errors - missing userId', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: '',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('User ID is required');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle validation errors - missing gameKey', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: '',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Game key is required');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle validation errors - empty ratings', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Ratings are required');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle validation errors - invalid rating value', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: 'safety', value: NaN },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Rating value for type safety must be a valid number');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle validation errors - missing provenance source', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: '',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Provenance source is required');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle validation errors - invalid date', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: 'invalid-date',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Provenance lastSyncedAt must be a valid date');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle repository errors gracefully', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockRepository.findByUserIdAndGameKey as any).mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toContain('Database connection failed');
|
||||
expect(mockRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should default verified to false when not provided', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [{ type: 'safety', value: 85.5 }],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
// verified not provided
|
||||
},
|
||||
};
|
||||
|
||||
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
|
||||
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.profile.verified).toBe(false);
|
||||
});
|
||||
|
||||
it('should trim rating types', async () => {
|
||||
const input: UpsertExternalGameRatingInput = {
|
||||
userId: 'user-123',
|
||||
gameKey: 'iracing',
|
||||
ratings: [
|
||||
{ type: ' safety ', value: 85.5 },
|
||||
],
|
||||
provenance: {
|
||||
source: 'iracing',
|
||||
lastSyncedAt: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
(mockRepository.findByUserIdAndGameKey as any).mockResolvedValue(null);
|
||||
(mockRepository.save as any).mockImplementation(async (profile: any) => profile);
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.profile.ratingTypes).toEqual(['safety']);
|
||||
});
|
||||
});
|
||||
|
||||
function createTestProfile(userId: string, gameKey: string): ExternalGameRatingProfile {
|
||||
const user = UserId.fromString(userId);
|
||||
const game = GameKey.create(gameKey);
|
||||
const ratings = new Map([
|
||||
['safety', ExternalRating.create(game, 'safety', 85.5)],
|
||||
]);
|
||||
const provenance = ExternalRatingProvenance.create({
|
||||
source: gameKey,
|
||||
lastSyncedAt: new Date('2024-01-01'),
|
||||
verified: false,
|
||||
});
|
||||
|
||||
return ExternalGameRatingProfile.create({
|
||||
userId: user,
|
||||
gameKey: game,
|
||||
ratings,
|
||||
provenance,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { IExternalGameRatingRepository } from '../../domain/repositories/IExternalGameRatingRepository';
|
||||
import { ExternalGameRatingProfile } from '../../domain/entities/ExternalGameRatingProfile';
|
||||
import { UserId } from '../../domain/value-objects/UserId';
|
||||
import { GameKey } from '../../domain/value-objects/GameKey';
|
||||
import { ExternalRating } from '../../domain/value-objects/ExternalRating';
|
||||
import { ExternalRatingProvenance } from '../../domain/value-objects/ExternalRatingProvenance';
|
||||
import { UpsertExternalGameRatingInput, UpsertExternalGameRatingOutput } from '../dtos/UpsertExternalGameRatingDto';
|
||||
|
||||
/**
|
||||
* Use Case: UpsertExternalGameRatingUseCase
|
||||
*
|
||||
* Upserts external game rating profile with latest data and provenance.
|
||||
* Follows CQRS Light: command side operation.
|
||||
*
|
||||
* Store/display only, no compute.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Validate input
|
||||
* 2. Check if profile exists
|
||||
* 3. Create or update profile with latest ratings
|
||||
* 4. Save to repository
|
||||
* 5. Return result
|
||||
*/
|
||||
export class UpsertExternalGameRatingUseCase {
|
||||
constructor(
|
||||
private readonly externalGameRatingRepository: IExternalGameRatingRepository
|
||||
) {}
|
||||
|
||||
async execute(input: UpsertExternalGameRatingInput): Promise<UpsertExternalGameRatingOutput> {
|
||||
try {
|
||||
// 1. Validate input
|
||||
const validationError = this.validateInput(input);
|
||||
if (validationError) {
|
||||
return {
|
||||
success: false,
|
||||
profile: {
|
||||
userId: input.userId,
|
||||
gameKey: input.gameKey,
|
||||
ratingCount: 0,
|
||||
ratingTypes: [],
|
||||
source: input.provenance.source,
|
||||
lastSyncedAt: input.provenance.lastSyncedAt,
|
||||
verified: input.provenance.verified ?? false,
|
||||
},
|
||||
action: 'created',
|
||||
errors: [validationError],
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Check if profile exists
|
||||
const existingProfile = await this.externalGameRatingRepository.findByUserIdAndGameKey(
|
||||
input.userId,
|
||||
input.gameKey
|
||||
);
|
||||
|
||||
// 3. Create or update profile
|
||||
let profile: ExternalGameRatingProfile;
|
||||
let action: 'created' | 'updated';
|
||||
|
||||
if (existingProfile) {
|
||||
// Update existing profile
|
||||
const ratingsMap = this.createRatingsMap(input.ratings, input.gameKey);
|
||||
const provenance = ExternalRatingProvenance.create({
|
||||
source: input.provenance.source,
|
||||
lastSyncedAt: new Date(input.provenance.lastSyncedAt),
|
||||
verified: input.provenance.verified ?? false,
|
||||
});
|
||||
|
||||
existingProfile.updateRatings(ratingsMap, provenance);
|
||||
profile = existingProfile;
|
||||
action = 'updated';
|
||||
} else {
|
||||
// Create new profile
|
||||
profile = this.createProfile(input);
|
||||
action = 'created';
|
||||
}
|
||||
|
||||
// 4. Save to repository
|
||||
const savedProfile = await this.externalGameRatingRepository.save(profile);
|
||||
|
||||
// 5. Return result
|
||||
const summary = savedProfile.toSummary();
|
||||
return {
|
||||
success: true,
|
||||
profile: {
|
||||
userId: summary.userId,
|
||||
gameKey: summary.gameKey,
|
||||
ratingCount: summary.ratingCount,
|
||||
ratingTypes: summary.ratingTypes,
|
||||
source: summary.source,
|
||||
lastSyncedAt: summary.lastSyncedAt.toISOString(),
|
||||
verified: summary.verified,
|
||||
},
|
||||
action,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return {
|
||||
success: false,
|
||||
profile: {
|
||||
userId: input.userId,
|
||||
gameKey: input.gameKey,
|
||||
ratingCount: 0,
|
||||
ratingTypes: [],
|
||||
source: input.provenance.source,
|
||||
lastSyncedAt: input.provenance.lastSyncedAt,
|
||||
verified: input.provenance.verified ?? false,
|
||||
},
|
||||
action: 'created',
|
||||
errors: [errorMessage],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(input: UpsertExternalGameRatingInput): string | null {
|
||||
if (!input.userId || input.userId.trim().length === 0) {
|
||||
return 'User ID is required';
|
||||
}
|
||||
|
||||
if (!input.gameKey || input.gameKey.trim().length === 0) {
|
||||
return 'Game key is required';
|
||||
}
|
||||
|
||||
if (!input.ratings || input.ratings.length === 0) {
|
||||
return 'Ratings are required';
|
||||
}
|
||||
|
||||
for (const rating of input.ratings) {
|
||||
if (!rating.type || rating.type.trim().length === 0) {
|
||||
return 'Rating type cannot be empty';
|
||||
}
|
||||
if (typeof rating.value !== 'number' || isNaN(rating.value)) {
|
||||
return `Rating value for type ${rating.type} must be a valid number`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.provenance.source || input.provenance.source.trim().length === 0) {
|
||||
return 'Provenance source is required';
|
||||
}
|
||||
|
||||
const syncDate = new Date(input.provenance.lastSyncedAt);
|
||||
if (isNaN(syncDate.getTime())) {
|
||||
return 'Provenance lastSyncedAt must be a valid date';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private createRatingsMap(
|
||||
ratingsData: Array<{ type: string; value: number }>,
|
||||
gameKeyString: string
|
||||
): Map<string, ExternalRating> {
|
||||
const gameKey = GameKey.create(gameKeyString);
|
||||
const ratingsMap = new Map<string, ExternalRating>();
|
||||
|
||||
for (const ratingData of ratingsData) {
|
||||
const rating = ExternalRating.create(
|
||||
gameKey,
|
||||
ratingData.type.trim(),
|
||||
ratingData.value
|
||||
);
|
||||
ratingsMap.set(ratingData.type.trim(), rating);
|
||||
}
|
||||
|
||||
return ratingsMap;
|
||||
}
|
||||
|
||||
private createProfile(input: UpsertExternalGameRatingInput): ExternalGameRatingProfile {
|
||||
const userId = UserId.fromString(input.userId);
|
||||
const gameKey = GameKey.create(input.gameKey);
|
||||
const ratingsMap = this.createRatingsMap(input.ratings, input.gameKey);
|
||||
const provenance = ExternalRatingProvenance.create({
|
||||
source: input.provenance.source,
|
||||
lastSyncedAt: new Date(input.provenance.lastSyncedAt),
|
||||
verified: input.provenance.verified ?? false,
|
||||
});
|
||||
|
||||
return ExternalGameRatingProfile.create({
|
||||
userId,
|
||||
gameKey,
|
||||
ratings: ratingsMap,
|
||||
provenance,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user