presenter refactoring

This commit is contained in:
2025-12-20 17:06:11 +01:00
parent 92be9d2e1b
commit e9d6f90bb2
109 changed files with 4159 additions and 1283 deletions

View File

@@ -35,8 +35,8 @@ describe('MediaController', () => {
describe('requestAvatarGeneration', () => {
it('should request avatar generation and return 201 on success', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: true, jobId: 'job-123' };
service.requestAvatarGeneration.mockResolvedValue(result);
const viewModel = { success: true, jobId: 'job-123' } as any;
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -47,13 +47,13 @@ describe('MediaController', () => {
expect(service.requestAvatarGeneration).toHaveBeenCalledWith(input);
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
it('should return 400 on failure', async () => {
const input: RequestAvatarGenerationInputDTO = { driverId: 'driver-123' };
const result = { success: false, error: 'Error' };
service.requestAvatarGeneration.mockResolvedValue(result);
const viewModel = { success: false, error: 'Error' } as any;
service.requestAvatarGeneration.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -63,7 +63,7 @@ describe('MediaController', () => {
await controller.requestAvatarGeneration(input, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});
@@ -71,8 +71,8 @@ describe('MediaController', () => {
it('should upload media and return 201 on success', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const input: UploadMediaInputDTO = { type: 'image' };
const result = { success: true, mediaId: 'media-123' };
service.uploadMedia.mockResolvedValue(result);
const viewModel = { success: true, mediaId: 'media-123' } as any;
service.uploadMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -83,15 +83,15 @@ describe('MediaController', () => {
expect(service.uploadMedia).toHaveBeenCalledWith({ ...input, file });
expect(mockRes.status).toHaveBeenCalledWith(201);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});
describe('getMedia', () => {
it('should return media if found', async () => {
const mediaId = 'media-123';
const result = { id: mediaId, url: 'url' };
service.getMedia.mockResolvedValue(result);
const viewModel = { id: mediaId, url: 'url' } as any;
service.getMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -102,12 +102,12 @@ describe('MediaController', () => {
expect(service.getMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
it('should return 404 if not found', async () => {
const mediaId = 'media-123';
service.getMedia.mockResolvedValue(null);
service.getMedia.mockResolvedValue({ viewModel: null } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -124,8 +124,8 @@ describe('MediaController', () => {
describe('deleteMedia', () => {
it('should delete media', async () => {
const mediaId = 'media-123';
const result = { success: true };
service.deleteMedia.mockResolvedValue(result);
const viewModel = { success: true } as any;
service.deleteMedia.mockResolvedValue({ viewModel } as any);
const mockRes: ReturnType<typeof vi.mocked<Response>> = {
status: vi.fn().mockReturnThis(),
@@ -136,7 +136,7 @@ describe('MediaController', () => {
expect(service.deleteMedia).toHaveBeenCalledWith(mediaId);
expect(mockRes.status).toHaveBeenCalledWith(200);
expect(mockRes.json).toHaveBeenCalledWith(result);
expect(mockRes.json).toHaveBeenCalledWith(viewModel);
});
});

View File

@@ -29,11 +29,13 @@ export class MediaController {
@Body() input: RequestAvatarGenerationInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.requestAvatarGeneration(input);
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
const presenter = await this.mediaService.requestAvatarGeneration(input);
const viewModel = presenter.viewModel;
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
}
}
@@ -47,11 +49,13 @@ export class MediaController {
@Body() input: UploadMediaInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.uploadMedia({ ...input, file });
if (result.success) {
res.status(HttpStatus.CREATED).json(result);
const presenter = await this.mediaService.uploadMedia({ ...input, file });
const viewModel = presenter.viewModel;
if (viewModel.success) {
res.status(HttpStatus.CREATED).json(viewModel);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
res.status(HttpStatus.BAD_REQUEST).json(viewModel);
}
}
@@ -63,9 +67,11 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.getMedia(mediaId);
if (result) {
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.getMedia(mediaId);
const viewModel = presenter.viewModel;
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Media not found' });
}
@@ -79,10 +85,12 @@ export class MediaController {
@Param('mediaId') mediaId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.deleteMedia(mediaId);
res.status(HttpStatus.OK).json(result);
}
const presenter = await this.mediaService.deleteMedia(mediaId);
const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
}
@Get('avatar/:driverId')
@ApiOperation({ summary: 'Get avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@@ -91,14 +99,16 @@ export class MediaController {
@Param('driverId') driverId: string,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.getAvatar(driverId);
if (result) {
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.getAvatar(driverId);
const viewModel = presenter.viewModel;
if (viewModel) {
res.status(HttpStatus.OK).json(viewModel);
} else {
res.status(HttpStatus.NOT_FOUND).json({ error: 'Avatar not found' });
}
}
@Put('avatar/:driverId')
@ApiOperation({ summary: 'Update avatar for driver' })
@ApiParam({ name: 'driverId', description: 'Driver ID' })
@@ -108,7 +118,9 @@ export class MediaController {
@Body() input: UpdateAvatarInput,
@Res() res: Response,
): Promise<void> {
const result = await this.mediaService.updateAvatar(driverId, input);
res.status(HttpStatus.OK).json(result);
const presenter = await this.mediaService.updateAvatar(driverId, input);
const viewModel = presenter.viewModel;
res.status(HttpStatus.OK).json(viewModel);
}
}

View File

@@ -1,24 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import type { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from './dtos/RequestAvatarGenerationOutputDTO';
import type { UploadMediaInputDTO } from './dtos/UploadMediaInputDTO';
import type { UploadMediaOutputDTO } from './dtos/UploadMediaOutputDTO';
import type { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type RequestAvatarGenerationOutput = RequestAvatarGenerationOutputDTO;
type UploadMediaInput = UploadMediaInputDTO;
type UploadMediaOutput = UploadMediaOutputDTO;
type GetMediaOutput = GetMediaOutputDTO;
type DeleteMediaOutput = DeleteMediaOutputDTO;
type GetAvatarOutput = GetAvatarOutputDTO;
type UpdateAvatarInput = UpdateAvatarInputDTO;
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
// Use cases
import { RequestAvatarGenerationUseCase } from '@core/media/application/use-cases/RequestAvatarGenerationUseCase';
@@ -60,7 +48,7 @@ export class MediaService {
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
) {}
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationOutput> {
async requestAvatarGeneration(input: RequestAvatarGenerationInput): Promise<RequestAvatarGenerationPresenter> {
this.logger.debug('[MediaService] Requesting avatar generation.');
const presenter = new RequestAvatarGenerationPresenter();
@@ -69,10 +57,11 @@ export class MediaService {
facePhotoData: input.facePhotoData,
suitColor: input.suitColor as RacingSuitColor,
}, presenter);
return presenter.viewModel;
return presenter;
}
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaOutput> {
async uploadMedia(input: UploadMediaInput & { file: Express.Multer.File }): Promise<UploadMediaPresenter> {
this.logger.debug('[MediaService] Uploading media.');
const presenter = new UploadMediaPresenter();
@@ -83,102 +72,49 @@ export class MediaService {
metadata: input.metadata,
}, presenter);
const result = presenter.viewModel;
if (result.success) {
return {
success: true,
mediaId: result.mediaId!,
url: result.url!,
};
} else {
return {
success: false,
errorMessage: result.errorMessage || 'Upload failed',
};
}
return presenter;
}
async getMedia(mediaId: string): Promise<GetMediaOutput | null> {
async getMedia(mediaId: string): Promise<GetMediaPresenter> {
this.logger.debug(`[MediaService] Getting media: ${mediaId}`);
const presenter = new GetMediaPresenter();
await this.getMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
if (result.success && result.media) {
return {
success: true,
mediaId: result.media.id,
filename: result.media.filename,
originalName: result.media.originalName,
mimeType: result.media.mimeType,
size: result.media.size,
url: result.media.url,
type: result.media.type,
uploadedBy: result.media.uploadedBy,
uploadedAt: result.media.uploadedAt,
metadata: result.media.metadata,
};
}
return null;
return presenter;
}
async deleteMedia(mediaId: string): Promise<DeleteMediaOutput> {
async deleteMedia(mediaId: string): Promise<DeleteMediaPresenter> {
this.logger.debug(`[MediaService] Deleting media: ${mediaId}`);
const presenter = new DeleteMediaPresenter();
await this.deleteMediaUseCase.execute({ mediaId }, presenter);
const result = presenter.viewModel;
return {
success: result.success,
errorMessage: result.errorMessage,
};
return presenter;
}
async getAvatar(driverId: string): Promise<GetAvatarOutput | null> {
async getAvatar(driverId: string): Promise<GetAvatarPresenter> {
this.logger.debug(`[MediaService] Getting avatar for driver: ${driverId}`);
const presenter = new GetAvatarPresenter();
await this.getAvatarUseCase.execute({ driverId }, presenter);
const result = presenter.viewModel;
if (result.success && result.avatar) {
return {
success: true,
avatarId: result.avatar.id,
driverId: result.avatar.driverId,
mediaUrl: result.avatar.mediaUrl,
selectedAt: result.avatar.selectedAt,
};
}
return null;
return presenter;
}
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarOutput> {
async updateAvatar(driverId: string, input: UpdateAvatarInput): Promise<UpdateAvatarPresenter> {
this.logger.debug(`[MediaService] Updating avatar for driver: ${driverId}`);
const presenter = new UpdateAvatarPresenter();
await this.updateAvatarUseCase.execute({
driverId,
mediaUrl: input.mediaUrl,
}, presenter);
const result = presenter.viewModel;
return {
success: result.success,
errorMessage: result.errorMessage,
};
return presenter;
}
}

View File

@@ -1,4 +1,7 @@
import type { IDeleteMediaPresenter, DeleteMediaResult } from '@core/media/application/presenters/IDeleteMediaPresenter';
import type { DeleteMediaOutputDTO } from '../dtos/DeleteMediaOutputDTO';
type DeleteMediaOutput = DeleteMediaOutputDTO;
export class DeleteMediaPresenter implements IDeleteMediaPresenter {
private result: DeleteMediaResult | null = null;
@@ -7,8 +10,12 @@ export class DeleteMediaPresenter implements IDeleteMediaPresenter {
this.result = result;
}
get viewModel(): DeleteMediaResult {
get viewModel(): DeleteMediaOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
return {
success: this.result.success,
error: this.result.errorMessage,
};
}
}

View File

@@ -1,4 +1,7 @@
import type { IGetAvatarPresenter, GetAvatarResult } from '@core/media/application/presenters/IGetAvatarPresenter';
import type { GetAvatarOutputDTO } from '../dtos/GetAvatarOutputDTO';
export type GetAvatarViewModel = GetAvatarOutputDTO | null;
export class GetAvatarPresenter implements IGetAvatarPresenter {
private result: GetAvatarResult | null = null;
@@ -7,8 +10,13 @@ export class GetAvatarPresenter implements IGetAvatarPresenter {
this.result = result;
}
get viewModel(): GetAvatarResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
get viewModel(): GetAvatarViewModel {
if (!this.result || !this.result.success || !this.result.avatar) {
return null;
}
return {
avatarUrl: this.result.avatar.mediaUrl,
};
}
}

View File

@@ -1,4 +1,8 @@
import type { IGetMediaPresenter, GetMediaResult } from '@core/media/application/presenters/IGetMediaPresenter';
import type { GetMediaOutputDTO } from '../dtos/GetMediaOutputDTO';
// The HTTP-facing DTO (or null when not found)
export type GetMediaViewModel = GetMediaOutputDTO | null;
export class GetMediaPresenter implements IGetMediaPresenter {
private result: GetMediaResult | null = null;
@@ -7,8 +11,21 @@ export class GetMediaPresenter implements IGetMediaPresenter {
this.result = result;
}
get viewModel(): GetMediaResult {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
get viewModel(): GetMediaViewModel {
if (!this.result || !this.result.success || !this.result.media) {
return null;
}
const media = this.result.media;
return {
id: media.id,
url: media.url,
type: media.type,
// Best-effort mapping from arbitrary metadata
category: (media.metadata as { category?: string } | undefined)?.category,
uploadedAt: media.uploadedAt,
size: media.size,
};
}
}

View File

@@ -1,14 +1,21 @@
import type { IUpdateAvatarPresenter, UpdateAvatarResult } from '@core/media/application/presenters/IUpdateAvatarPresenter';
import type { UpdateAvatarOutputDTO } from '../dtos/UpdateAvatarOutputDTO';
type UpdateAvatarOutput = UpdateAvatarOutputDTO;
export class UpdateAvatarPresenter implements IUpdateAvatarPresenter {
private result: UpdateAvatarResult | null = null;
present(result: UpdateAvatarResult) {
this.result = result;
}
get viewModel(): UpdateAvatarResult {
get viewModel(): UpdateAvatarOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
return {
success: this.result.success,
error: this.result.errorMessage,
};
}
}

View File

@@ -1,4 +1,7 @@
import type { IUploadMediaPresenter, UploadMediaResult } from '@core/media/application/presenters/IUploadMediaPresenter';
import type { UploadMediaOutputDTO } from '../dtos/UploadMediaOutputDTO';
type UploadMediaOutput = UploadMediaOutputDTO;
export class UploadMediaPresenter implements IUploadMediaPresenter {
private result: UploadMediaResult | null = null;
@@ -7,8 +10,20 @@ export class UploadMediaPresenter implements IUploadMediaPresenter {
this.result = result;
}
get viewModel(): UploadMediaResult {
get viewModel(): UploadMediaOutput {
if (!this.result) throw new Error('Presenter not presented');
return this.result;
if (this.result.success) {
return {
success: true,
mediaId: this.result.mediaId,
url: this.result.url,
};
}
return {
success: false,
error: this.result.errorMessage || 'Upload failed',
};
}
}