harden media
This commit is contained in:
530
core/domain/media/MediaReference.test.ts
Normal file
530
core/domain/media/MediaReference.test.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* TDD Tests for MediaReference value object
|
||||
*
|
||||
* Tests cover:
|
||||
* - Discriminated union validation
|
||||
* - Type-specific validation
|
||||
* - Serialization/deserialization
|
||||
* - Equality comparison
|
||||
* - Hash generation
|
||||
*/
|
||||
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
describe('MediaReference', () => {
|
||||
describe('System Default Type', () => {
|
||||
it('should create system-default reference', () => {
|
||||
const ref = MediaReference.createSystemDefault();
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar'); // default
|
||||
});
|
||||
|
||||
it('should create system-default with custom variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('logo');
|
||||
});
|
||||
|
||||
it('should create system-default with avatar variant', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'male');
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
expect(ref.avatarVariant).toBe('male');
|
||||
});
|
||||
|
||||
it('should create system-default without avatar variant for logo', () => {
|
||||
const ref = MediaReference.createSystemDefault('logo', 'male');
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('logo');
|
||||
expect(ref.avatarVariant).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should validate system-default type', () => {
|
||||
const ref = MediaReference.fromJSON({
|
||||
type: 'system-default',
|
||||
variant: 'avatar'
|
||||
});
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
});
|
||||
|
||||
it('should validate system-default with avatar variant', () => {
|
||||
const ref = MediaReference.fromJSON({
|
||||
type: 'system-default',
|
||||
variant: 'avatar',
|
||||
avatarVariant: 'female'
|
||||
});
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
expect(ref.avatarVariant).toBe('female');
|
||||
});
|
||||
|
||||
it('should reject system-default with invalid variant', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'system-default',
|
||||
variant: 'invalid' as any
|
||||
});
|
||||
}).toThrow('Invalid variant');
|
||||
});
|
||||
|
||||
it('should reject system-default with invalid avatar variant', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'system-default',
|
||||
variant: 'avatar',
|
||||
avatarVariant: 'invalid' as any
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('New Static Methods', () => {
|
||||
it('should create system-default using systemDefault method', () => {
|
||||
const ref = MediaReference.systemDefault('avatar');
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
});
|
||||
|
||||
it('should create system-default with avatar variant using systemDefault method', () => {
|
||||
const ref = MediaReference.systemDefault('male');
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
expect(ref.avatarVariant).toBe('male');
|
||||
});
|
||||
|
||||
it('should create generated using generated method', () => {
|
||||
const ref = MediaReference.generated('team', 'team-123');
|
||||
|
||||
expect(ref.type).toBe('generated');
|
||||
expect(ref.generationRequestId).toBe('team-team-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Generated Type', () => {
|
||||
it('should create generated reference with request ID', () => {
|
||||
const ref = MediaReference.createGenerated('req-123');
|
||||
|
||||
expect(ref.type).toBe('generated');
|
||||
expect(ref.generationRequestId).toBe('req-123');
|
||||
});
|
||||
|
||||
it('should validate generated type', () => {
|
||||
const ref = MediaReference.fromJSON({
|
||||
type: 'generated',
|
||||
generationRequestId: 'req-456'
|
||||
});
|
||||
|
||||
expect(ref.type).toBe('generated');
|
||||
expect(ref.generationRequestId).toBe('req-456');
|
||||
});
|
||||
|
||||
it('should reject generated without request ID', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'generated'
|
||||
} as any);
|
||||
}).toThrow('Generation request ID is required');
|
||||
});
|
||||
|
||||
it('should reject generated with empty request ID', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'generated',
|
||||
generationRequestId: ''
|
||||
});
|
||||
}).toThrow('Generation request ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Uploaded Type', () => {
|
||||
it('should create uploaded reference with media ID', () => {
|
||||
const ref = MediaReference.createUploaded('media-789');
|
||||
|
||||
expect(ref.type).toBe('uploaded');
|
||||
expect(ref.mediaId).toBe('media-789');
|
||||
});
|
||||
|
||||
it('should validate uploaded type', () => {
|
||||
const ref = MediaReference.fromJSON({
|
||||
type: 'uploaded',
|
||||
mediaId: 'media-abc'
|
||||
});
|
||||
|
||||
expect(ref.type).toBe('uploaded');
|
||||
expect(ref.mediaId).toBe('media-abc');
|
||||
});
|
||||
|
||||
it('should reject uploaded without media ID', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'uploaded'
|
||||
} as any);
|
||||
}).toThrow('Media ID is required');
|
||||
});
|
||||
|
||||
it('should reject uploaded with empty media ID', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'uploaded',
|
||||
mediaId: ''
|
||||
});
|
||||
}).toThrow('Media ID is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('None Type', () => {
|
||||
it('should create none reference', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
|
||||
expect(ref.type).toBe('none');
|
||||
});
|
||||
|
||||
it('should validate none type', () => {
|
||||
const ref = MediaReference.fromJSON({
|
||||
type: 'none'
|
||||
});
|
||||
|
||||
expect(ref.type).toBe('none');
|
||||
});
|
||||
|
||||
it('should reject none with extra properties', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'none',
|
||||
mediaId: 'should-not-exist'
|
||||
} as any);
|
||||
}).toThrow('None type should not have additional properties');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Invalid Types', () => {
|
||||
it('should reject unknown type', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({
|
||||
type: 'unknown'
|
||||
} as any);
|
||||
}).toThrow('Invalid type');
|
||||
});
|
||||
|
||||
it('should reject missing type', () => {
|
||||
expect(() => {
|
||||
MediaReference.fromJSON({} as any);
|
||||
}).toThrow('Type is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Serialization', () => {
|
||||
it('should serialize system-default to JSON', () => {
|
||||
const ref = MediaReference.createSystemDefault('logo');
|
||||
const json = ref.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'system-default',
|
||||
variant: 'logo'
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize system-default with avatar variant to JSON', () => {
|
||||
const ref = MediaReference.createSystemDefault('avatar', 'female');
|
||||
const json = ref.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'system-default',
|
||||
variant: 'avatar',
|
||||
avatarVariant: 'female'
|
||||
});
|
||||
});
|
||||
|
||||
it('should deserialize system-default with avatar variant from JSON', () => {
|
||||
const json = {
|
||||
type: 'system-default',
|
||||
variant: 'avatar',
|
||||
avatarVariant: 'neutral'
|
||||
};
|
||||
const ref = MediaReference.fromJSON(json as unknown as Record<string, unknown>);
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.variant).toBe('avatar');
|
||||
expect(ref.avatarVariant).toBe('neutral');
|
||||
});
|
||||
|
||||
it('should serialize generated to JSON', () => {
|
||||
const ref = MediaReference.createGenerated('req-123');
|
||||
const json = ref.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'generated',
|
||||
generationRequestId: 'req-123'
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize uploaded to JSON', () => {
|
||||
const ref = MediaReference.createUploaded('media-456');
|
||||
const json = ref.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'uploaded',
|
||||
mediaId: 'media-456'
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize none to JSON', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
const json = ref.toJSON();
|
||||
|
||||
expect(json).toEqual({
|
||||
type: 'none'
|
||||
});
|
||||
});
|
||||
|
||||
it('should deserialize from JSON', () => {
|
||||
const json = {
|
||||
type: 'uploaded',
|
||||
mediaId: 'media-789'
|
||||
};
|
||||
const ref = MediaReference.fromJSON(json);
|
||||
|
||||
expect(ref.type).toBe('uploaded');
|
||||
expect(ref.mediaId).toBe('media-789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Equality', () => {
|
||||
it('should be equal for same system-default with same variant', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal for system-default with different variants', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar');
|
||||
const ref2 = MediaReference.createSystemDefault('logo');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be equal for system-default with different avatar variants', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar', 'female');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be equal for system-default with same avatar variant', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal for system-default with and without avatar variant', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be equal for same generated with same request ID', () => {
|
||||
const ref1 = MediaReference.createGenerated('req-123');
|
||||
const ref2 = MediaReference.createGenerated('req-123');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal for generated with different request IDs', () => {
|
||||
const ref1 = MediaReference.createGenerated('req-123');
|
||||
const ref2 = MediaReference.createGenerated('req-456');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be equal for same uploaded with same media ID', () => {
|
||||
const ref1 = MediaReference.createUploaded('media-123');
|
||||
const ref2 = MediaReference.createUploaded('media-123');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal for uploaded with different media IDs', () => {
|
||||
const ref1 = MediaReference.createUploaded('media-123');
|
||||
const ref2 = MediaReference.createUploaded('media-456');
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should be equal for none references', () => {
|
||||
const ref1 = MediaReference.createNone();
|
||||
const ref2 = MediaReference.createNone();
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be equal for different types', () => {
|
||||
const ref1 = MediaReference.createSystemDefault();
|
||||
const ref2 = MediaReference.createNone();
|
||||
|
||||
expect(ref1.equals(ref2)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be equal to non-MediaReference', () => {
|
||||
const ref = MediaReference.createSystemDefault();
|
||||
|
||||
expect(ref.equals({} as any)).toBe(false);
|
||||
expect(ref.equals(null as any)).toBe(false);
|
||||
expect(ref.equals(undefined as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hash', () => {
|
||||
it('should generate consistent hash for same system-default', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
expect(ref1.hash()).toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for different variants', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar');
|
||||
const ref2 = MediaReference.createSystemDefault('logo');
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for different avatar variants', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar', 'female');
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate same hash for same avatar variant', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
|
||||
expect(ref1.hash()).toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for system-default with and without avatar variant', () => {
|
||||
const ref1 = MediaReference.createSystemDefault('avatar', 'male');
|
||||
const ref2 = MediaReference.createSystemDefault('avatar');
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate consistent hash for same generated', () => {
|
||||
const ref1 = MediaReference.createGenerated('req-123');
|
||||
const ref2 = MediaReference.createGenerated('req-123');
|
||||
|
||||
expect(ref1.hash()).toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for different request IDs', () => {
|
||||
const ref1 = MediaReference.createGenerated('req-123');
|
||||
const ref2 = MediaReference.createGenerated('req-456');
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate consistent hash for same uploaded', () => {
|
||||
const ref1 = MediaReference.createUploaded('media-123');
|
||||
const ref2 = MediaReference.createUploaded('media-123');
|
||||
|
||||
expect(ref1.hash()).toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for different media IDs', () => {
|
||||
const ref1 = MediaReference.createUploaded('media-123');
|
||||
const ref2 = MediaReference.createUploaded('media-456');
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate same hash for none references', () => {
|
||||
const ref1 = MediaReference.createNone();
|
||||
const ref2 = MediaReference.createNone();
|
||||
|
||||
expect(ref1.hash()).toBe(ref2.hash());
|
||||
});
|
||||
|
||||
it('should generate different hash for different types', () => {
|
||||
const ref1 = MediaReference.createSystemDefault();
|
||||
const ref2 = MediaReference.createNone();
|
||||
|
||||
expect(ref1.hash()).not.toBe(ref2.hash());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Guards', () => {
|
||||
it('should correctly identify system-default type', () => {
|
||||
const ref = MediaReference.createSystemDefault();
|
||||
|
||||
expect(ref.type).toBe('system-default');
|
||||
expect(ref.generationRequestId).toBeUndefined();
|
||||
expect(ref.mediaId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly identify generated type', () => {
|
||||
const ref = MediaReference.createGenerated('req-123');
|
||||
|
||||
expect(ref.type).toBe('generated');
|
||||
expect(ref.generationRequestId).toBe('req-123');
|
||||
expect(ref.mediaId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly identify uploaded type', () => {
|
||||
const ref = MediaReference.createUploaded('media-123');
|
||||
|
||||
expect(ref.type).toBe('uploaded');
|
||||
expect(ref.mediaId).toBe('media-123');
|
||||
expect(ref.generationRequestId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly identify none type', () => {
|
||||
const ref = MediaReference.createNone();
|
||||
|
||||
expect(ref.type).toBe('none');
|
||||
expect(ref.generationRequestId).toBeUndefined();
|
||||
expect(ref.mediaId).toBeUndefined();
|
||||
expect(ref.variant).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle whitespace in IDs', () => {
|
||||
const ref = MediaReference.createGenerated(' req-123 ');
|
||||
|
||||
expect(ref.generationRequestId).toBe('req-123');
|
||||
});
|
||||
|
||||
it('should handle special characters in IDs', () => {
|
||||
const ref = MediaReference.createUploaded('media-abc-123_XYZ');
|
||||
|
||||
expect(ref.mediaId).toBe('media-abc-123_XYZ');
|
||||
});
|
||||
|
||||
it('should preserve exact string values', () => {
|
||||
const id = 'CaseSensitive-ID_123';
|
||||
const ref = MediaReference.createUploaded(id);
|
||||
|
||||
expect(ref.mediaId).toBe(id);
|
||||
});
|
||||
|
||||
it('should handle JSON round-trip', () => {
|
||||
const original = MediaReference.createGenerated('req-999');
|
||||
const json = original.toJSON();
|
||||
const restored = MediaReference.fromJSON(json as unknown as Record<string, unknown>);
|
||||
|
||||
expect(restored.equals(original)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
286
core/domain/media/MediaReference.ts
Normal file
286
core/domain/media/MediaReference.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Domain Value Object: MediaReference
|
||||
*
|
||||
* Discriminated union representing different types of media references:
|
||||
* - system-default: Pre-defined default media (e.g., default avatar/logo)
|
||||
* - generated: Media generated by AI/algorithm (references generation request)
|
||||
* - uploaded: User-uploaded media (references media entity)
|
||||
* - none: Explicit no-media placeholder
|
||||
*
|
||||
* Follows clean architecture and TDD principles.
|
||||
*/
|
||||
|
||||
import type { IValueObject } from '@core/shared/domain';
|
||||
|
||||
// Variant types for system-default references
|
||||
export type MediaVariant = 'avatar' | 'logo';
|
||||
|
||||
// Specific variants for avatars (deterministic selection)
|
||||
export type AvatarVariant = 'male' | 'female' | 'neutral';
|
||||
|
||||
// Discriminated union types
|
||||
export interface SystemDefaultRef {
|
||||
type: 'system-default';
|
||||
variant: MediaVariant;
|
||||
avatarVariant?: AvatarVariant | undefined; // Only used when variant is 'avatar'
|
||||
}
|
||||
|
||||
export interface GeneratedRef {
|
||||
type: 'generated';
|
||||
generationRequestId: string;
|
||||
}
|
||||
|
||||
export interface UploadedRef {
|
||||
type: 'uploaded';
|
||||
mediaId: string;
|
||||
}
|
||||
|
||||
export interface NoneRef {
|
||||
type: 'none';
|
||||
}
|
||||
|
||||
// Union of all reference types
|
||||
export type MediaReferenceProps = SystemDefaultRef | GeneratedRef | UploadedRef | NoneRef;
|
||||
|
||||
// Type guards
|
||||
function isSystemDefaultRef(props: unknown): props is SystemDefaultRef {
|
||||
const typedProps = props as SystemDefaultRef;
|
||||
if (typedProps?.type !== 'system-default') {
|
||||
return false;
|
||||
}
|
||||
if (typedProps.variant !== 'avatar' && typedProps.variant !== 'logo') {
|
||||
return false;
|
||||
}
|
||||
// avatarVariant is optional, but if present must be valid
|
||||
if (typedProps.avatarVariant !== undefined) {
|
||||
return typedProps.avatarVariant === 'male' || typedProps.avatarVariant === 'female' || typedProps.avatarVariant === 'neutral';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGeneratedRef(props: unknown): props is GeneratedRef {
|
||||
const typedProps = props as GeneratedRef;
|
||||
return typedProps?.type === 'generated' &&
|
||||
typeof typedProps.generationRequestId === 'string' &&
|
||||
typedProps.generationRequestId.trim().length > 0;
|
||||
}
|
||||
|
||||
function isUploadedRef(props: unknown): props is UploadedRef {
|
||||
const typedProps = props as UploadedRef;
|
||||
return typedProps?.type === 'uploaded' &&
|
||||
typeof typedProps.mediaId === 'string' &&
|
||||
typedProps.mediaId.trim().length > 0;
|
||||
}
|
||||
|
||||
function isNoneRef(props: unknown): props is NoneRef {
|
||||
const typedProps = props as NoneRef;
|
||||
return typedProps?.type === 'none' &&
|
||||
Object.keys(typedProps).length === 1; // Only 'type' property
|
||||
}
|
||||
|
||||
export class MediaReference implements IValueObject<MediaReferenceProps> {
|
||||
public readonly props: MediaReferenceProps;
|
||||
|
||||
private constructor(props: MediaReferenceProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create system-default reference
|
||||
*/
|
||||
static createSystemDefault(variant: MediaVariant = 'avatar', avatarVariant?: AvatarVariant): MediaReference {
|
||||
const props: SystemDefaultRef = { type: 'system-default', variant };
|
||||
if (variant === 'avatar' && avatarVariant) {
|
||||
props.avatarVariant = avatarVariant;
|
||||
}
|
||||
return new MediaReference(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for system default (alias for task compatibility)
|
||||
*/
|
||||
static systemDefault(variant: MediaVariant | AvatarVariant = 'avatar'): MediaReference {
|
||||
// If it's an avatar variant, use it
|
||||
if (variant === 'male' || variant === 'female' || variant === 'neutral') {
|
||||
return new MediaReference({ type: 'system-default', variant: 'avatar', avatarVariant: variant });
|
||||
}
|
||||
// Otherwise it's a regular variant
|
||||
return new MediaReference({ type: 'system-default', variant: variant as MediaVariant });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method for generated references (alias for task compatibility)
|
||||
*/
|
||||
static generated(type: string, id: string): MediaReference {
|
||||
return new MediaReference({ type: 'generated', generationRequestId: `${type}-${id}` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create generated reference
|
||||
*/
|
||||
static createGenerated(generationRequestId: string): MediaReference {
|
||||
const trimmed = generationRequestId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Generation request ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'generated', generationRequestId: trimmed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create uploaded reference
|
||||
*/
|
||||
static createUploaded(mediaId: string): MediaReference {
|
||||
const trimmed = mediaId.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Media ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'uploaded', mediaId: trimmed });
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create none reference
|
||||
*/
|
||||
static createNone(): MediaReference {
|
||||
return new MediaReference({ type: 'none' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize from JSON
|
||||
*/
|
||||
static fromJSON(json: Record<string, unknown>): MediaReference {
|
||||
if (!json || typeof json !== 'object') {
|
||||
throw new Error('Invalid JSON: must be an object');
|
||||
}
|
||||
|
||||
const type = json.type;
|
||||
if (!type || typeof type !== 'string') {
|
||||
throw new Error('Type is required');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'system-default':
|
||||
if (!isSystemDefaultRef(json)) {
|
||||
throw new Error('Invalid variant for system-default. Must be "avatar" or "logo"');
|
||||
}
|
||||
return new MediaReference({
|
||||
type: 'system-default',
|
||||
variant: json.variant,
|
||||
avatarVariant: json.avatarVariant
|
||||
});
|
||||
|
||||
case 'generated':
|
||||
if (!isGeneratedRef(json)) {
|
||||
throw new Error('Generation request ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'generated', generationRequestId: json.generationRequestId });
|
||||
|
||||
case 'uploaded':
|
||||
if (!isUploadedRef(json)) {
|
||||
throw new Error('Media ID is required');
|
||||
}
|
||||
return new MediaReference({ type: 'uploaded', mediaId: json.mediaId });
|
||||
|
||||
case 'none':
|
||||
if (!isNoneRef(json)) {
|
||||
throw new Error('None type should not have additional properties');
|
||||
}
|
||||
return new MediaReference({ type: 'none' });
|
||||
|
||||
default:
|
||||
throw new Error('Invalid type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize to JSON
|
||||
*/
|
||||
toJSON(): MediaReferenceProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of this reference
|
||||
*/
|
||||
get type(): MediaReferenceProps['type'] {
|
||||
return this.props.type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variant (only for system-default)
|
||||
*/
|
||||
get variant(): MediaVariant | undefined {
|
||||
return this.props.type === 'system-default' ? this.props.variant : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar variant (only for system-default with avatar variant)
|
||||
*/
|
||||
get avatarVariant(): AvatarVariant | undefined {
|
||||
return this.props.type === 'system-default' ? this.props.avatarVariant : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get generation request ID (only for generated)
|
||||
*/
|
||||
get generationRequestId(): string | undefined {
|
||||
return this.props.type === 'generated' ? this.props.generationRequestId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media ID (only for uploaded)
|
||||
*/
|
||||
get mediaId(): string | undefined {
|
||||
return this.props.type === 'uploaded' ? this.props.mediaId : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality comparison
|
||||
*/
|
||||
equals(other: IValueObject<MediaReferenceProps>): boolean {
|
||||
if (!(other instanceof MediaReference)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const a = this.props;
|
||||
const b = other.props;
|
||||
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (a.type) {
|
||||
case 'system-default':
|
||||
return b.type === 'system-default' &&
|
||||
a.variant === b.variant &&
|
||||
a.avatarVariant === b.avatarVariant;
|
||||
case 'generated':
|
||||
return b.type === 'generated' && a.generationRequestId === b.generationRequestId;
|
||||
case 'uploaded':
|
||||
return b.type === 'uploaded' && a.mediaId === b.mediaId;
|
||||
case 'none':
|
||||
return b.type === 'none';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hash for this reference
|
||||
* Used for caching and comparison
|
||||
*/
|
||||
hash(): string {
|
||||
switch (this.props.type) {
|
||||
case 'system-default':
|
||||
return `system-default:${this.props.variant}${this.props.avatarVariant ? `:${this.props.avatarVariant}` : ''}`;
|
||||
case 'generated':
|
||||
return `generated:${this.props.generationRequestId}`;
|
||||
case 'uploaded':
|
||||
return `uploaded:${this.props.mediaId}`;
|
||||
case 'none':
|
||||
return 'none';
|
||||
default:
|
||||
// Exhaustive check - should never reach here
|
||||
throw new Error('Unknown type');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,8 @@ describe('Admin Vote Session Use Cases', () => {
|
||||
const now = new Date('2025-01-01T00:00:00Z');
|
||||
const tomorrow = new Date('2025-01-02T00:00:00Z');
|
||||
|
||||
let originalDateNow: () => number;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSessionRepo = new MockAdminVoteSessionRepository();
|
||||
mockEventRepo = new MockRatingEventRepository();
|
||||
@@ -218,11 +220,12 @@ describe('Admin Vote Session Use Cases', () => {
|
||||
);
|
||||
|
||||
// Mock Date.now to return our test time
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now.getTime());
|
||||
originalDateNow = Date.now;
|
||||
Date.now = (() => now.getTime()) as any;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
|
||||
describe('OpenAdminVoteSessionUseCase', () => {
|
||||
@@ -704,4 +707,4 @@ describe('Admin Vote Session Use Cases', () => {
|
||||
expect(snapshot!.adminTrust.value).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,22 @@ export interface MediaStoragePort {
|
||||
uploadMedia(buffer: Buffer, options: UploadOptions): Promise<UploadResult>;
|
||||
|
||||
/**
|
||||
* Delete a media file by URL
|
||||
* @param url Media URL to delete
|
||||
* Delete a media file by storage key
|
||||
* @param storageKey Storage key to delete
|
||||
*/
|
||||
deleteMedia(url: string): Promise<void>;
|
||||
deleteMedia(storageKey: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get file bytes as Buffer
|
||||
* @param storageKey Storage key
|
||||
* @returns Buffer or null if not found
|
||||
*/
|
||||
getBytes?(storageKey: string): Promise<Buffer | null>;
|
||||
|
||||
/**
|
||||
* Get file metadata
|
||||
* @param storageKey Storage key
|
||||
* @returns File metadata or null if not found
|
||||
*/
|
||||
getMetadata?(storageKey: string): Promise<{ size: number; contentType: string } | null>;
|
||||
}
|
||||
255
core/media/domain/services/MediaGenerationService.ts
Normal file
255
core/media/domain/services/MediaGenerationService.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
/**
|
||||
* Core Domain Service: MediaGenerationService
|
||||
*
|
||||
* Encapsulates business logic for generating media assets (SVGs) using Faker.
|
||||
* Ensures deterministic results by seeding Faker with entity IDs.
|
||||
*/
|
||||
export class MediaGenerationService {
|
||||
/**
|
||||
* Generates a deterministic SVG avatar for a driver
|
||||
*/
|
||||
generateDriverAvatar(driverId: string): string {
|
||||
faker.seed(this.hashCode(driverId));
|
||||
|
||||
const firstName = faker.person.firstName();
|
||||
const lastName = faker.person.lastName();
|
||||
const initials = ((firstName?.[0] || 'D') + (lastName?.[0] || 'R')).toUpperCase();
|
||||
|
||||
const primaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const secondaryColor = faker.color.rgb({ format: 'hex' });
|
||||
|
||||
const patterns = ['gradient', 'stripes', 'circles', 'diamond'];
|
||||
const pattern = faker.helpers.arrayElement(patterns);
|
||||
|
||||
let patternSvg = '';
|
||||
switch (pattern) {
|
||||
case 'gradient':
|
||||
patternSvg = `
|
||||
<defs>
|
||||
<linearGradient id="grad-${driverId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="50" fill="url(#grad-${driverId})"/>
|
||||
`;
|
||||
break;
|
||||
case 'stripes':
|
||||
patternSvg = `
|
||||
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
|
||||
<rect x="0" y="0" width="50" height="100" rx="50" fill="${secondaryColor}" opacity="0.3"/>
|
||||
`;
|
||||
break;
|
||||
case 'circles':
|
||||
patternSvg = `
|
||||
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
|
||||
<circle cx="30" cy="30" r="15" fill="${secondaryColor}" opacity="0.4"/>
|
||||
<circle cx="70" cy="70" r="10" fill="${secondaryColor}" opacity="0.4"/>
|
||||
`;
|
||||
break;
|
||||
case 'diamond':
|
||||
patternSvg = `
|
||||
<rect width="100" height="100" rx="50" fill="${primaryColor}"/>
|
||||
<path d="M50 20 L80 50 L50 80 L20 50 Z" fill="${secondaryColor}" opacity="0.3"/>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
||||
${patternSvg}
|
||||
<text x="50" y="58" font-family="Arial, sans-serif" font-size="36" font-weight="bold" fill="white" text-anchor="middle" letter-spacing="2">${initials}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic SVG logo for a team
|
||||
* Now includes team name initials for better branding
|
||||
*/
|
||||
generateTeamLogo(teamId: string): string {
|
||||
faker.seed(this.hashCode(teamId));
|
||||
|
||||
const primaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const secondaryColor = faker.color.rgb({ format: 'hex' });
|
||||
|
||||
// Generate deterministic initials from seeded faker data
|
||||
// This creates consistent initials for the same teamId
|
||||
const adjective = faker.company.buzzAdjective();
|
||||
const noun = faker.company.catchPhraseNoun();
|
||||
const initials = ((adjective?.[0] || 'T') + (noun?.[0] || 'M')).toUpperCase();
|
||||
|
||||
const shapes = ['circle', 'square', 'triangle', 'hexagon'];
|
||||
const shape = faker.helpers.arrayElement(shapes);
|
||||
|
||||
let shapeSvg = '';
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
shapeSvg = `<circle cx="20" cy="16" r="10" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'square':
|
||||
shapeSvg = `<rect x="10" y="6" width="20" height="20" rx="4" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'triangle':
|
||||
shapeSvg = `<path d="M20 6 L30 26 L10 26 Z" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'hexagon':
|
||||
shapeSvg = `<path d="M20 6 L28 10 L28 22 L20 26 L12 22 L12 10 Z" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad-${teamId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="120" height="40" rx="8" fill="#1e293b"/>
|
||||
${shapeSvg}
|
||||
<rect x="40" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
|
||||
<rect x="48" y="8" width="4" height="24" rx="1" fill="${primaryColor}" opacity="0.8"/>
|
||||
<rect x="56" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
|
||||
<text x="85" y="24" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="white" text-anchor="middle">${initials}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic SVG logo for a league
|
||||
* Updated to use the same faker style as team logos for consistency
|
||||
*/
|
||||
generateLeagueLogo(leagueId: string): string {
|
||||
faker.seed(this.hashCode(leagueId));
|
||||
|
||||
const primaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const secondaryColor = faker.color.rgb({ format: 'hex' });
|
||||
|
||||
// Generate deterministic initials from seeded faker data
|
||||
// This creates consistent initials for the same leagueId
|
||||
const adjective = faker.company.buzzAdjective();
|
||||
const noun = faker.company.catchPhraseNoun();
|
||||
const initials = ((adjective?.[0] || 'L') + (noun?.[0] || 'G')).toUpperCase();
|
||||
|
||||
const shapes = ['circle', 'square', 'triangle', 'hexagon'];
|
||||
const shape = faker.helpers.arrayElement(shapes);
|
||||
|
||||
let shapeSvg = '';
|
||||
switch (shape) {
|
||||
case 'circle':
|
||||
shapeSvg = `<circle cx="20" cy="16" r="10" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'square':
|
||||
shapeSvg = `<rect x="10" y="6" width="20" height="20" rx="4" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'triangle':
|
||||
shapeSvg = `<path d="M20 6 L30 26 L10 26 Z" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
case 'hexagon':
|
||||
shapeSvg = `<path d="M20 6 L28 10 L28 22 L20 26 L12 22 L12 10 Z" fill="${primaryColor}" opacity="0.8"/>`;
|
||||
break;
|
||||
}
|
||||
|
||||
return `
|
||||
<svg width="120" height="40" viewBox="0 0 120 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad-${leagueId}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="120" height="40" rx="8" fill="#1e293b"/>
|
||||
${shapeSvg}
|
||||
<rect x="40" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
|
||||
<rect x="48" y="8" width="4" height="24" rx="1" fill="${primaryColor}" opacity="0.8"/>
|
||||
<rect x="56" y="12" width="4" height="16" rx="1" fill="${secondaryColor}" opacity="0.6"/>
|
||||
<text x="85" y="24" font-family="Arial, sans-serif" font-size="12" font-weight="bold" fill="white" text-anchor="middle">${initials}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a deterministic SVG cover for a league
|
||||
*/
|
||||
generateLeagueCover(leagueId: string): string {
|
||||
faker.seed(this.hashCode(leagueId));
|
||||
|
||||
const primaryColor = faker.color.rgb({ format: 'hex' });
|
||||
const secondaryColor = faker.color.rgb({ format: 'hex' });
|
||||
|
||||
return `
|
||||
<svg width="800" height="200" viewBox="0 0 800 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad-${leagueId}" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:${primaryColor};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${secondaryColor};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="800" height="200" fill="url(#grad-${leagueId})"/>
|
||||
<rect width="800" height="200" fill="black" opacity="0.2"/>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple PNG placeholder (base64 encoded)
|
||||
* In production, this would serve actual PNG files from public assets
|
||||
*/
|
||||
generateDefaultPNG(variant: string): Buffer {
|
||||
// For now, generate a simple colored square as PNG placeholder
|
||||
// In production, this would read actual PNG files
|
||||
faker.seed(this.hashCode(variant));
|
||||
|
||||
const color = faker.color.rgb({ format: 'hex' });
|
||||
|
||||
// Parse the hex color
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
|
||||
// Create a minimal valid PNG (1x1 pixel) with the variant color
|
||||
// This is a very basic PNG - in production you'd serve real files
|
||||
// PNG header and minimal data for a 1x1 RGB pixel
|
||||
const pngHeader = Buffer.from([
|
||||
// PNG signature
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
// IHDR chunk (13 bytes)
|
||||
0x00, 0x00, 0x00, 0x0D, // Length: 13
|
||||
0x49, 0x48, 0x44, 0x52, // Type: IHDR
|
||||
0x00, 0x00, 0x00, 0x01, // Width: 1
|
||||
0x00, 0x00, 0x00, 0x01, // Height: 1
|
||||
0x08, // Bit depth: 8
|
||||
0x02, // Color type: RGB
|
||||
0x00, // Compression method
|
||||
0x00, // Filter method
|
||||
0x00, // Interlace method
|
||||
0x00, 0x00, 0x00, 0x00, // CRC (placeholder, simplified)
|
||||
// IDAT chunk (image data)
|
||||
0x00, 0x00, 0x00, 0x07, // Length: 7
|
||||
0x49, 0x44, 0x41, 0x54, // Type: IDAT
|
||||
0x08, 0x1D, // Zlib header
|
||||
0x01, // Deflate block header
|
||||
r, g, b, // RGB pixel data
|
||||
0x00, 0x00, 0x00, 0x00, // CRC (placeholder)
|
||||
// IEND chunk
|
||||
0x00, 0x00, 0x00, 0x00, // Length: 0
|
||||
0x49, 0x45, 0x4E, 0x44, // Type: IEND
|
||||
0xAE, 0x42, 0x60, 0x82 // CRC (placeholder)
|
||||
]);
|
||||
|
||||
return pngHeader;
|
||||
}
|
||||
|
||||
private hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
}
|
||||
148
core/ports/media/MediaResolverPort.ts
Normal file
148
core/ports/media/MediaResolverPort.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Port: MediaResolverPort
|
||||
*
|
||||
* Interface for resolving MediaReference objects to actual URLs.
|
||||
* Part of the clean architecture ports layer.
|
||||
*
|
||||
* Implementations:
|
||||
* - InMemoryMediaResolverAdapter (for tests/stubs)
|
||||
* - HttpMediaResolverAdapter (for production HTTP resolution)
|
||||
* - FileSystemMediaResolverAdapter (for local file resolution)
|
||||
*/
|
||||
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* MediaResolverPort interface
|
||||
*
|
||||
* Resolves a MediaReference to a concrete path string or null if no media exists.
|
||||
* Returns path-only URLs (e.g., /media/teams/123/logo) without baseUrl.
|
||||
*
|
||||
* @param ref - The media reference to resolve
|
||||
* @returns Promise resolving to a path string or null
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resolver: MediaResolverPort = new MediaResolverAdapter();
|
||||
* const ref = MediaReference.createSystemDefault('avatar');
|
||||
* const path = await resolver.resolve(ref);
|
||||
* // Returns: '/media/default/male-default-avatar.png'
|
||||
* ```
|
||||
*/
|
||||
export interface MediaResolverPort {
|
||||
/**
|
||||
* Resolve a media reference to a path-only URL
|
||||
*
|
||||
* @param ref - The media reference to resolve
|
||||
* @returns Promise resolving to path string or null (for 'none' type or resolution failures)
|
||||
*
|
||||
* @throws Should not throw for valid inputs - returns null instead
|
||||
* @throws May throw for invalid inputs (null ref)
|
||||
*/
|
||||
resolve(ref: MediaReference): Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an object implements MediaResolverPort
|
||||
*/
|
||||
export function isMediaResolverPort(obj: unknown): obj is MediaResolverPort {
|
||||
const typedObj = obj as MediaResolverPort;
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === 'object' &&
|
||||
typeof typedObj.resolve === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default resolution strategies for different reference types
|
||||
* Returns path-only URLs
|
||||
*/
|
||||
export const ResolutionStrategies = {
|
||||
/**
|
||||
* Resolve system-default references
|
||||
* Format: /media/default/{variant}
|
||||
*/
|
||||
systemDefault: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'system-default') return null;
|
||||
|
||||
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}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve generated references
|
||||
* Format: /media/teams/{id}/logo, /media/leagues/{id}/logo, /media/avatar/{id}
|
||||
*/
|
||||
generated: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'generated') return null;
|
||||
|
||||
if (!ref.generationRequestId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstHyphenIndex = ref.generationRequestId.indexOf('-');
|
||||
if (firstHyphenIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const type = ref.generationRequestId.substring(0, firstHyphenIndex);
|
||||
const id = ref.generationRequestId.substring(firstHyphenIndex + 1);
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
return `/media/generated/${type}/${id}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve uploaded references
|
||||
* Format: /media/uploaded/{mediaId}
|
||||
*/
|
||||
uploaded: (ref: MediaReference): string | null => {
|
||||
if (ref.type !== 'uploaded') return null;
|
||||
if (!ref.mediaId) return null;
|
||||
return `/media/uploaded/${ref.mediaId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve none references
|
||||
* Returns: null
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
none: (_ref: MediaReference): string | null => {
|
||||
return null;
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Helper function to resolve using default strategies
|
||||
*/
|
||||
export function resolveWithDefaults(ref: MediaReference): string | null {
|
||||
switch (ref.type) {
|
||||
case 'system-default':
|
||||
return ResolutionStrategies.systemDefault(ref);
|
||||
case 'generated':
|
||||
return ResolutionStrategies.generated(ref);
|
||||
case 'uploaded':
|
||||
return ResolutionStrategies.uploaded(ref);
|
||||
case 'none':
|
||||
return ResolutionStrategies.none(ref);
|
||||
default:
|
||||
// Exhaustive check - TypeScript will error if we miss a case
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ export class GetAllLeaguesWithCapacityAndScoringUseCase {
|
||||
});
|
||||
}
|
||||
|
||||
this.output.present({ leagues: enrichedLeagues });
|
||||
await this.output.present({ leagues: enrichedLeagues });
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { GetAllTeamsUseCase, type GetAllTeamsInput, type GetAllTeamsResult } fro
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
@@ -55,15 +54,6 @@ describe('GetAllTeamsUseCase', () => {
|
||||
existsByRaceId: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMediaRepo: IMediaRepository = {
|
||||
getDriverAvatar: vi.fn(),
|
||||
getTeamLogo: vi.fn(),
|
||||
getTrackImage: vi.fn(),
|
||||
getCategoryIcon: vi.fn(),
|
||||
getSponsorLogo: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
|
||||
const mockLogger: Logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -85,7 +75,6 @@ describe('GetAllTeamsUseCase', () => {
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockResultRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
@@ -99,6 +88,9 @@ describe('GetAllTeamsUseCase', () => {
|
||||
ownerId: { toString: () => 'owner1' },
|
||||
leagues: [{ toString: () => 'league1' }],
|
||||
createdAt: { toDate: () => new Date('2023-01-01T00:00:00Z') },
|
||||
logoRef: { toJSON: () => ({ type: 'generated', generationRequestId: 'team-team1' }) },
|
||||
category: undefined,
|
||||
isRecruiting: false,
|
||||
};
|
||||
const team2 = {
|
||||
id: 'team2',
|
||||
@@ -108,11 +100,39 @@ describe('GetAllTeamsUseCase', () => {
|
||||
ownerId: { toString: () => 'owner2' },
|
||||
leagues: [{ toString: () => 'league2' }],
|
||||
createdAt: { toDate: () => new Date('2023-01-02T00:00:00Z') },
|
||||
logoRef: { toJSON: () => ({ type: 'generated', generationRequestId: 'team-team2' }) },
|
||||
category: undefined,
|
||||
isRecruiting: true,
|
||||
};
|
||||
|
||||
mockTeamFindAll.mockResolvedValue([team1, team2]);
|
||||
mockTeamMembershipCountByTeamId.mockImplementation((id: string) => Promise.resolve(id === 'team1' ? 5 : 3));
|
||||
|
||||
// Provide precomputed stats so the use case doesn't compute from results.
|
||||
(mockTeamStatsRepo.getTeamStats as unknown as Mock).mockImplementation((teamId: string) =>
|
||||
Promise.resolve(
|
||||
teamId === 'team1'
|
||||
? {
|
||||
performanceLevel: 'intermediate',
|
||||
specialization: 'mixed',
|
||||
region: 'EU',
|
||||
languages: ['en'],
|
||||
totalWins: 2,
|
||||
totalRaces: 10,
|
||||
rating: 1200,
|
||||
}
|
||||
: {
|
||||
performanceLevel: 'advanced',
|
||||
specialization: 'mixed',
|
||||
region: 'US',
|
||||
languages: ['en', 'de'],
|
||||
totalWins: 5,
|
||||
totalRaces: 20,
|
||||
rating: 1400,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await useCase.execute({} as GetAllTeamsInput);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
@@ -132,6 +152,17 @@ describe('GetAllTeamsUseCase', () => {
|
||||
leagues: ['league1'],
|
||||
createdAt: new Date('2023-01-01T00:00:00Z'),
|
||||
memberCount: 5,
|
||||
totalWins: 2,
|
||||
totalRaces: 10,
|
||||
performanceLevel: 'intermediate',
|
||||
specialization: 'mixed',
|
||||
region: 'EU',
|
||||
languages: ['en'],
|
||||
logoRef: team1.logoRef,
|
||||
logoUrl: null,
|
||||
rating: 1200,
|
||||
category: undefined,
|
||||
isRecruiting: false,
|
||||
},
|
||||
{
|
||||
id: 'team2',
|
||||
@@ -142,6 +173,17 @@ describe('GetAllTeamsUseCase', () => {
|
||||
leagues: ['league2'],
|
||||
createdAt: new Date('2023-01-02T00:00:00Z'),
|
||||
memberCount: 3,
|
||||
totalWins: 5,
|
||||
totalRaces: 20,
|
||||
performanceLevel: 'advanced',
|
||||
specialization: 'mixed',
|
||||
region: 'US',
|
||||
languages: ['en', 'de'],
|
||||
logoRef: team2.logoRef,
|
||||
logoUrl: null,
|
||||
rating: 1400,
|
||||
category: undefined,
|
||||
isRecruiting: true,
|
||||
},
|
||||
],
|
||||
totalCount: 2,
|
||||
@@ -153,7 +195,6 @@ describe('GetAllTeamsUseCase', () => {
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockResultRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
@@ -180,7 +221,6 @@ describe('GetAllTeamsUseCase', () => {
|
||||
mockTeamRepo,
|
||||
mockTeamMembershipRepo,
|
||||
mockTeamStatsRepo,
|
||||
mockMediaRepo,
|
||||
mockResultRepo,
|
||||
mockLogger,
|
||||
output,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ITeamRepository } from '../../domain/repositories/ITeamRepository';
|
||||
import type { ITeamMembershipRepository } from '../../domain/repositories/ITeamMembershipRepository';
|
||||
import type { ITeamStatsRepository } from '../../domain/repositories/ITeamStatsRepository';
|
||||
import type { IMediaRepository } from '../../domain/repositories/IMediaRepository';
|
||||
import type { IResultRepository } from '../../domain/repositories/IResultRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export type GetAllTeamsInput = {};
|
||||
|
||||
@@ -27,7 +27,8 @@ export interface TeamSummary {
|
||||
specialization?: string;
|
||||
region?: string;
|
||||
languages?: string[];
|
||||
logoUrl?: string;
|
||||
logoRef?: MediaReference;
|
||||
logoUrl?: string | null;
|
||||
rating?: number;
|
||||
category?: string | undefined;
|
||||
isRecruiting: boolean;
|
||||
@@ -46,7 +47,6 @@ export class GetAllTeamsUseCase {
|
||||
private readonly teamRepository: ITeamRepository,
|
||||
private readonly teamMembershipRepository: ITeamMembershipRepository,
|
||||
private readonly teamStatsRepository: ITeamStatsRepository,
|
||||
private readonly mediaRepository: IMediaRepository,
|
||||
private readonly resultRepository: IResultRepository,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetAllTeamsResult>,
|
||||
@@ -64,7 +64,9 @@ export class GetAllTeamsUseCase {
|
||||
const enrichedTeams: TeamSummary[] = await Promise.all(
|
||||
teams.map(async (team) => {
|
||||
const memberCount = await this.teamMembershipRepository.countByTeamId(team.id);
|
||||
const logoUrl = await this.mediaRepository.getTeamLogo(team.id);
|
||||
|
||||
// Get logo reference from team entity
|
||||
const logoRef = team.logoRef;
|
||||
|
||||
// Try to get pre-computed stats first
|
||||
let stats = await this.teamStatsRepository.getTeamStats(team.id);
|
||||
@@ -95,7 +97,6 @@ export class GetAllTeamsUseCase {
|
||||
else performanceLevel = 'beginner';
|
||||
|
||||
stats = {
|
||||
logoUrl: await this.mediaRepository.getTeamLogo(team.id) || '',
|
||||
performanceLevel,
|
||||
specialization: 'mixed',
|
||||
region: 'International',
|
||||
@@ -121,7 +122,8 @@ export class GetAllTeamsUseCase {
|
||||
specialization: stats!.specialization,
|
||||
region: stats!.region,
|
||||
languages: stats!.languages,
|
||||
logoUrl: logoUrl || stats!.logoUrl,
|
||||
logoRef: logoRef,
|
||||
logoUrl: null, // Will be resolved by presenter
|
||||
rating: stats!.rating,
|
||||
category: team.category,
|
||||
isRecruiting: team.isRecruiting,
|
||||
|
||||
@@ -33,7 +33,6 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
getDriverStats: mockDriverStatsGetDriverStats,
|
||||
};
|
||||
|
||||
const mockGetDriverAvatar = vi.fn();
|
||||
const mockLogger: Logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
@@ -50,13 +49,22 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
mockDriverRepo,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
);
|
||||
|
||||
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
|
||||
const driver2 = { id: 'driver2', name: { value: 'Driver Two' }, country: { value: 'US' } };
|
||||
const driver1 = {
|
||||
id: 'driver1',
|
||||
name: { value: 'Driver One' },
|
||||
country: { value: 'US' },
|
||||
avatarRef: { type: 'system-default', variant: 'avatar' }
|
||||
};
|
||||
const driver2 = {
|
||||
id: 'driver2',
|
||||
name: { value: 'Driver Two' },
|
||||
country: { value: 'US' },
|
||||
avatarRef: { type: 'system-default', variant: 'avatar' }
|
||||
};
|
||||
const rankings = [
|
||||
{ driverId: 'driver1', rating: 2500, overallRank: 1 },
|
||||
{ driverId: 'driver2', rating: 2400, overallRank: 2 },
|
||||
@@ -71,11 +79,6 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
if (id === 'driver2') return stats2;
|
||||
return null;
|
||||
});
|
||||
mockGetDriverAvatar.mockImplementation((driverId: string) => {
|
||||
if (driverId === 'driver1') return Promise.resolve('avatar-driver1');
|
||||
if (driverId === 'driver2') return Promise.resolve('avatar-driver2');
|
||||
return Promise.resolve('avatar-default');
|
||||
});
|
||||
|
||||
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
|
||||
|
||||
@@ -94,7 +97,7 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
podiums: 7,
|
||||
isActive: true,
|
||||
rank: 1,
|
||||
avatarUrl: 'avatar-driver1',
|
||||
avatarRef: driver1.avatarRef,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
driver: driver2,
|
||||
@@ -105,7 +108,7 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
podiums: 4,
|
||||
isActive: true,
|
||||
rank: 2,
|
||||
avatarUrl: 'avatar-driver2',
|
||||
avatarRef: driver2.avatarRef,
|
||||
}),
|
||||
],
|
||||
totalRaces: 18,
|
||||
@@ -119,7 +122,6 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
mockDriverRepo,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
);
|
||||
@@ -146,18 +148,21 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
mockDriverRepo,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
);
|
||||
|
||||
const driver1 = { id: 'driver1', name: { value: 'Driver One' }, country: { value: 'US' } };
|
||||
const driver1 = {
|
||||
id: 'driver1',
|
||||
name: { value: 'Driver One' },
|
||||
country: { value: 'US' },
|
||||
avatarRef: { type: 'system-default', variant: 'avatar' }
|
||||
};
|
||||
const rankings = [{ driverId: 'driver1', rating: 2500, overallRank: 1 }];
|
||||
|
||||
mockDriverFindAll.mockResolvedValue([driver1]);
|
||||
mockRankingGetAllDriverRankings.mockReturnValue(rankings);
|
||||
mockDriverStatsGetDriverStats.mockReturnValue(null);
|
||||
mockGetDriverAvatar.mockResolvedValue('avatar-driver1');
|
||||
|
||||
const input: GetDriversLeaderboardInput = { leagueId: 'league-1' };
|
||||
|
||||
@@ -176,7 +181,7 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
podiums: 0,
|
||||
isActive: false,
|
||||
rank: 1,
|
||||
avatarUrl: 'avatar-driver1',
|
||||
avatarRef: driver1.avatarRef,
|
||||
}),
|
||||
],
|
||||
totalRaces: 0,
|
||||
@@ -190,7 +195,6 @@ describe('GetDriversLeaderboardUseCase', () => {
|
||||
mockDriverRepo,
|
||||
mockRankingUseCase,
|
||||
mockDriverStatsUseCase,
|
||||
mockGetDriverAvatar,
|
||||
mockLogger,
|
||||
mockOutput,
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IDriverRepository } from '../../domain/repositories/IDriverReposit
|
||||
import type { IDriverStatsUseCase } from './IDriverStatsUseCase';
|
||||
import type { IRankingUseCase } from './IRankingUseCase';
|
||||
import { SkillLevelService, type SkillLevel } from '../../domain/services/SkillLevelService';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export type GetDriversLeaderboardInput = {
|
||||
leagueId?: string;
|
||||
@@ -23,7 +24,7 @@ export interface DriverLeaderboardItem {
|
||||
podiums: number;
|
||||
isActive: boolean;
|
||||
rank: number;
|
||||
avatarUrl?: string;
|
||||
avatarRef?: MediaReference;
|
||||
}
|
||||
|
||||
export interface GetDriversLeaderboardResult {
|
||||
@@ -47,7 +48,6 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
private readonly driverRepository: IDriverRepository,
|
||||
private readonly rankingUseCase: IRankingUseCase,
|
||||
private readonly driverStatsUseCase: IDriverStatsUseCase,
|
||||
private readonly getDriverAvatar: (driverId: string) => Promise<string | undefined>,
|
||||
private readonly logger: Logger,
|
||||
private readonly output: UseCaseOutputPort<GetDriversLeaderboardResult>,
|
||||
) {}
|
||||
@@ -66,12 +66,6 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
const drivers = await this.driverRepository.findAll();
|
||||
const rankings = await this.rankingUseCase.getAllDriverRankings();
|
||||
|
||||
const avatarUrls: Record<string, string | undefined> = {};
|
||||
|
||||
for (const driver of drivers) {
|
||||
avatarUrls[driver.id] = await this.getDriverAvatar(driver.id);
|
||||
}
|
||||
|
||||
// Get stats for all drivers
|
||||
const statsPromises = drivers.map(driver =>
|
||||
this.driverStatsUseCase.getDriverStats(driver.id)
|
||||
@@ -90,7 +84,6 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
const rating = ranking?.rating ?? 0;
|
||||
const racesCompleted = stats?.totalRaces ?? 0;
|
||||
const skillLevel: SkillLevel = SkillLevelService.getSkillLevel(rating);
|
||||
const avatarUrl = avatarUrls[driver.id];
|
||||
|
||||
return {
|
||||
driver,
|
||||
@@ -101,7 +94,7 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
podiums: stats?.podiums ?? 0,
|
||||
isActive: racesCompleted > 0,
|
||||
rank: ranking?.overallRank ?? 0,
|
||||
...(avatarUrl !== undefined ? { avatarUrl } : {}),
|
||||
avatarRef: driver.avatarRef,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -132,4 +125,4 @@ export class GetDriversLeaderboardUseCase implements UseCase<GetDriversLeaderboa
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { DriverName } from '../value-objects/driver/DriverName';
|
||||
import { CountryCode } from '../value-objects/CountryCode';
|
||||
import { DriverBio } from '../value-objects/driver/DriverBio';
|
||||
import { JoinedAt } from '../value-objects/JoinedAt';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export class Driver implements IEntity<string> {
|
||||
readonly id: string;
|
||||
@@ -21,6 +22,7 @@ export class Driver implements IEntity<string> {
|
||||
readonly bio: DriverBio | undefined;
|
||||
readonly joinedAt: JoinedAt;
|
||||
readonly category: string | undefined;
|
||||
readonly avatarRef: MediaReference;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
@@ -30,6 +32,7 @@ export class Driver implements IEntity<string> {
|
||||
bio?: DriverBio;
|
||||
joinedAt: JoinedAt;
|
||||
category?: string;
|
||||
avatarRef: MediaReference;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.iracingId = props.iracingId;
|
||||
@@ -38,6 +41,7 @@ export class Driver implements IEntity<string> {
|
||||
this.bio = props.bio;
|
||||
this.joinedAt = props.joinedAt;
|
||||
this.category = props.category;
|
||||
this.avatarRef = props.avatarRef;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,6 +55,7 @@ export class Driver implements IEntity<string> {
|
||||
bio?: string;
|
||||
joinedAt?: Date;
|
||||
category?: string;
|
||||
avatarRef?: MediaReference;
|
||||
}): Driver {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Driver ID is required');
|
||||
@@ -64,12 +69,14 @@ export class Driver implements IEntity<string> {
|
||||
bio?: DriverBio;
|
||||
joinedAt: JoinedAt;
|
||||
category?: string;
|
||||
avatarRef: MediaReference;
|
||||
} = {
|
||||
id: props.id,
|
||||
iracingId: IRacingId.create(props.iracingId),
|
||||
name: DriverName.create(props.name),
|
||||
country: CountryCode.create(props.country),
|
||||
joinedAt: JoinedAt.create(props.joinedAt ?? new Date()),
|
||||
avatarRef: props.avatarRef ?? MediaReference.createSystemDefault('avatar'),
|
||||
};
|
||||
|
||||
if (props.bio !== undefined) {
|
||||
@@ -90,6 +97,7 @@ export class Driver implements IEntity<string> {
|
||||
bio?: string;
|
||||
joinedAt: Date;
|
||||
category?: string;
|
||||
avatarRef?: MediaReference;
|
||||
}): Driver {
|
||||
const driverProps: {
|
||||
id: string;
|
||||
@@ -99,12 +107,14 @@ export class Driver implements IEntity<string> {
|
||||
bio?: DriverBio;
|
||||
joinedAt: JoinedAt;
|
||||
category?: string;
|
||||
avatarRef: MediaReference;
|
||||
} = {
|
||||
id: props.id,
|
||||
iracingId: IRacingId.create(props.iracingId),
|
||||
name: DriverName.create(props.name),
|
||||
country: CountryCode.create(props.country),
|
||||
joinedAt: JoinedAt.create(props.joinedAt),
|
||||
avatarRef: props.avatarRef ?? MediaReference.createSystemDefault('avatar'),
|
||||
};
|
||||
|
||||
if (props.bio !== undefined) {
|
||||
@@ -125,11 +135,13 @@ export class Driver implements IEntity<string> {
|
||||
country: string;
|
||||
bio: string | undefined;
|
||||
category: string | undefined;
|
||||
avatarRef: MediaReference;
|
||||
}>): Driver {
|
||||
const nextName = 'name' in props ? DriverName.create(props.name!) : this.name;
|
||||
const nextCountry = 'country' in props ? CountryCode.create(props.country!) : this.country;
|
||||
const nextBio = 'bio' in props ? (props.bio ? DriverBio.create(props.bio) : undefined) : this.bio;
|
||||
const nextCategory = 'category' in props ? props.category : this.category;
|
||||
const nextAvatarRef = 'avatarRef' in props ? props.avatarRef! : this.avatarRef;
|
||||
|
||||
const driverProps: {
|
||||
id: string;
|
||||
@@ -139,12 +151,14 @@ export class Driver implements IEntity<string> {
|
||||
bio?: DriverBio;
|
||||
joinedAt: JoinedAt;
|
||||
category?: string;
|
||||
avatarRef: MediaReference;
|
||||
} = {
|
||||
id: this.id,
|
||||
iracingId: this.iracingId,
|
||||
name: nextName,
|
||||
country: nextCountry,
|
||||
joinedAt: this.joinedAt,
|
||||
avatarRef: nextAvatarRef,
|
||||
};
|
||||
|
||||
if (nextBio !== undefined) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ParticipantCount } from '../value-objects/ParticipantCount';
|
||||
import { MaxParticipants } from '../value-objects/MaxParticipants';
|
||||
import { SessionDuration } from '../value-objects/SessionDuration';
|
||||
import { RacingDomainValidationError, RacingDomainInvariantError } from '../errors/RacingDomainError';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
/**
|
||||
* Stewarding decision mode for protests
|
||||
@@ -99,6 +100,7 @@ export class League implements IEntity<LeagueId> {
|
||||
readonly category?: string | undefined;
|
||||
readonly createdAt: LeagueCreatedAt;
|
||||
readonly socialLinks: LeagueSocialLinks | undefined;
|
||||
readonly logoRef: MediaReference;
|
||||
|
||||
// Domain state for business rule enforcement
|
||||
private readonly _participantCount: ParticipantCount;
|
||||
@@ -115,6 +117,7 @@ export class League implements IEntity<LeagueId> {
|
||||
socialLinks?: LeagueSocialLinks;
|
||||
participantCount: ParticipantCount;
|
||||
visibility: LeagueVisibility;
|
||||
logoRef: MediaReference;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
@@ -126,6 +129,7 @@ export class League implements IEntity<LeagueId> {
|
||||
this.socialLinks = props.socialLinks;
|
||||
this._participantCount = props.participantCount;
|
||||
this._visibility = props.visibility;
|
||||
this.logoRef = props.logoRef;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,6 +150,7 @@ export class League implements IEntity<LeagueId> {
|
||||
websiteUrl?: string;
|
||||
};
|
||||
participantCount?: number;
|
||||
logoRef?: MediaReference;
|
||||
}): League {
|
||||
// Validate required fields
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
@@ -254,6 +259,7 @@ export class League implements IEntity<LeagueId> {
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount,
|
||||
visibility,
|
||||
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -271,6 +277,7 @@ export class League implements IEntity<LeagueId> {
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
logoRef?: MediaReference;
|
||||
}): League {
|
||||
const id = LeagueId.create(props.id);
|
||||
const name = LeagueName.create(props.name);
|
||||
@@ -297,6 +304,7 @@ export class League implements IEntity<LeagueId> {
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount,
|
||||
visibility,
|
||||
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -356,11 +364,13 @@ export class League implements IEntity<LeagueId> {
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
logoRef: MediaReference;
|
||||
}>): League {
|
||||
const name = props.name ? LeagueName.create(props.name) : this.name;
|
||||
const description = props.description ? LeagueDescription.create(props.description) : this.description;
|
||||
const ownerId = props.ownerId ? LeagueOwnerId.create(props.ownerId) : this.ownerId;
|
||||
const socialLinks = props.socialLinks ? LeagueSocialLinks.create(props.socialLinks) : this.socialLinks;
|
||||
const logoRef = 'logoRef' in props ? props.logoRef! : this.logoRef;
|
||||
|
||||
// If settings are being updated, validate them
|
||||
let newSettings = props.settings ?? this.settings;
|
||||
@@ -427,6 +437,7 @@ export class League implements IEntity<LeagueId> {
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount: this._participantCount,
|
||||
visibility: this._visibility,
|
||||
logoRef: logoRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -461,6 +472,7 @@ export class League implements IEntity<LeagueId> {
|
||||
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
|
||||
participantCount: newCount,
|
||||
visibility: this._visibility,
|
||||
logoRef: this.logoRef,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -485,6 +497,7 @@ export class League implements IEntity<LeagueId> {
|
||||
...(this.socialLinks !== undefined ? { socialLinks: this.socialLinks } : {}),
|
||||
participantCount: newCount,
|
||||
visibility: this._visibility,
|
||||
logoRef: this.logoRef,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TeamDescription } from '../value-objects/TeamDescription';
|
||||
import { DriverId } from './DriverId';
|
||||
import { LeagueId } from './LeagueId';
|
||||
import { TeamCreatedAt } from '../value-objects/TeamCreatedAt';
|
||||
import { MediaReference } from '@core/domain/media/MediaReference';
|
||||
|
||||
export class Team implements IEntity<string> {
|
||||
readonly id: string;
|
||||
@@ -25,6 +26,7 @@ export class Team implements IEntity<string> {
|
||||
readonly category: string | undefined;
|
||||
readonly isRecruiting: boolean;
|
||||
readonly createdAt: TeamCreatedAt;
|
||||
readonly logoRef: MediaReference;
|
||||
|
||||
private constructor(props: {
|
||||
id: string;
|
||||
@@ -36,6 +38,7 @@ export class Team implements IEntity<string> {
|
||||
category: string | undefined;
|
||||
isRecruiting: boolean;
|
||||
createdAt: TeamCreatedAt;
|
||||
logoRef: MediaReference;
|
||||
}) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
@@ -46,6 +49,7 @@ export class Team implements IEntity<string> {
|
||||
this.category = props.category;
|
||||
this.isRecruiting = props.isRecruiting;
|
||||
this.createdAt = props.createdAt;
|
||||
this.logoRef = props.logoRef;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +65,7 @@ export class Team implements IEntity<string> {
|
||||
category?: string;
|
||||
isRecruiting?: boolean;
|
||||
createdAt?: Date;
|
||||
logoRef?: MediaReference;
|
||||
}): Team {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
@@ -80,6 +85,7 @@ export class Team implements IEntity<string> {
|
||||
category: props.category,
|
||||
isRecruiting: props.isRecruiting ?? false,
|
||||
createdAt: TeamCreatedAt.create(props.createdAt ?? new Date()),
|
||||
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,6 +99,7 @@ export class Team implements IEntity<string> {
|
||||
category?: string;
|
||||
isRecruiting: boolean;
|
||||
createdAt: Date;
|
||||
logoRef?: MediaReference;
|
||||
}): Team {
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Team ID is required');
|
||||
@@ -112,6 +119,7 @@ export class Team implements IEntity<string> {
|
||||
category: props.category,
|
||||
isRecruiting: props.isRecruiting,
|
||||
createdAt: TeamCreatedAt.create(props.createdAt),
|
||||
logoRef: props.logoRef ?? MediaReference.createSystemDefault('logo'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,6 +134,7 @@ export class Team implements IEntity<string> {
|
||||
leagues: string[];
|
||||
category: string | undefined;
|
||||
isRecruiting: boolean;
|
||||
logoRef: MediaReference;
|
||||
}>): Team {
|
||||
const nextName = 'name' in props ? TeamName.create(props.name!) : this.name;
|
||||
const nextTag = 'tag' in props ? TeamTag.create(props.tag!) : this.tag;
|
||||
@@ -134,6 +143,7 @@ export class Team implements IEntity<string> {
|
||||
const nextLeagues = 'leagues' in props ? props.leagues!.map(leagueId => LeagueId.create(leagueId)) : this.leagues;
|
||||
const nextCategory = 'category' in props ? props.category : this.category;
|
||||
const nextIsRecruiting = 'isRecruiting' in props ? props.isRecruiting! : this.isRecruiting;
|
||||
const nextLogoRef = 'logoRef' in props ? props.logoRef! : this.logoRef;
|
||||
|
||||
return new Team({
|
||||
id: this.id,
|
||||
@@ -145,6 +155,7 @@ export class Team implements IEntity<string> {
|
||||
category: nextCategory,
|
||||
isRecruiting: nextIsRecruiting,
|
||||
createdAt: this.createdAt,
|
||||
logoRef: nextLogoRef,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Application Port: IMediaRepository
|
||||
*
|
||||
* Repository interface for static media assets (logos, images, icons).
|
||||
* Handles frontend assets like team logos, driver avatars, etc.
|
||||
* Repository interface for media assets (logos, avatars).
|
||||
* Handles frontend assets like team logos and driver avatars.
|
||||
*/
|
||||
|
||||
export interface IMediaRepository {
|
||||
@@ -17,22 +17,17 @@ export interface IMediaRepository {
|
||||
getTeamLogo(teamId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get track image URL
|
||||
* Get league logo URL
|
||||
*/
|
||||
getTrackImage(trackId: string): Promise<string | null>;
|
||||
getLeagueLogo(leagueId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get category icon URL
|
||||
* Get league cover URL
|
||||
*/
|
||||
getCategoryIcon(categoryId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Get sponsor logo URL
|
||||
*/
|
||||
getSponsorLogo(sponsorId: string): Promise<string | null>;
|
||||
getLeagueCover(leagueId: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Clear all media data (for reseeding)
|
||||
*/
|
||||
clear(): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
export interface TeamStats {
|
||||
logoUrl: string;
|
||||
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||
specialization: 'endurance' | 'sprint' | 'mixed';
|
||||
region: string;
|
||||
|
||||
Reference in New Issue
Block a user