This commit is contained in:
2025-12-16 15:42:38 +01:00
parent 29410708c8
commit 362894d1a5
147 changed files with 780 additions and 375 deletions

View File

@@ -0,0 +1,62 @@
import { Controller, Get, Post, Body, Req, Param } from '@nestjs/common';
import { Request } from 'express';
import { ApiTags, ApiResponse, ApiOperation } from '@nestjs/swagger';
import { DriverService } from './DriverService';
import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel } from './dto/DriverDto';
@ApiTags('drivers')
@Controller('drivers')
export class DriverController {
constructor(private readonly driverService: DriverService) {}
@Get('leaderboard')
@ApiOperation({ summary: 'Get drivers leaderboard' })
@ApiResponse({ status: 200, description: 'List of drivers for the leaderboard', type: DriversLeaderboardViewModel })
async getDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
return this.driverService.getDriversLeaderboard();
}
@Get('total-drivers')
@ApiOperation({ summary: 'Get the total number of drivers' })
@ApiResponse({ status: 200, description: 'Total number of drivers', type: DriverStatsDto })
async getTotalDrivers(): Promise<DriverStatsDto> {
return this.driverService.getTotalDrivers();
}
@Get('current')
@ApiOperation({ summary: 'Get current authenticated driver' })
@ApiResponse({ status: 200, description: 'Current driver data', type: DriverDTO })
@ApiResponse({ status: 404, description: 'Driver not found' })
async getCurrentDriver(@Req() req: Request): Promise<DriverDTO | null> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user']?.userId;
if (!userId) {
return null;
}
return this.driverService.getCurrentDriver(userId);
}
@Post('complete-onboarding')
@ApiOperation({ summary: 'Complete driver onboarding for a user' })
@ApiResponse({ status: 200, description: 'Onboarding complete', type: CompleteOnboardingOutput })
async completeOnboarding(
@Body() input: CompleteOnboardingInput,
@Req() req: Request,
): Promise<CompleteOnboardingOutput> {
// Assuming userId is available from the request (e.g., via auth middleware)
const userId = req['user'].userId; // Placeholder for actual user extraction
return this.driverService.completeOnboarding(userId, input);
}
@Get(':driverId/races/:raceId/registration-status')
@ApiOperation({ summary: 'Get driver registration status for a specific race' })
@ApiResponse({ status: 200, description: 'Driver registration status', type: DriverRegistrationStatusViewModel })
async getDriverRegistrationStatus(
@Param('driverId') driverId: string,
@Param('raceId') raceId: string,
): Promise<DriverRegistrationStatusViewModel> {
return this.driverService.getDriverRegistrationStatus({ driverId, raceId });
}
// Add other Driver endpoints here based on other presenters
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DriverService } from './DriverService';
import { DriverController } from './DriverController';
import { DriverProviders } from './DriverProviders';
@Module({
controllers: [DriverController],
providers: DriverProviders,
exports: [DriverService],
})
export class DriverModule {}

View File

@@ -0,0 +1,108 @@
import { Provider } from '@nestjs/common';
import { DriverService } from './DriverService';
// Import core interfaces
import { IDriverRepository } from '@core/racing/domain/repositories/IDriverRepository';
import { IRankingService } from '@core/racing/domain/services/IRankingService';
import { IDriverStatsService } from '@core/racing/domain/services/IDriverStatsService';
import { DriverRatingProvider } from '@core/racing/application/ports/DriverRatingProvider';
import { IImageServicePort } from '@core/racing/application/ports/IImageServicePort';
import { IRaceRegistrationRepository } from '@core/racing/domain/repositories/IRaceRegistrationRepository';
import { INotificationPreferenceRepository } from '@core/notifications/domain/repositories/INotificationPreferenceRepository';
import type { Logger } from "@gridpilot/core/shared/application";
// Import use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
// Import concrete in-memory implementations
import { InMemoryDriverRepository } from '../../..//racing/persistence/inmemory/InMemoryDriverRepository';
import { InMemoryRankingService } from '../../..//racing/services/InMemoryRankingService';
import { InMemoryDriverStatsService } from '../../..//racing/services/InMemoryDriverStatsService';
import { InMemoryDriverRatingProvider } from '../../..//racing/ports/InMemoryDriverRatingProvider';
import { InMemoryImageServiceAdapter } from '../../..//media/ports/InMemoryImageServiceAdapter';
import { InMemoryRaceRegistrationRepository } from '../../..//racing/persistence/inmemory/InMemoryRaceRegistrationRepository';
import { InMemoryNotificationPreferenceRepository } from '../../..//notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
import { ConsoleLogger } from '../../..//logging/ConsoleLogger';
// Define injection tokens
export const DRIVER_REPOSITORY_TOKEN = 'IDriverRepository';
export const RANKING_SERVICE_TOKEN = 'IRankingService';
export const DRIVER_STATS_SERVICE_TOKEN = 'IDriverStatsService';
export const DRIVER_RATING_PROVIDER_TOKEN = 'DriverRatingProvider';
export const IMAGE_SERVICE_PORT_TOKEN = 'IImageServicePort';
export const RACE_REGISTRATION_REPOSITORY_TOKEN = 'IRaceRegistrationRepository';
export const NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN = 'INotificationPreferenceRepository';
export const LOGGER_TOKEN = 'Logger'; // Already defined in AuthProviders, but good to have here too
// Use case tokens
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';
export const COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN = 'CompleteDriverOnboardingUseCase';
export const IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN = 'IsDriverRegisteredForRaceUseCase';
export const DriverProviders: Provider[] = [
DriverService, // Provide the service itself
{
provide: DRIVER_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRepository(logger), // Factory for InMemoryDriverRepository
inject: [LOGGER_TOKEN],
},
{
provide: RANKING_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryRankingService(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_STATS_SERVICE_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverStatsService(logger),
inject: [LOGGER_TOKEN],
},
{
provide: DRIVER_RATING_PROVIDER_TOKEN,
useFactory: (logger: Logger) => new InMemoryDriverRatingProvider(logger),
inject: [LOGGER_TOKEN],
},
{
provide: IMAGE_SERVICE_PORT_TOKEN,
useFactory: (logger: Logger) => new InMemoryImageServiceAdapter(logger),
inject: [LOGGER_TOKEN],
},
{
provide: RACE_REGISTRATION_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryRaceRegistrationRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: NOTIFICATION_PREFERENCE_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryNotificationPreferenceRepository(logger),
inject: [LOGGER_TOKEN],
},
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger,
},
// Use cases
{
provide: GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository, rankingService: IRankingService, driverStatsService: IDriverStatsService, imageService: IImageServicePort) =>
new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new GetTotalDriversUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
},
{
provide: COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN,
useFactory: (driverRepo: IDriverRepository) => new CompleteDriverOnboardingUseCase(driverRepo),
inject: [DRIVER_REPOSITORY_TOKEN],
},
{
provide: IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN,
useFactory: (registrationRepo: IRaceRegistrationRepository) => new IsDriverRegisteredForRaceUseCase(registrationRepo),
inject: [RACE_REGISTRATION_REPOSITORY_TOKEN],
},
];

View File

@@ -0,0 +1,187 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DriverService } from './DriverService';
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import type { Logger } from '@core/shared/application';
describe('DriverService', () => {
let service: DriverService;
let getDriversLeaderboardUseCase: jest.Mocked<GetDriversLeaderboardUseCase>;
let getTotalDriversUseCase: jest.Mocked<GetTotalDriversUseCase>;
let completeDriverOnboardingUseCase: jest.Mocked<CompleteDriverOnboardingUseCase>;
let isDriverRegisteredForRaceUseCase: jest.Mocked<IsDriverRegisteredForRaceUseCase>;
let logger: jest.Mocked<Logger>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DriverService,
{
provide: 'GetDriversLeaderboardUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'GetTotalDriversUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'CompleteDriverOnboardingUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'IsDriverRegisteredForRaceUseCase',
useValue: {
execute: jest.fn(),
},
},
{
provide: 'Logger',
useValue: {
debug: jest.fn(),
},
},
],
}).compile();
service = module.get<DriverService>(DriverService);
getDriversLeaderboardUseCase = module.get('GetDriversLeaderboardUseCase');
getTotalDriversUseCase = module.get('GetTotalDriversUseCase');
completeDriverOnboardingUseCase = module.get('CompleteDriverOnboardingUseCase');
isDriverRegisteredForRaceUseCase = module.get('IsDriverRegisteredForRaceUseCase');
logger = module.get('Logger');
});
describe('getDriversLeaderboard', () => {
it('should call GetDriversLeaderboardUseCase and return the view model', async () => {
const mockViewModel = {
drivers: [
{
id: 'driver-1',
name: 'Driver 1',
rating: 2500,
skillLevel: 'Pro',
nationality: 'DE',
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
const mockPresenter = {
viewModel: mockViewModel,
};
getDriversLeaderboardUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getDriversLeaderboard();
expect(getDriversLeaderboardUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching drivers leaderboard.');
expect(result).toEqual(mockViewModel);
});
});
describe('getTotalDrivers', () => {
it('should call GetTotalDriversUseCase and return the view model', async () => {
const mockViewModel = { totalDrivers: 5 };
const mockPresenter = {
viewModel: mockViewModel,
};
getTotalDriversUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getTotalDrivers();
expect(getTotalDriversUseCase.execute).toHaveBeenCalledWith(undefined, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('[DriverService] Fetching total drivers count.');
expect(result).toEqual(mockViewModel);
});
});
describe('completeOnboarding', () => {
it('should call CompleteDriverOnboardingUseCase and return the view model', async () => {
const input = {
firstName: 'John',
lastName: 'Doe',
displayName: 'John Doe',
country: 'US',
timezone: 'America/New_York',
bio: 'Racing enthusiast',
};
const mockViewModel = {
success: true,
driverId: 'user-123',
};
const mockPresenter = {
viewModel: mockViewModel,
};
completeDriverOnboardingUseCase.execute.mockImplementation(async (input, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.completeOnboarding('user-123', input);
expect(completeDriverOnboardingUseCase.execute).toHaveBeenCalledWith(
{
userId: 'user-123',
...input,
},
expect.any(Object)
);
expect(logger.debug).toHaveBeenCalledWith('Completing onboarding for user:', 'user-123');
expect(result).toEqual(mockViewModel);
});
});
describe('getDriverRegistrationStatus', () => {
it('should call IsDriverRegisteredForRaceUseCase and return the view model', async () => {
const query = {
driverId: 'driver-1',
raceId: 'race-1',
};
const mockViewModel = {
isRegistered: true,
raceId: 'race-1',
driverId: 'driver-1',
};
const mockPresenter = {
viewModel: mockViewModel,
};
isDriverRegisteredForRaceUseCase.execute.mockImplementation(async (params, presenter) => {
Object.assign(presenter, mockPresenter);
});
const result = await service.getDriverRegistrationStatus(query);
expect(isDriverRegisteredForRaceUseCase.execute).toHaveBeenCalledWith(query, expect.any(Object));
expect(logger.debug).toHaveBeenCalledWith('Checking driver registration status:', query);
expect(result).toEqual(mockViewModel);
});
});
});

View File

@@ -0,0 +1,69 @@
import { Injectable, Inject } from '@nestjs/common';
import { DriversLeaderboardViewModel, DriverStatsDto, CompleteOnboardingInput, CompleteOnboardingOutput, GetDriverRegistrationStatusQuery, DriverRegistrationStatusViewModel } from './dto/DriverDto';
// Use cases
import { GetDriversLeaderboardUseCase } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
// Presenters
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
import { DriverStatsPresenter } from './presenters/DriverStatsPresenter';
import { CompleteOnboardingPresenter } from './presenters/CompleteOnboardingPresenter';
import { DriverRegistrationStatusPresenter } from './presenters/DriverRegistrationStatusPresenter';
// Tokens
import { GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN, GET_TOTAL_DRIVERS_USE_CASE_TOKEN, COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN, IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN, LOGGER_TOKEN } from './DriverProviders';
import type { Logger } from '@core/shared/application';
@Injectable()
export class DriverService {
constructor(
@Inject(GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN) private readonly getDriversLeaderboardUseCase: GetDriversLeaderboardUseCase,
@Inject(GET_TOTAL_DRIVERS_USE_CASE_TOKEN) private readonly getTotalDriversUseCase: GetTotalDriversUseCase,
@Inject(COMPLETE_DRIVER_ONBOARDING_USE_CASE_TOKEN) private readonly completeDriverOnboardingUseCase: CompleteDriverOnboardingUseCase,
@Inject(IS_DRIVER_REGISTERED_FOR_RACE_USE_CASE_TOKEN) private readonly isDriverRegisteredForRaceUseCase: IsDriverRegisteredForRaceUseCase,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async getDriversLeaderboard(): Promise<DriversLeaderboardViewModel> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
const presenter = new DriversLeaderboardPresenter();
await this.getDriversLeaderboardUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async getTotalDrivers(): Promise<DriverStatsDto> {
this.logger.debug('[DriverService] Fetching total drivers count.');
const presenter = new DriverStatsPresenter();
await this.getTotalDriversUseCase.execute(undefined, presenter);
return presenter.viewModel;
}
async completeOnboarding(userId: string, input: CompleteOnboardingInput): Promise<CompleteOnboardingOutput> {
this.logger.debug('Completing onboarding for user:', userId);
const presenter = new CompleteOnboardingPresenter();
await this.completeDriverOnboardingUseCase.execute({
userId,
firstName: input.firstName,
lastName: input.lastName,
displayName: input.displayName,
country: input.country,
timezone: input.timezone,
bio: input.bio,
}, presenter);
return presenter.viewModel;
}
async getDriverRegistrationStatus(query: GetDriverRegistrationStatusQuery): Promise<DriverRegistrationStatusViewModel> {
this.logger.debug('Checking driver registration status:', query);
const presenter = new DriverRegistrationStatusPresenter();
await this.isDriverRegisteredForRaceUseCase.execute({ raceId: query.raceId, driverId: query.driverId }, presenter);
return presenter.viewModel;
}
}

View File

@@ -0,0 +1,138 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
export class DriverLeaderboardItemViewModel {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
rating: number;
@ApiProperty()
skillLevel: string; // Assuming skillLevel is a string like 'Rookie', 'Pro', etc.
@ApiProperty()
nationality: string;
@ApiProperty()
racesCompleted: number;
@ApiProperty()
wins: number;
@ApiProperty()
podiums: number;
@ApiProperty()
isActive: boolean;
@ApiProperty()
rank: number;
@ApiProperty({ nullable: true })
avatarUrl?: string;
}
export class DriversLeaderboardViewModel {
@ApiProperty({ type: [DriverLeaderboardItemViewModel] })
drivers: DriverLeaderboardItemViewModel[];
@ApiProperty()
totalRaces: number;
@ApiProperty()
totalWins: number;
@ApiProperty()
activeCount: number;
}
export class DriverStatsDto {
@ApiProperty()
totalDrivers: number;
}
export class CompleteOnboardingInput {
@ApiProperty()
@IsString()
@IsNotEmpty()
firstName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
lastName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
displayName: string;
@ApiProperty()
@IsString()
@IsNotEmpty()
country: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
timezone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
bio?: string;
}
export class CompleteOnboardingOutput {
@ApiProperty()
@IsBoolean()
success: boolean;
@ApiProperty({ required: false })
@IsString()
driverId?: string;
@ApiProperty({ required: false })
@IsString()
errorMessage?: string;
}
export class GetDriverRegistrationStatusQuery {
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsString()
driverId: string;
}
export class DriverRegistrationStatusViewModel {
@ApiProperty()
@IsBoolean()
isRegistered: boolean;
@ApiProperty()
@IsString()
raceId: string;
@ApiProperty()
@IsString()
driverId: string;
}
export class DriverDto {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@IsString()
name: string; // Display name or full name
}
// Add other DTOs for driver-related logic as needed

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CompleteOnboardingPresenter } from './CompleteOnboardingPresenter';
import type { CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter';
describe('CompleteOnboardingPresenter', () => {
let presenter: CompleteOnboardingPresenter;
beforeEach(() => {
presenter = new CompleteOnboardingPresenter();
});
describe('present', () => {
it('should map successful core DTO to API view model', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: true,
driverId: 'driver-123',
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
success: true,
driverId: 'driver-123',
errorMessage: undefined,
});
});
it('should map failed core DTO to API view model', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: false,
errorMessage: 'Driver already exists',
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
success: false,
driverId: undefined,
errorMessage: 'Driver already exists',
});
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: CompleteDriverOnboardingResultDTO = {
success: true,
driverId: 'driver-123',
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,23 @@
import { CompleteOnboardingOutput } from '../dto/DriverDto';
import type { ICompleteDriverOnboardingPresenter, CompleteDriverOnboardingResultDTO } from '../../../../../core/racing/application/presenters/ICompleteDriverOnboardingPresenter';
export class CompleteOnboardingPresenter implements ICompleteDriverOnboardingPresenter {
private result: CompleteOnboardingOutput | null = null;
reset() {
this.result = null;
}
present(dto: CompleteDriverOnboardingResultDTO) {
this.result = {
success: dto.success,
driverId: dto.driverId,
errorMessage: dto.errorMessage,
};
}
get viewModel(): CompleteOnboardingOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriverRegistrationStatusPresenter } from './DriverRegistrationStatusPresenter';
describe('DriverRegistrationStatusPresenter', () => {
let presenter: DriverRegistrationStatusPresenter;
beforeEach(() => {
presenter = new DriverRegistrationStatusPresenter();
});
describe('present', () => {
it('should map parameters to view model for registered driver', () => {
presenter.present(true, 'race-123', 'driver-456');
const result = presenter.viewModel;
expect(result).toEqual({
isRegistered: true,
raceId: 'race-123',
driverId: 'driver-456',
});
});
it('should map parameters to view model for unregistered driver', () => {
presenter.present(false, 'race-789', 'driver-101');
const result = presenter.viewModel;
expect(result).toEqual({
isRegistered: false,
raceId: 'race-789',
driverId: 'driver-101',
});
});
});
describe('reset', () => {
it('should reset the result', () => {
presenter.present(true, 'race-123', 'driver-456');
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,28 @@
import { DriverRegistrationStatusViewModel } from '../dto/DriverDto';
import type { IDriverRegistrationStatusPresenter } from '../../../../../core/racing/application/presenters/IDriverRegistrationStatusPresenter';
export class DriverRegistrationStatusPresenter implements IDriverRegistrationStatusPresenter {
private result: DriverRegistrationStatusViewModel | null = null;
present(isRegistered: boolean, raceId: string, driverId: string) {
this.result = {
isRegistered,
raceId,
driverId,
};
}
getViewModel(): DriverRegistrationStatusViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
// For consistency with other presenters
reset() {
this.result = null;
}
get viewModel(): DriverRegistrationStatusViewModel {
return this.getViewModel();
}
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriverStatsPresenter } from './DriverStatsPresenter';
import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
describe('DriverStatsPresenter', () => {
let presenter: DriverStatsPresenter;
beforeEach(() => {
presenter = new DriverStatsPresenter();
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: TotalDriversResultDTO = {
totalDrivers: 42,
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result).toEqual({
totalDrivers: 42,
});
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: TotalDriversResultDTO = {
totalDrivers: 10,
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,21 @@
import { DriverStatsDto } from '../dto/DriverDto';
import type { ITotalDriversPresenter, TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
export class DriverStatsPresenter implements ITotalDriversPresenter {
private result: DriverStatsDto | null = null;
reset() {
this.result = null;
}
present(dto: TotalDriversResultDTO) {
this.result = {
totalDrivers: dto.totalDrivers,
};
}
get viewModel(): DriverStatsDto {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
beforeEach(() => {
presenter = new DriversLeaderboardPresenter();
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date('2023-01-01'),
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date('2023-01-02'),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
{ driverId: 'driver-2', rating: 2400, overallRank: 2 },
],
stats: {
'driver-1': { racesCompleted: 50, wins: 10, podiums: 20 },
'driver-2': { racesCompleted: 40, wins: 5, podiums: 15 },
},
avatarUrls: {
'driver-1': 'https://example.com/avatar1.png',
'driver-2': 'https://example.com/avatar2.png',
},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({
id: 'driver-1',
name: 'Driver One',
rating: 2500,
skillLevel: 'Pro',
nationality: 'US',
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
});
expect(result.drivers[1]).toEqual({
id: 'driver-2',
name: 'Driver Two',
rating: 2400,
skillLevel: 'Pro',
nationality: 'DE',
racesCompleted: 40,
wins: 5,
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
});
expect(result.totalRaces).toBe(90);
expect(result.totalWins).toBe(15);
expect(result.activeCount).toBe(2);
});
it('should sort drivers by rating descending', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date(),
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date(),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2400, overallRank: 2 },
{ driverId: 'driver-2', rating: 2500, overallRank: 1 },
],
stats: {},
avatarUrls: {},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers[0].id).toBe('driver-2'); // Higher rating first
expect(result.drivers[1].id).toBe('driver-1');
});
it('should handle missing stats gracefully', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date(),
},
],
rankings: [
{ driverId: 'driver-1', rating: 2500, overallRank: 1 },
],
stats: {}, // No stats
avatarUrls: {},
};
presenter.present(dto);
const result = presenter.viewModel;
expect(result.drivers[0].racesCompleted).toBe(0);
expect(result.drivers[0].wins).toBe(0);
expect(result.drivers[0].podiums).toBe(0);
});
});
describe('reset', () => {
it('should reset the result', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [],
rankings: [],
stats: {},
avatarUrls: {},
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -0,0 +1,49 @@
import { DriversLeaderboardViewModel, DriverLeaderboardItemViewModel } from '../dto/DriverDto';
import type { IDriversLeaderboardPresenter, DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
export class DriversLeaderboardPresenter implements IDriversLeaderboardPresenter {
private result: DriversLeaderboardViewModel | null = null;
reset() {
this.result = null;
}
present(dto: DriversLeaderboardResultDTO) {
const drivers: DriverLeaderboardItemViewModel[] = dto.drivers.map(driver => {
const ranking = dto.rankings.find(r => r.driverId === driver.id);
const stats = dto.stats[driver.id];
const avatarUrl = dto.avatarUrls[driver.id];
return {
id: driver.id,
name: driver.name,
rating: ranking?.rating ?? 0,
skillLevel: 'Pro', // TODO: map from domain
nationality: driver.country,
racesCompleted: stats?.racesCompleted ?? 0,
wins: stats?.wins ?? 0,
podiums: stats?.podiums ?? 0,
isActive: true, // TODO: determine from domain
rank: ranking?.overallRank ?? 0,
avatarUrl,
};
});
// Calculate totals
const totalRaces = drivers.reduce((sum, d) => sum + (d.racesCompleted ?? 0), 0);
const totalWins = drivers.reduce((sum, d) => sum + (d.wins ?? 0), 0);
const activeCount = drivers.filter(d => d.isActive).length;
this.result = {
drivers: drivers.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0)),
totalRaces,
totalWins,
activeCount,
};
}
get viewModel(): DriversLeaderboardViewModel {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}