refactor driver module (wip)

This commit is contained in:
2025-12-22 10:24:40 +01:00
parent e7dbec4a85
commit 9da528d5bd
108 changed files with 842 additions and 947 deletions

View File

@@ -128,7 +128,7 @@ export const DriverProviders: Provider[] = [
driverStatsService: IDriverStatsService,
imageService: IImageServicePort,
logger: Logger,
) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, imageService, logger),
) => new GetDriversLeaderboardUseCase(driverRepo, rankingService, driverStatsService, (driverId: string) => Promise.resolve(imageService.getDriverAvatar(driverId)), logger),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, IMAGE_SERVICE_PORT_TOKEN, LOGGER_TOKEN],
},
{
@@ -169,7 +169,7 @@ export const DriverProviders: Provider[] = [
teamRepository,
teamMembershipRepository,
socialRepository,
(driverId: string) => Promise.resolve(imageService.getDriverAvatar(driverId)),
imageService,
driverExtendedProfileProvider,
(driverId: string) => {
const stats = driverStatsService.getDriverStats(driverId);

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
export enum DriverProfileAchievementRarity {
COMMON = 'common',
RARE = 'rare',
EPIC = 'epic',
LEGENDARY = 'legendary',
}
export class DriverProfileAchievementDTO {
@ApiProperty()
id!: string;
@ApiProperty()
title!: string;
@ApiProperty()
description!: string;
@ApiProperty({ enum: ['trophy', 'medal', 'star', 'crown', 'target', 'zap'] })
icon!: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
@ApiProperty({ enum: DriverProfileAchievementRarity })
rarity!: DriverProfileAchievementRarity;
@ApiProperty()
earnedAt!: string;
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileDriverSummaryDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
iracingId!: string | null;
@ApiProperty()
joinedAt!: string;
@ApiProperty({ nullable: true })
rating!: number | null;
@ApiProperty({ nullable: true })
globalRank!: number | null;
@ApiProperty({ nullable: true })
consistency!: number | null;
@ApiProperty({ nullable: true })
bio!: string | null;
@ApiProperty({ nullable: true })
totalDrivers!: number | null;
}

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
import { DriverProfileSocialHandleDTO } from './DriverProfileSocialHandleDTO';
import { DriverProfileAchievementDTO } from './DriverProfileAchievementDTO';
export class DriverProfileExtendedProfileDTO {
@ApiProperty({ type: [DriverProfileSocialHandleDTO] })
socialHandles!: DriverProfileSocialHandleDTO[];
@ApiProperty({ type: [DriverProfileAchievementDTO] })
achievements!: DriverProfileAchievementDTO[];
@ApiProperty()
racingStyle!: string;
@ApiProperty()
favoriteTrack!: string;
@ApiProperty()
favoriteCar!: string;
@ApiProperty()
timezone!: string;
@ApiProperty()
availableHours!: string;
@ApiProperty()
lookingForTeam!: boolean;
@ApiProperty()
openToRequests!: boolean;
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileFinishDistributionDTO {
@ApiProperty()
totalRaces!: number;
@ApiProperty()
wins!: number;
@ApiProperty()
podiums!: number;
@ApiProperty()
topTen!: number;
@ApiProperty()
dnfs!: number;
@ApiProperty()
other!: number;
}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileSocialFriendSummaryDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export class DriverProfileSocialHandleDTO {
@ApiProperty({ enum: ['twitter', 'youtube', 'twitch', 'discord'] })
platform!: DriverProfileSocialPlatform;
@ApiProperty()
handle!: string;
@ApiProperty()
url!: string;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { DriverProfileSocialFriendSummaryDTO } from './DriverProfileSocialFriendSummaryDTO';
export class DriverProfileSocialSummaryDTO {
@ApiProperty()
friendsCount!: number;
@ApiProperty({ type: [DriverProfileSocialFriendSummaryDTO] })
friends!: DriverProfileSocialFriendSummaryDTO[];
}

View File

@@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileStatsDTO {
@ApiProperty()
totalRaces!: number;
@ApiProperty()
wins!: number;
@ApiProperty()
podiums!: number;
@ApiProperty()
dnfs!: number;
@ApiProperty({ nullable: true })
avgFinish!: number | null;
@ApiProperty({ nullable: true })
bestFinish!: number | null;
@ApiProperty({ nullable: true })
worstFinish!: number | null;
@ApiProperty({ nullable: true })
finishRate!: number | null;
@ApiProperty({ nullable: true })
winRate!: number | null;
@ApiProperty({ nullable: true })
podiumRate!: number | null;
@ApiProperty({ nullable: true })
percentile!: number | null;
@ApiProperty({ nullable: true })
rating!: number | null;
@ApiProperty({ nullable: true })
consistency!: number | null;
@ApiProperty({ nullable: true })
overallRank!: number | null;
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileTeamMembershipDTO {
@ApiProperty()
teamId!: string;
@ApiProperty()
teamName!: string;
@ApiProperty({ nullable: true })
teamTag!: string | null;
@ApiProperty()
role!: string;
@ApiProperty()
joinedAt!: string;
@ApiProperty()
isCurrent!: boolean;
}

View File

@@ -1,231 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
export class DriverProfileDriverSummaryDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
iracingId!: string | null;
@ApiProperty()
joinedAt!: string;
@ApiProperty({ nullable: true })
rating!: number | null;
@ApiProperty({ nullable: true })
globalRank!: number | null;
@ApiProperty({ nullable: true })
consistency!: number | null;
@ApiProperty({ nullable: true })
bio!: string | null;
@ApiProperty({ nullable: true })
totalDrivers!: number | null;
}
export class DriverProfileStatsDTO {
@ApiProperty()
totalRaces!: number;
@ApiProperty()
wins!: number;
@ApiProperty()
podiums!: number;
@ApiProperty()
dnfs!: number;
@ApiProperty({ nullable: true })
avgFinish!: number | null;
@ApiProperty({ nullable: true })
bestFinish!: number | null;
@ApiProperty({ nullable: true })
worstFinish!: number | null;
@ApiProperty({ nullable: true })
finishRate!: number | null;
@ApiProperty({ nullable: true })
winRate!: number | null;
@ApiProperty({ nullable: true })
podiumRate!: number | null;
@ApiProperty({ nullable: true })
percentile!: number | null;
@ApiProperty({ nullable: true })
rating!: number | null;
@ApiProperty({ nullable: true })
consistency!: number | null;
@ApiProperty({ nullable: true })
overallRank!: number | null;
}
export class DriverProfileFinishDistributionDTO {
@ApiProperty()
totalRaces: number;
@ApiProperty()
wins: number;
@ApiProperty()
podiums: number;
@ApiProperty()
topTen: number;
@ApiProperty()
dnfs: number;
@ApiProperty()
other: number;
}
export class DriverProfileTeamMembershipDTO {
@ApiProperty()
teamId: string;
@ApiProperty()
teamName: string;
@ApiProperty({ nullable: true })
teamTag: string | null;
@ApiProperty()
role: string;
@ApiProperty()
joinedAt: string;
@ApiProperty()
isCurrent: boolean;
}
export class DriverProfileSocialFriendSummaryDTO {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty()
country: string;
@ApiProperty()
avatarUrl: string;
}
export class DriverProfileSocialSummaryDTO {
@ApiProperty()
friendsCount: number;
@ApiProperty({ type: [DriverProfileSocialFriendSummaryDTO] })
friends: DriverProfileSocialFriendSummaryDTO[];
}
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export enum DriverProfileAchievementRarity {
COMMON = 'common',
RARE = 'rare',
EPIC = 'epic',
LEGENDARY = 'legendary',
}
export class DriverProfileAchievementDTO {
@ApiProperty()
id: string;
@ApiProperty()
title: string;
@ApiProperty()
description: string;
@ApiProperty({ enum: ['trophy', 'medal', 'star', 'crown', 'target', 'zap'] })
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
@ApiProperty({ enum: DriverProfileAchievementRarity })
rarity: DriverProfileAchievementRarity;
@ApiProperty()
earnedAt: string;
}
export class DriverProfileSocialHandleDTO {
@ApiProperty({ enum: DriverProfileSocialPlatform })
platform: DriverProfileSocialPlatform;
@ApiProperty()
handle: string;
@ApiProperty()
url: string;
}
export class DriverProfileExtendedProfileDTO {
@ApiProperty({ type: [DriverProfileSocialHandleDTO] })
socialHandles: DriverProfileSocialHandleDTO[];
@ApiProperty({ type: [DriverProfileAchievementDTO] })
achievements: DriverProfileAchievementDTO[];
@ApiProperty()
racingStyle: string;
@ApiProperty()
favoriteTrack: string;
@ApiProperty()
favoriteCar: string;
@ApiProperty()
timezone: string;
@ApiProperty()
availableHours: string;
@ApiProperty()
lookingForTeam: boolean;
@ApiProperty()
openToRequests: boolean;
}
import { DriverProfileDriverSummaryDTO } from './DriverProfileDriverSummaryDTO';
import { DriverProfileStatsDTO } from './DriverProfileStatsDTO';
import { DriverProfileFinishDistributionDTO } from './DriverProfileFinishDistributionDTO';
import { DriverProfileTeamMembershipDTO } from './DriverProfileTeamMembershipDTO';
import { DriverProfileSocialSummaryDTO } from './DriverProfileSocialSummaryDTO';
import { DriverProfileExtendedProfileDTO } from './DriverProfileExtendedProfileDTO';
export class GetDriverProfileOutputDTO {
@ApiProperty({ type: DriverProfileDriverSummaryDTO, nullable: true })
currentDriver: DriverProfileDriverSummaryDTO | null;
currentDriver!: DriverProfileDriverSummaryDTO | null;
@ApiProperty({ type: DriverProfileStatsDTO, nullable: true })
stats: DriverProfileStatsDTO | null;
stats!: DriverProfileStatsDTO | null;
@ApiProperty({ type: DriverProfileFinishDistributionDTO, nullable: true })
finishDistribution: DriverProfileFinishDistributionDTO | null;
finishDistribution!: DriverProfileFinishDistributionDTO | null;
@ApiProperty({ type: [DriverProfileTeamMembershipDTO] })
teamMemberships: DriverProfileTeamMembershipDTO[];
teamMemberships!: DriverProfileTeamMembershipDTO[];
@ApiProperty({ type: DriverProfileSocialSummaryDTO })
socialSummary: DriverProfileSocialSummaryDTO;
socialSummary!: DriverProfileSocialSummaryDTO;
@ApiProperty({ type: DriverProfileExtendedProfileDTO, nullable: true })
extendedProfile: DriverProfileExtendedProfileDTO | null;
extendedProfile!: DriverProfileExtendedProfileDTO | null;
}

View File

@@ -1,7 +1,8 @@
import type {
GetProfileOverviewResult,
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO, DriverProfileExtendedProfileDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { DriverProfileExtendedProfileDTO } from '../dtos/DriverProfileExtendedProfileDTO';
export class DriverProfilePresenter
{

View File

@@ -1,7 +1,6 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DriverStatsPresenter } from './DriverStatsPresenter';
import type { GetTotalDriversResult } from '../../../../../core/racing/application/use-cases/GetTotalDriversUseCase';
import type { GetTotalDriversResult } from '@core/racing/application/use-cases/GetTotalDriversUseCase';
describe('DriverStatsPresenter', () => {
let presenter: DriverStatsPresenter;
@@ -16,31 +15,13 @@ describe('DriverStatsPresenter', () => {
totalDrivers: 42,
};
const result = Result.ok<GetTotalDriversResult, never>(output);
presenter.present(output);
presenter.present(result);
const response = presenter.responseModel;
const response = presenter.getResponseModel();
expect(response).toEqual({
totalDrivers: 42,
});
});
});
describe('reset', () => {
it('should reset the result', () => {
const output: GetTotalDriversResult = {
totalDrivers: 10,
};
const result = Result.ok<GetTotalDriversResult, never>(output);
presenter.present(result);
expect(presenter.responseModel).toBeDefined();
presenter.reset();
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -2,6 +2,8 @@ import { GetDriversLeaderboardResult } from '@core/racing/application/use-cases/
import { Result } from '@core/shared/application/Result';
import { beforeEach, describe, expect, it } from 'vitest';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { Driver } from '@core/racing/domain/entities/Driver';
import type { SkillLevel } from '@core/racing/domain/services/SkillLevelService';
// TODO fix eslint issues
@@ -19,11 +21,11 @@ describe('DriversLeaderboardPresenter', () => {
{
driver: {
id: 'driver-1',
name: 'Driver One' as unknown,
country: 'US' as unknown,
} as unknown,
name: 'Driver One',
country: 'US',
} as unknown as Driver,
rating: 2500,
skillLevel: 'advanced' as unknown,
skillLevel: 'advanced' as unknown as SkillLevel,
racesCompleted: 50,
wins: 10,
podiums: 20,
@@ -34,11 +36,11 @@ describe('DriversLeaderboardPresenter', () => {
{
driver: {
id: 'driver-2',
name: 'Driver Two' as unknown,
country: 'DE' as unknown,
} as unknown,
name: 'Driver Two',
country: 'DE',
} as unknown as Driver,
rating: 2400,
skillLevel: 'intermediate' as unknown,
skillLevel: 'intermediate' as unknown as SkillLevel,
racesCompleted: 40,
wins: 5,
podiums: 15,
@@ -56,9 +58,10 @@ describe('DriversLeaderboardPresenter', () => {
presenter.present(result);
const output = presenter.getResponseModel();
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({
expect(output.drivers).toHaveLength(2);
expect(output.drivers[0]).toEqual({
id: 'driver-1',
name: 'Driver One',
rating: 2500,
@@ -71,7 +74,7 @@ describe('DriversLeaderboardPresenter', () => {
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
});
expect(result.drivers[1]).toEqual({
expect(output.drivers[1]).toEqual({
id: 'driver-2',
name: 'Driver Two',
rating: 2400,
@@ -84,126 +87,10 @@ describe('DriversLeaderboardPresenter', () => {
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
});
expect(result.totalRaces).toBe(90);
expect(result.totalWins).toBe(15);
expect(result.activeCount).toBe(2);
expect(output.totalRaces).toBe(90);
expect(output.totalWins).toBe(15);
expect(output.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);
expect(result.drivers[0].isActive).toBe(false);
});
it('should derive skill level from rating bands', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
{ id: 'd1', name: 'Beginner', country: 'US', iracingId: '1', joinedAt: new Date() },
{ id: 'd2', name: 'Intermediate', country: 'US', iracingId: '2', joinedAt: new Date() },
{ id: 'd3', name: 'Advanced', country: 'US', iracingId: '3', joinedAt: new Date() },
{ id: 'd4', name: 'Pro', country: 'US', iracingId: '4', joinedAt: new Date() },
],
rankings: [
{ driverId: 'd1', rating: 1700, overallRank: 4 },
{ driverId: 'd2', rating: 2000, overallRank: 3 },
{ driverId: 'd3', rating: 2600, overallRank: 2 },
{ driverId: 'd4', rating: 3100, overallRank: 1 },
],
stats: {
d1: { racesCompleted: 5, wins: 0, podiums: 0 },
d2: { racesCompleted: 5, wins: 0, podiums: 0 },
d3: { racesCompleted: 5, wins: 0, podiums: 0 },
d4: { racesCompleted: 5, wins: 0, podiums: 0 },
},
avatarUrls: {
d1: 'avatar-1',
d2: 'avatar-2',
d3: 'avatar-3',
d4: 'avatar-4',
},
};
presenter.present(dto);
const result = presenter.viewModel;
const levels = result.drivers
.sort((a, b) => a.rating - b.rating)
.map(d => d.skillLevel);
expect(levels).toEqual(['beginner', 'intermediate', 'advanced', 'pro']);
});
});
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');
});
});
});