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

418 lines
15 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, Mock } from 'vitest';
import {
CompleteRaceUseCaseWithRatings,
type CompleteRaceWithRatingsInput,
type CompleteRaceWithRatingsResult,
} from './CompleteRaceUseCaseWithRatings';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { IRaceRegistrationRepository } from '../../domain/repositories/IRaceRegistrationRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
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;
let raceRepository: {
findById: Mock;
update: Mock;
};
let raceRegistrationRepository: {
getRegisteredDrivers: Mock;
};
let resultRepository: {
create: Mock;
};
let standingRepository: {
findByDriverIdAndLeagueId: Mock;
save: Mock;
};
let driverRatingProvider: {
getRatings: Mock;
};
let ratingUpdateService: {
updateDriverRatingsAfterRace: Mock;
recordRaceRatingEvents: Mock;
};
let raceResultsProvider: {
getRaceResults: Mock;
hasRaceResults: Mock;
};
let output: { present: Mock };
beforeEach(() => {
raceRepository = {
findById: vi.fn(),
update: vi.fn(),
};
raceRegistrationRepository = {
getRegisteredDrivers: vi.fn(),
};
resultRepository = {
create: vi.fn(),
};
standingRepository = {
findByDriverIdAndLeagueId: vi.fn(),
save: vi.fn(),
};
driverRatingProvider = {
getRatings: vi.fn(),
};
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,
resultRepository as unknown as IResultRepository,
standingRepository as unknown as IStandingRepository,
driverRatingProvider,
ratingUpdateService as unknown as RatingUpdateService,
output as unknown as UseCaseOutputPort<CompleteRaceWithRatingsResult>,
);
});
it('completes race with ratings when race exists and has registered drivers', 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);
ratingUpdateService.updateDriverRatingsAfterRace.mockResolvedValue(undefined);
raceRepository.update.mockResolvedValue(undefined);
const result = await useCase.execute(command);
expect(result.isOk()).toBe(true);
expect(result.unwrap()).toBeUndefined();
expect(raceRepository.findById).toHaveBeenCalledWith('race-1');
expect(raceRegistrationRepository.getRegisteredDrivers).toHaveBeenCalledWith('race-1');
expect(driverRatingProvider.getRatings).toHaveBeenCalledWith(['driver-1', 'driver-2']);
expect(resultRepository.create).toHaveBeenCalledTimes(2);
expect(standingRepository.save).toHaveBeenCalledTimes(2);
expect(ratingUpdateService.updateDriverRatingsAfterRace).toHaveBeenCalled();
expect(mockRace.complete).toHaveBeenCalled();
expect(raceRepository.update).toHaveBeenCalledWith({ id: 'race-1', status: 'completed' });
expect(output.present).toHaveBeenCalledTimes(1);
expect(output.present).toHaveBeenCalledWith({
raceId: 'race-1',
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
});
});
it('returns error when race does not exist', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
raceRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('RACE_NOT_FOUND');
expect(output.present).not.toHaveBeenCalled();
});
it('returns error when race is already completed', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'completed',
complete: vi.fn(),
};
raceRepository.findById.mockResolvedValue(mockRace);
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('ALREADY_COMPLETED');
expect(output.present).not.toHaveBeenCalled();
expect(raceRegistrationRepository.getRegisteredDrivers).not.toHaveBeenCalled();
});
it('returns error when no registered drivers', async () => {
const command: CompleteRaceWithRatingsInput = {
raceId: 'race-1',
};
const mockRace = {
id: 'race-1',
leagueId: 'league-1',
status: 'scheduled',
complete: vi.fn(),
};
raceRepository.findById.mockResolvedValue(mockRace);
raceRegistrationRepository.getRegisteredDrivers.mockResolvedValue([]);
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().code).toBe('NO_REGISTERED_DRIVERS');
expect(output.present).not.toHaveBeenCalled();
});
it('returns rating update error when rating service throws', 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.mockRejectedValue(new Error('Rating error'));
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('RATING_UPDATE_FAILED');
expect(error.details?.message).toBe('Rating error');
expect(output.present).not.toHaveBeenCalled();
});
it('returns repository error when persistence 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.mockRejectedValue(new Error('DB error'));
const result = await useCase.execute(command);
expect(result.isErr()).toBe(true);
const error = result.unwrapErr();
expect(error.code).toBe('REPOSITORY_ERROR');
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();
});
});
});