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

@@ -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,