377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
import { RecordRaceRatingEventsUseCase } from './RecordRaceRatingEventsUseCase';
|
|
import { RaceResultsProvider, RaceResultsData } from '../ports/RaceResultsProvider';
|
|
import { RatingEventRepository } from '../../domain/repositories/RatingEventRepository';
|
|
import { UserRatingRepository } from '../../domain/repositories/UserRatingRepository';
|
|
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 { describe, it, expect, beforeEach } from 'vitest';
|
|
|
|
// Mock implementations
|
|
class MockRaceResultsProvider implements RaceResultsProvider {
|
|
private results: RaceResultsData | null = null;
|
|
|
|
setResults(results: RaceResultsData | null) {
|
|
this.results = results;
|
|
}
|
|
|
|
async getRaceResults(): Promise<RaceResultsData | null> {
|
|
return this.results;
|
|
}
|
|
|
|
async hasRaceResults(): Promise<boolean> {
|
|
return this.results !== null;
|
|
}
|
|
}
|
|
|
|
class MockRatingEventRepository implements RatingEventRepository {
|
|
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/RatingEventRepository').PaginatedQueryOptions): Promise<import('@core/identity/domain/repositories/RatingEventRepository').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/RatingEventRepository').PaginatedResult<RatingEvent> = {
|
|
items,
|
|
total,
|
|
limit,
|
|
offset,
|
|
hasMore
|
|
};
|
|
|
|
if (nextOffset !== undefined) {
|
|
result.nextOffset = nextOffset;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
class MockUserRatingRepository implements UserRatingRepository {
|
|
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,
|
|
},
|
|
],
|
|
});
|
|
|
|
// Set ratings for both users
|
|
mockUserRatingRepository.setRating('user-123', UserRating.create('user-123'));
|
|
mockUserRatingRepository.setRating('user-456', UserRating.create('user-456'));
|
|
|
|
// Make the repository throw an error for user-456
|
|
const originalSave = mockRatingEventRepository.save;
|
|
let user456CallCount = 0;
|
|
mockRatingEventRepository.save = async (event: RatingEvent) => {
|
|
if (event.userId === 'user-456') {
|
|
user456CallCount++;
|
|
if (user456CallCount === 1) { // Fail on first save attempt
|
|
throw new Error('Database constraint violation for user-456');
|
|
}
|
|
}
|
|
return event;
|
|
};
|
|
|
|
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);
|
|
expect(result.errors![0]).toContain('user-456');
|
|
|
|
// Restore
|
|
mockRatingEventRepository.save = originalSave;
|
|
});
|
|
|
|
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;
|
|
});
|
|
});
|
|
});
|