harden media
This commit is contained in:
@@ -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