wip
This commit is contained in:
99
tests/bdd/race-event-performance-summary.feature
Normal file
99
tests/bdd/race-event-performance-summary.feature
Normal 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
|
||||
284
tests/bdd/race-event-performance-summary.test.ts
Normal file
284
tests/bdd/race-event-performance-summary.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user