refactor media module

This commit is contained in:
2025-12-22 15:58:20 +01:00
parent f59e1b13e7
commit c19c26ffe7
17 changed files with 118 additions and 120 deletions

View File

@@ -1,48 +0,0 @@
import { vi } from 'vitest';
import { DashboardService } from './DashboardService';
import { DashboardOverviewUseCase } from '@core/racing/application/use-cases/DashboardOverviewUseCase';
import type { Logger } from '@core/shared/application/Logger';
import { DashboardOverviewPresenter } from './presenters/DashboardOverviewPresenter';
describe('DashboardService', () => {
let service: DashboardService;
let mockUseCase: ReturnType<typeof vi.mocked<DashboardOverviewUseCase>>;
let mockPresenter: ReturnType<typeof vi.mocked<DashboardOverviewPresenter>>;
let mockLogger: ReturnType<typeof vi.mocked<Logger>>;
beforeEach(() => {
mockUseCase = {
execute: vi.fn(),
} as any;
mockPresenter = {
present: vi.fn(),
getResponseModel: vi.fn(),
} as any;
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as any;
service = new DashboardService(
mockLogger,
mockUseCase,
mockPresenter
);
});
it('should get dashboard overview', async () => {
const mockResult = { totalUsers: 100 };
mockUseCase.execute.mockResolvedValue(undefined);
mockPresenter.getResponseModel.mockReturnValue(mockResult);
const result = await service.getDashboardOverview('driver-1');
expect(mockUseCase.execute).toHaveBeenCalledWith({ driverId: 'driver-1' });
expect(mockPresenter.getResponseModel).toHaveBeenCalled();
expect(result).toBe(mockResult);
});
});

View File

@@ -11,10 +11,19 @@ import { GetMediaOutputDTO } from './dtos/GetMediaOutputDTO';
import { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import type { MulterFile } from './types/MulterFile';
describe('MediaController', () => {
let controller: MediaController;
let service: jest.Mocked<MediaService>;
let service: MediaService & {
requestAvatarGeneration: ReturnType<typeof vi.fn>;
uploadMedia: ReturnType<typeof vi.fn>;
getMedia: ReturnType<typeof vi.fn>;
deleteMedia: ReturnType<typeof vi.fn>;
getAvatar: ReturnType<typeof vi.fn>;
updateAvatar: ReturnType<typeof vi.fn>;
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -35,7 +44,14 @@ describe('MediaController', () => {
}).compile();
controller = module.get<MediaController>(MediaController);
service = module.get(MediaService) as jest.Mocked<MediaService>;
service = module.get(MediaService) as MediaService & {
requestAvatarGeneration: ReturnType<typeof vi.fn>;
uploadMedia: ReturnType<typeof vi.fn>;
getMedia: ReturnType<typeof vi.fn>;
deleteMedia: ReturnType<typeof vi.fn>;
getAvatar: ReturnType<typeof vi.fn>;
updateAvatar: ReturnType<typeof vi.fn>;
};
});
const createMockResponse = (): Response => {
@@ -92,7 +108,7 @@ describe('MediaController', () => {
describe('uploadMedia', () => {
it('should upload media and return 201 on success', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const file: MulterFile = { filename: 'file.jpg' } as MulterFile;
const input: UploadMediaInputDTO = { type: 'image' } as UploadMediaInputDTO;
const dto: UploadMediaOutputDTO = {
success: true,
@@ -111,7 +127,7 @@ describe('MediaController', () => {
});
it('should return 400 when upload fails', async () => {
const file: Express.Multer.File = { filename: 'file.jpg' } as Express.Multer.File;
const file: MulterFile = { filename: 'file.jpg' } as MulterFile;
const input: UploadMediaInputDTO = { type: 'image' } as UploadMediaInputDTO;
const dto: UploadMediaOutputDTO = {
success: false,
@@ -217,7 +233,7 @@ describe('MediaController', () => {
describe('updateAvatar', () => {
it('should update avatar and return result', async () => {
const driverId = 'driver-123';
const input: UpdateAvatarInputDTO = { avatarUrl: 'https://example.com/new-avatar.png' };
const input: UpdateAvatarInputDTO = { driverId: 'driver-123', avatarUrl: 'https://example.com/new-avatar.png' };
const dto: UpdateAvatarOutputDTO = {
success: true,
};

View File

@@ -1,6 +1,6 @@
import { Controller, Post, Get, Delete, Put, Body, HttpStatus, Res, Param, UseInterceptors, UploadedFile } from '@nestjs/common';
import { ApiTags, ApiResponse, ApiOperation, ApiParam, ApiConsumes } from '@nestjs/swagger';
import { Response } from 'express';
import type { Response } from 'express';
import { FileInterceptor } from '@nestjs/platform-express';
import { MediaService } from './MediaService';
import { RequestAvatarGenerationInputDTO } from './dtos/RequestAvatarGenerationInputDTO';
@@ -12,6 +12,7 @@ import { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import { UpdateAvatarInputDTO } from './dtos/UpdateAvatarInputDTO';
import { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { MulterFile } from './types/MulterFile';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO;
@@ -44,7 +45,7 @@ export class MediaController {
@ApiConsumes('multipart/form-data')
@ApiResponse({ status: 201, description: 'Media uploaded successfully', type: UploadMediaOutputDTO })
async uploadMedia(
@UploadedFile() file: Express.Multer.File,
@UploadedFile() file: MulterFile,
@Body() input: UploadMediaInput,
@Res() res: Response,
): Promise<void> {

View File

@@ -9,6 +9,7 @@ import type { DeleteMediaOutputDTO } from './dtos/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './dtos/GetAvatarOutputDTO';
import type { UpdateAvatarOutputDTO } from './dtos/UpdateAvatarOutputDTO';
import type { RacingSuitColor } from '@core/media/domain/types/AvatarGenerationRequest';
import type { MulterFile } from './types/MulterFile';
type RequestAvatarGenerationInput = RequestAvatarGenerationInputDTO;
type UploadMediaInput = UploadMediaInputDTO;
@@ -92,7 +93,7 @@ export class MediaService {
}
async uploadMedia(
input: UploadMediaInput & { file: Express.Multer.File } & { userId?: string; metadata?: Record<string, unknown> },
input: UploadMediaInput & { file: MulterFile } & { userId?: string; metadata?: Record<string, unknown> },
): Promise<UploadMediaOutputDTO> {
this.logger.debug('[MediaService] Uploading media.');

View File

@@ -4,7 +4,7 @@ import { IsBoolean, IsString, IsOptional } from 'class-validator';
export class DeleteMediaOutputDTO {
@ApiProperty()
@IsBoolean()
success: boolean;
success: boolean = false;
@ApiProperty({ required: false })
@IsString()

View File

@@ -4,5 +4,5 @@ import { IsString } from 'class-validator';
export class GetAvatarOutputDTO {
@ApiProperty()
@IsString()
avatarUrl: string;
avatarUrl: string = '';
}

View File

@@ -4,15 +4,15 @@ import { IsString, IsOptional, IsNumber } from 'class-validator';
export class GetMediaOutputDTO {
@ApiProperty()
@IsString()
id: string;
id: string = '';
@ApiProperty()
@IsString()
url: string;
url: string = '';
@ApiProperty()
@IsString()
type: string;
type: string = '';
@ApiProperty()
@IsString()
@@ -20,7 +20,7 @@ export class GetMediaOutputDTO {
category?: string;
@ApiProperty()
uploadedAt: Date;
uploadedAt: Date = new Date();
@ApiProperty()
@IsNumber()

View File

@@ -5,15 +5,15 @@ export class RequestAvatarGenerationInputDTO {
@ApiProperty()
@IsString()
@IsNotEmpty()
userId: string;
userId: string = '';
@ApiProperty()
@IsString()
@IsNotEmpty()
facePhotoData: string;
facePhotoData: string = '';
@ApiProperty()
@IsString()
@IsNotEmpty()
suitColor: string;
suitColor: string = '';
}

View File

@@ -4,7 +4,7 @@ import { IsBoolean, IsString } from 'class-validator';
export class RequestAvatarGenerationOutputDTO {
@ApiProperty({ type: Boolean })
@IsBoolean()
success: boolean;
success: boolean = false;
@ApiProperty({ required: false })
@IsString()

View File

@@ -4,9 +4,9 @@ import { IsString } from 'class-validator';
export class UpdateAvatarInputDTO {
@ApiProperty()
@IsString()
driverId: string;
driverId: string = '';
@ApiProperty()
@IsString()
avatarUrl: string;
avatarUrl: string = '';
}

View File

@@ -4,7 +4,7 @@ import { IsBoolean, IsString, IsOptional } from 'class-validator';
export class UpdateAvatarOutputDTO {
@ApiProperty()
@IsBoolean()
success: boolean;
success: boolean = false;
@ApiProperty({ required: false })
@IsString()

View File

@@ -3,11 +3,11 @@ import { IsString, IsOptional } from 'class-validator';
export class UploadMediaInputDTO {
@ApiProperty({ type: 'string', format: 'binary' })
file: Express.Multer.File;
file: any;
@ApiProperty()
@IsString()
type: string;
type: string = '';
@ApiProperty({ required: false })
@IsString()

View File

@@ -4,7 +4,7 @@ import { IsString, IsBoolean, IsOptional } from 'class-validator';
export class UploadMediaOutputDTO {
@ApiProperty()
@IsBoolean()
success: boolean;
success: boolean = false;
@ApiProperty({ required: false })
@IsString()

View File

@@ -14,15 +14,21 @@ export class GetMediaPresenter implements UseCaseOutputPort<GetMediaResult> {
present(result: GetMediaResult): void {
const media = result.media;
this.model = {
const model: GetMediaResponseModel = {
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,
};
// Best-effort mapping from arbitrary metadata
const category = (media.metadata as { category?: string } | undefined)?.category;
if (category !== undefined) {
model.category = category;
}
this.model = model;
}
getResponseModel(): GetMediaResponseModel | null {

View File

@@ -12,11 +12,16 @@ export class UploadMediaPresenter implements UseCaseOutputPort<UploadMediaResult
}
present(result: UploadMediaResult): void {
this.model = {
const model: UploadMediaResponseModel = {
success: true,
mediaId: result.mediaId,
url: result.url,
};
if (result.url !== undefined) {
model.url = result.url;
}
this.model = model;
}
getResponseModel(): UploadMediaResponseModel | null {

View File

@@ -0,0 +1,17 @@
/**
* Multer file type definition
* Used for file upload functionality in media domain
*/
export interface MulterFile {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
stream: NodeJS.ReadableStream;
destination: string;
filename: string;
path: string;
}

View File

@@ -1,71 +1,71 @@
import { Injectable, Inject } from '@nestjs/common';
import type { Logger } from '@core/shared/application/Logger';
import { Inject, Injectable } from '@nestjs/common';
// Use cases
import type { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
import type { CreatePaymentUseCase } from '@core/payments/application/use-cases/CreatePaymentUseCase';
import type { UpdatePaymentStatusUseCase } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import type { GetMembershipFeesUseCase } from '@core/payments/application/use-cases/GetMembershipFeesUseCase';
import type { UpsertMembershipFeeUseCase } from '@core/payments/application/use-cases/UpsertMembershipFeeUseCase';
import type { UpdateMemberPaymentUseCase } from '@core/payments/application/use-cases/UpdateMemberPaymentUseCase';
import type { GetPrizesUseCase } from '@core/payments/application/use-cases/GetPrizesUseCase';
import type { CreatePrizeUseCase } from '@core/payments/application/use-cases/CreatePrizeUseCase';
import type { AwardPrizeUseCase } from '@core/payments/application/use-cases/AwardPrizeUseCase';
import type { CreatePaymentUseCase } from '@core/payments/application/use-cases/CreatePaymentUseCase';
import type { CreatePrizeUseCase } from '@core/payments/application/use-cases/CreatePrizeUseCase';
import type { DeletePrizeUseCase } from '@core/payments/application/use-cases/DeletePrizeUseCase';
import type { GetMembershipFeesUseCase } from '@core/payments/application/use-cases/GetMembershipFeesUseCase';
import type { GetPaymentsUseCase } from '@core/payments/application/use-cases/GetPaymentsUseCase';
import type { GetPrizesUseCase } from '@core/payments/application/use-cases/GetPrizesUseCase';
import type { GetWalletUseCase } from '@core/payments/application/use-cases/GetWalletUseCase';
import type { ProcessWalletTransactionUseCase } from '@core/payments/application/use-cases/ProcessWalletTransactionUseCase';
import type { UpdateMemberPaymentUseCase } from '@core/payments/application/use-cases/UpdateMemberPaymentUseCase';
import type { UpdatePaymentStatusUseCase } from '@core/payments/application/use-cases/UpdatePaymentStatusUseCase';
import type { UpsertMembershipFeeUseCase } from '@core/payments/application/use-cases/UpsertMembershipFeeUseCase';
// Presenters
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { AwardPrizePresenter } from './presenters/AwardPrizePresenter';
import { CreatePaymentPresenter } from './presenters/CreatePaymentPresenter';
import { CreatePrizePresenter } from './presenters/CreatePrizePresenter';
import { DeletePrizePresenter } from './presenters/DeletePrizePresenter';
import { GetMembershipFeesPresenter } from './presenters/GetMembershipFeesPresenter';
import { GetPaymentsPresenter } from './presenters/GetPaymentsPresenter';
import { GetPrizesPresenter } from './presenters/GetPrizesPresenter';
import { GetWalletPresenter } from './presenters/GetWalletPresenter';
import { ProcessWalletTransactionPresenter } from './presenters/ProcessWalletTransactionPresenter';
import { UpdateMemberPaymentPresenter } from './presenters/UpdateMemberPaymentPresenter';
import { UpdatePaymentStatusPresenter } from './presenters/UpdatePaymentStatusPresenter';
import { UpsertMembershipFeePresenter } from './presenters/UpsertMembershipFeePresenter';
// DTOs
import type {
AwardPrizeInput,
CreatePaymentInput,
CreatePaymentOutput,
UpdatePaymentStatusInput,
UpdatePaymentStatusOutput,
GetPaymentsQuery,
GetPaymentsOutput,
GetMembershipFeesQuery,
GetMembershipFeesOutput,
UpsertMembershipFeeInput,
UpsertMembershipFeeOutput,
UpdateMemberPaymentInput,
UpdateMemberPaymentOutput,
GetPrizesQuery,
CreatePrizeInput,
AwardPrizeInput,
DeletePrizeInput,
GetMembershipFeesOutput,
GetMembershipFeesQuery,
GetPaymentsOutput,
GetPaymentsQuery,
GetPrizesQuery,
GetWalletQuery,
ProcessWalletTransactionInput,
UpdateMemberPaymentInput,
UpdateMemberPaymentOutput,
UpdatePaymentStatusInput,
UpdatePaymentStatusOutput,
UpsertMembershipFeeInput,
UpsertMembershipFeeOutput,
} from './dtos/PaymentsDto';
// Injection tokens
import {
GET_PAYMENTS_USE_CASE_TOKEN,
CREATE_PAYMENT_USE_CASE_TOKEN,
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
GET_PRIZES_USE_CASE_TOKEN,
CREATE_PRIZE_USE_CASE_TOKEN,
AWARD_PRIZE_USE_CASE_TOKEN,
CREATE_PAYMENT_USE_CASE_TOKEN,
CREATE_PRIZE_USE_CASE_TOKEN,
DELETE_PRIZE_USE_CASE_TOKEN,
GET_MEMBERSHIP_FEES_USE_CASE_TOKEN,
GET_PAYMENTS_USE_CASE_TOKEN,
GET_PRIZES_USE_CASE_TOKEN,
GET_WALLET_USE_CASE_TOKEN,
PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
LOGGER_TOKEN,
PROCESS_WALLET_TRANSACTION_USE_CASE_TOKEN,
UPDATE_MEMBER_PAYMENT_USE_CASE_TOKEN,
UPDATE_PAYMENT_STATUS_USE_CASE_TOKEN,
UPSERT_MEMBERSHIP_FEE_USE_CASE_TOKEN,
} from './PaymentsProviders';
@Injectable()
@@ -158,7 +158,7 @@ export class PaymentsService {
return this.updateMemberPaymentPresenter.getResponseModel();
}
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesPresenter> {
async getPrizes(query: GetPrizesQuery): Promise<GetPrizesPresenter> { // TODO must return ResponseModel not Presenter
this.logger.debug('[PaymentsService] Getting prizes', { query });
const presenter = new GetPrizesPresenter();
@@ -166,7 +166,7 @@ export class PaymentsService {
return presenter;
}
async createPrize(input: CreatePrizeInput): Promise<CreatePrizePresenter> {
async createPrize(input: CreatePrizeInput): Promise<CreatePrizePresenter> { // TODO must return ResponseModel not Presenter
this.logger.debug('[PaymentsService] Creating prize', { input });
const presenter = new CreatePrizePresenter();
@@ -174,7 +174,7 @@ export class PaymentsService {
return presenter;
}
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizePresenter> {
async awardPrize(input: AwardPrizeInput): Promise<AwardPrizePresenter> { // TODO must return ResponseModel not Presenter
this.logger.debug('[PaymentsService] Awarding prize', { input });
const presenter = new AwardPrizePresenter();
@@ -182,7 +182,7 @@ export class PaymentsService {
return presenter;
}
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizePresenter> {
async deletePrize(input: DeletePrizeInput): Promise<DeletePrizePresenter> { // TODO must return ResponseModel not Presenter
this.logger.debug('[PaymentsService] Deleting prize', { input });
const presenter = new DeletePrizePresenter();
@@ -190,7 +190,7 @@ export class PaymentsService {
return presenter;
}
async getWallet(query: GetWalletQuery): Promise<GetWalletPresenter> {
async getWallet(query: GetWalletQuery): Promise<GetWalletPresenter> { // TODO must return ResponseModel not Presenter
this.logger.debug('[PaymentsService] Getting wallet', { query });
const presenter = new GetWalletPresenter();