harden media
This commit is contained in:
229
adapters/media/MediaResolverAdapter.test.ts
Normal file
229
adapters/media/MediaResolverAdapter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
127
adapters/media/MediaResolverAdapter.ts
Normal file
127
adapters/media/MediaResolverAdapter.ts
Normal 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({})
|
||||
};
|
||||
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal file
229
adapters/media/MediaResolverInMemoryAdapter.ts
Normal 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;
|
||||
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal file
166
adapters/media/ports/FileSystemMediaStorageAdapter.ts
Normal 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal file
79
adapters/media/resolvers/DefaultMediaResolverAdapter.ts
Normal 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);
|
||||
}
|
||||
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal file
92
adapters/media/resolvers/GeneratedMediaResolverAdapter.ts
Normal 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);
|
||||
}
|
||||
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal file
71
adapters/media/resolvers/UploadedMediaResolverAdapter.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user