Files
gridpilot.gg/core/racing/application/use-cases/CompleteRaceUseCaseWithRatings.test.ts
2026-01-16 19:46:49 +01:00

406 lines
15 KiB
TypeScript

import type { RaceResultsProvider } from '@core/identity/application/ports/RaceResultsProvider';
import { RatingUpdateService } from '@core/identity/domain/services/RatingUpdateService';
import { beforeEach, describe, expect, it, Mock, vi } from 'vitest';
import type { RaceRegistrationRepository } from '../../domain/repositories/RaceRegistrationRepository';
import type { RaceRepository } from '../../domain/repositories/RaceRepository';
import type { ResultRepository } from '../../domain/repositories/ResultRepository';
import type { StandingRepository } from '../../domain/repositories/StandingRepository';
import {
CompleteRaceUseCaseWithRatings,
type CompleteRaceWithRatingsInput,
} from './CompleteRaceUseCaseWithRatings';
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;
};
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(),
};
// Test without raceResultsProvider (backward compatible mode)
useCase = new CompleteRaceUseCaseWithRatings(
raceRepository as unknown as RaceRepository,
raceRegistrationRepository as unknown as RaceRegistrationRepository,
resultRepository as unknown as ResultRepository,
standingRepository as unknown as StandingRepository,
driverRatingProvider,
ratingUpdateService as unknown as RatingUpdateService,
);
});
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);
const value = result.unwrap();
expect(value).toEqual({
raceId: 'race-1',
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
});
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' });
});
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');
});
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(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');
});
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');
});
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');
});
// 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 RaceRepository,
raceRegistrationRepository as unknown as RaceRegistrationRepository,
resultRepository as unknown as ResultRepository,
standingRepository as unknown as StandingRepository,
driverRatingProvider,
ratingUpdateService as unknown as RatingUpdateService,
raceResultsProvider as unknown as RaceResultsProvider,
);
});
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);
const value = result.unwrap();
expect(value).toEqual({
raceId: 'race-1',
ratingsUpdatedForDriverIds: ['driver-1', 'driver-2'],
});
// 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' });
});
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();
});
});
});