This commit is contained in:
2025-12-29 22:27:33 +01:00
parent 3f610c1cb6
commit 7a853d4e43
96 changed files with 14790 additions and 111 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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