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

@@ -0,0 +1,229 @@
/**
* TDD Tests for MediaResolverAdapter and its components
*
* Tests the complete resolution flow for all media reference types
*/
import { describe, expect, it } from 'vitest';
import { MediaReference } from '@core/domain/media/MediaReference';
import { MediaResolverAdapter, DefaultResolvers } from './MediaResolverAdapter';
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
describe('DefaultMediaResolverAdapter', () => {
const adapter = new DefaultMediaResolverAdapter();
describe('System Default URLs', () => {
it('should resolve avatar default without variant', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/neutral-default-avatar.png');
});
it('should resolve male avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'male');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/male-default-avatar.png');
});
it('should resolve female avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'female');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/female-default-avatar.png');
});
it('should resolve neutral avatar default', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'neutral');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/neutral-default-avatar.png');
});
it('should resolve team logo default', async () => {
const ref = MediaReference.createSystemDefault('logo');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/logo.png');
});
it('should resolve league logo default', async () => {
const ref = MediaReference.createSystemDefault('logo');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/default/logo.png');
});
it('should return null for non-system-default references', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('GeneratedMediaResolverAdapter', () => {
const adapter = new GeneratedMediaResolverAdapter();
describe('Generated URLs', () => {
it('should resolve team logo generated', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/teams/123/logo');
});
it('should resolve league logo generated', async () => {
const ref = MediaReference.createGenerated('league-456');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/leagues/456/logo');
});
it('should resolve driver avatar generated', async () => {
const ref = MediaReference.createGenerated('driver-789');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/avatar/789');
});
it('should handle complex type names with hyphens', async () => {
const ref = MediaReference.createGenerated('team-league-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/teams/league-123/logo');
});
it('should return null for invalid format (no hyphen)', async () => {
const ref = MediaReference.createGenerated('invalid');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
it('should return null for non-generated references', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('UploadedMediaResolverAdapter', () => {
const adapter = new UploadedMediaResolverAdapter();
describe('Uploaded URLs', () => {
it('should resolve uploaded media', async () => {
const ref = MediaReference.createUploaded('media-123');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/uploaded/media-123');
});
it('should handle different media IDs', async () => {
const ref = MediaReference.createUploaded('media-456');
const url = await adapter.resolve(ref);
expect(url).toBe('/media/uploaded/media-456');
});
it('should return null for non-uploaded references', async () => {
const ref = MediaReference.createSystemDefault('avatar');
const url = await adapter.resolve(ref);
expect(url).toBeNull();
});
});
});
describe('MediaResolverAdapter (Composite)', () => {
const resolver = new MediaResolverAdapter();
describe('Composite Resolution', () => {
it('should resolve system-default references', async () => {
const ref = MediaReference.createSystemDefault('avatar', 'male');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/default/male-default-avatar.png');
});
it('should resolve generated references', async () => {
const ref = MediaReference.createGenerated('team-123');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/teams/123/logo');
});
it('should resolve uploaded references', async () => {
const ref = MediaReference.createUploaded('media-456');
const url = await resolver.resolve(ref);
expect(url).toBe('/media/uploaded/media-456');
});
it('should return null for none references', async () => {
const ref = MediaReference.createNone();
const url = await resolver.resolve(ref);
expect(url).toBeNull();
});
it('should return null for null/undefined input', async () => {
expect(await resolver.resolve(null as unknown as MediaReference)).toBeNull();
expect(await resolver.resolve(undefined as unknown as MediaReference)).toBeNull();
});
});
describe('Factory Functions', () => {
it('should create local resolver', () => {
const local = DefaultResolvers.local();
// Local resolver should work without baseUrl (path-only)
expect(local).toBeInstanceOf(MediaResolverAdapter);
});
it('should create production resolver', () => {
const prod = DefaultResolvers.production();
// Production resolver should work without baseUrl (path-only)
expect(prod).toBeInstanceOf(MediaResolverAdapter);
});
});
});
describe('Integration: End-to-End Resolution', () => {
const resolver = new MediaResolverAdapter();
it('should resolve all reference types consistently', async () => {
const testCases = [
{
ref: MediaReference.createSystemDefault('avatar', 'male'),
expected: '/media/default/male-default-avatar.png'
},
{
ref: MediaReference.createSystemDefault('avatar', 'female'),
expected: '/media/default/female-default-avatar.png'
},
{
ref: MediaReference.createSystemDefault('logo'),
expected: '/media/default/logo.png'
},
{
ref: MediaReference.createGenerated('team-abc123'),
expected: '/media/teams/abc123/logo'
},
{
ref: MediaReference.createGenerated('league-def456'),
expected: '/media/leagues/def456/logo'
},
{
ref: MediaReference.createUploaded('media-ghi789'),
expected: '/media/uploaded/media-ghi789'
},
{
ref: MediaReference.createNone(),
expected: null
}
];
for (const testCase of testCases) {
const result = await resolver.resolve(testCase.ref);
expect(result).toBe(testCase.expected);
}
});
it('should maintain URL consistency across multiple resolutions', async () => {
const ref = MediaReference.createGenerated('team-123');
const url1 = await resolver.resolve(ref);
const url2 = await resolver.resolve(ref);
const url3 = await resolver.resolve(ref);
expect(url1).toBe(url2);
expect(url2).toBe(url3);
expect(url1).toBe('/media/teams/123/logo');
});
});

View File

@@ -0,0 +1,127 @@
/**
* MediaResolverAdapter (Composite)
*
* Composite adapter that delegates resolution to type-specific adapters.
* This is the main entry point for media resolution.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
import { DefaultMediaResolverAdapter } from './resolvers/DefaultMediaResolverAdapter';
import { GeneratedMediaResolverAdapter } from './resolvers/GeneratedMediaResolverAdapter';
import { UploadedMediaResolverAdapter } from './resolvers/UploadedMediaResolverAdapter';
/**
* Configuration for the composite MediaResolverAdapter
*/
export interface MediaResolverAdapterConfig {
/**
* Base path for default assets (defaults to '/media/default')
*/
defaultPath?: string;
/**
* Base path for generated assets (defaults to '/media/generated')
*/
generatedPath?: string;
/**
* Base path for uploaded assets (defaults to '/media/uploaded')
*/
uploadedPath?: string;
}
/**
* MediaResolverAdapter
*
* Composite adapter that delegates to type-specific resolvers.
* Implements the MediaResolverPort interface.
*
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
*
* Usage:
* ```typescript
* const resolver = new MediaResolverAdapter({
* defaultPath: '/media/default',
* generatedPath: '/media/generated',
* uploadedPath: '/media/uploaded'
* });
*
* const path = await resolver.resolve(mediaReference);
* ```
*/
export class MediaResolverAdapter implements MediaResolverPort {
private readonly defaultResolver: DefaultMediaResolverAdapter;
private readonly generatedResolver: GeneratedMediaResolverAdapter;
private readonly uploadedResolver: UploadedMediaResolverAdapter;
constructor(config: MediaResolverAdapterConfig = {}) {
// Initialize type-specific resolvers
this.defaultResolver = new DefaultMediaResolverAdapter({
basePath: config.defaultPath
});
this.generatedResolver = new GeneratedMediaResolverAdapter({
basePath: config.generatedPath
});
this.uploadedResolver = new UploadedMediaResolverAdapter({
basePath: config.uploadedPath
});
}
/**
* Resolve a media reference to a path-only URL
*
* Delegates to the appropriate type-specific resolver based on the reference type.
* Returns paths like /media/... (no baseUrl).
*/
async resolve(ref: MediaReference): Promise<string | null> {
if (!ref) {
return null;
}
// Delegate to the appropriate resolver based on type
switch (ref.type) {
case 'system-default':
return this.defaultResolver.resolve(ref);
case 'generated':
return this.generatedResolver.resolve(ref);
case 'uploaded':
return this.uploadedResolver.resolve(ref);
case 'none':
return null;
default:
// Unknown type
return null;
}
}
}
/**
* Factory function for creating MediaResolverAdapter instances
*/
export function createMediaResolver(
config: MediaResolverAdapterConfig = {}
): MediaResolverAdapter {
return new MediaResolverAdapter(config);
}
/**
* Default configuration for development/testing
*/
export const DefaultResolvers = {
/**
* Creates a resolver for local development
*/
local: () => createMediaResolver({}),
/**
* Creates a resolver for production
*/
production: () => createMediaResolver({})
};

View File

@@ -0,0 +1,229 @@
/**
* In-Memory Media Resolver Adapter
*
* Stub implementation for testing purposes.
* Resolves MediaReference objects to fake URLs without external dependencies.
*
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for InMemoryMediaResolverAdapter
*/
export interface InMemoryMediaResolverConfig {
/**
* Base URL to use for generated URLs
* @default 'https://fake-media.example.com'
*/
baseUrl?: string;
/**
* Whether to simulate network delays
* @default false
*/
simulateDelay?: boolean;
/**
* Delay in milliseconds when simulateDelay is true
* @default 50
*/
delayMs?: number;
/**
* Whether to return null for certain reference types (simulating missing media)
* @default false
*/
simulateMissingMedia?: boolean;
}
/**
* In-Memory Media Resolver Adapter
*
* Stub implementation that resolves media references to fake URLs.
* Designed for use in tests and development environments.
*
* @example
* ```typescript
* const adapter = new InMemoryMediaResolverAdapter({
* baseUrl: 'https://test.example.com',
* simulateDelay: true
* });
*
* const ref = MediaReference.createSystemDefault('avatar');
* const url = await adapter.resolve(ref);
* // Returns: '/media/default/male-default-avatar.png'
* ```
*/
export class InMemoryMediaResolverAdapter implements MediaResolverPort {
private readonly config: Required<InMemoryMediaResolverConfig>;
constructor(config: InMemoryMediaResolverConfig = {}) {
this.config = {
baseUrl: config.baseUrl ?? 'https://fake-media.example.com',
simulateDelay: config.simulateDelay ?? false,
delayMs: config.delayMs ?? 50,
simulateMissingMedia: config.simulateMissingMedia ?? false,
};
}
/**
* Resolve a media reference to a path-only URL
*
* @param ref - The media reference to resolve
* @returns Promise resolving to path string or null
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Simulate network delay if configured
if (this.config.simulateDelay) {
await this.delay(this.config.delayMs);
}
// Simulate missing media for some cases
if (this.config.simulateMissingMedia && this.shouldReturnNull()) {
return null;
}
switch (ref.type) {
case 'system-default':
let filename: string;
if (ref.variant === 'avatar' && ref.avatarVariant) {
filename = `${ref.avatarVariant}-default-avatar.png`;
} else if (ref.variant === 'avatar') {
filename = `neutral-default-avatar.png`;
} else {
filename = `${ref.variant}.png`;
}
return `/media/default/${filename}`;
case 'generated':
// Parse the generationRequestId to extract type and id
// Format: "{type}-{id}" where id can contain hyphens
if (ref.generationRequestId) {
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
if (firstHyphenIndex !== -1) {
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
// Use the correct API routes
if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
} else if (type === 'driver') {
return `/media/avatar/${id}`;
}
// Fallback for other types
return `/media/generated/${type}/${id}`;
}
}
// Fallback for unexpected format
return null;
case 'uploaded':
return `/media/uploaded/${ref.mediaId}`;
case 'none':
return null;
default:
return null;
}
}
/**
* Simulate network delay
*/
private delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* Determine if this reference should return null (simulating missing media)
*/
private shouldReturnNull(): boolean {
// Randomly return null for 20% of cases
return Math.random() < 0.2;
}
/**
* Get the configured base URL
*/
getBaseUrl(): string {
return this.config.baseUrl;
}
/**
* Update configuration
*/
updateConfig(config: Partial<InMemoryMediaResolverConfig>): void {
Object.assign(this.config, config);
}
/**
* Reset to default configuration
*/
reset(): void {
this.config.baseUrl = 'https://fake-media.example.com';
this.config.simulateDelay = false;
this.config.delayMs = 50;
this.config.simulateMissingMedia = false;
}
}
/**
* Factory function to create a configured in-memory resolver
*/
export function createInMemoryResolver(
config: InMemoryMediaResolverConfig = {}
): MediaResolverPort {
return new InMemoryMediaResolverAdapter(config);
}
/**
* Pre-configured resolver for common test scenarios
*/
export const TestResolvers = {
/**
* Fast resolver with no delays
*/
fast: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateDelay: false,
}),
/**
* Slow resolver that simulates network latency
*/
slow: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateDelay: true,
delayMs: 200,
}),
/**
* Unreliable resolver that sometimes returns null
*/
unreliable: () => new InMemoryMediaResolverAdapter({
baseUrl: 'https://test.example.com',
simulateMissingMedia: true,
}),
/**
* Custom base URL resolver
*/
withBaseUrl: (baseUrl: string) => new InMemoryMediaResolverAdapter({
baseUrl,
simulateDelay: false,
}),
/**
* Local development resolver
*/
local: () => new InMemoryMediaResolverAdapter({
baseUrl: 'http://localhost:3000/media',
simulateDelay: false,
}),
} as const;

View File

@@ -0,0 +1,166 @@
/**
* FileSystemMediaStorageAdapter
*
* Concrete adapter for storing media files on the filesystem.
* Implements the MediaStoragePort interface.
*/
import { MediaStoragePort, UploadOptions, UploadResult } from '@core/media/application/ports/MediaStoragePort';
import * as fs from 'fs/promises';
import * as path from 'path';
/**
* Configuration for FileSystemMediaStorageAdapter
*/
export interface FileSystemMediaStorageConfig {
/**
* Base directory for storing media files
* @default '/data/media'
*/
baseDir?: string;
}
/**
* FileSystemMediaStorageAdapter
*
* Stores media files in a local filesystem directory.
* Uses deterministic storage keys based on mediaId.
*/
export class FileSystemMediaStorageAdapter implements MediaStoragePort {
private readonly baseDir: string;
constructor(config: FileSystemMediaStorageConfig = {}) {
this.baseDir = config.baseDir || '/data/media';
}
/**
* Upload a media file to the filesystem
*
* @param buffer File buffer
* @param options Upload options
* @returns Upload result with storage key
*/
async uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult> {
try {
// Validate content type
const allowedTypes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/gif'];
if (!allowedTypes.includes(options.mimeType)) {
return {
success: false,
errorMessage: `Content type ${options.mimeType} is not allowed`,
};
}
// Generate deterministic storage key
const mediaId = this.generateMediaId(options.filename);
const storageKey = `uploaded/${mediaId}`;
const filePath = path.join(this.baseDir, storageKey);
// Ensure directory exists
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Write file
await fs.writeFile(filePath, buffer);
return {
success: true,
filename: options.filename,
url: storageKey, // Return storage key, not full URL
};
} catch (error) {
return {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Delete a media file from the filesystem
*
* @param storageKey Storage key (e.g., 'uploaded/media-123')
*/
async deleteMedia(storageKey: string): Promise<void> {
try {
const filePath = path.join(this.baseDir, storageKey);
await fs.unlink(filePath);
} catch (error) {
// Ignore if file doesn't exist
if (error instanceof Error && 'code' in error && (error as any).code === 'ENOENT') {
return;
}
throw error;
}
}
/**
* Get file bytes as Buffer
*
* @param storageKey Storage key
* @returns Buffer or null if not found
*/
async getBytes(storageKey: string): Promise<Buffer | null> {
try {
const filePath = path.join(this.baseDir, storageKey);
return await fs.readFile(filePath);
} catch (error) {
return null;
}
}
/**
* Get file metadata
*
* @param storageKey Storage key
* @returns File metadata or null if not found
*/
async getMetadata(storageKey: string): Promise<{ size: number; contentType: string } | null> {
try {
const filePath = path.join(this.baseDir, storageKey);
const stat = await fs.stat(filePath);
// Determine content type from extension
const ext = path.extname(filePath).toLowerCase();
const contentType = this.getContentTypeFromExtension(ext);
return {
size: stat.size,
contentType,
};
} catch (error) {
return null;
}
}
/**
* Generate a deterministic media ID from filename
*/
private generateMediaId(filename: string): string {
const timestamp = Date.now();
const cleanFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
return `media-${timestamp}-${cleanFilename}`;
}
/**
* Get content type from file extension
*/
private getContentTypeFromExtension(ext: string): string {
const map: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.gif': 'image/gif',
};
return map[ext] || 'application/octet-stream';
}
}
/**
* Factory function for creating FileSystemMediaStorageAdapter instances
*/
export function createFileSystemMediaStorage(
config: FileSystemMediaStorageConfig = {}
): FileSystemMediaStorageAdapter {
return new FileSystemMediaStorageAdapter(config);
}

View File

@@ -13,9 +13,9 @@ describe('InMemoryImageServiceAdapter', () => {
const adapter = new InMemoryImageServiceAdapter(logger);
expect(adapter.getDriverAvatar('driver-1')).toContain('/images/avatars/');
expect(adapter.getTeamLogo('team-1')).toBe('/images/ff1600.jpeg');
expect(adapter.getLeagueCover('league-1')).toBe('/images/header.jpeg');
expect(adapter.getLeagueLogo('league-1')).toBe('/images/ff1600.jpeg');
expect(adapter.getDriverAvatar('driver-1')).toBe('/media/avatar/driver-1');
expect(adapter.getTeamLogo('team-1')).toBe('/media/teams/team-1/logo');
expect(adapter.getLeagueCover('league-1')).toBe('/media/leagues/league-1/cover');
expect(adapter.getLeagueLogo('league-1')).toBe('/media/leagues/league-1/logo');
});
});

View File

@@ -0,0 +1,79 @@
/**
* DefaultMediaResolverAdapter
*
* Resolves system-default media references to public asset URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the DefaultMediaResolverAdapter
*/
export interface DefaultMediaResolverConfig {
/**
* Base path for default assets (defaults to '/media/default')
*/
basePath?: string | undefined;
}
/**
* DefaultMediaResolverAdapter
*
* Resolves system-default media references to public asset URLs.
*
* URL format: /media/default/{variant}
* Examples:
* - /media/default/male-default-avatar
* - /media/default/female-default-avatar
* - /media/default/neutral-default-avatar
* - /media/default/team-logo.png
* - /media/default/league-logo.png
*/
export class DefaultMediaResolverAdapter implements MediaResolverPort {
private readonly basePath: string;
constructor(config: DefaultMediaResolverConfig = {}) {
this.basePath = config.basePath || '/media/default';
}
/**
* Resolve a system-default media reference to a path-only URL
* Returns paths like /media/default/{variant} (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle system-default references
if (ref.type !== 'system-default') {
return null;
}
// Determine the filename based on variant and avatarVariant
let filename: string;
if (ref.variant === 'avatar' && ref.avatarVariant) {
// Driver avatars must use website public assets:
// apps/website/public/images/avatars/{male|female|neutral}-default-avatar.(jpg|jpeg)
// We intentionally keep the URL extension-less; MediaController maps it to the real file.
filename = `${ref.avatarVariant}-default-avatar`;
} else if (ref.variant === 'avatar') {
// Avatar without specific variant (fallback to neutral)
filename = `neutral-default-avatar`;
} else {
// Other variants (team, league, etc.)
filename = `${ref.variant}.png`;
}
// Return path-only URL
return `${this.basePath}/${filename}`;
}
}
/**
* Factory function for creating DefaultMediaResolverAdapter instances
*/
export function createDefaultMediaResolver(
config: DefaultMediaResolverConfig = {}
): DefaultMediaResolverAdapter {
return new DefaultMediaResolverAdapter(config);
}

View File

@@ -0,0 +1,92 @@
/**
* GeneratedMediaResolverAdapter
*
* Resolves generated media references to image serving URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the GeneratedMediaResolverAdapter
*/
export interface GeneratedMediaResolverConfig {
/**
* Base path for generated assets (defaults to '/media/generated')
* @deprecated No longer used - returns path-only URLs
*/
basePath?: string | undefined;
}
/**
* GeneratedMediaResolverAdapter
*
* Resolves generated media references to image serving URLs.
*
* URL format: /media/generated/{type}/{id}
* Examples:
* - /media/teams/{id}/logo
* - /media/leagues/{id}/logo
* - /media/avatar/{id}
*
* The type and id are extracted from the generationRequestId.
* Format: "{type}-{id}" (e.g., "team-123", "league-456")
*/
export class GeneratedMediaResolverAdapter implements MediaResolverPort {
constructor(_config: GeneratedMediaResolverConfig = {}) {
// basePath is not used since we return path-only URLs
// config.basePath is ignored for backward compatibility
}
/**
* Resolve a generated media reference to a path-only URL
* Returns paths like /media/teams/{id}/logo (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle generated references
if (ref.type !== 'generated') {
return null;
}
// Parse the generationRequestId to extract type and id
// Format: "{type}-{id}" or "{type}-{subtype}-{id}"
const requestId = ref.generationRequestId;
if (!requestId) {
return null;
}
// Find the first hyphen to split type and id
// Format: "{type}-{id}" where id can contain hyphens
const firstHyphenIndex = requestId.indexOf('-');
if (firstHyphenIndex === -1) {
// Invalid format
return null;
}
const type = requestId.substring(0, firstHyphenIndex);
const id = requestId.substring(firstHyphenIndex + 1);
// Return path-only URLs matching the API routes
if (type === 'team') {
return `/media/teams/${id}/logo`;
} else if (type === 'league') {
return `/media/leagues/${id}/logo`;
} else if (type === 'driver') {
return `/media/avatar/${id}`;
}
// Fallback for other types
return `/media/generated/${type}/${id}`;
}
}
/**
* Factory function for creating GeneratedMediaResolverAdapter instances
*/
export function createGeneratedMediaResolver(
config: GeneratedMediaResolverConfig = {}
): GeneratedMediaResolverAdapter {
return new GeneratedMediaResolverAdapter(config);
}

View File

@@ -0,0 +1,71 @@
/**
* UploadedMediaResolverAdapter
*
* Resolves uploaded media references to image serving URLs.
* Part of the adapters layer, implementing the MediaResolverPort interface.
*/
import { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
/**
* Configuration for the UploadedMediaResolverAdapter
*/
export interface UploadedMediaResolverConfig {
/**
* Base path for uploaded assets (defaults to '/media/uploaded')
*/
basePath?: string | undefined;
}
/**
* UploadedMediaResolverAdapter
*
* Resolves uploaded media references to image serving URLs.
*
* URL format: /media/uploaded/{mediaId}
* Examples:
* - /media/uploaded/media-123
* - /media/uploaded/media-456
*
* Note: This is a stub implementation. In production, this would:
* - Check if the media exists in storage
* - Handle different file types (images, videos, documents)
* - Handle access control and permissions
* - Generate signed URLs for private media
*/
export class UploadedMediaResolverAdapter implements MediaResolverPort {
private readonly basePath: string;
constructor(config: UploadedMediaResolverConfig = {}) {
this.basePath = config.basePath || '/media/uploaded';
}
/**
* Resolve an uploaded media reference to a path-only URL
* Returns paths like /media/uploaded/{mediaId} (no baseUrl)
*/
async resolve(ref: MediaReference): Promise<string | null> {
// Only handle uploaded references
if (ref.type !== 'uploaded') {
return null;
}
// Validate mediaId exists
if (!ref.mediaId) {
return null;
}
// Return path-only URL
return `${this.basePath}/${ref.mediaId}`;
}
}
/**
* Factory function for creating UploadedMediaResolverAdapter instances
*/
export function createUploadedMediaResolver(
config: UploadedMediaResolverConfig = {}
): UploadedMediaResolverAdapter {
return new UploadedMediaResolverAdapter(config);
}