Files
gridpilot.gg/core/identity/application/use-cases/RecordRaceRatingEventsUseCase.integration.test.ts
2025-12-29 22:27:33 +01:00

430 lines
14 KiB
TypeScript

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();
});
});
});