284 lines
9.8 KiB
TypeScript
284 lines
9.8 KiB
TypeScript
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
|
import { Session } from '@core/racing/domain/entities/Session';
|
|
import { RaceEvent } from '@core/racing/domain/entities/RaceEvent';
|
|
import { SessionType } from '@core/racing/domain/value-objects/SessionType';
|
|
import { MainRaceCompletedEvent } from '@core/racing/domain/events/MainRaceCompleted';
|
|
import { RaceEventStewardingClosedEvent } from '@core/racing/domain/events/RaceEventStewardingClosed';
|
|
import { SendPerformanceSummaryUseCase } from '@core/racing/application/use-cases/SendPerformanceSummaryUseCase';
|
|
import { SendFinalResultsUseCase } from '@core/racing/application/use-cases/SendFinalResultsUseCase';
|
|
import { CloseRaceEventStewardingUseCase } from '@core/racing/application/use-cases/CloseRaceEventStewardingUseCase';
|
|
import { InMemoryRaceEventRepository } from '@core/racing/infrastructure/repositories/InMemoryRaceEventRepository';
|
|
import { InMemorySessionRepository } from '@core/racing/infrastructure/repositories/InMemorySessionRepository';
|
|
|
|
// Mock notification service
|
|
const mockNotificationService = {
|
|
sendNotification: vi.fn(),
|
|
};
|
|
|
|
// Test data builders
|
|
const createTestSession = (overrides: Partial<{
|
|
id: string;
|
|
raceEventId: string;
|
|
sessionType: SessionType;
|
|
status: 'scheduled' | 'running' | 'completed';
|
|
scheduledAt: Date;
|
|
}> = {}) => {
|
|
return Session.create({
|
|
id: overrides.id ?? 'session-1',
|
|
raceEventId: overrides.raceEventId ?? 'race-event-1',
|
|
scheduledAt: overrides.scheduledAt ?? new Date(),
|
|
track: 'Monza',
|
|
car: 'F1 Car',
|
|
sessionType: overrides.sessionType ?? SessionType.main(),
|
|
status: overrides.status ?? 'scheduled',
|
|
});
|
|
};
|
|
|
|
const createTestRaceEvent = (overrides: Partial<{
|
|
id: string;
|
|
seasonId: string;
|
|
leagueId: string;
|
|
name: string;
|
|
sessions: Session[];
|
|
status: 'scheduled' | 'in_progress' | 'awaiting_stewarding' | 'closed';
|
|
stewardingClosesAt: Date;
|
|
}> = {}) => {
|
|
const sessions = overrides.sessions ?? [
|
|
createTestSession({ id: 'practice-1', sessionType: SessionType.practice() }),
|
|
createTestSession({ id: 'qualifying-1', sessionType: SessionType.qualifying() }),
|
|
createTestSession({ id: 'main-1', sessionType: SessionType.main() }),
|
|
];
|
|
|
|
return RaceEvent.create({
|
|
id: overrides.id ?? 'race-event-1',
|
|
seasonId: overrides.seasonId ?? 'season-1',
|
|
leagueId: overrides.leagueId ?? 'league-1',
|
|
name: overrides.name ?? 'Monza Grand Prix',
|
|
sessions,
|
|
status: overrides.status ?? 'scheduled',
|
|
stewardingClosesAt: overrides.stewardingClosesAt,
|
|
});
|
|
};
|
|
|
|
describe('Race Event Performance Summary Notifications', () => {
|
|
let raceEventRepository: InMemoryRaceEventRepository;
|
|
let sessionRepository: InMemorySessionRepository;
|
|
let sendPerformanceSummaryUseCase: SendPerformanceSummaryUseCase;
|
|
let sendFinalResultsUseCase: SendFinalResultsUseCase;
|
|
let closeStewardingUseCase: CloseRaceEventStewardingUseCase;
|
|
|
|
beforeEach(() => {
|
|
raceEventRepository = new InMemoryRaceEventRepository();
|
|
sessionRepository = new InMemorySessionRepository();
|
|
sendPerformanceSummaryUseCase = new SendPerformanceSummaryUseCase(
|
|
mockNotificationService as any,
|
|
raceEventRepository as any,
|
|
{} as any // Mock result repository
|
|
);
|
|
sendFinalResultsUseCase = new SendFinalResultsUseCase(
|
|
mockNotificationService as any,
|
|
raceEventRepository as any,
|
|
{} as any // Mock result repository
|
|
);
|
|
closeStewardingUseCase = new CloseRaceEventStewardingUseCase(
|
|
raceEventRepository as any,
|
|
{} as any // Mock domain event publisher
|
|
);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Performance Summary After Main Race Completion', () => {
|
|
it('should send performance summary notification when main race completes', async () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent();
|
|
await raceEventRepository.create(raceEvent);
|
|
|
|
const mainRaceCompletedEvent = new MainRaceCompletedEvent({
|
|
raceEventId: raceEvent.id,
|
|
sessionId: 'main-1',
|
|
leagueId: raceEvent.leagueId,
|
|
seasonId: raceEvent.seasonId,
|
|
completedAt: new Date(),
|
|
driverIds: ['driver-1', 'driver-2'],
|
|
});
|
|
|
|
// When
|
|
await sendPerformanceSummaryUseCase.execute(mainRaceCompletedEvent);
|
|
|
|
// Then
|
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
recipientId: 'driver-1',
|
|
type: 'race_performance_summary',
|
|
urgency: 'modal',
|
|
title: expect.stringContaining('Race Complete'),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should calculate provisional rating changes correctly', async () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent();
|
|
await raceEventRepository.create(raceEvent);
|
|
|
|
// Mock result repository to return position data
|
|
const mockResultRepository = {
|
|
findByRaceId: vi.fn().mockResolvedValue([
|
|
{ driverId: 'driver-1', position: 1, incidents: 0, getPositionChange: () => 0 },
|
|
]),
|
|
};
|
|
|
|
const useCase = new SendPerformanceSummaryUseCase(
|
|
mockNotificationService as any,
|
|
raceEventRepository as any,
|
|
mockResultRepository as any
|
|
);
|
|
|
|
const event = new MainRaceCompletedEvent({
|
|
raceEventId: raceEvent.id,
|
|
sessionId: 'main-1',
|
|
leagueId: raceEvent.leagueId,
|
|
seasonId: raceEvent.seasonId,
|
|
completedAt: new Date(),
|
|
driverIds: ['driver-1'],
|
|
});
|
|
|
|
// When
|
|
await useCase.execute(event);
|
|
|
|
// Then
|
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
provisionalRatingChange: 25, // P1 with 0 incidents = +25
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Final Results After Stewarding Closes', () => {
|
|
it('should send final results notification when stewarding closes', async () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
|
await raceEventRepository.create(raceEvent);
|
|
|
|
const stewardingClosedEvent = new RaceEventStewardingClosedEvent({
|
|
raceEventId: raceEvent.id,
|
|
leagueId: raceEvent.leagueId,
|
|
seasonId: raceEvent.seasonId,
|
|
closedAt: new Date(),
|
|
driverIds: ['driver-1', 'driver-2'],
|
|
hadPenaltiesApplied: false,
|
|
});
|
|
|
|
// When
|
|
await sendFinalResultsUseCase.execute(stewardingClosedEvent);
|
|
|
|
// Then
|
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledTimes(2);
|
|
expect(mockNotificationService.sendNotification).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
recipientId: 'driver-1',
|
|
type: 'race_final_results',
|
|
urgency: 'modal',
|
|
title: expect.stringContaining('Final Results'),
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Stewarding Window Management', () => {
|
|
it('should close expired stewarding windows', async () => {
|
|
// Given
|
|
const pastDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
|
|
const raceEvent = createTestRaceEvent({
|
|
status: 'awaiting_stewarding',
|
|
stewardingClosesAt: pastDate,
|
|
});
|
|
await raceEventRepository.create(raceEvent);
|
|
|
|
// When
|
|
await closeStewardingUseCase.execute({});
|
|
|
|
// Then
|
|
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
|
expect(updatedEvent?.status).toBe('closed');
|
|
});
|
|
|
|
it('should not close unexpired stewarding windows', async () => {
|
|
// Given
|
|
const futureDate = new Date(Date.now() + 25 * 60 * 60 * 1000); // 25 hours from now
|
|
const raceEvent = createTestRaceEvent({
|
|
status: 'awaiting_stewarding',
|
|
stewardingClosesAt: futureDate,
|
|
});
|
|
await raceEventRepository.create(raceEvent);
|
|
|
|
// When
|
|
await closeStewardingUseCase.execute({});
|
|
|
|
// Then
|
|
const updatedEvent = await raceEventRepository.findById(raceEvent.id);
|
|
expect(updatedEvent?.status).toBe('awaiting_stewarding');
|
|
});
|
|
});
|
|
|
|
describe('Race Event Lifecycle', () => {
|
|
it('should transition from scheduled to in_progress when sessions start', () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent({ status: 'scheduled' });
|
|
|
|
// When
|
|
const startedEvent = raceEvent.start();
|
|
|
|
// Then
|
|
expect(startedEvent.status).toBe('in_progress');
|
|
});
|
|
|
|
it('should transition to awaiting_stewarding when main race completes', () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent({ status: 'in_progress' });
|
|
|
|
// When
|
|
const completedEvent = raceEvent.completeMainRace();
|
|
|
|
// Then
|
|
expect(completedEvent.status).toBe('awaiting_stewarding');
|
|
});
|
|
|
|
it('should transition to closed when stewarding closes', () => {
|
|
// Given
|
|
const raceEvent = createTestRaceEvent({ status: 'awaiting_stewarding' });
|
|
|
|
// When
|
|
const closedEvent = raceEvent.closeStewarding();
|
|
|
|
// Then
|
|
expect(closedEvent.status).toBe('closed');
|
|
});
|
|
});
|
|
|
|
describe('Session Type Behavior', () => {
|
|
it('should identify main race sessions correctly', () => {
|
|
// Given
|
|
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
|
const practiceSession = createTestSession({ sessionType: SessionType.practice() });
|
|
|
|
// Then
|
|
expect(mainSession.countsForPoints()).toBe(true);
|
|
expect(practiceSession.countsForPoints()).toBe(false);
|
|
});
|
|
|
|
it('should identify qualifying sessions correctly', () => {
|
|
// Given
|
|
const qualiSession = createTestSession({ sessionType: SessionType.qualifying() });
|
|
const mainSession = createTestSession({ sessionType: SessionType.main() });
|
|
|
|
// Then
|
|
expect(qualiSession.determinesGrid()).toBe(true);
|
|
expect(mainSession.determinesGrid()).toBe(false);
|
|
});
|
|
});
|
|
}); |