view models

This commit is contained in:
2025-12-18 01:20:23 +01:00
parent 7c449af311
commit cc2553876a
216 changed files with 485 additions and 10179 deletions

View File

@@ -1,259 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AvatarService } from './AvatarService';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { AvatarPresenter } from '../../presenters/AvatarPresenter';
import type {
RequestAvatarGenerationInputDto,
RequestAvatarGenerationOutputDto,
GetAvatarOutputDto,
UpdateAvatarInputDto,
UpdateAvatarOutputDto
} from '../../dtos';
import type {
RequestAvatarGenerationViewModel,
AvatarViewModel,
UpdateAvatarViewModel
} from '../../view-models';
describe('AvatarService', () => {
let service: AvatarService;
let mockApiClient: MediaApiClient;
let mockPresenter: AvatarPresenter;
beforeEach(() => {
mockApiClient = {
requestAvatarGeneration: vi.fn(),
getAvatar: vi.fn(),
updateAvatar: vi.fn(),
} as unknown as MediaApiClient;
mockPresenter = {
presentRequestGeneration: vi.fn(),
presentAvatar: vi.fn(),
presentUpdate: vi.fn(),
} as unknown as AvatarPresenter;
service = new AvatarService(mockApiClient, mockPresenter);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(AvatarService);
});
});
describe('requestAvatarGeneration', () => {
it('should request avatar generation and transform via presenter', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
style: 'realistic',
};
const mockDto: RequestAvatarGenerationOutputDto = {
success: true,
avatarUrl: 'https://example.com/avatar/generated.jpg',
};
const mockViewModel: RequestAvatarGenerationViewModel = {
success: true,
avatarUrl: 'https://example.com/avatar/generated.jpg',
};
vi.mocked(mockApiClient.requestAvatarGeneration).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentRequestGeneration).mockReturnValue(mockViewModel);
// Act
const result = await service.requestAvatarGeneration(input);
// Assert
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle generation failure', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
};
const mockDto: RequestAvatarGenerationOutputDto = {
success: false,
error: 'Generation failed',
};
const mockViewModel: RequestAvatarGenerationViewModel = {
success: false,
error: 'Generation failed',
};
vi.mocked(mockApiClient.requestAvatarGeneration).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentRequestGeneration).mockReturnValue(mockViewModel);
// Act
const result = await service.requestAvatarGeneration(input);
// Assert
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: RequestAvatarGenerationInputDto = {
driverId: 'driver-123',
};
const error = new Error('Network error');
vi.mocked(mockApiClient.requestAvatarGeneration).mockRejectedValue(error);
// Act & Assert
await expect(service.requestAvatarGeneration(input)).rejects.toThrow('Network error');
expect(mockApiClient.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockPresenter.presentRequestGeneration).not.toHaveBeenCalled();
});
});
describe('getAvatar', () => {
it('should fetch avatar and transform via presenter', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetAvatarOutputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
hasAvatar: true,
};
const mockViewModel: AvatarViewModel = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/avatar.jpg',
hasAvatar: true,
};
vi.mocked(mockApiClient.getAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentAvatar).mockReturnValue(mockViewModel);
// Act
const result = await service.getAvatar(driverId);
// Assert
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle driver without avatar', async () => {
// Arrange
const driverId = 'driver-123';
const mockDto: GetAvatarOutputDto = {
driverId: 'driver-123',
hasAvatar: false,
};
const mockViewModel: AvatarViewModel = {
driverId: 'driver-123',
hasAvatar: false,
};
vi.mocked(mockApiClient.getAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentAvatar).mockReturnValue(mockViewModel);
// Act
const result = await service.getAvatar(driverId);
// Assert
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.hasAvatar).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const driverId = 'driver-123';
const error = new Error('Avatar not found');
vi.mocked(mockApiClient.getAvatar).mockRejectedValue(error);
// Act & Assert
await expect(service.getAvatar(driverId)).rejects.toThrow('Avatar not found');
expect(mockApiClient.getAvatar).toHaveBeenCalledWith(driverId);
expect(mockPresenter.presentAvatar).not.toHaveBeenCalled();
});
});
describe('updateAvatar', () => {
it('should update avatar and transform via presenter', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const mockDto: UpdateAvatarOutputDto = {
success: true,
};
const mockViewModel: UpdateAvatarViewModel = {
success: true,
};
vi.mocked(mockApiClient.updateAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpdate).mockReturnValue(mockViewModel);
// Act
const result = await service.updateAvatar(input);
// Assert
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle update failure', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const mockDto: UpdateAvatarOutputDto = {
success: false,
error: 'Update failed',
};
const mockViewModel: UpdateAvatarViewModel = {
success: false,
error: 'Update failed',
};
vi.mocked(mockApiClient.updateAvatar).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpdate).mockReturnValue(mockViewModel);
// Act
const result = await service.updateAvatar(input);
// Assert
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: UpdateAvatarInputDto = {
driverId: 'driver-123',
avatarUrl: 'https://example.com/new-avatar.jpg',
};
const error = new Error('Update failed');
vi.mocked(mockApiClient.updateAvatar).mockRejectedValue(error);
// Act & Assert
await expect(service.updateAvatar(input)).rejects.toThrow('Update failed');
expect(mockApiClient.updateAvatar).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpdate).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,10 +1,9 @@
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { AvatarPresenter } from '../../presenters/AvatarPresenter';
import type {
RequestAvatarGenerationInputDto,
UpdateAvatarInputDto
} from '../../dtos';
import type {
import type { RequestAvatarGenerationInputDTO } from '../../types/generated';
// TODO: Move these types to apps/website/lib/types/generated when available
type UpdateAvatarInputDto = { driverId: string; avatarUrl: string };
import {
RequestAvatarGenerationViewModel,
AvatarViewModel,
UpdateAvatarViewModel
@@ -13,48 +12,35 @@ import type {
/**
* Avatar Service
*
* Orchestrates avatar operations by coordinating API calls and presentation logic.
* Orchestrates avatar operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class AvatarService {
constructor(
private readonly apiClient: MediaApiClient,
private readonly presenter: AvatarPresenter
private readonly apiClient: MediaApiClient
) {}
/**
* Request avatar generation with presentation transformation
* Request avatar generation with view model transformation
*/
async requestAvatarGeneration(input: RequestAvatarGenerationInputDto): Promise<RequestAvatarGenerationViewModel> {
try {
const dto = await this.apiClient.requestAvatarGeneration(input);
return this.presenter.presentRequestGeneration(dto);
} catch (error) {
throw error;
}
async requestAvatarGeneration(input: RequestAvatarGenerationInputDTO): Promise<RequestAvatarGenerationViewModel> {
const dto = await this.apiClient.requestAvatarGeneration(input);
return new RequestAvatarGenerationViewModel(dto);
}
/**
* Get avatar for driver with presentation transformation
* Get avatar for driver with view model transformation
*/
async getAvatar(driverId: string): Promise<AvatarViewModel> {
try {
const dto = await this.apiClient.getAvatar(driverId);
return this.presenter.presentAvatar(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getAvatar(driverId);
return new AvatarViewModel(dto);
}
/**
* Update avatar for driver with presentation transformation
* Update avatar for driver with view model transformation
*/
async updateAvatar(input: UpdateAvatarInputDto): Promise<UpdateAvatarViewModel> {
try {
const dto = await this.apiClient.updateAvatar(input);
return this.presenter.presentUpdate(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.updateAvatar(input);
return new UpdateAvatarViewModel(dto);
}
}

View File

@@ -1,261 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MediaService } from './MediaService';
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { MediaPresenter } from '../../presenters/MediaPresenter';
import type {
UploadMediaInputDto,
UploadMediaOutputDto,
GetMediaOutputDto,
DeleteMediaOutputDto
} from '../../dtos';
import type {
UploadMediaViewModel,
MediaViewModel,
DeleteMediaViewModel
} from '../../view-models';
describe('MediaService', () => {
let service: MediaService;
let mockApiClient: MediaApiClient;
let mockPresenter: MediaPresenter;
beforeEach(() => {
mockApiClient = {
uploadMedia: vi.fn(),
getMedia: vi.fn(),
deleteMedia: vi.fn(),
} as unknown as MediaApiClient;
mockPresenter = {
presentUpload: vi.fn(),
presentMedia: vi.fn(),
presentDelete: vi.fn(),
} as unknown as MediaPresenter;
service = new MediaService(mockApiClient, mockPresenter);
});
describe('constructor', () => {
it('should create instance with injected dependencies', () => {
expect(service).toBeInstanceOf(MediaService);
});
});
describe('uploadMedia', () => {
it('should upload media and transform via presenter', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
category: 'avatar',
};
const mockDto: UploadMediaOutputDto = {
success: true,
mediaId: 'media-123',
url: 'https://example.com/media/test.jpg',
};
const mockViewModel: UploadMediaViewModel = {
success: true,
mediaId: 'media-123',
url: 'https://example.com/media/test.jpg',
};
vi.mocked(mockApiClient.uploadMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpload).mockReturnValue(mockViewModel);
// Act
const result = await service.uploadMedia(input);
// Assert
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle upload failure', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const mockDto: UploadMediaOutputDto = {
success: false,
error: 'Upload failed',
};
const mockViewModel: UploadMediaViewModel = {
success: false,
error: 'Upload failed',
};
vi.mocked(mockApiClient.uploadMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentUpload).mockReturnValue(mockViewModel);
// Act
const result = await service.uploadMedia(input);
// Assert
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const input: UploadMediaInputDto = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
type: 'image',
};
const error = new Error('Network error');
vi.mocked(mockApiClient.uploadMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.uploadMedia(input)).rejects.toThrow('Network error');
expect(mockApiClient.uploadMedia).toHaveBeenCalledWith(input);
expect(mockPresenter.presentUpload).not.toHaveBeenCalled();
});
});
describe('getMedia', () => {
it('should fetch media and transform via presenter', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: GetMediaOutputDto = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
category: 'avatar',
uploadedAt: '2023-01-01T00:00:00Z',
size: 1024,
};
const mockViewModel: MediaViewModel = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
category: 'avatar',
uploadedAt: new Date('2023-01-01T00:00:00Z'),
size: 1024,
};
vi.mocked(mockApiClient.getMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentMedia).mockReturnValue(mockViewModel);
// Act
const result = await service.getMedia(mediaId);
// Assert
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle media without optional fields', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: GetMediaOutputDto = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
uploadedAt: '2023-01-01T00:00:00Z',
};
const mockViewModel: MediaViewModel = {
id: 'media-123',
url: 'https://example.com/media/test.jpg',
type: 'image',
uploadedAt: new Date('2023-01-01T00:00:00Z'),
};
vi.mocked(mockApiClient.getMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentMedia).mockReturnValue(mockViewModel);
// Act
const result = await service.getMedia(mediaId);
// Assert
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should propagate errors from API client', async () => {
// Arrange
const mediaId = 'media-123';
const error = new Error('Media not found');
vi.mocked(mockApiClient.getMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.getMedia(mediaId)).rejects.toThrow('Media not found');
expect(mockApiClient.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentMedia).not.toHaveBeenCalled();
});
});
describe('deleteMedia', () => {
it('should delete media and transform via presenter', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: DeleteMediaOutputDto = {
success: true,
};
const mockViewModel: DeleteMediaViewModel = {
success: true,
};
vi.mocked(mockApiClient.deleteMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentDelete).mockReturnValue(mockViewModel);
// Act
const result = await service.deleteMedia(mediaId);
// Assert
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
});
it('should handle delete failure', async () => {
// Arrange
const mediaId = 'media-123';
const mockDto: DeleteMediaOutputDto = {
success: false,
error: 'Delete failed',
};
const mockViewModel: DeleteMediaViewModel = {
success: false,
error: 'Delete failed',
};
vi.mocked(mockApiClient.deleteMedia).mockResolvedValue(mockDto);
vi.mocked(mockPresenter.presentDelete).mockReturnValue(mockViewModel);
// Act
const result = await service.deleteMedia(mediaId);
// Assert
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).toHaveBeenCalledWith(mockDto);
expect(result).toEqual(mockViewModel);
expect(result.success).toBe(false);
});
it('should propagate errors from API client', async () => {
// Arrange
const mediaId = 'media-123';
const error = new Error('Delete failed');
vi.mocked(mockApiClient.deleteMedia).mockRejectedValue(error);
// Act & Assert
await expect(service.deleteMedia(mediaId)).rejects.toThrow('Delete failed');
expect(mockApiClient.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockPresenter.presentDelete).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,53 +1,41 @@
import type { MediaApiClient } from '../../api/media/MediaApiClient';
import type { MediaPresenter } from '../../presenters/MediaPresenter';
import type { UploadMediaInputDto, GetMediaOutputDto, DeleteMediaOutputDto } from '../../dtos';
import type { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../../view-models';
import { MediaViewModel, UploadMediaViewModel, DeleteMediaViewModel } from '../../view-models';
// TODO: Move these types to apps/website/lib/types/generated when available
type UploadMediaInputDto = { url: string; mediaType: string; entityType: string; entityId: string };
/**
* Media Service
*
* Orchestrates media operations by coordinating API calls and presentation logic.
* Orchestrates media operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class MediaService {
constructor(
private readonly apiClient: MediaApiClient,
private readonly presenter: MediaPresenter
private readonly apiClient: MediaApiClient
) {}
/**
* Upload media file with presentation transformation
* Upload media file with view model transformation
*/
async uploadMedia(input: UploadMediaInputDto): Promise<UploadMediaViewModel> {
try {
const dto = await this.apiClient.uploadMedia(input);
return this.presenter.presentUpload(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.uploadMedia(input);
return new UploadMediaViewModel(dto);
}
/**
* Get media by ID with presentation transformation
* Get media by ID with view model transformation
*/
async getMedia(mediaId: string): Promise<MediaViewModel> {
try {
const dto = await this.apiClient.getMedia(mediaId);
return this.presenter.presentMedia(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.getMedia(mediaId);
return new MediaViewModel(dto);
}
/**
* Delete media by ID with presentation transformation
* Delete media by ID with view model transformation
*/
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
try {
const dto = await this.apiClient.deleteMedia(mediaId);
return this.presenter.presentDelete(dto);
} catch (error) {
throw error;
}
const dto = await this.apiClient.deleteMedia(mediaId);
return new DeleteMediaViewModel(dto);
}
}