This commit is contained in:
2025-12-13 11:43:09 +01:00
parent 4b6fc668b5
commit bb0497f429
38 changed files with 3838 additions and 55 deletions

View File

@@ -0,0 +1,99 @@
Feature: Race Event Performance Summary Notifications
As a driver
I want to receive performance summary notifications after races
So that I can see my results and rating changes immediately
Background:
Given a league exists with stewarding configuration
And a season exists for that league
And a race event is scheduled with practice, qualifying, and main race sessions
Scenario: Driver receives performance summary after main race completion
Given I am a registered driver for the race event
And all sessions are scheduled
When the main race session is completed
Then a MainRaceCompleted domain event is published
And I receive a race_performance_summary notification
And the notification shows my position, incidents, and provisional rating change
And the notification has modal urgency and requires no response
Scenario: Driver receives final results after stewarding closes
Given I am a registered driver for the race event
And the main race has been completed
And the race event is in awaiting_stewarding status
When the stewarding window expires
Then a RaceEventStewardingClosed domain event is published
And I receive a race_final_results notification
And the notification shows my final position and rating change
And the notification indicates if penalties were applied
Scenario: Practice and qualifying sessions don't trigger notifications
Given I am a registered driver for the race event
When practice and qualifying sessions are completed
Then no performance summary notifications are sent
And the race event status remains in_progress
Scenario: Only main race completion triggers performance summary
Given I am a registered driver for the race event
And the race event has practice, qualifying, sprint, and main race sessions
When the sprint race session is completed
Then no performance summary notification is sent
When the main race session is completed
Then a performance summary notification is sent
Scenario: Provisional rating changes are calculated correctly
Given I finished in position 1 with 0 incidents
When the main race is completed
Then my provisional rating change should be +25 points
And the notification should display "+25 rating"
Scenario: Rating penalties are applied for incidents
Given I finished in position 5 with 3 incidents
When the main race is completed
Then my provisional rating change should be reduced by 15 points
And the notification should show the adjusted rating change
Scenario: DNF results show appropriate rating penalty
Given I did not finish the race (DNF)
When the main race is completed
Then my provisional rating change should be -10 points
And the notification should display "DNF" as position
Scenario: Stewarding close mechanism works correctly
Given a race event is awaiting_stewarding
And the stewarding window is configured for 24 hours
When 24 hours have passed since the main race completion
Then the CloseRaceEventStewardingUseCase should close the event
And final results notifications should be sent to all participants
Scenario: Race event lifecycle transitions work correctly
Given a race event is scheduled
When practice and qualifying sessions start
Then the race event status becomes in_progress
When the main race completes
Then the race event status becomes awaiting_stewarding
When stewarding closes
Then the race event status becomes closed
Scenario: Notifications include proper action buttons
Given I receive a performance summary notification
Then it should have a "View Full Results" action button
And clicking it should navigate to the race results page
Scenario: Final results notifications include championship standings link
Given I receive a final results notification
Then it should have a "View Championship Standings" action button
And clicking it should navigate to the league standings page
Scenario: Notifications are sent to all registered drivers
Given 10 drivers are registered for the race event
When the main race is completed
Then 10 performance summary notifications should be sent
When stewarding closes
Then 10 final results notifications should be sent
Scenario: League configuration affects stewarding window
Given a league has stewardingClosesHours set to 48
When a race event is created for that league
Then the stewarding window should be 48 hours after main race completion

View File

@@ -0,0 +1,284 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import { Session } from '../../packages/racing/domain/entities/Session';
import { RaceEvent } from '../../packages/racing/domain/entities/RaceEvent';
import { SessionType } from '../../packages/racing/domain/value-objects/SessionType';
import { MainRaceCompletedEvent } from '../../packages/racing/domain/events/MainRaceCompleted';
import { RaceEventStewardingClosedEvent } from '../../packages/racing/domain/events/RaceEventStewardingClosed';
import { SendPerformanceSummaryUseCase } from '../../packages/racing/application/use-cases/SendPerformanceSummaryUseCase';
import { SendFinalResultsUseCase } from '../../packages/racing/application/use-cases/SendFinalResultsUseCase';
import { CloseRaceEventStewardingUseCase } from '../../packages/racing/application/use-cases/CloseRaceEventStewardingUseCase';
import { InMemoryRaceEventRepository } from '../../packages/racing/infrastructure/repositories/InMemoryRaceEventRepository';
import { InMemorySessionRepository } from '../../packages/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);
});
});
});