harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -0,0 +1,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);
});
});
});

View 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');
}
}
}