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

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

View File

@@ -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(),