refactor racing use cases

This commit is contained in:
2025-12-21 00:43:42 +01:00
parent e9d6f90bb2
commit c12656d671
308 changed files with 14401 additions and 7419 deletions

View File

@@ -1,25 +1,88 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
import {
SendPerformanceSummaryUseCase,
type SendPerformanceSummaryInput,
type SendPerformanceSummaryResult,
type SendPerformanceSummaryErrorCode,
} from './SendPerformanceSummaryUseCase';
import type { NotificationService } from '../../../notifications/application/ports/NotificationService';
import type { MainRaceCompletedEvent } from '../../domain/events/MainRaceCompleted';
import type { IRaceEventRepository } from '../../domain/repositories/IRaceEventRepository';
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
import { SendPerformanceSummaryUseCase } from './SendPerformanceSummaryUseCase';
import type { ILeagueRepository } from '../../domain/repositories/ILeagueRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import type { IDriverRepository } from '../../domain/repositories/IDriverRepository';
import type { UseCaseOutputPort } from '@core/shared/application';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { Result } from '@core/shared/application/Result';
import type { Logger } from '@core/shared/application/Logger';
const unwrapError = (
result: Result<void, ApplicationErrorCode<SendPerformanceSummaryErrorCode, { message: string }>>,
): ApplicationErrorCode<SendPerformanceSummaryErrorCode, { message: string }> => {
expect(result.isErr()).toBe(true);
return result.unwrapErr();
};
describe('SendPerformanceSummaryUseCase', () => {
it('sends performance summary notifications to all participating drivers', async () => {
const mockNotificationService = {
sendNotification: vi.fn(),
} as unknown as NotificationService;
let notificationService: { sendNotification: Mock };
let raceEventRepository: { findById: Mock };
let resultRepository: { findByRaceId: Mock };
let leagueRepository: { findById: Mock };
let membershipRepository: { getMembership: Mock };
let driverRepository: { findById: Mock };
let logger: Logger;
let output: UseCaseOutputPort<SendPerformanceSummaryResult> & { present: Mock };
let useCase: SendPerformanceSummaryUseCase;
beforeEach(() => {
notificationService = { sendNotification: vi.fn() };
raceEventRepository = { findById: vi.fn() };
resultRepository = { findByRaceId: vi.fn() };
leagueRepository = { findById: vi.fn() };
membershipRepository = { getMembership: vi.fn() };
driverRepository = { findById: vi.fn() };
logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
output = { present: vi.fn() } as unknown as UseCaseOutputPort<SendPerformanceSummaryResult> & { present: Mock };
useCase = new SendPerformanceSummaryUseCase(
notificationService as unknown as NotificationService,
raceEventRepository as unknown as IRaceEventRepository,
resultRepository as unknown as IResultRepository,
leagueRepository as unknown as ILeagueRepository,
membershipRepository as unknown as ILeagueMembershipRepository,
driverRepository as unknown as IDriverRepository,
logger,
output,
);
});
const createInput = (overrides: Partial<SendPerformanceSummaryInput> = {}): SendPerformanceSummaryInput => ({
leagueId: 'league-1',
raceId: 'race-1',
driverId: 'driver-1',
triggeredById: 'driver-1',
...overrides,
});
it('sends performance summary notification and presents result on success', async () => {
const mockRaceEvent = {
id: 'race-1',
leagueId: 'league-1',
name: 'Test Race',
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1' }),
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }),
};
const mockRaceEventRepository = {
findById: vi.fn().mockResolvedValue(mockRaceEvent),
} as unknown as IRaceEventRepository;
const mockLeague = { id: 'league-1' };
const mockDriver = { id: 'driver-1' };
leagueRepository.findById.mockResolvedValue(mockLeague);
raceEventRepository.findById.mockResolvedValue(mockRaceEvent);
driverRepository.findById.mockResolvedValue(mockDriver);
const mockResults = [
{
@@ -28,117 +91,149 @@ describe('SendPerformanceSummaryUseCase', () => {
incidents: 0,
getPositionChange: vi.fn().mockReturnValue(2),
},
{
driverId: 'driver-2',
position: 2,
incidents: 1,
getPositionChange: vi.fn().mockReturnValue(-1),
},
];
const mockResultRepository = {
findByRaceId: vi.fn().mockResolvedValue(mockResults),
} as unknown as IResultRepository;
resultRepository.findByRaceId.mockResolvedValue(mockResults);
const useCase = new SendPerformanceSummaryUseCase(
mockNotificationService,
mockRaceEventRepository,
mockResultRepository,
);
const input = createInput();
const event: MainRaceCompletedEvent = {
eventType: 'MainRaceCompleted',
aggregateId: 'race-1',
occurredAt: new Date(),
eventData: {
raceEventId: 'race-1',
sessionId: 'session-1',
seasonId: 'season-1',
leagueId: 'league-1',
driverIds: ['driver-1', 'driver-2'],
completedAt: new Date(),
},
};
const result = await useCase.execute(event);
const result = await useCase.execute(input);
expect(result.isOk()).toBe(true);
expect(mockRaceEventRepository.findById).toHaveBeenCalledWith('race-1');
expect(mockResultRepository.findByRaceId).toHaveBeenCalledWith('session-1');
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
expect(result.unwrap()).toBeUndefined();
// Check first notification
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
expect(leagueRepository.findById).toHaveBeenCalledWith('league-1');
expect(raceEventRepository.findById).toHaveBeenCalledWith('race-1');
expect(driverRepository.findById).toHaveBeenCalledWith('driver-1');
expect(resultRepository.findByRaceId).toHaveBeenCalledWith('session-1');
expect(notificationService.sendNotification).toHaveBeenCalledTimes(1);
expect(notificationService.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
recipientId: 'driver-1',
type: 'race_performance_summary',
title: 'Race Complete: Test Race',
body: expect.stringContaining('You finished P1 (+2 positions). Clean race! Provisional +35 rating.'),
data: expect.objectContaining({
raceEventId: 'race-1',
sessionId: 'session-1',
leagueId: 'league-1',
position: 1,
positionChange: 2,
incidents: 0,
provisionalRatingChange: 35,
}),
}),
);
// Check second notification
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
expect.objectContaining({
recipientId: 'driver-2',
type: 'race_performance_summary',
title: 'Race Complete: Test Race',
body: expect.stringContaining('You finished P2 (-1 positions). 1 incident Provisional +20 rating.'),
data: expect.objectContaining({
position: 2,
positionChange: -1,
incidents: 1,
provisionalRatingChange: 20,
}),
}),
);
expect(output.present).toHaveBeenCalledTimes(1);
const presented = output.present.mock.calls[0][0] as SendPerformanceSummaryResult;
expect(presented).toEqual({
leagueId: 'league-1',
raceId: 'race-1',
driverId: 'driver-1',
notificationsSent: 1,
});
});
it('skips sending notifications if race event not found', async () => {
const mockNotificationService = {
sendNotification: vi.fn(),
} as unknown as NotificationService;
it('returns LEAGUE_NOT_FOUND when league does not exist', async () => {
leagueRepository.findById.mockResolvedValue(null);
const mockRaceEventRepository = {
findById: vi.fn().mockResolvedValue(null),
} as unknown as IRaceEventRepository;
const result = await useCase.execute(createInput());
const mockResultRepository = {
findByRaceId: vi.fn(),
} as unknown as IResultRepository;
const useCase = new SendPerformanceSummaryUseCase(
mockNotificationService,
mockRaceEventRepository,
mockResultRepository,
);
const event: MainRaceCompletedEvent = {
eventType: 'MainRaceCompleted',
aggregateId: 'race-1',
occurredAt: new Date(),
eventData: {
raceEventId: 'race-1',
sessionId: 'session-1',
seasonId: 'season-1',
leagueId: 'league-1',
driverIds: ['driver-1'],
completedAt: new Date(),
},
};
const result = await useCase.execute(event);
expect(result.isOk()).toBe(true);
expect(mockNotificationService.sendNotification).not.toHaveBeenCalled();
const error = unwrapError(result);
expect(error.code).toBe('LEAGUE_NOT_FOUND');
expect(error.details?.message).toBe('League not found');
expect(output.present).not.toHaveBeenCalled();
});
});
it('returns RACE_NOT_FOUND when race event does not exist', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
raceEventRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(createInput());
const error = unwrapError(result);
expect(error.code).toBe('RACE_NOT_FOUND');
expect(error.details?.message).toBe('Race event not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns DRIVER_NOT_FOUND when driver does not exist', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
raceEventRepository.findById.mockResolvedValue({
id: 'race-1',
leagueId: 'league-1',
name: 'Test Race',
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }),
});
driverRepository.findById.mockResolvedValue(null);
const result = await useCase.execute(createInput());
const error = unwrapError(result);
expect(error.code).toBe('DRIVER_NOT_FOUND');
expect(error.details?.message).toBe('Driver not found');
expect(output.present).not.toHaveBeenCalled();
});
it('returns INSUFFICIENT_PERMISSIONS when triggeredBy is not driver and not steward or higher', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
raceEventRepository.findById.mockResolvedValue({
id: 'race-1',
leagueId: 'league-1',
name: 'Test Race',
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }),
});
driverRepository.findById.mockResolvedValue({ id: 'driver-1' });
membershipRepository.getMembership.mockResolvedValue(null);
const result = await useCase.execute(createInput({ triggeredById: 'user-1' }));
const error = unwrapError(result);
expect(error.code).toBe('INSUFFICIENT_PERMISSIONS');
expect(error.details?.message).toBe('Insufficient permissions to send performance summary');
expect(output.present).not.toHaveBeenCalled();
});
it('returns SUMMARY_NOT_AVAILABLE when main race session is missing or not completed', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
raceEventRepository.findById.mockResolvedValue({
id: 'race-1',
leagueId: 'league-1',
name: 'Test Race',
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'in_progress' }),
});
driverRepository.findById.mockResolvedValue({ id: 'driver-1' });
const result = await useCase.execute(createInput());
const error = unwrapError(result);
expect(error.code).toBe('SUMMARY_NOT_AVAILABLE');
expect(error.details?.message).toBe('Performance summary is not available for this race');
expect(output.present).not.toHaveBeenCalled();
});
it('returns SUMMARY_NOT_AVAILABLE when no result exists for driver', async () => {
leagueRepository.findById.mockResolvedValue({ id: 'league-1' });
raceEventRepository.findById.mockResolvedValue({
id: 'race-1',
leagueId: 'league-1',
name: 'Test Race',
getMainRaceSession: vi.fn().mockReturnValue({ id: 'session-1', status: 'completed' }),
});
driverRepository.findById.mockResolvedValue({ id: 'driver-1' });
resultRepository.findByRaceId.mockResolvedValue([]);
const result = await useCase.execute(createInput());
const error = unwrapError(result);
expect(error.code).toBe('SUMMARY_NOT_AVAILABLE');
expect(error.details?.message).toBe('Performance summary is not available for this driver');
expect(output.present).not.toHaveBeenCalled();
});
it('wraps repository errors into REPOSITORY_ERROR and does not present output', async () => {
const mockError = new Error('Repository failure');
leagueRepository.findById.mockRejectedValue(mockError);
const result = await useCase.execute(createInput());
const error = unwrapError(result);
expect(error.code).toBe('REPOSITORY_ERROR');
expect(error.details?.message).toBe('Repository failure');
expect(output.present).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalled();
});
});