rating
This commit is contained in:
@@ -10,6 +10,7 @@ import type { IResultRepository } from '../../domain/repositories/IResultReposit
|
||||
import type { IStandingRepository } from '../../domain/repositories/IStandingRepository';
|
||||
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
|
||||
|
||||
describe('CompleteRaceUseCaseWithRatings', () => {
|
||||
let useCase: CompleteRaceUseCaseWithRatings;
|
||||
@@ -32,6 +33,11 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
||||
};
|
||||
let ratingUpdateService: {
|
||||
updateDriverRatingsAfterRace: Mock;
|
||||
recordRaceRatingEvents: Mock;
|
||||
};
|
||||
let raceResultsProvider: {
|
||||
getRaceResults: Mock;
|
||||
hasRaceResults: Mock;
|
||||
};
|
||||
let output: { present: Mock };
|
||||
|
||||
@@ -55,9 +61,15 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
||||
};
|
||||
ratingUpdateService = {
|
||||
updateDriverRatingsAfterRace: vi.fn(),
|
||||
recordRaceRatingEvents: vi.fn(),
|
||||
};
|
||||
raceResultsProvider = {
|
||||
getRaceResults: vi.fn(),
|
||||
hasRaceResults: vi.fn(),
|
||||
};
|
||||
output = { present: vi.fn() };
|
||||
|
||||
// Test without raceResultsProvider (backward compatible mode)
|
||||
useCase = new CompleteRaceUseCaseWithRatings(
|
||||
raceRepository as unknown as IRaceRepository,
|
||||
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
|
||||
@@ -221,4 +233,185 @@ describe('CompleteRaceUseCaseWithRatings', () => {
|
||||
expect(error.details?.message).toBe('DB error');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// SLICE 7: New tests for ledger-based approach
|
||||
describe('Ledger-based rating updates (Slice 7)', () => {
|
||||
let useCaseWithLedger: CompleteRaceUseCaseWithRatings;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create use case with raceResultsProvider for ledger mode
|
||||
useCaseWithLedger = new CompleteRaceUseCaseWithRatings(
|
||||
raceRepository as unknown as IRaceRepository,
|
||||
raceRegistrationRepository as unknown as IRaceRegistrationRepository,
|
||||
resultRepository as unknown as IResultRepository,
|
||||
standingRepository as unknown as IStandingRepository,
|
||||
driverRatingProvider,
|
||||
ratingUpdateService as unknown as RatingUpdateService,
|
||||
output as unknown as UseCaseOutputPort<CompleteRaceWithRatingsResult>,
|
||||
raceResultsProvider as unknown as IRaceResultsProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes race with ledger-based rating updates when raceResultsProvider is available', async () => {
|
||||
const command: CompleteRaceWithRatingsInput = {
|
||||
raceId: 'race-1',
|
||||
};
|
||||
|
||||
const mockRace = {
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
status: 'scheduled',
|
||||
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
|
||||
};
|
||||
raceRepository.findById.mockResolvedValue(mockRace);
|
||||
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1', 'driver-2']);
|
||||
driverRatingProvider.getRatings.mockReturnValue(
|
||||
new Map([
|
||||
['driver-1', 1600],
|
||||
['driver-2', 1500],
|
||||
]),
|
||||
);
|
||||
resultRepository.create.mockResolvedValue(undefined);
|
||||
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
|
||||
standingRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
// Mock ledger-based rating update
|
||||
ratingUpdateService.recordRaceRatingEvents.mockResolvedValue({
|
||||
success: true,
|
||||
eventsCreated: 4,
|
||||
driversUpdated: ['driver-1', 'driver-2'],
|
||||
});
|
||||
|
||||
raceRepository.update.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCaseWithLedger.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.unwrap()).toBeUndefined();
|
||||
|
||||
// Verify ledger-based approach was used
|
||||
expect(ratingUpdateService.recordRaceRatingEvents).toHaveBeenCalledWith(
|
||||
'race-1',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
userId: 'driver-1',
|
||||
startPos: expect.any(Number),
|
||||
finishPos: expect.any(Number),
|
||||
incidents: expect.any(Number),
|
||||
status: 'finished',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Verify legacy method was NOT called
|
||||
expect(ratingUpdateService.updateDriverRatingsAfterRace).not.toHaveBeenCalled();
|
||||
|
||||
expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' });
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
raceId: 'race-1',
|
||||
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to legacy approach when ledger update fails', async () => {
|
||||
const command: CompleteRaceWithRatingsInput = {
|
||||
raceId: 'race-1',
|
||||
};
|
||||
|
||||
const mockRace = {
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
status: 'scheduled',
|
||||
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
|
||||
};
|
||||
raceRepository.findById.mockResolvedValue(mockRace);
|
||||
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
|
||||
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
|
||||
resultRepository.create.mockResolvedValue(undefined);
|
||||
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
|
||||
standingRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
// Mock ledger-based rating update failure
|
||||
ratingUpdateService.recordRaceRatingEvents.mockResolvedValue({
|
||||
success: false,
|
||||
eventsCreated: 0,
|
||||
driversUpdated: [],
|
||||
});
|
||||
|
||||
// Legacy method should be called as fallback
|
||||
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
|
||||
raceRepository.update.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCaseWithLedger.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
// Verify both methods were called (ledger attempted, then fallback)
|
||||
expect(ratingUpdateService.recordRaceRatingEvents).toHaveBeenCalled();
|
||||
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles ledger update errors gracefully', async () => {
|
||||
const command: CompleteRaceWithRatingsInput = {
|
||||
raceId: 'race-1',
|
||||
};
|
||||
|
||||
const mockRace = {
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
status: 'scheduled',
|
||||
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
|
||||
};
|
||||
raceRepository.findById.mockResolvedValue(mockRace);
|
||||
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
|
||||
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
|
||||
resultRepository.create.mockResolvedValue(undefined);
|
||||
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
|
||||
standingRepository.save.mockResolvedValue(undefined);
|
||||
|
||||
// Mock ledger-based rating update throwing error
|
||||
ratingUpdateService.recordRaceRatingEvents.mockRejectedValue(new Error('Ledger error'));
|
||||
|
||||
// Legacy method should be called as fallback
|
||||
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
|
||||
raceRepository.update.mockResolvedValue(undefined);
|
||||
|
||||
const result = await useCaseWithLedger.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
// Verify fallback was used
|
||||
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still works without raceResultsProvider (backward compatibility)', async () => {
|
||||
const command: CompleteRaceWithRatingsInput = {
|
||||
raceId: 'race-1',
|
||||
};
|
||||
|
||||
const mockRace = {
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
status: 'scheduled',
|
||||
complete: vi.fn().mockReturnValue({ id: 'race-1', status: 'completed' }),
|
||||
};
|
||||
raceRepository.findById.mockResolvedValue(mockRace);
|
||||
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue(['driver-1']);
|
||||
driverRatingProvider.getRatings.mockReturnValue(new Map([['driver-1', 1600]]));
|
||||
resultRepository.create.mockResolvedValue(undefined);
|
||||
standingRepository.findByDriverIdAndLeagueId.mockResolvedValue(null);
|
||||
standingRepository.save.mockResolvedValue(undefined);
|
||||
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
|
||||
raceRepository.update.mockResolvedValue(undefined);
|
||||
|
||||
// Use original useCase without raceResultsProvider
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
|
||||
// Verify only legacy method was called
|
||||
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
|
||||
expect(ratingUpdateService.recordRaceRatingEvents).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdate
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import type { IRaceResultsProvider } from '@core/identity/application/ports/IRaceResultsProvider';
|
||||
|
||||
export interface CompleteRaceWithRatingsInput {
|
||||
raceId: string;
|
||||
@@ -32,6 +33,7 @@ interface DriverRatingProvider {
|
||||
|
||||
/**
|
||||
* Enhanced CompleteRaceUseCase that includes rating updates.
|
||||
* EVOLVED (Slice 7): Now uses ledger-based rating updates for transparency and auditability.
|
||||
*/
|
||||
export class CompleteRaceUseCaseWithRatings {
|
||||
constructor(
|
||||
@@ -42,6 +44,7 @@ export class CompleteRaceUseCaseWithRatings {
|
||||
private readonly driverRatingProvider: DriverRatingProvider,
|
||||
private readonly ratingUpdateService: RatingUpdateService,
|
||||
private readonly output: UseCaseOutputPort<CompleteRaceWithRatingsResult>,
|
||||
private readonly raceResultsProvider?: IRaceResultsProvider, // Optional: for new ledger flow
|
||||
) {}
|
||||
|
||||
async execute(command: CompleteRaceWithRatingsInput): Promise<
|
||||
@@ -93,8 +96,41 @@ export class CompleteRaceUseCaseWithRatings {
|
||||
|
||||
await this.updateStandings(race.leagueId, results);
|
||||
|
||||
// SLICE 7: Use new ledger-based approach if raceResultsProvider is available
|
||||
// This provides backward compatibility while evolving to event-driven architecture
|
||||
try {
|
||||
await this.updateDriverRatings(results, registeredDriverIds.length);
|
||||
if (this.raceResultsProvider) {
|
||||
// NEW LEDGER APPROACH: Use RecordRaceRatingEventsUseCase via RatingUpdateService
|
||||
const raceResultsData = {
|
||||
raceId,
|
||||
results: results.map(result => ({
|
||||
userId: result.driverId.toString(),
|
||||
startPos: result.startPosition.toNumber(),
|
||||
finishPos: result.position.toNumber(),
|
||||
incidents: result.incidents.toNumber(),
|
||||
status: 'finished' as const, // RaceResultGenerator only generates finished results
|
||||
})),
|
||||
};
|
||||
|
||||
try {
|
||||
const ratingResult = await this.ratingUpdateService.recordRaceRatingEvents(
|
||||
raceId,
|
||||
raceResultsData.results
|
||||
);
|
||||
|
||||
if (!ratingResult.success) {
|
||||
console.warn(`[Slice 7] Ledger-based rating update failed for race ${raceId}, falling back to legacy method`);
|
||||
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
|
||||
}
|
||||
} catch (error) {
|
||||
// If ledger approach throws error, fall back to legacy method
|
||||
console.warn(`[Slice 7] Ledger-based rating update threw error for race ${raceId}, falling back to legacy method: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
|
||||
}
|
||||
} else {
|
||||
// BACKWARD COMPATIBLE: Use legacy direct update approach
|
||||
await this.updateDriverRatingsLegacy(results, registeredDriverIds.length);
|
||||
}
|
||||
} catch (error) {
|
||||
return Result.err({
|
||||
code: 'RATING_UPDATE_FAILED',
|
||||
@@ -161,7 +197,11 @@ export class CompleteRaceUseCaseWithRatings {
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDriverRatings(results: RaceResult[], totalDrivers: number): Promise<void> {
|
||||
/**
|
||||
* Legacy rating update method (BACKWARD COMPATIBLE)
|
||||
* Uses direct updates via RatingUpdateService
|
||||
*/
|
||||
private async updateDriverRatingsLegacy(results: RaceResult[], totalDrivers: number): Promise<void> {
|
||||
const driverResults = results.map((result) => ({
|
||||
driverId: result.driverId.toString(),
|
||||
position: result.position.toNumber(),
|
||||
|
||||
Reference in New Issue
Block a user