inmemory to postgres

This commit is contained in:
2025-12-29 20:50:03 +01:00
parent 12ae6e1dad
commit 3f610c1cb6
64 changed files with 3689 additions and 63 deletions

View File

@@ -0,0 +1,38 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm';
@Entity({ name: 'avatar_generation_requests' })
export class AvatarGenerationRequestOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Index()
@Column({ type: 'text' })
userId!: string;
@Column({ type: 'text' })
facePhotoUrl!: string;
@Column({ type: 'text' })
suitColor!: string;
@Column({ type: 'text' })
style!: string;
@Column({ type: 'text' })
status!: string;
@Column({ type: 'jsonb' })
generatedAvatarUrls!: string[];
@Column({ type: 'integer', nullable: true })
selectedAvatarIndex!: number | null;
@Column({ type: 'text', nullable: true })
errorMessage!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -0,0 +1,20 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'avatars' })
export class AvatarOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Index()
@Column({ type: 'text' })
driverId!: string;
@Column({ type: 'text' })
mediaUrl!: string;
@CreateDateColumn({ type: 'timestamptz' })
selectedAt!: Date;
@Column({ type: 'boolean', default: true })
isActive!: boolean;
}

View File

@@ -0,0 +1,35 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity({ name: 'media_files' })
export class MediaOrmEntity {
@PrimaryColumn({ type: 'uuid' })
id!: string;
@Column({ type: 'text' })
filename!: string;
@Column({ type: 'text' })
originalName!: string;
@Column({ type: 'text' })
mimeType!: string;
@Column({ type: 'integer' })
size!: number;
@Column({ type: 'text' })
url!: string;
@Column({ type: 'text' })
type!: string;
@Index()
@Column({ type: 'text' })
uploadedBy!: string;
@CreateDateColumn({ type: 'timestamptz' })
uploadedAt!: Date;
@Column({ type: 'jsonb', nullable: true })
metadata!: Record<string, unknown> | null;
}

View File

@@ -0,0 +1,34 @@
export type TypeOrmMediaSchemaErrorReason =
| 'missing'
| 'not_string'
| 'empty_string'
| 'not_number'
| 'not_integer'
| 'not_boolean'
| 'not_date'
| 'invalid_date'
| 'not_iso_date'
| 'not_array'
| 'not_object'
| 'invalid_enum_value'
| 'invalid_shape';
export class TypeOrmMediaSchemaError extends Error {
readonly entityName: string;
readonly fieldName: string;
readonly reason: TypeOrmMediaSchemaErrorReason | (string & {});
constructor(params: {
entityName: string;
fieldName: string;
reason: TypeOrmMediaSchemaError['reason'];
message?: string;
}) {
const message = params.message ?? `Invalid persisted ${params.entityName}.${params.fieldName}: ${params.reason}`;
super(message);
this.name = 'TypeOrmMediaSchemaError';
this.entityName = params.entityName;
this.fieldName = params.fieldName;
this.reason = params.reason;
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest';
import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import { AvatarGenerationRequestOrmMapper } from './AvatarGenerationRequestOrmMapper';
describe('AvatarGenerationRequestOrmMapper', () => {
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
const mapper = new AvatarGenerationRequestOrmMapper();
const entity = new AvatarGenerationRequestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.userId = 'user-123';
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
entity.suitColor = 'red';
entity.style = 'realistic';
entity.status = 'completed';
entity.generatedAvatarUrls = ['https://cdn.example.com/avatars/av-1.png', 'https://cdn.example.com/avatars/av-2.png'];
entity.selectedAvatarIndex = 0;
entity.errorMessage = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
const reconstituteSpy = vi.spyOn(AvatarGenerationRequest as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.userId).toBe(entity.userId);
expect(domain.facePhotoUrl.value).toBe(entity.facePhotoUrl);
expect(domain.suitColor).toBe(entity.suitColor);
expect(domain.status).toBe(entity.status);
expect(domain.generatedAvatarUrls).toEqual(entity.generatedAvatarUrls);
expect(domain.selectedAvatarIndex).toBe(entity.selectedAvatarIndex);
expect(reconstituteSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape', () => {
const mapper = new AvatarGenerationRequestOrmMapper();
const entity = new AvatarGenerationRequestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.userId = 123 as unknown as string;
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
entity.suitColor = 'red';
entity.style = 'realistic';
entity.status = 'completed';
entity.generatedAvatarUrls = [];
entity.selectedAvatarIndex = null;
entity.errorMessage = null;
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
expect(error).toMatchObject({
entityName: 'AvatarGenerationRequest',
fieldName: 'userId',
reason: 'not_string',
});
}
});
it('toOrmEntity converts domain entity to ORM entity', () => {
const mapper = new AvatarGenerationRequestOrmMapper();
const domain = AvatarGenerationRequest.create({
id: '00000000-0000-4000-8000-000000000001',
userId: 'user-123',
facePhotoUrl: 'https://cdn.example.com/faces/face-1.png',
suitColor: 'blue',
style: 'cartoon',
});
// Simulate completion
domain.completeWithAvatars(['https://cdn.example.com/avatars/av-1.png']);
const entity = mapper.toOrmEntity(domain);
expect(entity.id).toBe(domain.id);
expect(entity.userId).toBe(domain.userId);
expect(entity.facePhotoUrl).toBe(domain.facePhotoUrl.value);
expect(entity.suitColor).toBe(domain.suitColor);
expect(entity.style).toBe(domain.style);
expect(entity.status).toBe(domain.status);
expect(entity.generatedAvatarUrls).toEqual(domain.generatedAvatarUrls);
expect(entity.selectedAvatarIndex).toBeNull();
expect(entity.errorMessage).toBeNull();
});
it('toDomain handles optional fields correctly', () => {
const mapper = new AvatarGenerationRequestOrmMapper();
const entity = new AvatarGenerationRequestOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.userId = 'user-123';
entity.facePhotoUrl = 'https://cdn.example.com/faces/face-1.png';
entity.suitColor = 'red';
entity.style = 'realistic';
entity.status = 'failed';
entity.generatedAvatarUrls = [];
entity.selectedAvatarIndex = null;
entity.errorMessage = 'Generation failed';
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
entity.updatedAt = new Date('2025-01-01T01:00:00.000Z');
const domain = mapper.toDomain(entity);
expect(domain.errorMessage).toBe('Generation failed');
expect(domain.selectedAvatarIndex).toBeUndefined();
});
});

View File

@@ -0,0 +1,89 @@
import { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import {
assertNonEmptyString,
assertDate,
assertStringArray,
assertOptionalIntegerOrNull,
assertOptionalStringOrNull,
assertRacingSuitColor,
assertAvatarStyle,
assertAvatarGenerationStatus,
} from '../schema/TypeOrmMediaSchemaGuards';
export class AvatarGenerationRequestOrmMapper {
toDomain(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest {
const entityName = 'AvatarGenerationRequest';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'userId', entity.userId);
assertNonEmptyString(entityName, 'facePhotoUrl', entity.facePhotoUrl);
assertRacingSuitColor(entityName, 'suitColor', entity.suitColor);
assertAvatarStyle(entityName, 'style', entity.style);
assertAvatarGenerationStatus(entityName, 'status', entity.status);
assertStringArray(entityName, 'generatedAvatarUrls', entity.generatedAvatarUrls);
assertOptionalIntegerOrNull(entityName, 'selectedAvatarIndex', entity.selectedAvatarIndex);
assertOptionalStringOrNull(entityName, 'errorMessage', entity.errorMessage);
assertDate(entityName, 'createdAt', entity.createdAt);
assertDate(entityName, 'updatedAt', entity.updatedAt);
} catch (error) {
if (error instanceof TypeOrmMediaSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
try {
const props: any = {
id: entity.id,
userId: entity.userId,
facePhotoUrl: entity.facePhotoUrl,
suitColor: entity.suitColor,
style: entity.style,
status: entity.status,
generatedAvatarUrls: entity.generatedAvatarUrls,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
if (entity.selectedAvatarIndex !== null && entity.selectedAvatarIndex !== undefined) {
props.selectedAvatarIndex = entity.selectedAvatarIndex;
}
if (entity.errorMessage !== null && entity.errorMessage !== undefined) {
props.errorMessage = entity.errorMessage;
}
return AvatarGenerationRequest.reconstitute(props);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted AvatarGenerationRequest';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
toOrmEntity(request: AvatarGenerationRequest): AvatarGenerationRequestOrmEntity {
const entity = new AvatarGenerationRequestOrmEntity();
const props = request.toProps();
entity.id = props.id;
entity.userId = props.userId;
entity.facePhotoUrl = props.facePhotoUrl;
entity.suitColor = props.suitColor;
entity.style = props.style;
entity.status = props.status;
entity.generatedAvatarUrls = props.generatedAvatarUrls;
entity.selectedAvatarIndex = props.selectedAvatarIndex ?? null;
entity.errorMessage = props.errorMessage ?? null;
entity.createdAt = props.createdAt;
entity.updatedAt = props.updatedAt;
return entity;
}
toStored(entity: AvatarGenerationRequestOrmEntity): AvatarGenerationRequest {
return this.toDomain(entity);
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest';
import { Avatar } from '@core/media/domain/entities/Avatar';
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import { AvatarOrmMapper } from './AvatarOrmMapper';
describe('AvatarOrmMapper', () => {
it('toDomain preserves persisted identity and uses reconstitute semantics', () => {
const mapper = new AvatarOrmMapper();
const entity = new AvatarOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.driverId = 'driver-123';
entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png';
entity.selectedAt = new Date('2025-01-01T00:00:00.000Z');
entity.isActive = true;
const reconstituteSpy = vi.spyOn(Avatar as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.driverId).toBe(entity.driverId);
expect(domain.mediaUrl.value).toBe(entity.mediaUrl);
expect(domain.isActive).toBe(entity.isActive);
expect(reconstituteSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape', () => {
const mapper = new AvatarOrmMapper();
const entity = new AvatarOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.driverId = 123 as unknown as string;
entity.mediaUrl = 'https://cdn.example.com/avatars/avatar-1.png';
entity.selectedAt = new Date('2025-01-01T00:00:00.000Z');
entity.isActive = true;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
expect(error).toMatchObject({
entityName: 'Avatar',
fieldName: 'driverId',
reason: 'not_string',
});
}
});
it('toOrmEntity converts domain entity to ORM entity', () => {
const mapper = new AvatarOrmMapper();
const domain = Avatar.create({
id: '00000000-0000-4000-8000-000000000001',
driverId: 'driver-123',
mediaUrl: 'https://cdn.example.com/avatars/avatar-1.png',
});
const entity = mapper.toOrmEntity(domain);
expect(entity.id).toBe(domain.id);
expect(entity.driverId).toBe(domain.driverId);
expect(entity.mediaUrl).toBe(domain.mediaUrl.value);
expect(entity.isActive).toBe(true);
});
});

View File

@@ -0,0 +1,58 @@
import { Avatar } from '@core/media/domain/entities/Avatar';
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import {
assertNonEmptyString,
assertDate,
assertBoolean,
} from '../schema/TypeOrmMediaSchemaGuards';
export class AvatarOrmMapper {
toDomain(entity: AvatarOrmEntity): Avatar {
const entityName = 'Avatar';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'driverId', entity.driverId);
assertNonEmptyString(entityName, 'mediaUrl', entity.mediaUrl);
assertDate(entityName, 'selectedAt', entity.selectedAt);
assertBoolean(entityName, 'isActive', entity.isActive);
} catch (error) {
if (error instanceof TypeOrmMediaSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted Avatar';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
try {
return Avatar.reconstitute({
id: entity.id,
driverId: entity.driverId,
mediaUrl: entity.mediaUrl,
selectedAt: entity.selectedAt,
isActive: entity.isActive,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted Avatar';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
toOrmEntity(avatar: Avatar): AvatarOrmEntity {
const entity = new AvatarOrmEntity();
const props = avatar.toProps();
entity.id = props.id;
entity.driverId = props.driverId;
entity.mediaUrl = props.mediaUrl;
entity.selectedAt = props.selectedAt;
entity.isActive = props.isActive;
return entity;
}
toStored(entity: AvatarOrmEntity): Avatar {
return this.toDomain(entity);
}
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import { Media } from '@core/media/domain/entities/Media';
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import { MediaOrmMapper } from './MediaOrmMapper';
describe('MediaOrmMapper', () => {
it('toDomain preserves persisted identity and uses reconstitute semantics (does not call create)', () => {
const mapper = new MediaOrmMapper();
const entity = new MediaOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.filename = 'test-image.png';
entity.originalName = 'original.png';
entity.mimeType = 'image/png';
entity.size = 12345;
entity.url = 'https://cdn.example.com/test-image.png';
entity.type = 'image';
entity.uploadedBy = 'user-123';
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
entity.metadata = { width: 800, height: 600 };
if (typeof (Media as unknown as { reconstitute?: unknown }).reconstitute !== 'function') {
throw new Error('reconstitute-missing');
}
const reconstituteSpy = vi.spyOn(Media as unknown as { reconstitute: (...args: unknown[]) => unknown }, 'reconstitute');
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.filename).toBe(entity.filename);
expect(domain.url.value).toBe(entity.url);
expect(reconstituteSpy).toHaveBeenCalled();
});
it('toDomain validates persisted shape and throws adapter-scoped base schema error type', () => {
const mapper = new MediaOrmMapper();
const entity = new MediaOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.filename = 123 as unknown as string;
entity.originalName = 'original.png';
entity.mimeType = 'image/png';
entity.size = 12345;
entity.url = 'https://cdn.example.com/test-image.png';
entity.type = 'image';
entity.uploadedBy = 'user-123';
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
entity.metadata = null;
try {
mapper.toDomain(entity);
throw new Error('expected-to-throw');
} catch (error) {
expect(error).toBeInstanceOf(TypeOrmMediaSchemaError);
expect(error).toMatchObject({
entityName: 'Media',
fieldName: 'filename',
reason: 'not_string',
});
}
});
it('toOrmEntity converts domain entity to ORM entity', () => {
const mapper = new MediaOrmMapper();
const domain = Media.create({
id: '00000000-0000-4000-8000-000000000001',
filename: 'test-image.png',
originalName: 'original.png',
mimeType: 'image/png',
size: 12345,
url: 'https://cdn.example.com/test-image.png',
type: 'image',
uploadedBy: 'user-123',
metadata: { width: 800, height: 600 },
});
const entity = mapper.toOrmEntity(domain);
expect(entity.id).toBe(domain.id);
expect(entity.filename).toBe(domain.filename);
expect(entity.url).toBe(domain.url.value);
expect(entity.metadata).toEqual({ width: 800, height: 600 });
});
it('toDomain handles null metadata', () => {
const mapper = new MediaOrmMapper();
const entity = new MediaOrmEntity();
entity.id = '00000000-0000-4000-8000-000000000001';
entity.filename = 'test-image.png';
entity.originalName = 'original.png';
entity.mimeType = 'image/png';
entity.size = 12345;
entity.url = 'https://cdn.example.com/test-image.png';
entity.type = 'image';
entity.uploadedBy = 'user-123';
entity.uploadedAt = new Date('2025-01-01T00:00:00.000Z');
entity.metadata = null;
const domain = mapper.toDomain(entity);
expect(domain.id).toBe(entity.id);
expect(domain.metadata).toBeUndefined();
});
});

View File

@@ -0,0 +1,78 @@
import { Media } from '@core/media/domain/entities/Media';
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
import {
assertNonEmptyString,
assertDate,
assertInteger,
assertMediaType,
} from '../schema/TypeOrmMediaSchemaGuards';
export class MediaOrmMapper {
toDomain(entity: MediaOrmEntity): Media {
const entityName = 'Media';
try {
assertNonEmptyString(entityName, 'id', entity.id);
assertNonEmptyString(entityName, 'filename', entity.filename);
assertNonEmptyString(entityName, 'originalName', entity.originalName);
assertNonEmptyString(entityName, 'mimeType', entity.mimeType);
assertInteger(entityName, 'size', entity.size);
assertNonEmptyString(entityName, 'url', entity.url);
assertMediaType(entityName, 'type', entity.type);
assertNonEmptyString(entityName, 'uploadedBy', entity.uploadedBy);
assertDate(entityName, 'uploadedAt', entity.uploadedAt);
} catch (error) {
if (error instanceof TypeOrmMediaSchemaError) {
throw error;
}
const message = error instanceof Error ? error.message : 'Invalid persisted Media';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
try {
const domainProps: any = {
id: entity.id,
filename: entity.filename,
originalName: entity.originalName,
mimeType: entity.mimeType,
size: entity.size,
url: entity.url,
type: entity.type as 'image' | 'video' | 'document',
uploadedBy: entity.uploadedBy,
uploadedAt: entity.uploadedAt,
};
if (entity.metadata !== null && entity.metadata !== undefined) {
domainProps.metadata = entity.metadata as Record<string, unknown>;
}
return Media.reconstitute(domainProps);
} catch (error) {
const message = error instanceof Error ? error.message : 'Invalid persisted Media';
throw new TypeOrmMediaSchemaError({ entityName, fieldName: 'unknown', reason: 'invalid_shape', message });
}
}
toOrmEntity(media: Media): MediaOrmEntity {
const entity = new MediaOrmEntity();
const props = media.toProps();
entity.id = props.id;
entity.filename = props.filename;
entity.originalName = props.originalName;
entity.mimeType = props.mimeType;
entity.size = props.size;
entity.url = props.url;
entity.type = props.type;
entity.uploadedBy = props.uploadedBy;
entity.uploadedAt = props.uploadedAt;
entity.metadata = props.metadata ?? null;
return entity;
}
toStored(entity: MediaOrmEntity): Media {
return this.toDomain(entity);
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TypeOrmAvatarGenerationRepository } from './TypeOrmAvatarGenerationRepository';
describe('TypeOrmAvatarGenerationRepository', () => {
it('does not construct its own mapper dependencies', () => {
const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarGenerationRepository.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
expect(source).not.toMatch(/new\s+AvatarGenerationRequestOrmMapper\s*\(/);
expect(source).not.toMatch(/=\s*new\s+AvatarGenerationRequestOrmMapper\s*\(/);
});
it('requires mapper injection via constructor (no default mapper)', () => {
expect(TypeOrmAvatarGenerationRepository.length).toBe(2);
});
it('uses the injected mapper at runtime (DB-free)', async () => {
const ormRepo = {
findOne: vi.fn().mockResolvedValue({ id: 'request-1' }),
find: vi.fn().mockResolvedValue([{ id: 'request-1' }, { id: 'request-2' }]),
save: vi.fn().mockResolvedValue({ id: 'request-1' }),
delete: vi.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = {
toStored: vi.fn().mockReturnValue({ id: 'stored-request-1' }),
toDomain: vi.fn().mockReturnValue({ id: 'domain-request-1' }),
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-request-1' }),
};
const repo = new TypeOrmAvatarGenerationRepository(dataSource as any, mapper as any);
// Test findById
const request = await repo.findById('request-1');
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'request-1' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
expect(request).toEqual({ id: 'domain-request-1' });
// Test findByUserId
const requests = await repo.findByUserId('user-1');
expect(ormRepo.find).toHaveBeenCalledWith({
where: { userId: 'user-1' },
order: { createdAt: 'DESC' }
});
expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUserId
expect(requests).toHaveLength(2);
// Test findLatestByUserId
await repo.findLatestByUserId('user-1');
expect(ormRepo.findOne).toHaveBeenCalledWith({
where: { userId: 'user-1' },
order: { createdAt: 'DESC' }
});
// Test save
const domainRequest = { id: 'new-request', toProps: () => ({ id: 'new-request' }) };
await repo.save(domainRequest as any);
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainRequest);
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-request-1' });
// Test delete
await repo.delete('request-1');
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'request-1' });
});
});

View File

@@ -0,0 +1,47 @@
import type { DataSource } from 'typeorm';
import type { IAvatarGenerationRepository } from '@core/media/domain/repositories/IAvatarGenerationRepository';
import type { AvatarGenerationRequest } from '@core/media/domain/entities/AvatarGenerationRequest';
import { AvatarGenerationRequestOrmEntity } from '../entities/AvatarGenerationRequestOrmEntity';
import { AvatarGenerationRequestOrmMapper } from '../mappers/AvatarGenerationRequestOrmMapper';
export class TypeOrmAvatarGenerationRepository implements IAvatarGenerationRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: AvatarGenerationRequestOrmMapper,
) {}
async save(request: AvatarGenerationRequest): Promise<void> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
const entity = this.mapper.toOrmEntity(request);
await repo.save(entity);
}
async findById(id: string): Promise<AvatarGenerationRequest | null> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByUserId(userId: string): Promise<AvatarGenerationRequest[]> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
const entities = await repo.find({
where: { userId },
order: { createdAt: 'DESC' }
});
return entities.map(entity => this.mapper.toDomain(entity));
}
async findLatestByUserId(userId: string): Promise<AvatarGenerationRequest | null> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
const entity = await repo.findOne({
where: { userId },
order: { createdAt: 'DESC' }
});
return entity ? this.mapper.toDomain(entity) : null;
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(AvatarGenerationRequestOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TypeOrmAvatarRepository } from './TypeOrmAvatarRepository';
describe('TypeOrmAvatarRepository', () => {
it('does not construct its own mapper dependencies', () => {
const sourcePath = path.resolve(__dirname, 'TypeOrmAvatarRepository.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
expect(source).not.toMatch(/new\s+AvatarOrmMapper\s*\(/);
expect(source).not.toMatch(/=\s*new\s+AvatarOrmMapper\s*\(/);
});
it('requires mapper injection via constructor (no default mapper)', () => {
expect(TypeOrmAvatarRepository.length).toBe(2);
});
it('uses the injected mapper at runtime (DB-free)', async () => {
const ormRepo = {
findOne: vi.fn().mockResolvedValue({ id: 'avatar-1' }),
find: vi.fn().mockResolvedValue([{ id: 'avatar-1' }, { id: 'avatar-2' }]),
save: vi.fn().mockResolvedValue({ id: 'avatar-1' }),
delete: vi.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = {
toStored: vi.fn().mockReturnValue({ id: 'stored-avatar-1' }),
toDomain: vi.fn().mockReturnValue({ id: 'domain-avatar-1' }),
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-avatar-1' }),
};
const repo = new TypeOrmAvatarRepository(dataSource as any, mapper as any);
// Test findById
const avatar = await repo.findById('avatar-1');
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'avatar-1' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
expect(avatar).toEqual({ id: 'domain-avatar-1' });
// Test findActiveByDriverId
await repo.findActiveByDriverId('driver-1');
expect(ormRepo.findOne).toHaveBeenCalledWith({
where: { driverId: 'driver-1', isActive: true },
order: { selectedAt: 'DESC' }
});
// Test findByDriverId
const avatars = await repo.findByDriverId('driver-1');
expect(ormRepo.find).toHaveBeenCalledWith({
where: { driverId: 'driver-1' },
order: { selectedAt: 'DESC' }
});
expect(mapper.toDomain).toHaveBeenCalledTimes(4); // 1 from findById + 1 from findActiveByDriverId + 2 from findByDriverId
expect(avatars).toHaveLength(2);
// Test save
const domainAvatar = { id: 'new-avatar', toProps: () => ({ id: 'new-avatar' }) };
await repo.save(domainAvatar as any);
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainAvatar);
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-avatar-1' });
// Test delete
await repo.delete('avatar-1');
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'avatar-1' });
});
});

View File

@@ -0,0 +1,47 @@
import type { DataSource } from 'typeorm';
import type { IAvatarRepository } from '@core/media/domain/repositories/IAvatarRepository';
import type { Avatar } from '@core/media/domain/entities/Avatar';
import { AvatarOrmEntity } from '../entities/AvatarOrmEntity';
import { AvatarOrmMapper } from '../mappers/AvatarOrmMapper';
export class TypeOrmAvatarRepository implements IAvatarRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: AvatarOrmMapper,
) {}
async save(avatar: Avatar): Promise<void> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
const entity = this.mapper.toOrmEntity(avatar);
await repo.save(entity);
}
async findById(id: string): Promise<Avatar | null> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findActiveByDriverId(driverId: string): Promise<Avatar | null> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
const entity = await repo.findOne({
where: { driverId, isActive: true },
order: { selectedAt: 'DESC' }
});
return entity ? this.mapper.toDomain(entity) : null;
}
async findByDriverId(driverId: string): Promise<Avatar[]> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
const entities = await repo.find({
where: { driverId },
order: { selectedAt: 'DESC' }
});
return entities.map(entity => this.mapper.toDomain(entity));
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(AvatarOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TypeOrmMediaRepository } from './TypeOrmMediaRepository';
describe('TypeOrmMediaRepository', () => {
it('does not construct its own mapper dependencies', () => {
const sourcePath = path.resolve(__dirname, 'TypeOrmMediaRepository.ts');
const source = fs.readFileSync(sourcePath, 'utf8');
expect(source).not.toMatch(/new\s+MediaOrmMapper\s*\(/);
expect(source).not.toMatch(/=\s*new\s+MediaOrmMapper\s*\(/);
});
it('requires mapper injection via constructor (no default mapper)', () => {
expect(TypeOrmMediaRepository.length).toBe(2);
});
it('uses the injected mapper at runtime (DB-free)', async () => {
const ormRepo = {
findOne: vi.fn().mockResolvedValue({ id: 'media-1' }),
find: vi.fn().mockResolvedValue([{ id: 'media-1' }, { id: 'media-2' }]),
save: vi.fn().mockResolvedValue({ id: 'media-1' }),
delete: vi.fn().mockResolvedValue({ affected: 1 }),
};
const dataSource = {
getRepository: vi.fn().mockReturnValue(ormRepo),
};
const mapper = {
toStored: vi.fn().mockReturnValue({ id: 'stored-media-1' }),
toDomain: vi.fn().mockReturnValue({ id: 'domain-media-1' }),
toOrmEntity: vi.fn().mockReturnValue({ id: 'orm-media-1' }),
};
const repo = new TypeOrmMediaRepository(dataSource as any, mapper as any);
// Test findById
const media = await repo.findById('media-1');
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
expect(ormRepo.findOne).toHaveBeenCalledWith({ where: { id: 'media-1' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
expect(media).toEqual({ id: 'domain-media-1' });
// Test findByUploadedBy
const medias = await repo.findByUploadedBy('user-1');
expect(ormRepo.find).toHaveBeenCalledWith({ where: { uploadedBy: 'user-1' } });
expect(mapper.toDomain).toHaveBeenCalledTimes(3); // 1 from findById + 2 from findByUploadedBy
expect(medias).toHaveLength(2);
// Test save
const domainMedia = { id: 'new-media', toProps: () => ({ id: 'new-media' }) };
await repo.save(domainMedia as any);
expect(mapper.toOrmEntity).toHaveBeenCalledWith(domainMedia);
expect(ormRepo.save).toHaveBeenCalledWith({ id: 'orm-media-1' });
// Test delete
await repo.delete('media-1');
expect(ormRepo.delete).toHaveBeenCalledWith({ id: 'media-1' });
});
});

View File

@@ -0,0 +1,35 @@
import type { DataSource } from 'typeorm';
import type { IMediaRepository } from '@core/media/domain/repositories/IMediaRepository';
import type { Media } from '@core/media/domain/entities/Media';
import { MediaOrmEntity } from '../entities/MediaOrmEntity';
import { MediaOrmMapper } from '../mappers/MediaOrmMapper';
export class TypeOrmMediaRepository implements IMediaRepository {
constructor(
private readonly dataSource: DataSource,
private readonly mapper: MediaOrmMapper,
) {}
async save(media: Media): Promise<void> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
const entity = this.mapper.toOrmEntity(media);
await repo.save(entity);
}
async findById(id: string): Promise<Media | null> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
const entity = await repo.findOne({ where: { id } });
return entity ? this.mapper.toDomain(entity) : null;
}
async findByUploadedBy(uploadedBy: string): Promise<Media[]> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
const entities = await repo.find({ where: { uploadedBy } });
return entities.map(entity => this.mapper.toDomain(entity));
}
async delete(id: string): Promise<void> {
const repo = this.dataSource.getRepository(MediaOrmEntity);
await repo.delete({ id });
}
}

View File

@@ -0,0 +1,155 @@
import { TypeOrmMediaSchemaError } from '../errors/TypeOrmMediaSchemaError';
export function assertNonEmptyString(entityName: string, fieldName: string, value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (value.trim().length === 0) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'empty_string' });
}
}
export function assertDate(entityName: string, fieldName: string, value: unknown): asserts value is Date {
if (!(value instanceof Date)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_date' });
}
if (Number.isNaN(value.getTime())) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_date' });
}
}
export function assertNumber(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
if (Number.isNaN(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertInteger(entityName: string, fieldName: string, value: unknown): asserts value is number {
if (typeof value !== 'number') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
if (!Number.isInteger(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}
export function assertBoolean(entityName: string, fieldName: string, value: unknown): asserts value is boolean {
if (typeof value !== 'boolean') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_boolean' });
}
}
export function assertOptionalStringOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string | null | undefined {
if (value === null || value === undefined) {
return;
}
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
}
export function assertOptionalNumberOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === null || value === undefined) {
return;
}
if (typeof value !== 'number') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
if (Number.isNaN(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
}
export function assertStringArray(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is string[] {
if (!Array.isArray(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' });
}
if (!value.every(item => typeof item === 'string')) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_array' });
}
}
export function assertMediaType(entityName: string, fieldName: string, value: unknown): asserts value is 'image' | 'video' | 'document' {
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!['image', 'video', 'document'].includes(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertRacingSuitColor(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is 'red' | 'blue' | 'green' | 'yellow' | 'orange' | 'purple' | 'black' | 'white' | 'pink' | 'cyan' {
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (
!['red', 'blue', 'green', 'yellow', 'orange', 'purple', 'black', 'white', 'pink', 'cyan'].includes(value)
) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertAvatarStyle(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is 'realistic' | 'cartoon' | 'pixel-art' {
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!['realistic', 'cartoon', 'pixel-art'].includes(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertAvatarGenerationStatus(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is 'pending' | 'validating' | 'generating' | 'completed' | 'failed' {
if (typeof value !== 'string') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_string' });
}
if (!['pending', 'validating', 'generating', 'completed', 'failed'].includes(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'invalid_enum_value' });
}
}
export function assertOptionalIntegerOrNull(
entityName: string,
fieldName: string,
value: unknown,
): asserts value is number | null | undefined {
if (value === null || value === undefined) {
return;
}
if (typeof value !== 'number') {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_number' });
}
if (!Number.isInteger(value)) {
throw new TypeOrmMediaSchemaError({ entityName, fieldName, reason: 'not_integer' });
}
}