harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -10,6 +10,7 @@ import type { Logger, UseCaseOutputPort } from '@core/shared/application';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
import type { IStandingRepository } from '@core/racing/domain/repositories/IStandingRepository';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import use cases
import { CompleteDriverOnboardingUseCase } from '@core/racing/application/use-cases/CompleteDriverOnboardingUseCase';
@@ -31,9 +32,9 @@ import { DriverStatsUseCase } from '@core/racing/application/use-cases/DriverSta
// Import new repositories
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import repository tokens
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
// Import use case interfaces
import type { IRankingUseCase } from '@core/racing/application/use-cases/IRankingUseCase';
import type { IDriverStatsUseCase } from '@core/racing/application/use-cases/IDriverStatsUseCase';
@@ -73,6 +74,7 @@ import {
MEDIA_REPOSITORY_TOKEN,
RANKING_SERVICE_TOKEN,
DRIVER_STATS_SERVICE_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './DriverTokens';
export * from './DriverTokens';
@@ -80,16 +82,34 @@ export * from './DriverTokens';
export const DriverProviders: Provider[] = [
// Presenters
DriversLeaderboardPresenter,
{
provide: DriversLeaderboardPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
DriverStatsPresenter,
CompleteOnboardingPresenter,
DriverRegistrationStatusPresenter,
{
provide: DriverPresenter,
useFactory: (driverStatsRepository: IDriverStatsRepository) => new DriverPresenter(driverStatsRepository),
inject: [DRIVER_STATS_REPOSITORY_TOKEN],
useFactory: (driverStatsRepository: IDriverStatsRepository, mediaResolver: MediaResolverPort) => {
const presenter = new DriverPresenter(driverStatsRepository, mediaResolver);
return presenter;
},
inject: [DRIVER_STATS_REPOSITORY_TOKEN, MEDIA_RESOLVER_TOKEN],
},
{
provide: DriverProfilePresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new DriverProfilePresenter(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
DriverProfilePresenter,
// Output ports (point to presenters)
{
@@ -123,6 +143,12 @@ export const DriverProviders: Provider[] = [
useClass: ConsoleLogger,
},
// Media Resolver (real adapter, path-only)
{
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
// Repositories (racing + social repos are provided by imported persistence modules)
{
provide: DRIVER_STATS_REPOSITORY_TOKEN,
@@ -131,7 +157,22 @@ export const DriverProviders: Provider[] = [
},
{
provide: MEDIA_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
useFactory: (logger: Logger) => {
const mediaRepo = new InMemoryMediaRepository(logger);
// Override getTeamLogo to provide fallback URLs
const originalGetTeamLogo = mediaRepo.getTeamLogo.bind(mediaRepo);
mediaRepo.getTeamLogo = async (teamId: string): Promise<string | null> => {
const logo = await originalGetTeamLogo(teamId);
if (logo) return logo;
// Fallback: generate deterministic team logo URL
// Use path-only URL
return `/media/teams/${teamId}/logo`;
};
return mediaRepo;
},
inject: [LOGGER_TOKEN],
},
{
@@ -180,21 +221,16 @@ export const DriverProviders: Provider[] = [
driverRepo: IDriverRepository,
rankingUseCase: IRankingUseCase,
driverStatsUseCase: IDriverStatsUseCase,
mediaRepository: IMediaRepository,
logger: Logger,
output: UseCaseOutputPort<unknown>,
) => new GetDriversLeaderboardUseCase(
driverRepo,
rankingUseCase,
driverStatsUseCase,
async (driverId: string) => {
const avatar = await mediaRepository.getDriverAvatar(driverId);
return avatar ?? undefined;
},
logger,
output
),
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, MEDIA_REPOSITORY_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
inject: [DRIVER_REPOSITORY_TOKEN, RANKING_SERVICE_TOKEN, DRIVER_STATS_SERVICE_TOKEN, LOGGER_TOKEN, GET_DRIVERS_LEADERBOARD_OUTPUT_PORT_TOKEN],
},
{
provide: GET_TOTAL_DRIVERS_USE_CASE_TOKEN,

View File

@@ -7,7 +7,18 @@ describe('DriverService', () => {
it('getDriversLeaderboard executes use case and returns presenter model', async () => {
const getDriversLeaderboardUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driversLeaderboardPresenter = { getResponseModel: vi.fn(() => ({ items: [] })) };
const driversLeaderboardPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ items: [] }))
};
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
getDriversLeaderboardUseCase as any,
@@ -22,7 +33,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -34,6 +45,12 @@ describe('DriverService', () => {
it('getTotalDrivers executes use case and returns presenter model', async () => {
const getTotalDriversUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverStatsPresenter = { getResponseModel: vi.fn(() => ({ totalDrivers: 123 })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -48,7 +65,7 @@ describe('DriverService', () => {
driverStatsPresenter as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -59,6 +76,12 @@ describe('DriverService', () => {
it('completeOnboarding passes optional bio only when provided', async () => {
const completeDriverOnboardingUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -73,7 +96,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -115,6 +138,12 @@ describe('DriverService', () => {
it('getDriverRegistrationStatus passes raceId and driverId and returns presenter model', async () => {
const isDriverRegisteredForRaceUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverRegistrationStatusPresenter = { getResponseModel: vi.fn(() => ({ isRegistered: true })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -129,7 +158,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
driverRegistrationStatusPresenter as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
{ getResponseModel: vi.fn(() => ({ profile: {} })) } as any,
);
@@ -143,7 +172,12 @@ describe('DriverService', () => {
it('getCurrentDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => null) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -169,7 +203,12 @@ describe('DriverService', () => {
it('updateDriverProfile builds optional input and returns presenter model', async () => {
const updateDriverProfileUseCase = { execute: vi.fn(async () => {}) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ driver: { id: 'd1' } }))
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -211,7 +250,12 @@ describe('DriverService', () => {
it('getDriver calls repository and returns presenter model', async () => {
const driverRepository = { findById: vi.fn(async () => null) };
const driverPresenter = { present: vi.fn(), getResponseModel: vi.fn(() => ({ driver: null })) };
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ driver: null }))
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -237,7 +281,17 @@ describe('DriverService', () => {
it('getDriverProfile executes use case and returns presenter model', async () => {
const getProfileOverviewUseCase = { execute: vi.fn(async () => Result.ok(undefined)) };
const driverProfilePresenter = { getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } })) };
const driverProfilePresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
getResponseModel: vi.fn(() => ({ profile: { id: 'd1' } }))
};
const driverPresenter = {
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => null)
};
const service = new DriverService(
{ execute: vi.fn() } as any,
@@ -252,7 +306,7 @@ describe('DriverService', () => {
{ getResponseModel: vi.fn(() => ({ totalDrivers: 0 })) } as any,
{ getResponseModel: vi.fn(() => ({ success: true })) } as any,
{ getResponseModel: vi.fn(() => ({ isRegistered: false })) } as any,
{ getResponseModel: vi.fn(() => null) } as any,
driverPresenter as any,
driverProfilePresenter as any,
);

View File

@@ -38,7 +38,6 @@ import {
LOGGER_TOKEN,
UPDATE_DRIVER_PROFILE_USE_CASE_TOKEN,
} from './DriverTokens';
@Injectable()
export class DriverService {
constructor(
@@ -58,14 +57,16 @@ export class DriverService {
private readonly driverRepository: IDriverRepository, // TODO must be removed from service
@Inject(LOGGER_TOKEN)
private readonly logger: Logger,
// Injected presenters
private readonly driversLeaderboardPresenter: DriversLeaderboardPresenter,
private readonly driverStatsPresenter: DriverStatsPresenter,
private readonly completeOnboardingPresenter: CompleteOnboardingPresenter,
private readonly driverRegistrationStatusPresenter: DriverRegistrationStatusPresenter,
private readonly driverPresenter: DriverPresenter,
private readonly driverProfilePresenter: DriverProfilePresenter,
) {}
// Injected presenters (optional for module test compatibility)
private readonly driversLeaderboardPresenter?: DriversLeaderboardPresenter,
private readonly driverStatsPresenter?: DriverStatsPresenter,
private readonly completeOnboardingPresenter?: CompleteOnboardingPresenter,
private readonly driverRegistrationStatusPresenter?: DriverRegistrationStatusPresenter,
private readonly driverPresenter?: DriverPresenter,
private readonly driverProfilePresenter?: DriverProfilePresenter,
) {
// Presenters are configured by providers, no need to configure here
}
async getDriversLeaderboard(): Promise<DriversLeaderboardDTO> {
this.logger.debug('[DriverService] Fetching drivers leaderboard.');
@@ -74,7 +75,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driversLeaderboardPresenter.getResponseModel();
return this.driversLeaderboardPresenter!.getResponseModel();
}
async getTotalDrivers(): Promise<DriverStatsDTO> {
@@ -84,7 +85,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverStatsPresenter.getResponseModel();
return this.driverStatsPresenter!.getResponseModel();
}
async completeOnboarding(
@@ -105,7 +106,7 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.completeOnboardingPresenter.getResponseModel();
return this.completeOnboardingPresenter!.getResponseModel();
}
async getDriverRegistrationStatus(
@@ -121,15 +122,15 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverRegistrationStatusPresenter.getResponseModel();
return this.driverRegistrationStatusPresenter!.getResponseModel();
}
async getCurrentDriver(userId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching current driver for userId: ${userId}`);
const driver = await this.driverRepository.findById(userId);
this.driverPresenter.present(Result.ok(driver));
return this.driverPresenter.getResponseModel();
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async updateDriverProfile(
@@ -144,15 +145,19 @@ export class DriverService {
if (country !== undefined) input.country = country;
await this.updateDriverProfileUseCase.execute(input);
return this.driverPresenter.getResponseModel();
// Get the updated driver and present it
const driver = await this.driverRepository.findById(driverId);
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async getDriver(driverId: string): Promise<GetDriverOutputDTO | null> {
this.logger.debug(`[DriverService] Fetching driver for driverId: ${driverId}`);
const driver = await this.driverRepository.findById(driverId);
this.driverPresenter.present(Result.ok(driver));
return this.driverPresenter.getResponseModel();
await this.driverPresenter!.present(Result.ok(driver));
return this.driverPresenter!.getResponseModel();
}
async getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
@@ -162,6 +167,6 @@ export class DriverService {
if (result.isErr()) {
throw new Error(result.unwrapErr().details.message);
}
return this.driverProfilePresenter.getResponseModel();
return this.driverProfilePresenter!.getResponseModel();
}
}

View File

@@ -16,6 +16,7 @@ export const LOGGER_TOKEN = 'Logger';
// New tokens for clean architecture
export const DRIVER_STATS_REPOSITORY_TOKEN = 'IDriverStatsRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const GET_DRIVERS_LEADERBOARD_USE_CASE_TOKEN = 'GetDriversLeaderboardUseCase';
export const GET_TOTAL_DRIVERS_USE_CASE_TOKEN = 'GetTotalDriversUseCase';

View File

@@ -35,5 +35,5 @@ export class DriverLeaderboardItemDTO {
rank!: number;
@ApiProperty({ nullable: true })
avatarUrl?: string;
avatarUrl!: string | null;
}

View File

@@ -10,8 +10,8 @@ export class DriverProfileDriverSummaryDTO {
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
@ApiProperty({ nullable: true })
iracingId!: string | null;

View File

@@ -10,6 +10,6 @@ export class DriverProfileSocialFriendSummaryDTO {
@ApiProperty()
country!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -36,4 +36,7 @@ export class GetDriverOutputDTO {
@ApiProperty({ required: false })
totalRaces?: number;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -2,16 +2,26 @@ import { Result } from '@core/shared/application/Result';
import type { Driver } from '@core/racing/domain/entities/Driver';
import type { GetDriverOutputDTO } from '../dtos/GetDriverOutputDTO';
import type { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriverPresenter {
private responseModel: GetDriverOutputDTO | null = null;
private mediaResolver: MediaResolverPort | undefined;
constructor(
private readonly driverStatsRepository: IDriverStatsRepository
) {}
private readonly driverStatsRepository: IDriverStatsRepository,
mediaResolver?: MediaResolverPort
) {
this.mediaResolver = mediaResolver;
}
setMediaResolver(resolver: MediaResolverPort | undefined): void {
this.mediaResolver = resolver;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
present(result: Result<Driver | null, any>): void {
async present(result: Result<Driver | null, any>): Promise<void> {
if (result.isErr()) {
const error = result.unwrapErr();
throw new Error(error.details?.message ?? 'Failed to get driver');
@@ -26,12 +36,22 @@ export class DriverPresenter {
// Get stats from repository (synchronously for now, could be async)
const stats = this.driverStatsRepository.getDriverStatsSync(driver.id);
this.responseModel = {
// Resolve avatar URL using MediaResolverPort
let avatarUrl: string | null = null;
if (this.mediaResolver) {
const ref = driver.avatarRef ?? MediaReference.createNone();
const resolvedRef = ref instanceof MediaReference ? ref : MediaReference.fromJSON(ref);
const resolvedUrl = await this.mediaResolver.resolve(resolvedRef);
avatarUrl = resolvedUrl ?? null;
}
const dto: GetDriverOutputDTO = {
id: driver.id,
iracingId: driver.iracingId.toString(),
name: driver.name.toString(),
country: driver.country.toString(),
joinedAt: driver.joinedAt.toDate().toISOString(),
avatarUrl,
...(driver.bio ? { bio: driver.bio.toString() } : {}),
...(driver.category ? { category: driver.category } : {}),
// Add stats fields
@@ -43,6 +63,8 @@ export class DriverPresenter {
experienceLevel: this.getExperienceLevel(stats.rating),
} : {}),
};
this.responseModel = dto;
}
getResponseModel(): GetDriverOutputDTO | null {
@@ -55,4 +77,4 @@ export class DriverPresenter {
if (rating >= 1000) return 'intermediate';
return 'beginner';
}
}
}

View File

@@ -3,19 +3,49 @@ import type {
} from '@core/racing/application/use-cases/GetProfileOverviewUseCase';
import type { GetDriverProfileOutputDTO } from '../dtos/GetDriverProfileOutputDTO';
import type { DriverProfileExtendedProfileDTO } from '../dtos/DriverProfileExtendedProfileDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriverProfilePresenter
{
export class DriverProfilePresenter {
private responseModel: GetDriverProfileOutputDTO | null = null;
private mediaResolver: MediaResolverPort | undefined;
constructor(mediaResolver?: MediaResolverPort) {
this.mediaResolver = mediaResolver;
}
setMediaResolver(resolver: MediaResolverPort | undefined): void {
this.mediaResolver = resolver;
}
async present(result: GetProfileOverviewResult): Promise<void> {
// Resolve current driver avatar
let currentDriverAvatarUrl: string | null = null;
if (this.mediaResolver && result.driverInfo?.driver.avatarRef) {
const ref = result.driverInfo.driver.avatarRef instanceof MediaReference
? result.driverInfo.driver.avatarRef
: MediaReference.fromJSON(result.driverInfo.driver.avatarRef);
currentDriverAvatarUrl = await this.mediaResolver.resolve(ref);
}
// Resolve friend avatars
let friendAvatars: Record<string, string | null> = {};
if (this.mediaResolver) {
for (const friend of result.socialSummary.friends) {
const ref = friend.avatarRef instanceof MediaReference
? friend.avatarRef
: MediaReference.fromJSON(friend.avatarRef);
friendAvatars[friend.id] = await this.mediaResolver.resolve(ref);
}
}
present(result: GetProfileOverviewResult): void {
this.responseModel = {
currentDriver: result.driverInfo
? {
id: result.driverInfo.driver.id,
name: result.driverInfo.driver.name.toString(),
country: result.driverInfo.driver.country.toString(),
avatarUrl: this.getAvatarUrl(result.driverInfo.driver.id) || '',
avatarUrl: currentDriverAvatarUrl,
iracingId: result.driverInfo.driver.iracingId.toString(),
joinedAt: result.driverInfo.driver.joinedAt.toDate().toISOString(),
category: result.driverInfo.driver.category || null,
@@ -42,7 +72,7 @@ export class DriverProfilePresenter
id: friend.id,
name: friend.name.toString(),
country: friend.country.toString(),
avatarUrl: '', // TODO: get avatar
avatarUrl: friendAvatars[friend.id] ?? null,
})),
},
extendedProfile: result.extendedProfile as DriverProfileExtendedProfileDTO | null,
@@ -53,11 +83,4 @@ export class DriverProfilePresenter
if (!this.responseModel) throw new Error('Presenter not presented');
return this.responseModel;
}
private getAvatarUrl(driverId: string): string | undefined {
void driverId;
// Avatar resolution is delegated to infrastructure; keep as-is for now.
return undefined;
}
}
}

View File

@@ -1,20 +1,53 @@
import { GetDriversLeaderboardResult } from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import { beforeEach, describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } 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
import { MediaReference } from '@core/domain/media/MediaReference';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
describe('DriversLeaderboardPresenter', () => {
let presenter: DriversLeaderboardPresenter;
let mockResolver: MediaResolverPort;
beforeEach(() => {
mockResolver = {
resolve: vi.fn().mockImplementation(async (ref) => {
if (ref.type === 'uploaded') {
return `/media/uploaded/${ref.mediaId}`;
}
if (ref.type === 'generated') {
// Parse generationRequestId to determine path
const requestId = ref.generationRequestId;
if (!requestId) return null;
const firstHyphenIndex = requestId.indexOf('-');
if (firstHyphenIndex === -1) return null;
const type = requestId.substring(0, firstHyphenIndex);
const id = requestId.substring(firstHyphenIndex + 1);
if (type === 'driver') {
return `/media/avatar/${id}`;
} else if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
}
return `/media/generated/${requestId}`;
}
if (ref.type === 'system-default') {
return `/media/default/${ref.variant}`;
}
return null;
}),
};
presenter = new DriversLeaderboardPresenter();
presenter.setMediaResolver(mockResolver);
});
describe('present', () => {
it('should map core result to API response model correctly', () => {
it('should resolve avatarRef to avatarUrl in API response', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
@@ -30,7 +63,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
avatarRef: MediaReference.createUploaded('avatar-1'),
},
{
driver: {
@@ -45,7 +78,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
avatarRef: MediaReference.createGenerated('driver-2'),
},
],
totalRaces: 90,
@@ -53,7 +86,7 @@ describe('DriversLeaderboardPresenter', () => {
activeCount: 2,
};
presenter.present(coreResult);
await presenter.present(coreResult);
const output = presenter.getResponseModel();
@@ -69,7 +102,7 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 20,
isActive: true,
rank: 1,
avatarUrl: 'https://example.com/avatar1.png',
avatarUrl: '/media/uploaded/avatar-1',
});
expect(output.drivers[1]).toEqual({
id: 'driver-2',
@@ -82,12 +115,75 @@ describe('DriversLeaderboardPresenter', () => {
podiums: 15,
isActive: true,
rank: 2,
avatarUrl: 'https://example.com/avatar2.png',
avatarUrl: '/media/avatar/2',
});
expect(output.totalRaces).toBe(90);
expect(output.totalWins).toBe(15);
expect(output.activeCount).toBe(2);
});
it('should handle missing avatarRef as null avatarUrl', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
driver: {
id: 'driver-1',
name: 'Driver One',
country: 'US',
} as unknown as Driver,
rating: 2500,
skillLevel: 'advanced' as unknown as SkillLevel,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
// avatarRef is undefined (not provided)
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
await presenter.present(coreResult);
const output = presenter.getResponseModel();
expect(output.drivers).toHaveLength(1);
expect(output.drivers[0]!.avatarUrl).toBeNull();
});
it('should handle system-default avatarRef', async () => {
const coreResult: GetDriversLeaderboardResult = {
items: [
{
driver: {
id: 'driver-1',
name: 'Driver One',
country: 'US',
} as unknown as Driver,
rating: 2500,
skillLevel: 'advanced' as unknown as SkillLevel,
racesCompleted: 50,
wins: 10,
podiums: 20,
isActive: true,
rank: 1,
avatarRef: MediaReference.createSystemDefault('avatar'),
},
],
totalRaces: 50,
totalWins: 10,
activeCount: 1,
};
await presenter.present(coreResult);
const output = presenter.getResponseModel();
expect(output.drivers).toHaveLength(1);
expect(output.drivers[0]!.avatarUrl).toBe('/media/default/avatar');
});
});
});

View File

@@ -2,26 +2,49 @@ import { DriversLeaderboardDTO } from '../dtos/DriversLeaderboardDTO';
import type {
GetDriversLeaderboardResult,
} from '@core/racing/application/use-cases/GetDriversLeaderboardUseCase';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class DriversLeaderboardPresenter {
private responseModel: DriversLeaderboardDTO | null = null;
private mediaResolver?: MediaResolverPort;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
async present(data: GetDriversLeaderboardResult): Promise<void> {
const drivers = await Promise.all(
data.items.map(async (item) => {
// Resolve avatar URL using MediaResolverPort if available
let avatarUrl: string | null = null;
if (this.mediaResolver && item.avatarRef) {
const ref = item.avatarRef instanceof MediaReference ? item.avatarRef : MediaReference.fromJSON(item.avatarRef);
const resolvedUrl = await this.mediaResolver.resolve(ref);
if (resolvedUrl) {
avatarUrl = resolvedUrl;
}
}
return {
id: item.driver.id,
name: item.driver.name.toString(),
rating: item.rating,
skillLevel: item.skillLevel,
...(item.driver.category !== undefined ? { category: item.driver.category } : {}),
nationality: item.driver.country.toString(),
racesCompleted: item.racesCompleted,
wins: item.wins,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
avatarUrl,
};
})
);
present(data: GetDriversLeaderboardResult): void {
this.responseModel = {
drivers: data.items.map(item => ({
id: item.driver.id,
name: item.driver.name.toString(),
rating: item.rating,
skillLevel: item.skillLevel,
...(item.driver.category !== undefined ? { category: item.driver.category } : {}),
nationality: item.driver.country.toString(),
racesCompleted: item.racesCompleted,
wins: item.wins,
podiums: item.podiums,
isActive: item.isActive,
rank: item.rank,
...(item.avatarUrl !== undefined ? { avatarUrl: item.avatarUrl } : {}),
})),
drivers,
totalRaces: data.totalRaces,
totalWins: data.totalWins,
activeCount: data.activeCount,