harden media
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -35,5 +35,5 @@ export class DriverLeaderboardItemDTO {
|
||||
rank!: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl?: string;
|
||||
avatarUrl!: string | null;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,6 @@ export class DriverProfileSocialFriendSummaryDTO {
|
||||
@ApiProperty()
|
||||
country!: string;
|
||||
|
||||
@ApiProperty()
|
||||
avatarUrl!: string;
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
}
|
||||
@@ -36,4 +36,7 @@ export class GetDriverOutputDTO {
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
totalRaces?: number;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
avatarUrl!: string | null;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user