This commit is contained in:
2025-12-21 19:53:22 +01:00
parent f2d8a23583
commit 3c64f328e2
105 changed files with 3191 additions and 1706 deletions

View File

@@ -1,29 +1,23 @@
import type { CompleteDriverOnboardingOutputPort } from '@core/racing/application/ports/output/CompleteDriverOnboardingOutputPort';
import type {
CompleteDriverOnboardingResult,
} from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
import type { CompleteOnboardingOutputDTO } from '../dtos/CompleteOnboardingOutputDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class CompleteOnboardingPresenter {
private result: CompleteOnboardingOutputDTO | null = null;
export class CompleteOnboardingPresenter
implements UseCaseOutputPort<CompleteDriverOnboardingResult>
{
private responseModel: CompleteOnboardingOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: CompleteDriverOnboardingOutputPort): void {
this.result = {
present(result: CompleteDriverOnboardingResult): void {
this.responseModel = {
success: true,
driverId: output.driverId,
driverId: result.driver.id,
};
}
presentError(errorCode: string): void {
this.result = {
success: false,
errorMessage: errorCode,
};
}
get viewModel(): CompleteOnboardingOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): CompleteOnboardingOutputDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -2,19 +2,15 @@ import type { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
export class DriverPresenter {
private result: GetDriverOutputDTO | null = null;
reset(): void {
this.result = null;
}
private responseModel: GetDriverOutputDTO | null = null;
present(driver: Driver | null): void {
if (!driver) {
this.result = null;
this.responseModel = null;
return;
}
this.result = {
this.responseModel = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
@@ -24,7 +20,7 @@ export class DriverPresenter {
};
}
get viewModel(): GetDriverOutputDTO | null {
return this.result;
getResponseModel(): GetDriverOutputDTO | null {
return this.responseModel;
}
}

View File

@@ -1,47 +1,61 @@
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
import type {
GetProfileOverviewResult,
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverProfilePresenter {
private result: GetDriverProfileOutputDTO | null = null;
export class DriverProfilePresenter
implements UseCaseOutputPort<GetProfileOverviewResult>
{
private responseModel: GetDriverProfileOutputDTO | null = null;
reset(): void {
this.result = null;
}
present(output: ProfileOverviewOutputPort): void {
this.result = {
currentDriver: output.driver
present(result: GetProfileOverviewResult): void {
this.responseModel = {
currentDriver: result.driverInfo
? {
id: output.driver.id,
name: output.driver.name,
country: output.driver.country,
avatarUrl: output.driver.avatarUrl,
iracingId: output.driver.iracingId,
joinedAt: output.driver.joinedAt.toISOString(),
rating: output.driver.rating,
globalRank: output.driver.globalRank,
consistency: output.driver.consistency,
bio: output.driver.bio,
totalDrivers: output.driver.totalDrivers,
id: result.driverInfo.driver.id,
name: result.driverInfo.driver.name.toString(),
country: result.driverInfo.driver.country.toString(),
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
rating: result.driverInfo.rating,
globalRank: result.driverInfo.globalRank,
consistency: result.driverInfo.consistency,
bio: result.driverInfo.driver.bio?.toString() || null,
totalDrivers: result.driverInfo.totalDrivers,
}
: null,
stats: output.stats,
finishDistribution: output.finishDistribution,
teamMemberships: output.teamMemberships.map(membership => ({
teamId: membership.teamId,
teamName: membership.teamName,
teamTag: membership.teamTag,
role: membership.role,
joinedAt: membership.joinedAt.toISOString(),
isCurrent: membership.isCurrent,
stats: result.stats,
finishDistribution: result.finishDistribution,
teamMemberships: result.teamMemberships.map(membership => ({
teamId: membership.team.id,
teamName: membership.team.name.toString(),
teamTag: membership.team.tag.toString(),
role: membership.membership.role,
joinedAt: membership.membership.joinedAt.toISOString(),
isCurrent: true, // TODO: check membership status
})),
socialSummary: output.socialSummary,
extendedProfile: output.extendedProfile,
socialSummary: {
friendsCount: result.socialSummary.friendsCount,
friends: result.socialSummary.friends.map(friend => ({
id: friend.id,
name: friend.name.toString(),
country: friend.country.toString(),
avatarUrl: '', // TODO: get avatar
})),
},
extendedProfile: result.extendedProfile as any,
};
}
get viewModel(): GetDriverProfileOutputDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): GetDriverProfileOutputDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
private getAvatarUrl(driverId: string): string | undefined {
// Avatar resolution is delegated to infrastructure; keep as-is for now.
return undefined;
}
}

View File

@@ -1,25 +1,24 @@
import type {
IsDriverRegisteredForRaceResult,
} from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
import { DriverRegistrationStatusDTO } from '../dtos/DriverRegistrationStatusDTO';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverRegistrationStatusPresenter {
private result: DriverRegistrationStatusDTO | null = null;
export class DriverRegistrationStatusPresenter
implements UseCaseOutputPort<IsDriverRegisteredForRaceResult>
{
private responseModel: DriverRegistrationStatusDTO | null = null;
reset(): void {
this.result = null;
}
present(isRegistered: boolean, raceId: string, driverId: string): void {
this.result = {
isRegistered,
raceId,
driverId,
present(result: IsDriverRegisteredForRaceResult): void {
this.responseModel = {
isRegistered: result.isRegistered,
raceId: result.raceId,
driverId: result.driverId,
};
}
get viewModel(): DriverRegistrationStatusDTO {
if (!this.result) {
throw new Error('Presenter not presented');
}
return this.result;
getResponseModel(): DriverRegistrationStatusDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DriverStatsPresenter } from './DriverStatsPresenter';
import type { TotalDriversResultDTO } from '../../../../../core/racing/application/presenters/ITotalDriversPresenter';
import type { GetTotalDriversResult } from '../../../../../core/racing/application/use-cases/GetTotalDriversUseCase';
describe('DriverStatsPresenter', () => {
let presenter: DriverStatsPresenter;
@@ -10,16 +11,18 @@ describe('DriverStatsPresenter', () => {
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: TotalDriversResultDTO = {
it('should map core result to API response model correctly', () => {
const output: GetTotalDriversResult = {
totalDrivers: 42,
};
presenter.present(dto);
const result = Result.ok<GetTotalDriversResult, never>(output);
const result = presenter.viewModel;
presenter.present(result);
expect(result).toEqual({
const response = presenter.responseModel;
expect(response).toEqual({
totalDrivers: 42,
});
});
@@ -27,15 +30,17 @@ describe('DriverStatsPresenter', () => {
describe('reset', () => {
it('should reset the result', () => {
const dto: TotalDriversResultDTO = {
const output: GetTotalDriversResult = {
totalDrivers: 10,
};
presenter.present(dto);
expect(presenter.viewModel).toBeDefined();
const result = Result.ok<GetTotalDriversResult, never>(output);
presenter.present(result);
expect(presenter.responseModel).toBeDefined();
presenter.reset();
expect(() => presenter.viewModel).toThrow('Presenter not presented');
expect(() => presenter.responseModel).toThrow('Presenter not presented');
});
});
});

View File

@@ -1,21 +1,22 @@
import { DriverStatsDTO } from '../dtos/DriverStatsDTO';
import type { TotalDriversOutputPort } from '../../../../../core/racing/application/ports/output/TotalDriversOutputPort';
import type {
GetTotalDriversResult,
} from '@core/racing/application/use-cases/GetTotalDriversUseCase';
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
export class DriverStatsPresenter {
private result: DriverStatsDTO | null = null;
export class DriverStatsPresenter
implements UseCaseOutputPort<GetTotalDriversResult>
{
private responseModel: DriverStatsDTO | null = null;
reset() {
this.result = null;
}
present(output: TotalDriversOutputPort) {
this.result = {
totalDrivers: output.totalDrivers,
present(result: GetTotalDriversResult): void {
this.responseModel = {
totalDrivers: result.totalDrivers,
};
}
get viewModel(): DriverStatsDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
getResponseModel(): DriverStatsDTO {
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { Result } from '@core/shared/application/Result';
import { DriversLeaderboardPresenter } from './DriversLeaderboardPresenter';
import type { DriversLeaderboardResultDTO } from '../../../../../core/racing/application/presenters/IDriversLeaderboardPresenter';
import type { GetDriversLeaderboardResult } from '../../../../../core/racing/application/use-cases/GetDriversLeaderboardUseCase';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
@@ -10,41 +11,50 @@ describe('DriversLeaderboardPresenter', () => {
});
describe('present', () => {
it('should map core DTO to API view model correctly', () => {
const dto: DriversLeaderboardResultDTO = {
drivers: [
it('should map core result to API response model correctly', () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
id: 'driver-1',
name: 'Driver One',
country: 'US',
iracingId: '12345',
joinedAt: new Date('2023-01-01'),
driver: {
id: 'driver-1',
name: 'Driver One' as any,
country: 'US' as any,
} as any,
rating: 2500,
skillLevel: 'advanced' as any,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
},
{
id: 'driver-2',
name: 'Driver Two',
country: 'DE',
iracingId: '67890',
joinedAt: new Date('2023-01-02'),
driver: {
id: 'driver-2',
name: 'Driver Two' as any,
country: 'DE' as any,
} as any,
rating: 2400,
skillLevel: 'intermediate' as any,
racesCompleted: 40,
wins: 5,
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
},
],
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',
},
totalRaces: 90,
totalWins: 15,
activeCount: 2,
};
presenter.present(dto);
const result = Result.ok<GetDriversLeaderboardResult, never>(coreResult);
const result = presenter.viewModel;
presenter.present(result);
const api = presenter.responseModel;
expect(result.drivers).toHaveLength(2);
expect(result.drivers[0]).toEqual({

View File

@@ -1,36 +1,44 @@
import type { Result } from '@core/shared/application/Result';
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import type { DriversLeaderboardOutputPort } from '../../../../../core/racing/application/ports/output/DriversLeaderboardOutputPort';
import type {
GetDriversLeaderboardResult,
GetDriversLeaderboardErrorCode,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
export type DriversLeaderboardApplicationError = ApplicationErrorCode<
GetDriversLeaderboardErrorCode,
{ message: string }
>;
export class DriversLeaderboardPresenter {
private result: DriversLeaderboardDTO | null = null;
present(
result: Result<GetDriversLeaderboardResult, DriversLeaderboardApplicationError>,
): DriversLeaderboardDTO {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to load drivers leaderboard');
}
reset(): void {
this.result = null;
}
const output = result.unwrap();
present(output: DriversLeaderboardOutputPort): void {
this.result = {
drivers: output.drivers.map(driver => ({
id: driver.id,
name: driver.name,
rating: driver.rating,
skillLevel: driver.skillLevel,
nationality: driver.nationality,
racesCompleted: driver.racesCompleted,
wins: driver.wins,
podiums: driver.podiums,
isActive: driver.isActive,
rank: driver.rank,
avatarUrl: driver.avatarUrl,
return {
drivers: output.items.map(item => ({
id: item.driver.id,
name: item.driver.name.toString(),
rating: item.rating,
skillLevel: item.skillLevel,
nationality: item.driver.country.toString(),
racesCompleted: item.racesCompleted,
wins: item.wins,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
avatarUrl: item.avatarUrl,
})),
totalRaces: output.totalRaces,
totalWins: output.totalWins,
activeCount: output.activeCount,
totalRaces: output.items.reduce((sum, d) => sum + d.racesCompleted, 0),
totalWins: output.items.reduce((sum, d) => sum + d.wins, 0),
activeCount: output.items.filter(d => d.isActive).length,
};
}
get viewModel(): DriversLeaderboardDTO {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
}
}